pawdance/pawdance
2025-07-25 12:45:57 +02:00

357 lines
12 KiB
Bash
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# pawdance.sh EXACT client *and* server logic driven by an easytoedit config file
# -----------------------------------------------------------------------------
# Modes (ROLE in config):
# client brings up a pointtopoint tunnel by SSHwing into the server.
# server creates the matching tunnel locally and (optionally) enables routing.
#
# Connection source (CONNECT_MODE):
# dns resolve REMOTE_HOST on every run.
# ip use the hardcoded REMOTE_CONNECT_IP4 and/or REMOTE_CONNECT_IP6.
# auto if REMOTE_HOST is set use dns, otherwise ip (default).
#
# Extra dial options:
# CONNECT_PREFER which address family to try first (auto|ipv4|ipv6).
# SSH_KEY_MODE set to "true" to pass an explicit private key via -i.
# SSH_KEY absolute path to that key (required if SSH_KEY_MODE=true).
#
# -----------------------------------------------------------------------------
# v4.5.1 better --help, commented example configs, SSH_KEY_MODE support.
# -----------------------------------------------------------------------------
set -euo pipefail
SCRIPT_NAME="pawdance"
VERSION="4.5.1"
CONFIG_FILE=""
SUBCMD=""
MAKECFG_OUT=""
SUDO=""
[[ $EUID -ne 0 ]] && SUDO="sudo"
log() { echo "[*] $*"; }
ok() { echo "[✔] $*"; }
err() { echo "[!] $*" >&2; exit 1; }
require_cmd() {
for c in "$@"; do command -v "$c" >/dev/null 2>&1 || err "Missing command: $c"; done
}
# ---------- globals (for cleanup) ----------
REMOTE_IP4_RESOLVED=""
REMOTE_IP6_RESOLVED=""
IPV4_GW=""; IFACE4=""
IPV6_GW=""; IFACE6=""
IPT_RULES_INSTALLED=false
# ---------------- Config ----------------
load_config() {
[[ -f "$CONFIG_FILE" ]] || err "Config not found: $CONFIG_FILE"
# shellcheck disable=SC1090
source "$CONFIG_FILE"
: "${ROLE:=client}" ; ROLE="${ROLE,,}"
: "${TUN_INDEX:=0}"
: "${TUN_DEV:=tun${TUN_INDEX}}"
: "${LOCAL_IP4:?LOCAL_IP4 must be set (CIDR)}"
: "${LOCAL_IP6:=}"
: "${MTU:=1500}"
case "$ROLE" in
client)
: "${CONNECT_MODE:=auto}" ; CONNECT_MODE="${CONNECT_MODE,,}"
: "${REMOTE_HOST:=}"
: "${REMOTE_CONNECT_IP4:=}"
: "${REMOTE_CONNECT_IP6:=}"
case "$CONNECT_MODE" in
dns) [[ -z "$REMOTE_HOST" ]] && err "CONNECT_MODE=dns requires REMOTE_HOST" ;;
ip) [[ -z "$REMOTE_CONNECT_IP4" && -z "$REMOTE_CONNECT_IP6" ]] && err "CONNECT_MODE=ip requires REMOTE_CONNECT_IP*" ;;
auto) : ;; # handled later
*) err "CONNECT_MODE must be dns, ip, or auto" ;;
esac
: "${REMOTE_USER:?REMOTE_USER must be set}"
: "${REMOTE_IP4:=}"
: "${REMOTE_IP6:=}"
# Crypto knobs with safe defaults
: "${SSH_KEX:=mlkem768x25519-sha256}"
: "${SSH_CIPHERS:=chacha20-poly1305@openssh.com}"
: "${SSH_MACS:=hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com}"
# Privatekey dial support
: "${SSH_KEY_MODE:=false}"
: "${SSH_KEY:=}"
[[ "$SSH_KEY_MODE" == "true" && -z "$SSH_KEY" ]] && err "SSH_KEY_MODE=true but SSH_KEY is empty"
# Routing toggles
: "${DEFAULT_ROUTE_IPV4:=true}"
: "${DEFAULT_ROUTE_IPV6:=true}"
: "${CONNECT_PREFER:=auto}" ;;
server)
: "${VPN_FORWARD:=true}"
: "${IP_FORWARD:=true}" ;;
*) err "Unknown ROLE '$ROLE' (client|server)" ;;
esac
}
# -------------- Example configs --------------
make_example_client_cfg() {
cat <<'EOF'
# ---------------------------------------------------------------------------
# pawdance
# ---------------------------------------------------------------------------
ROLE="client" # client or server
CONNECT_MODE="auto" # dns|ip|auto
REMOTE_HOST="vpn.example.com" # used when dns/auto
# REMOTE_CONNECT_IP4="203.0.113.42" # used when ip/auto with no REMOTE_HOST.
# REMOTE_CONNECT_IP6="2001:db8::42"
CONNECT_PREFER="auto" # auto|ipv4|ipv6
# --- SSH authentication -----------------------------------------------------
REMOTE_USER="youruser"
SSH_KEY_MODE="false" # true = pass explicit key; false = default chain
SSH_KEY="/home/alice/.ssh/id_ed25519" # only if SSH_KEY_MODE=true
# --- Tunnel parameters ------------------------------------------------------
TUN_INDEX="1"
TUN_DEV="tun${TUN_INDEX}"
LOCAL_IP4="10.0.1.2/24"
REMOTE_IP4="10.0.1.1"
LOCAL_IP6="2001:db8:1::2/64"
REMOTE_IP6="2001:db8:1::1"
MTU="1500"
# --- Crypto preferences -----------------------------------------
SSH_KEX="mlkem768x25519-sha256"
SSH_CIPHERS="chacha20-poly1305@openssh.com"
SSH_MACS="hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com"
# use remote server as vpn for all traffic.
# if set to false, vpn becomes transparent.
DEFAULT_ROUTE_IPV4="true"
DEFAULT_ROUTE_IPV6="true"
EOF
}
make_example_server_cfg() {
cat <<'EOF'
# ---------------------------------------------------------------------------
# pawdance
# ---------------------------------------------------------------------------
ROLE="server"
# --- Tunnel parameters ------------------------------------------------------
TUN_INDEX="1"
TUN_DEV="tun${TUN_INDEX}"
LOCAL_IP4="10.0.1.1/24"
LOCAL_IP6="2001:db8:1::1/64"
MTU="1500"
# allow clients to accsess networks on the server?
VPN_FORWARD="true" # iptables/ip6tables FORWARD rules
#keep this to true. It is required for the tunnel to work.
# this enables net.ipv4.ip_forward + net.ipv6.conf.all.forwarding
IP_FORWARD="true"
EOF
}
# -------------- Resolve remote (client) --------------
resolve_remote() {
case "$CONNECT_MODE" in
dns)
log "Resolving $REMOTE_HOST (dns)…"
REMOTE_IP4_RESOLVED=$(dig +short A "$REMOTE_HOST" | awk 'NF' | head -n1)
REMOTE_IP6_RESOLVED=$(dig +short AAAA "$REMOTE_HOST" | awk 'NF' | head -n1) ;;
ip)
REMOTE_IP4_RESOLVED="$REMOTE_CONNECT_IP4"
REMOTE_IP6_RESOLVED="$REMOTE_CONNECT_IP6" ;;
auto)
if [[ -n "$REMOTE_HOST" ]]; then
log "Resolving $REMOTE_HOST (auto)…"
REMOTE_IP4_RESOLVED=$(dig +short A "$REMOTE_HOST" | awk 'NF' | head -n1)
REMOTE_IP6_RESOLVED=$(dig +short AAAA "$REMOTE_HOST" | awk 'NF' | head -n1)
else
REMOTE_IP4_RESOLVED="$REMOTE_CONNECT_IP4"
REMOTE_IP6_RESOLVED="$REMOTE_CONNECT_IP6"
fi ;;
esac
[[ -z "$REMOTE_IP4_RESOLVED" && -z "$REMOTE_IP6_RESOLVED" ]] && err "No connect IP determined"
echo "[+] Connect IPv4: ${REMOTE_IP4_RESOLVED:-N/A}"
echo "[+] Connect IPv6: ${REMOTE_IP6_RESOLVED:-N/A}"
}
get_default_gws() {
IPV4_GW=$(ip route show default | awk '/default/ {print $3; exit}')
IFACE4=$(ip route show default | awk '/default/ {print $5; exit}')
IPV6_GW=$(ip -6 route show default | awk '/default/ {print $3; exit}')
IFACE6=$(ip -6 route show default | awk '/default/ {print $5; exit}')
}
# -------------- Cleanup (common) --------------
cleanup() {
log "Cleaning up…"
if [[ "$ROLE" == "client" ]]; then
$SUDO ip route del default via "$REMOTE_IP4" dev "$TUN_DEV" metric 1 2>/dev/null || true
$SUDO ip -6 route del default via "$REMOTE_IP6" dev "$TUN_DEV" metric 1 2>/dev/null || true
fi
$SUDO ip addr del "$LOCAL_IP4" dev "$TUN_DEV" 2>/dev/null || true
[[ -n "$LOCAL_IP6" ]] && $SUDO ip addr del "$LOCAL_IP6" dev "$TUN_DEV" 2>/dev/null || true
$SUDO ip link set "$TUN_DEV" down 2>/dev/null || true
$SUDO ip tuntap del dev "$TUN_DEV" mode tun 2>/dev/null || true
if [[ "$ROLE" == "client" ]]; then
[[ -n "$REMOTE_IP4_RESOLVED" ]] && \
$SUDO ip route del "$REMOTE_IP4_RESOLVED" via "$IPV4_GW" dev "$IFACE4" 2>/dev/null || true
[[ -n "$REMOTE_IP6_RESOLVED" ]] && \
$SUDO ip -6 route del "$REMOTE_IP6_RESOLVED" via "$IPV6_GW" dev "$IFACE6" 2>/dev/null || true
fi
if [[ "$ROLE" == "server" && "$VPN_FORWARD" == "true" && "$IPT_RULES_INSTALLED" == "true" ]]; then
$SUDO iptables -D FORWARD -i "$TUN_DEV" -m conntrack --ctstate NEW,RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
$SUDO iptables -D FORWARD -o "$TUN_DEV" -m conntrack --ctstate NEW,RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
$SUDO ip6tables -D FORWARD -i "$TUN_DEV" -m conntrack --ctstate NEW,RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
$SUDO ip6tables -D FORWARD -o "$TUN_DEV" -m conntrack --ctstate NEW,RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true
fi
ok "Done."
exit 0
}
trap cleanup INT TERM
# -------------- Core logic --------------
client_up() {
require_cmd ip ssh dig
resolve_remote
get_default_gws
log "Creating $TUN_DEV"
$SUDO ip tuntap add dev "$TUN_DEV" mode tun
$SUDO ip addr add "$LOCAL_IP4" dev "$TUN_DEV"
[[ -n "$LOCAL_IP6" ]] && $SUDO ip -6 addr add "$LOCAL_IP6" dev "$TUN_DEV"
$SUDO ip link set "$TUN_DEV" mtu "$MTU" up
log "Adding passthrough routes…"
[[ -n "$REMOTE_IP4_RESOLVED" && -n "$IPV4_GW" ]] && \
$SUDO ip route add "$REMOTE_IP4_RESOLVED" via "$IPV4_GW" dev "$IFACE4"
[[ -n "$REMOTE_IP6_RESOLVED" && -n "$IPV6_GW" ]] && \
$SUDO ip -6 route add "$REMOTE_IP6_RESOLVED" via "$IPV6_GW" dev "$IFACE6"
log "Setting default routes (metric 1)…"
[[ "$DEFAULT_ROUTE_IPV4" == "true" && -n "$REMOTE_IP4" ]] && \
$SUDO ip route add default via "$REMOTE_IP4" dev "$TUN_DEV" metric 1
[[ "$DEFAULT_ROUTE_IPV6" == "true" && -n "$REMOTE_IP6" ]] && \
$SUDO ip -6 route add default via "$REMOTE_IP6" dev "$TUN_DEV" metric 1
# Build SSH options
SSH_OPTS="-oKexAlgorithms=$SSH_KEX -oCiphers=$SSH_CIPHERS -oMACs=$SSH_MACS"
[[ "$SSH_KEY_MODE" == "true" ]] && SSH_OPTS="$SSH_OPTS -i $SSH_KEY"
# Choose IP to dial
case "$CONNECT_PREFER" in
ipv4) dial_ip="$REMOTE_IP4_RESOLVED" ;;
ipv6) dial_ip="$REMOTE_IP6_RESOLVED" ;;
auto)
dial_ip="${REMOTE_IP4_RESOLVED:-$REMOTE_IP6_RESOLVED}"
[[ -z "$dial_ip" ]] && dial_ip="$REMOTE_IP6_RESOLVED" ;;
*) err "CONNECT_PREFER must be auto|ipv4|ipv6" ;;
esac
log "Starting SSH tunnel to [$dial_ip] as $REMOTE_USER (tun$TUN_INDEX)…"
$SUDO ssh -T -w "$TUN_INDEX:$TUN_INDEX" $SSH_OPTS "$REMOTE_USER@$dial_ip"
ok "SSH exited. Cleaning up…"
cleanup
}
server_up() {
require_cmd ip
[[ "$VPN_FORWARD" == "true" ]] && require_cmd iptables ip6tables
log "Creating $TUN_DEV (server)…"
$SUDO ip tuntap add dev "$TUN_DEV" mode tun
$SUDO ip addr add "$LOCAL_IP4" dev "$TUN_DEV"
[[ -n "$LOCAL_IP6" ]] && $SUDO ip -6 addr add "$LOCAL_IP6" dev "$TUN_DEV"
$SUDO ip link set "$TUN_DEV" mtu "$MTU" up
if [[ "$IP_FORWARD" == "true" ]]; then
log "Enabling kernel IP forwarding…"
$SUDO sysctl -w net.ipv4.ip_forward=1
$SUDO sysctl -w net.ipv6.conf.all.forwarding=1
fi
if [[ "$VPN_FORWARD" == "true" ]]; then
log "Installing FORWARD rules for $TUN_DEV"
$SUDO iptables -A FORWARD -i "$TUN_DEV" -m conntrack --ctstate NEW,RELATED,ESTABLISHED -j ACCEPT
$SUDO iptables -A FORWARD -o "$TUN_DEV" -m conntrack --ctstate NEW,RELATED,ESTABLISHED -j ACCEPT
$SUDO ip6tables -A FORWARD -i "$TUN_DEV" -m conntrack --ctstate NEW,RELATED,ESTABLISHED -j ACCEPT
$SUDO ip6tables -A FORWARD -o "$TUN_DEV" -m conntrack --ctstate NEW,RELATED,ESTABLISHED -j ACCEPT
IPT_RULES_INSTALLED=true
fi
ok "Server tunnel up. Ctrl+C (or 'pawdance down') to clean up."
while true; do sleep 3600; done
}
# -------------- CLI --------------
usage() {
cat <<EOF
$SCRIPT_NAME $VERSION vpn wrapper over SSH
Subcommands:
up --config <file> Bring tunnel up (client or server, per ROLE).
down --config <file> Tear tunnel down (runs cleanup logic).
make-config --role client|server -o <file>
Generate commented example config to <file>.
help Show this help.
Example:
$SCRIPT_NAME make-config --role client -o ~/pawdance-client.conf
$SCRIPT_NAME up --config ~/pawdance-client.conf
EOF
}
[[ $# -lt 1 ]] && usage && exit 1
SUBCMD="$1"; shift
MAKECFG_ROLE="client"
while [[ $# -gt 0 ]]; do
case "$1" in
--config) CONFIG_FILE="$2"; shift 2 ;;
--role) MAKECFG_ROLE="$2"; shift 2 ;;
-o) MAKECFG_OUT="$2"; shift 2 ;;
-h|--help|help) usage; exit 0 ;;
*) shift ;;
esac
done
case "$SUBCMD" in
up)
load_config
[[ "$ROLE" == "server" ]] && server_up || client_up
;;
down)
load_config
cleanup
;;
make-config)
[[ -z "$MAKECFG_OUT" ]] && err "-o <file> required"
[[ "${MAKECFG_ROLE,,}" == "server" ]] && make_example_server_cfg >"$MAKECFG_OUT" \
|| make_example_client_cfg >"$MAKECFG_OUT"
ok "Config written to $MAKECFG_OUT"
;;
help|-h|--help) usage ;;
*) err "Unknown subcommand: $SUBCMD" ;;
esac