SSH Reverse Tunnel Manager

Updated April 22, 2025 7 minutes

To gain remote access to a machine behind a NAT, you have quite a few options. Some of the better ones include:

  1. Open a static port at the router.

  2. Use a VPN.

Both of these top options require some preplanning. Sometimes I want a method that is considerably more temporary or ad-hoc, so heres another option:

  1. Initiate a reverse tunnel to any old SSH host and expose a public port via the GatewayPorts option.

I like this third option for several reasons:

  • It doesn’t require you to open any static ports on the NAT router (which you may not even have access to).
  • You probably already have some little VPS on the internet someplace that is running SSH.
  • No other software on the public host is required.
  • It’s out of band of your primary VPN, so if you need to do remote maintaince on the VPN connection itself, you can still use this as a backdoor to get into your machines.

Autossh is a nice tool to automatically maintain SSH tunnels, restarting them automatically if they die. The following script sets up a systemd service that runs autossh to maintain a reverse tunnel to your public SSH server and expose a configured local port to the public internet.

Dependencies

  • An SSH server running on some public VPS somewhere.
  • On the public VPS, install your local user’s SSH pubkey in the ~root/.ssh/authorized_keys file.
  • On your local machine, setup ~/.ssh/config with an entry for the remote host:
# Example host in ~/.ssh/config

Host sentry
     Hostname sentry.example.com
     User root
     Port 22
  • On the public VPS, make sure that the port you wish to expose (e.g. 2222) is not blocked by any firewall.

  • On your local machine, if you wish to create persistent tunnels that start on system boot, you must enable systemd lingering for your user account:

sudo loginctl enable-linger ${USER}
  • On your local machine, install autossh from your package manager.
  • On your local machine, download the script:
wget https://raw.githubusercontent.com/EnigmaCurry/blog.rymcg.tech/master/src/ssh/ssh_expose.sh
chmod +x ssh_expose.sh

Usage

## Usage: ssh_expose.sh <subcommand> [options]

Subcommands:
  port           Expose a local port to a remote SSH server
  sshd-config    Reconfigure a remote sshd server config
  list           List active tunnels

Port usage:
  ssh_expose.sh port [--persistent|--close] HOST PUBLIC_PORT LOCAL_PORT
  ssh_expose.sh port HOST PUBLIC_PORT LOCAL_PORT [--persistent|--close]
  ssh_expose.sh port --close-all

Examples (HOST=sentry):
  ssh_expose.sh sshd-config sentry GatewayPorts=yes AllowTcpForwarding=yes

  ssh_expose.sh port sentry 8888 8000
  ssh_expose.sh port --persistent sentry 8888 8000
  ssh_expose.sh port sentry 8888 8000 --close
  ssh_expose.sh port --close-all

  ssh_expose.sh list

Example

All of the following commands are to be run from your local machine.

To setup the remote SSH config, use the script to enable GatewayPorts and AllowTcpForwarding (this will automatically edit the remote host’s /etc/ssh/sshd_config file and restart the service):

./ssh_expose.sh sshd-config sentry GatewayPorts=yes AllowTcpForwarding=yes

To create a temporary reverse tunnel from the VPS (sentry) port 2222 to localhost:22:

./ssh_expose.sh port sentry 2222 22

To make the tunnel survive a reboot, add --persistent:

./ssh_expose.sh port sentry 2222 22 --persistent

To close the tunnel (permanently):

./ssh_expose.sh port sentry 2222 22 --close

Or to close all tunnels (permanently):

./ssh_expose.sh port --close-all

List all active tunnels:

./ssh_expose.sh list

The script

#!/bin/bash

######################################################
#             SSH Reverse Tunnel Manager             #
#   https://blog.rymcg.tech/blog/linux/ssh_expose    #
######################################################

stderr(){ echo "$@" >/dev/stderr; }
error(){ stderr "Error: $@"; }
fault(){ test -n "$1" && error "$1"; stderr "Exiting."; exit 1; }
print_array(){ printf '%s\n' "$@"; }
check_var() {
    local missing=()
    for varname in "$@"; do
        if [[ -z "${!varname}" ]]; then
            missing+=("$varname")
        fi
    done
    if [[ ${#missing[@]} -gt 0 ]]; then
        echo ""
        __help
        echo ""
        echo "## Error: Missing:"
        for var in "${missing[@]}"; do
            echo "   - $var"
        done
        echo ""
        exit 1
    fi
}
check_num(){
    local var=$1
    check_var var
    if ! [[ ${!var} =~ ^[0-9]+$ ]]; then
        fault "${var} is not a number: '${!var}'"
    fi
}
debug_var(){ local var=$1; check_var var; stderr "## DEBUG: ${var}=${!var}"; }
check_deps(){
    missing=""
    for var in "$@"; do
        if ! command -v "$var" >/dev/null 2>&1; then
            missing="${missing} ${var}"
        fi
    done
    if [[ -n "$missing" ]]; then fault "Missing dependencies:${missing}"; fi
}

__print_active_tunnels() {
    tunnels=($(systemctl --user list-units --all --no-legend --no-pager --plain --state=active | awk '/^reverse-tunnel-.*(\.scope|\.service)/{print $1}'))
    if [ ${#tunnels[@]} -eq 0 ]; then
        echo "## No active tunnels."
    else
        parsed=()
        for tunnel in "${tunnels[@]}"; do
            name="${tunnel#reverse-tunnel-}"
            name="${name%.scope}"
            name="${name%.service}"
            host=$(cut -d'-' -f1 <<< "$name")
            public_port=$(cut -d'-' -f2 <<< "$name")
            private_port=$(cut -d'-' -f3 <<< "$name")
            type="ephemeral"
            [[ "$tunnel" == *.service ]] && type="persistent"
            parsed+=("$host $public_port $private_port $type")
        done

        printf "\n\e[1m%-15s %-12s %-12s %-12s\e[0m\n" "HOST" "PUBLIC_PORT" "LOCAL_PORT" "TYPE"
        printf "\e[1m%-15s %-12s %-12s %-12s\e[0m\n" "----" "-----------" "------------" "----"
        printf "%s\n" "${parsed[@]}" | sort -k1,1 -k2,2n | while read -r host public private type; do
            printf "%-15s %-12s %-12s %-12s\n" "$host" "$public" "$private" "$type"
        done
    fi
}

__create_persistent_tunnel() {
    local host=$1 public_port=$2 local_port=$3
    UNIT="reverse-tunnel-${host}-${public_port}-${local_port}"
    mkdir -p "${HOME}/.config/systemd/user"
    SERVICE_FILE="${HOME}/.config/systemd/user/${UNIT}.service"

    cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=Reverse SSH Tunnel for ${host} (Public: ${public_port}, Local: ${local_port})
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/bin/autossh -o StrictHostKeyChecking=accept-new -o ControlMaster=no -o ControlPersist=no -o ControlPath=none -N -M 0 -R 0.0.0.0:${public_port}:0.0.0.0:${local_port} ${host}
Restart=always
RestartSec=10
StartLimitBurst=100
StartLimitIntervalSec=300

[Install]
WantedBy=default.target
EOF

    systemctl --user daemon-reload
    systemctl --user enable "${UNIT}.service"
    systemctl --user start "${UNIT}.service"
    systemctl --user status "${UNIT}.service" --no-pager
    echo
    echo "Persistent tunnel created and started."

    __print_active_tunnels

    if [ ! -f "/var/lib/systemd/linger/$USER" ]; then
        echo -e "\nWARNING: Systemd linger not enabled for $USER."
        echo "Run: sudo loginctl enable-linger ${USER}"
    fi
}

__one_shot_tunnel() {
    local host=$1 public_port=$2 local_port=$3
    check_var host public_port local_port
    UNIT="reverse-tunnel-${host}-${public_port}-${local_port}"
    systemd-run \
        --user \
        --unit="$UNIT" \
        --scope autossh \
        -o StrictHostKeyChecking=accept-new \
        -o ControlMaster=no \
        -o ControlPersist=no \
        -o ControlPath=none \
        -N -M 0 \
        -R 0.0.0.0:"${public_port}":0.0.0.0:"${local_port}" \
        "${host}" &
    echo "## Reverse tunnel started."
    #debug_var public_port
    #debug_var local_port
    sleep 2
    echo
}

__close_all_tunnels() {
    tunnels=($(systemctl --user list-units --all --no-legend --no-pager --plain --state=active | awk '/^reverse-tunnel-.*(\.scope|\.service)/{print $1}'))
    if [ ${#tunnels[@]} -eq 0 ]; then
        echo "## No active tunnels to close."
    else
        for tunnel in "${tunnels[@]}"; do
            systemctl --user stop "$tunnel"
            [[ "$tunnel" == *.service ]] && systemctl --user disable "$tunnel" && rm -f "${HOME}/.config/systemd/user/$tunnel"
        done
        systemctl --user daemon-reload
        echo "## All tunnels closed."
    fi
    __print_active_tunnels
}

__close_tunnel() {
    local host=$1 public_port=$2 local_port=$3
    UNIT="reverse-tunnel-${host}-${public_port}-${local_port}"
    if systemctl --user is-active --quiet "${UNIT}.scope"; then
        systemctl --user stop "${UNIT}.scope" && echo "Ephemeral tunnel closed."
    elif systemctl --user is-active --quiet "${UNIT}.service"; then
        systemctl --user stop "${UNIT}.service" && systemctl --user disable "${UNIT}.service"
        rm -f "${HOME}/.config/systemd/user/${UNIT}.service" && echo "Persistent tunnel closed."
    else
        echo "No tunnel found: ${UNIT}."
    fi
    __print_active_tunnels
}

__reconfigure_sshd() {
    if [[ $# -lt 2 ]]; then
        __help
        exit 1
    fi
    local HOST=$1
    shift
    ssh "$HOST" "sudo whoami" 2>/dev/null | grep -q '^root$' || fault "Cannot run sudo on remote host ${HOST}"

    TMP_FILE=$(ssh "$HOST" "mktemp /tmp/sshd_config.XXXXXX")
    [[ -z "$TMP_FILE" ]] && fault "Failed to create remote temp file."

    echo "Created temporary file ${TMP_FILE} on ${HOST}"

    ssh "$HOST" "sudo cp /etc/ssh/sshd_config $TMP_FILE"

    for CONFIG in "$@"; do
        KEY=$(cut -d= -f1 <<< "$CONFIG")
        VALUE=$(cut -d= -f2 <<< "$CONFIG")
        ssh "$HOST" "sudo sed -i '/^#${KEY}/d; /^${KEY}/d' $TMP_FILE && echo '${KEY} ${VALUE}' | sudo tee -a $TMP_FILE"
    done

    if ssh "$HOST" "sudo sshd -t -f $TMP_FILE"; then
        echo "Configuration valid, applying..."
        ssh "$HOST" "sudo mv $TMP_FILE /etc/ssh/sshd_config && sudo systemctl restart sshd"
    else
        echo "Invalid config, cancelling."
        ssh "$HOST" "sudo rm -f $TMP_FILE"
    fi
}

__subcommand_port() {
    local persistent="" close="" host="" public_port="" local_port=""
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --persistent) persistent="yes"; shift ;;
            --close) close="yes"; shift ;;
            --close-all) __close_all_tunnels; exit 0 ;;
            -*)
                fault "Unknown option: $1"
                ;;
            *)
                if [[ -z "$host" ]]; then host="$1"
                elif [[ -z "$public_port" ]]; then public_port="$1"
                elif [[ -z "$local_port" ]]; then local_port="$1"
                else fault "Too many positional arguments."
                fi
                shift
                ;;
        esac
    done

    check_var host public_port local_port
    check_num public_port
    check_num local_port
    check_deps autossh

    if [[ "$close" == "yes" ]]; then
        __close_tunnel "$host" "$public_port" "$local_port"
    else
        if [[ "$persistent" == "yes" ]]; then
            __create_persistent_tunnel "$host" "$public_port" "$local_port"
        else
            __one_shot_tunnel "$host" "$public_port" "$local_port"
            __print_active_tunnels
        fi
    fi
}

__subcommand_sshd_config() {
    __reconfigure_sshd "$@"
}

main() {
    if [[ $# -lt 1 ]]; then
        __help
        exit 1
    fi

    local subcommand="$1"
    shift

    case "$subcommand" in
        port) __subcommand_port "$@" ;;
        sshd-config) __subcommand_sshd_config "$@" ;;
        list) __print_active_tunnels ;;
        *) error "Unknown subcommand: $subcommand"; __help; exit 1 ;;
    esac
}

__help() {
    SCRIPT=$(basename $0)
    echo "## Usage: $SCRIPT <subcommand> [options]"
    echo ""
    echo "Subcommands:"
    echo "  port           Expose a local port to a remote SSH server"
    echo "  sshd-config    Reconfigure a remote sshd server config"
    echo "  list           List active tunnels"
    echo ""
    echo "Port usage:"
    echo "  $SCRIPT port [--persistent|--close] HOST PUBLIC_PORT LOCAL_PORT"
    echo "  $SCRIPT port HOST PUBLIC_PORT LOCAL_PORT [--persistent|--close]"
    echo "  $SCRIPT port --close-all"
    echo ""
    echo "Examples (HOST=sentry):"
    echo "  $SCRIPT sshd-config sentry GatewayPorts=yes AllowTcpForwarding=yes"
    echo ""
    echo "  $SCRIPT port sentry 8888 8000"
    echo "  $SCRIPT port --persistent sentry 8888 8000"
    echo "  $SCRIPT port sentry 8888 8000 --close"
    echo "  $SCRIPT port --close-all"
    echo ""
    echo "  $SCRIPT list"
    echo ""
    __print_active_tunnels
}

main "$@"


You can discuss this blog on Matrix (Element): #blog-rymcg-tech:enigmacurry.com

This blog is copyright EnigmaCurry and dual-licensed CC-BY-SA and MIT. The source is on github: enigmacurry/blog.rymcg.tech and PRs are welcome. ❤️