357 lines
12 KiB
Bash
357 lines
12 KiB
Bash
#!/usr/bin/env bash
|
||
# pawdance.sh – EXACT client *and* server logic driven by an easy‑to‑edit config file
|
||
# -----------------------------------------------------------------------------
|
||
# Modes (ROLE in config):
|
||
# client – brings up a point‑to‑point tunnel by SSH‑w’ing 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 hard‑coded 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}"
|
||
|
||
# Private‑key 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
|
||
|
||
Sub‑commands:
|
||
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
|