diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0d8de4c --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +/Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e61ecd5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM alpine:3.9 + +COPY ./ /rn + +RUN apk add --update bash autossh openssh-client netcat-openbsd grep \ + && rm -rf /var/cache/apk/* \ + && mkdir -p /home/revproxy \ + && addgroup -g 1005 revproxy \ + && adduser -D -u 1005 -h /home/revproxy -G revproxy revproxy \ + && chown -R revproxy:revproxy /home/revproxy + +VOLUME "/rn/conf.d" +VOLUME "/home/revproxy/.ssh" +WORKDIR "/rn" + +ENTRYPOINT ["/rn/docker-entrypoint.sh"] +CMD ["/rn/bin/bind-network.sh --healthcheck-loop"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4cab999 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +SUDO=sudo + +all: build build_arm + +build: + ${SUDO} docker build . -t wolnosciowiec/reverse-networking + +build_arm: + ${SUDO} docker build -f ./armhf.Dockerfile . -t wolnosciowiec/reverse-networking:armhf diff --git a/README.md b/README.md index fcbf3fc..1f92112 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ # reverse-networking -Network setup automation scripts written in Bash, based on reverse proxy idea. -Allows to create multiple reverse tunnels from inside of NAT to the external server. +Network setup automation scripts written in Bash. +Allows to create multiple tunnels from inside of NAT to the external server. + +Works in two cases: +- #1: Can expose a NAT hidden service to the external server (or to the internet via external server) +- #2: Can encrypt a connection with external server by adding SSH layer (eg. MySQL replication with external server with SSH encryption layer) ![example structure](./docs/Reverse%20networking%20infrastructure.png "Reverse networking structure") ## Requirements -Those packages needs to be installed: +Those very basic packages needs to be installed: - bash - autossh - ssh (client) @@ -14,6 +18,9 @@ Those packages needs to be installed: - grep - nc +Works with GNU utils as well as with Busybox. +Tested on Arch Linux, Debian and Alpine Linux. + *The remote server needs to support public-key authorization method.* ## Setup @@ -26,7 +33,7 @@ Those packages needs to be installed: 3. File must be in a proper syntax and implement proper configuration variables described as an example in the "config-example.sh.md" ``` - + Send public key to all servers described in your configuration so the communication could be without a password using a ssh key. @@ -41,6 +48,66 @@ Your local services should be exposed to the remote server and be visible on eg. http://localhost:1234, so you need an internal proxy or a load balancer like nginx to forward the traffic to the internet. +## Docker + +Use images `wolnosciowiec/reverse-networking` and `wolnosciowiec/reverse-networking:armhf` to run container with reverse-networking installed. + +## Example configurations + + +##### Expose MySQL from docker container + +How to connect between two separate docker networks using SSH, and access a hidden MySQL server. + +```gherkin +Given we have a HOST_1 with SSH container + MySQL container +And we have a client HOST_2 +When we want to access MySQL:3306 from HOST_2 +Then we make a tunnel from HOST_2 to HOST_1 SSH container that exposes db_mysql:3306 +And we make it available as a localhost:3307 at HOST_2 +``` + +```bash +PN_USER=revproxy # HOST_1 user in SSH +PN_PORT=9800 # HOST_1 port +PN_HOST=192.168.0.114 # HOST_1 host +PN_VALIDATE=none +PN_TYPE=local # connection type - we access remote resource, not exposing self to remote +PN_SSH_OPTS= # optional SSH options +PORTS[0]="3307>3306>db_mysql" # HOST_1 container name +#PORTS[1]="3307>3306>db_mysql>@gateway" # expose on HOST_2 gateway interface (visible from internet) +``` + +##### Expose ports to external server + +Expose health check endpoints of a machine hidden behind NAT/firewall to an external machine via SSH. + +```gherkin +Given we have a HOST_1 that is a VPS with public IP address and SSH server +And we have a HOST_2 that is behind NAT +When we want to access /healthcheck endpoint placed at HOST_2 from internet we call http://some-subdomain.HOST_1/healthcheck +Then we make a tunnel from HOST_2 to HOST_1 exposing a HTTP webserver from HOST_2 to HOST_1:8000 +``` + +```bash +PN_USER=some_host_1_user +PN_PORT=22 +PN_HOST=host_1.org +PN_VALIDATE=local +PN_TYPE=reverse +PN_SSH_OPTS= + +# optional: +#PN_VALIDATE_COMMAND="curl http://mydomain.org" # custom validation command that will be ran locally or remotely + +# destination port on remote server => local port, will be available as localhost:8000 on HOST_1 +PORTS[0]="80>8000" + +# requires GatewayPorts in SSH to be enabled, can be insecure, will be available at PUBLIC_IP_ADDRESS:8001 +# easier option to configure, does not require a webserver to expose local port to the internet +#PORTS[1]="80>8001>@gateway" # port will be available publicly +``` + #### Monitoring There is a tool in `./bin/monitor.sh` that verifies all tunnels by doing a ping @@ -61,4 +128,5 @@ Use `PN_VALIDATE_COMMAND` for custom validation executed locally or remotely if Examples: - PN_VALIDATE_COMMAND="/bin/true" # for testing purposes, try it yourself - PN_VALIDATE_COMMAND="/bin/false" # for testing -- PN_VALIDATE_COMMAND="curl http://your-domain.org:8002" \ No newline at end of file +- PN_VALIDATE_COMMAND="curl http://your-domain.org:8002" +- PN_VALIDATE_COMMAND="wget -O - -T 2 http://172.28.0.6:3307 2>&1|grep mariadb" diff --git a/armhf.Dockerfile b/armhf.Dockerfile new file mode 100644 index 0000000..ce425dd --- /dev/null +++ b/armhf.Dockerfile @@ -0,0 +1,19 @@ +FROM balenalib/armv7hf-alpine:3.9 + +COPY ./ /rn + +RUN [ "cross-build-start" ] +RUN apk add --update bash autossh openssh-client netcat-openbsd grep \ + && rm -rf /var/cache/apk/* \ + && mkdir -p /home/revproxy \ + && addgroup -g 1005 revproxy \ + && adduser -D -u 1005 -h /home/revproxy -G revproxy revproxy \ + && chown -R revproxy:revproxy /home/revproxy +RUN [ "cross-build-end" ] + +VOLUME "/rn/conf.d" +VOLUME "/home/revproxy/.ssh" +WORKDIR "/rn" + +ENTRYPOINT ["/rn/docker-entrypoint.sh"] +CMD ["/rn/bin/bind-network.sh --healthcheck-loop"] diff --git a/bin/add-to-known-hosts.sh b/bin/add-to-known-hosts.sh new file mode 100755 index 0000000..4a1a482 --- /dev/null +++ b/bin/add-to-known-hosts.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +#-------------------------------------------- +# Kill all previously opened ssh sessions +# +# @author RiotKit Team +# @see riotkit.org +#-------------------------------------------- + +cd "$( dirname "${BASH_SOURCE[0]}" )" +source include/functions.sh + +KNOWN_HOSTS_FILE=~/.ssh/known_hosts + +# +# Iterate over each host and fetch it's fingerprint +# +# @framework method +# +executeIterationAction() { + config_file_name=$1 + + if contains_fingerprint ${PN_HOST} ${PN_PORT}; then + echo " .. Fingerprint already present" + return 0 + fi + + echo " .. Fetching a fingerprint for ${PN_HOST}:${PN_PORT}" + ssh-keyscan -p "${PN_PORT}" "${PN_HOST}" >> ${KNOWN_HOSTS_FILE} +} + +# +# $1 - hostname +# $2 - port +# +contains_fingerprint () { + content=$(cat ${KNOWN_HOSTS_FILE}) + host_name=${1} + + # non-standard port is differently formatted + if [[ "${2}" != "22" ]]; then + host_name="[${1}]:${2}" + fi + + if [[ "${content}" == *"${host_name} ssh-"* ]] \ + || [[ "${content}" == *"${host_name} ecdsa"* ]] \ + || [[ "${content}" == *"${host_name},"* ]]; then + return 0 + fi + + return 1 +} + +echo " >> Fetching hosts fingerprint first time" +cat ${KNOWN_HOSTS_FILE} +iterateOverConfiguration diff --git a/bin/bind-network.sh b/bin/bind-network.sh index b89cc40..8757414 100755 --- a/bin/bind-network.sh +++ b/bin/bind-network.sh @@ -4,48 +4,41 @@ # Bind network ports to the remote server # using a reverse proxy strategy # -# @author Wolnoƛciowiec Team -# @see https://wolnosciowiec.net +# @author RiotKit Team +# @see riotkit.org #-------------------------------------------- cd "$( dirname "${BASH_SOURCE[0]}" )" source include/functions.sh DIR=$(pwd) -./kill-previous-sessions.sh - -for config_file_name in ../conf.d/*.sh -do - echo " >> Reading $config_file_name" - source "$config_file_name" - - for forward_ports in ${PORTS[*]} - do - IFS='>' read -r -a parts <<< "$forward_ports" - source_port=${parts[0]} - dest_port=${parts[1]} - dest_host="" - - if [[ "${parts[2]}" ]]; then - dest_host="${parts[2]}:" - - if [[ "${dest_host}" == "@gateway:" ]]; then - dest_host="$(getHostIpAddress $PN_HOST):" - fi - fi - - echo " --> Forwarding ${dest_host}${source_port}:${PN_HOST}:${dest_port}" - autossh -M 0 -N -f -o "PubkeyAuthentication=yes" -o "PasswordAuthentication=no" -R "${dest_host}${source_port}:localhost:${dest_port}" "${PN_USER}@${PN_HOST}" -p ${PN_PORT} - - if [[ $? != 0 ]]; then - echo " ~ The port forwarding failed, please verify if your SSH keys are well installed" - exit 1 - fi - done -done - -if [[ $1 == "--loop" ]]; then - while true; do - sleep 10 - done -fi +# +# @framework method +# +executeIterationAction () { + setupTunnelsForHost "${PN_USER}" "${PN_HOST}" "${PN_PORT}" "${PN_TYPE}" "${PORTS}" +} + +main () { + ./kill-previous-sessions.sh + iterateOverConfiguration + + if [[ $1 == "--loop" ]]; then + echo ' >> Running in a loop' + + while true; do + sleep 10 + done + fi + + if [[ $1 == "--healthcheck-loop" ]]; then + echo " >> Running a healthcheck loop (SLEEP_TIME=${LOOP_SLEEP_TIME})" + + while true; do + sleep ${LOOP_SLEEP_TIME:-5} + $(dirname "${BASH_SOURCE[0]}")/../bin/monitor.sh + done + fi +} + +main "$@" diff --git a/bin/include/functions.sh b/bin/include/functions.sh index cf86076..c3bbed2 100644 --- a/bin/include/functions.sh +++ b/bin/include/functions.sh @@ -1,14 +1,22 @@ -function resetIteration() { + +# +# Common methods +# Naming convention: Common methods and the framework methods are camelCase, the rest is snake_case +# + +resetIteration() { PN_USER="" PN_PORT="" PN_HOST="" + PN_TYPE=remote + PN_SSH_OPTS= PN_VALIDATE="" PN_VALIDATE_COMMAND="" PORTS=() } -function iterateOverConfiguration() { +iterateOverConfiguration() { cd "$( dirname "${BASH_SOURCE[0]}" )" DIR=$(pwd) @@ -20,13 +28,44 @@ function iterateOverConfiguration() { done } -function parsePortForwarding() { - IFS='>' read -r -a parts <<< "$1" - source_port=${parts[0]} - dest_port=${parts[1]} +# +# @param $1 port_definition +# @param $2 PN_HOST +# +parsePortForwarding() { + port_definition=$1 + PN_HOST=$2 + + IFS='>' read -r -a parts <<< "${port_definition}" + local_port=${parts[0]} + remote_port=${parts[1]} + remote_port_host="localhost:" + local_gateway_host="" + + # + # gateway at remote means public server IP address + # + if [[ "${parts[2]}" ]]; then + remote_port_host="${parts[2]}:" + + if [[ "${remote_port_host}" == "@gateway:" ]]; then + remote_port_host="$(getHostIpAddress $PN_HOST):" + fi + fi + + # + # local gateway means our public IP address + # + if [[ ${parts[3]} ]]; then + local_gateway_host="${parts[3]}:" + + if [[ "${local_gateway_host}" == "@gateway:" ]]; then + local_gateway_host="$(getSelfIpAddress):" + fi + fi } -function executeHooks() { +executeHooks() { for hook_name in $(ls ../../hooks.d/$1.d |grep .sh) do file_name=$(basename "$hook_name") @@ -44,6 +83,121 @@ function executeHooks() { # # @param $1 host name # -function getHostIpAddress() { +getHostIpAddress() { getent hosts $1 | awk '{ print $1 }' } + +getSelfIpAddress () { + if [[ -f /.dockerenv ]]; then + awk 'END{print $1}' /etc/hosts + return 0 + fi + + # get IP address of a network, that is a default gateway + ip route| grep $(ip route |grep default | awk '{ print $5 }') | grep -v "default" | awk '/scope/ { print $9 }' + return 0 +} + +# +# Creates arguments for the SSH forwarding +# Examples: +# Gateway: "10.50.30.40:3307:db_mysql:3306" +# To localhost: "3307:db_mysql:3306" +# From remote localhost to local localhost: "3307:localhost:3306" +# +# @param $1 port_definition +# @param $2 PN_HOST +# +createForwarding() { + parsePortForwarding "${1}" "${2}" + + echo "${local_gateway_host}${local_port}:${remote_port_host}${remote_port}" + return 0 +} + +# +# Factory method + spawner, basing on the arguments creates a proper command +# +spawnForwarding () { + port_definition=$1 + PN_USER=$2 + PN_HOST=$3 + PN_PORT=$4 + PN_TYPE=$5 + + forwarding=$(createForwarding "${port_definition}" "${PN_HOST}") + + if [[ ${PN_TYPE} == "local" ]]; then + echo " --> Forwarding locally ${forwarding}" + spawnAutossh ${PN_SSH_OPTS} -L "${forwarding}" "${PN_USER}@${PN_HOST}" -p ${PN_PORT} + + return $? + fi + + echo " --> Forwarding ${forwarding}" + spawnAutossh ${PN_SSH_OPTS} -R "${forwarding}" "${PN_USER}@${PN_HOST}" -p ${PN_PORT} + + return $? +} + +# +# Spawns a SSH tunnel using autossh +# +spawnAutossh () { + echo " --> Spawning SSH" + autossh -M 0 -N -f -o 'PubkeyAuthentication=yes' -o 'PasswordAuthentication=no' -nNT "$@" + echo " --> SSH should be spawned" + + return $? +} + +setupTunnelsForHost () { + local ssh_user=${1} + local ssh_host=${2} + local ssh_port=${3} + local type=${4} + local ports=${PORTS} + + for forward_ports in ${ports[*]} + do + spawnForwarding "${forward_ports}" "${ssh_user}" "${ssh_host}" "${ssh_port}" "${type}" + + if [[ $? != 0 ]]; then + echo " ~ The port forwarding failed, please verify if your SSH keys are well installed" + exit 1 + fi + done +} + +killAllTunnelsForHost () { + local ports=${1} + local host=${2} + + for forward_ports in ${ports[*]} + do + forwarding=$(createForwarding "${forward_ports}" "${PN_HOST}") + pid=$(ps ax -o pid,comm,args|grep autossh|grep "${forwarding}"|grep -v "grep"|awk '{print $1}') + + if [[ $pid ]]; then + echo "Killing ${pid}" + kill "${pid}" + fi + done +} + +hasHostAtLeastOneTunnelDown () { + local ports=${1} + local host=${2} + + for forward_ports in ${ports[*]} + do + forwarding=$(createForwarding "${forward_ports}" "${PN_HOST}") + pid=$(ps ax -o pid,comm,args|grep autossh|grep "${forwarding}"|grep -v "grep"|awk '{print $1}') + + if [[ ! $pid ]]; then + return 0 + fi + done + + return 1 +} \ No newline at end of file diff --git a/bin/kill-previous-sessions.sh b/bin/kill-previous-sessions.sh index 7e34032..5ff15a3 100755 --- a/bin/kill-previous-sessions.sh +++ b/bin/kill-previous-sessions.sh @@ -3,28 +3,24 @@ #-------------------------------------------- # Kill all previously opened ssh sessions # -# @author Wolnoƛciowiec Team -# @see https://wolnosciowiec.net +# @author RiotKit Team +# @see riotkit.org #-------------------------------------------- cd "$( dirname "${BASH_SOURCE[0]}" )" +source include/functions.sh DIR=$(pwd) +FILTER=${1} for config_file_name in ../conf.d/*.sh do - source "$config_file_name" + if [[ "${FILTER}" ]] && [[ ${config_file_name} != *"${FILTER}"* ]]; then + echo " .. Skipping ${config_file_name} (filtered out)" + continue + fi - for forward_ports in ${PORTS[*]} - do - IFS='>' read -r -a parts <<< "$forward_ports" - source_port=${parts[0]} - dest_port=${parts[1]} - - pid=$(ps aux |grep ssh|grep "$source_port:localhost:$dest_port"|grep -v "grep"|awk '{print $2}') + source "$config_file_name" - if [[ $pid ]]; then - echo " >> Killing $pid" - kill -9 $pid - fi - done + echo " >> Killing all tunnels for ${config_file_name}" + killAllTunnelsForHost "${PORTS}" "${PN_HOST}" done diff --git a/bin/monitor.sh b/bin/monitor.sh index 57b2aa6..3ca710c 100755 --- a/bin/monitor.sh +++ b/bin/monitor.sh @@ -1,9 +1,16 @@ #!/bin/bash +#-------------------------------------------- +# Kill all previously opened ssh sessions +# +# @author RiotKit Team +# @see riotkit.org +#-------------------------------------------- + cd "$( dirname "${BASH_SOURCE[0]}" )" source include/functions.sh -function getPingCommand() { +function get_ping_cmd() { if [[ $PN_VALIDATE_COMMAND ]]; then echo $PN_VALIDATE_COMMAND return 0 @@ -17,23 +24,41 @@ function getPingCommand() { # Use local nc to validate the connection # (this may be faster, but do not work if destination address is not accessible from external internet) # -function performLocalValidation() { - command=$(getPingCommand $1 $2) +function perform_local_validation() { + command=$(get_ping_cmd $1 $2) + echo " .. ${command}" eval "$command" > /dev/null 2>&1 + return $? } +make_sure_tunnel_is_alive () { + echo " >> There is a problem with SSH tunnels or the application..." + + if hasHostAtLeastOneTunnelDown "${PORTS}" "${PN_HOST}"; then + echo " .. Restarting all SSH tunnels for host ${PN_HOST}, because at least one tunnel was down" + echo " .. Killing all existing tunnels" + killAllTunnelsForHost "${PORTS}" "${PN_HOST}" + + echo " .. Spawning new tunnels" + setupTunnelsForHost "${PN_USER}" "${PN_HOST}" "${PN_PORT}" "${PN_TYPE}" "${PORTS}" + echo " .. Done" + + sleep 2 + fi +} + # # Connect to remote server via ssh and perform a validation using nc # (safe way to perform validation) # -function performRemoteValidation() { +function perform_remote_validation() { # include internal forwarding - hosts=( $1 localhost ) + hosts=( ${1//\:/} localhost ) for host in ${hosts[@]}; do - command=$(getPingCommand $host $2) + command=$(get_ping_cmd $host $2) if ssh -o ConnectTimeout=30 -o PubkeyAuthentication=yes $PN_USER@$PN_HOST -p $PN_PORT "$command" > /dev/null 2>&1; then return 0 @@ -51,17 +76,30 @@ function executeIterationAction() { for forward_ports in ${PORTS[*]} do - parsePortForwarding $forward_ports - echo " >> Performing a health check for $config_file_name - $PN_HOST $dest_port" + parsePortForwarding ${forward_ports} ${PN_HOST} + echo " >> Performing a health check for ${config_file_name}" + + local_gateway_host=${local_gateway_host//\:/} + + if [[ $PN_VALIDATE == "local" ]] && ! perform_local_validation $PN_HOST $remote_port; then + echo " ~ $PN_HOST $remote_port IS DOWN" + make_sure_tunnel_is_alive ${config_file_name} + executeHooks "monitor-down" - if [[ $PN_VALIDATE == "local" ]] && ! performLocalValidation $PN_HOST $dest_port; then - echo " ~ $PN_HOST $dest_port IS DOWN" + continue + + elif [[ $PN_VALIDATE == "local-port" ]] && ! perform_local_validation ${local_gateway_host} $local_port; then + echo " ~ ${local_gateway_host} $local_port IS DOWN" + make_sure_tunnel_is_alive ${config_file_name} executeHooks "monitor-down" + continue - elif [[ $PN_VALIDATE == "ssh" ]] && ! performRemoteValidation $PN_HOST $dest_port; then - echo " ~ $PN_HOST $dest_port IS DOWN" + elif [[ $PN_VALIDATE == "ssh" ]] && ! perform_remote_validation $PN_HOST $remote_port; then + echo " ~ $PN_HOST $remote_port IS DOWN" + make_sure_tunnel_is_alive ${config_file_name} executeHooks "monitor-down" + continue elif [[ ! $PN_VALIDATE ]]; then @@ -69,7 +107,7 @@ function executeIterationAction() { continue fi - echo " ~ $PN_HOST $dest_port is up" + echo " ~ ${config_file_name} is up" executeHooks "monitor-up" echo "" done diff --git a/bin/send-public-key.sh b/bin/send-public-key.sh index c7b8b32..208f280 100755 --- a/bin/send-public-key.sh +++ b/bin/send-public-key.sh @@ -1,5 +1,13 @@ #!/bin/bash +#-------------------------------------------- +# Kill all previously opened ssh sessions +# +# @author RiotKit Team +# @see riotkit.org +#-------------------------------------------- + + cd "$( dirname "${BASH_SOURCE[0]}" )" DIR=$(pwd) diff --git a/conf.d/config-example.sh.md b/conf.d/config-example.sh.md deleted file mode 100644 index 7b286e2..0000000 --- a/conf.d/config-example.sh.md +++ /dev/null @@ -1,14 +0,0 @@ -``` -PN_USER=xxx -PN_PORT=22 -PN_HOST=mydomain.org -PN_VALIDATE=local # local or ssh, both requires "nc" to be installed on local or remote ($PN_HOST) machine - -# optional: -#PN_VALIDATE_COMMAND="curl http://mydomain.org" # custom validation command that will be ran locally or remotely - -# destination port on remote server => local port -PORTS[0]="80>8000" -PORTS[1]="2222>22" -PORTS[2]="80>8001>@gateway" # port will be available publicly -``` diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..97b3d4e --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +echo " >> Correcting permissions" +chown -R revproxy:revproxy /home/revproxy + +echo " >> Adding hosts to known hosts first time, if there are not all added" +su revproxy -c "/rn/bin/add-to-known-hosts.sh" + +echo " >> Starting port forwarding" +exec su revproxy -c "$@"