commit 3dde79cfe2abf430300642b7676f5950de290a51 Author: Jason Thistlethwaite Date: Sun Jun 7 13:55:01 2026 -0400 Initial commit diff --git a/LICENSE.md b/LICENSE.md new file mode 100755 index 0000000..aa2df5a --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,27 @@ +--- +created: 2026-06-07T13:36:16-04:00 +modified: 2026-06-07T13:49:20-04:00 +--- + +# Copyright (c)2026 Jason Thistlethwaite + +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. + +4. Individuals known as Brice Gottesman, as well as any organization employing or contracting with him in any capacity, are not permitted to use this source or binaries in any capacity whatsoever. + +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. + +## Provenance & Authorship + +This software, including its architectural design, failover logic, and multi-host configuration schemas, was designed, engineered, and authored exclusively by Jason Thistlethwaite. + +No other individuals, former contractors, or external helpers hold authorship, intellectual property rights, or foundational design claims over this codebase. Any representations to the contrary made to third parties, online portfolios, or professional networking platforms are entirely fraudulent. + +### License Restrictions +Please note that this software is distributed under a restricted, source-available license. Under **Condition 4** of the included LICENSE file, specific individuals—namely Brice Gottesman, alongside any parent organizations, subsidiaries, or clients engaging his services—are strictly prohibited from downloading, running, modifying, or distributing this software in any capacity. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbbeebb --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +--- +title: ssh-pipeweasel - manage complicated SSH network paths with ease +author: Jason Thistlethwaite +tags: + - ssh + - vpn + - wireguard + - wip +modified: 2026-06-07T13:49:45-04:00 +--- + +# ssh-pipeweasel +ssh-pipeweasel is a tool for making complicated SSH connections easier to manage. + +I created it to handle situations where I need to connect to various hosts that aren't always accessible for the internet, particularly from my laptop, where multiple routes could be possible like LAN, a WireGuard tunnel, or something else. + +## Standard Usage +- Modify ~/.ssh/config and add `ProxyCommand ~/.ssh/ssh-pipeweasel.sh %h` to `Host` declarations you want to use it with. +- **Recommended**: + - Add `ServerAliveInterval 60` and `ServerAliveCountMax 3` to any connections using ssh-pipeweasel +- Copy `proxyconfig.example.sh` to `~/.ssh/hosts.d/`, rename it to match your host, and then edit it to list the options for that host +- **Testing it:** + - `DEBUG=true ~/.ssh/ssh-pipeweasel.sh ` + - The above should output the availability and latency of all configured routes to that host +- **Using it:** + - `ssh ` and ssh-pipeweasel automatically works in the background, selecting the best path to reach your configured ssh server. + +## Installation and Setup +```bash +# Create directory for host config files +mkdir -p ~/.ssh/hosts.d/ + +# Set secure, default permissions on .ssh directories +chmod 0700 ~/.ssh/ && chmod 0700 ~/.ssh/hosts.d/ + +# Put the pipeweasel in place and mark it executable +cp ./ssh-pipeweasel.sh ~/.ssh/ +chmod +x ~/.ssh/ssh-pipeweasel.sh +``` + +### Example Config File +```sh +# Order does not matter; it automatically selects the lowest latency response +ENDPOINTS=( + "LAN|192.168.1.10|22" + "WireGuard|10.11.0.10|22" + "Public|mybastion.uplinklounge.com|9002" +) +``` +The file should be named after the Host alias you used inside of `~/.ssh/config` + +### Example ssh_config (`~/.ssh/config`) file entry +``` +# Connection muxing is a must-have for me, but this section is not needed for +# ssh-pipeweasel to function +ControlMaster auto +ControlPersist 4800 +ControlPath ~/.ssh/control/%r.%n.%p.sock + +# List of hosts that use the pipeweasel +Host bastion, mgmt01, mgmt02, pve + ProxyCommand ~/.ssh/ssh-pipeweasel.sh %h + ServerAliveInterval 60 + ServerAliveCountMax 3 + +# Host-specific configs like port forwards, username, which key to use, etc. +Host bastion + IdentityFile ~/.ssh/id_ecdsa + User jason + LocalForward 13307 localhost:3306 + LocalForward 18081 localhost:8081 + DynamicForward localhost:19011 + LocalForward 2222 localhost:2222 + TCPKeepAlive yes + ExitOnForwardFailure no + + +``` + +# Dependencies +ssh-pipeweasel has been tested and is known to work on Ubuntu Linux 26.04 LTS. + +It's only dependencies are: +- netcat + - Test with netcat-openbsd 1.206, which comes with Ubuntu by default +- bash +- GNU Awk +- openssh, I would think obviously + +# Disclaimer +This tool is released without any particular warranty or claim it's actually useful or safe to use for anything. I'm posting this just in case other people have the same headache I have and this helps them. + +## License & Authorship +This project is authored exclusively by Jason Thistlethwaite. It is distributed under a custom, restricted license. Specific individuals and affiliated entities are strictly prohibited from using this software. + +Please see the full [LICENSE.md](./LICENSE.md) file for complete details, provenance documentation, and restriction clauses. diff --git a/proxyrule.example.conf b/proxyrule.example.conf new file mode 100755 index 0000000..17e85d0 --- /dev/null +++ b/proxyrule.example.conf @@ -0,0 +1,6 @@ +# Order does not matter; it automatically selects the lowest latency response +ENDPOINTS=( + "LAN|192.168.1.10|22" + "WireGuard|10.11.0.10|22" + "Public|mybastion.uplinklounge.com|9002" +) diff --git a/ssh-pipeweasel.md b/ssh-pipeweasel.md new file mode 100755 index 0000000..3f049af --- /dev/null +++ b/ssh-pipeweasel.md @@ -0,0 +1,5 @@ +--- +tags: MOCs +--- +```folder-index-content +``` \ No newline at end of file diff --git a/ssh-pipeweasel.sh b/ssh-pipeweasel.sh new file mode 100755 index 0000000..2ad6040 --- /dev/null +++ b/ssh-pipeweasel.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# Save to: ~/.ssh/ssh-failover.sh + +TARGET_HOST="$1" +CONFIG_DIR="$HOME/.ssh/hosts.d" + +# Force lower-case comparison to prevent case mismatches from SSH %h token +TARGET_LOWER=$(echo "$TARGET_HOST" | tr '[:upper:]' '[:lower:]') +CONFIG_FILE="$CONFIG_DIR/proxyrule.${TARGET_LOWER}.conf" + +TIMEOUT=0.5 +DEBUG=${DEBUG:-false} + +if [ ! -f "$CONFIG_FILE" ]; then + echo "FAILOVER ERROR: Config file missing at $CONFIG_FILE" >&2 + exit 1 +fi + +source "$CONFIG_FILE" + +if [ ${#ENDPOINTS[@]} -eq 0 ]; then + echo "FAILOVER ERROR: No ENDPOINTS array found inside $CONFIG_FILE" >&2 + exit 1 +fi + +# Helper function to resolve hostnames to IP addresses before the timer starts +resolve_to_ip() { + local target=$1 + # Check if it's already an IPv4 or IPv6 address to save process execution + if [[ "$target" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ || "$target" =~ : ]]; then + echo "$target" + else + # Resolve using standard system hosts databases (handles DNS, /etc/hosts, etc.) + local ip + ip=$(getent hosts "$target" | awk '{print $1; exit}') + # Fall back to dig if getent isn't providing a quick single target ip + [ -z "$ip" ] && ip=$(dig +short "$target" | tail -n1) + # If both fail, return original target so nc can try anyway + echo "${ip:-$target}" + fi +} + +get_tcp_ms() { + local host=$1 + local port=$2 + local start=$EPOCHREALTIME + + if timeout "$TIMEOUT" nc -z "$host" "$port" 2>/dev/null; then + local end=$EPOCHREALTIME + awk "BEGIN {print int(($end - $start) * 1000)}" + else + echo "9999" + fi +} + +declare -a results +declare -a resolved_hosts +fastest_ms=9999 +chosen_index="" + +for ((i=0; i<${#ENDPOINTS[@]}; i++)); do + IFS="|" read -r label host port <<< "${ENDPOINTS[$i]}" + + # CRITICAL BUGFIX: Strip any hidden trailing newlines (\n) or carriage returns (\r) + port=$(echo "$port" | tr -d '\r\n[:space:]') + + # Pre-resolve hostnames to eliminate DNS lookup latency skew + resolved_host=$(resolve_to_ip "$host") + resolved_hosts[$i]=$resolved_host + + ms=$(get_tcp_ms "$resolved_host" "$port") + results[$i]=$ms + + # if [ "$DEBUG" = true ]; then + # echo "Comparing $ms to $fastest_ms" + # fi + + if [ "$ms" -lt "$fastest_ms" ]; then + fastest_ms=$ms + chosen_index=$i + fi +done + +# --- DYNAMIC DEBUG DISPLAY LOOP --- +if [ "$DEBUG" = true ]; then + echo "--------------------------------------------------------" >&2 + printf "Testing routes for: \e[1;34m%s\e[0m\n" "$TARGET_HOST" >&2 + echo "--------------------------------------------------------" >&2 + printf "%-25s | %-12s | %-10s\n" "Endpoint / Host" "Status" "Latency" >&2 + echo "--------------------------------------------------------" >&2 + + for ((i=0; i<${#ENDPOINTS[@]}; i++)); do + IFS="|" read -r label host port <<< "${ENDPOINTS[$i]}" + ms=${results[$i]} + + if [ "$ms" -eq 9999 ]; then + printf "%-25s | \e[31m%-12s\e[0m | %-10s\n" "$label" "OFFLINE" "---" >&2 + else + printf "%-25s | \e[32m%-12s\e[0m | %-10s\n" "$label" "ONLINE" "${ms}ms" >&2 + fi + done + echo "--------------------------------------------------------" >&2 +fi + +# 2. Route traffic to the fastest endpoint found +if [ -n "$chosen_index" ] && [ "$fastest_ms" -lt 9999 ]; then + IFS="|" read -r label host port <<< "${ENDPOINTS[$chosen_index]}" + port=$(echo "$port" | tr -d '\r\n[:space:]') + + [ "$DEBUG" = true ] && echo "-> Routing via $label ($host:$port)..." >&2 + exec nc "$host" "$port" +else + echo "FAILOVER ERROR: All paths to $TARGET_HOST are dead." >&2 + exit 1 +fi