Creare una VPN utilizzando tunnel ssh e socat

Introduzione

Con l’avvento del lavoro remoto, l’utilità delle reti private (VPN) è decisamente aumentata. Infatti attraverso una VPN è possibile accedere a delle risorse solitamente esposte in una rete locale (LAN) lavorativa anche da connessione remota. Questi strumenti rappresentano una soluzione efficace, attraverso la quale è possibile accedere a qualsiasi risorsa. Tuttavia spesso è necessario installare software dedicati (e.g. openvpn, strongSwan, etc..) che richiedono una configurazione laboriosa. Bisogna ad esempio installare certificati, curare la sicurezza, ottenere un IP relativo alla rete locale, pensare alla modalità split/full tunneling e molto altro. Probabilmente avremo bisogno di un responsabile IT aziendale per la configurazione completa e magari non funzionerà alla prima. Al contrario, connettersi ad un singolo server remoto è un’operazione abbastanza semplice e sicura, attraverso il famoso strumento ssh. Una volta sul server, si possono raggiungere le suddette risorse aziendali, ma saremo costretti a lavorare su una macchina diversa da quella che usiamo per lo sviluppo (basti solo pensare all’assenza di appropriata configurazione del nostro editor preferito). Per queste ragioni, ove possibile, ho pensato che una soluzione intermedia avrebbe potuto fare al caso mio, con lo scopo di accedere ad alcune risorse aziendali, ma con la comodità di lavorare da remoto sulla stessa macchina di sviluppo.

Caso d’uso

Il caso d’uso che mi ha portato a lavorare a questa rete VPN personale è legato a delle schede embedded legacy che comunicano tramite servizi TCP/UDP e che utilizzano il canale multicast/broadcast per fini di log e discovery. Il problema più ovvio di questa architettura è l’isolamento. Infatti, collegando queste schede alla stessa rete LAN aziendale, esse saranno visibili a tutti. Ciò significa che sicuramente due sviluppatori che lavorassero su due schede diverse, vedrebbero la scheda dell’altro.

Creare una VPN 1

Altra necessità è realizzare un banchino di test, su cui installare un certo numero di dispositivi embedded in modo da poter condurre delle prove di funzionamento. Anche se tale banchino è situato all’interno dell’azienda, sarebbe auspicabile che tali dispositivi lavorassero in una rete LAN separata, al fine di evitare che i test condotti siano perturbati dal lavoro di altri colleghi.

Creare una VPN Host Bridge

Ancora una volta, sarebbe molto comodo poter accedere ai dispositivi dal proprio computer di sviluppo, senza dover lavorare dalla macchina host bridge. Quest’ultima avrà generalmente due interfacce di rete, una collegata alla rete aziendale e l’altra alla sua rete interna LAN. Purtroppo, però, in questo caso la configurazione di una VPN “completa” potrebbe essere overkilling. Non ci resta che costruire una piccola VPN personale, sfruttando strumenti già ampiamente conosciuti, come ssh e socat. Prima però è opportuno identificare quali sono i servizi a cui accedere.

Identificare i servizi

Al fine di realizzare la nostra rete privata personale bisogna identificare quali sono i servizi essenziali, ovvero quelli che vogliamo utilizzare da una rete esterna. Facendo riferimento al caso d’uso che ho menzionato, i servizi per me importanti sono:

  1. Telnet
  2. Tftp
  3. Syslog
  4. Discovery (custom)

Telnet

Ogni dispositivo espone un server telnet sulla porta 20000. Per accedere a questo servizio è sufficiente utilizzare il client telnet, passando l’indirizzo IP della scheda e porta 20000. Dal momento che questo servizio utilizza il protocollo TCP, possiamo sfruttare la funzionalità di tunnel ssh, per rimappare una certa porta locale verso l’IP di destinazione, porta 20000.

Creare una VPN telnet

Si può instaurare un tunnel ssh tra la macchina di sviluppo e host bridge, in modo che sia aperta una porta locale TCP 20000 sull’indirizzo 10.10.1.2 e che tutto il traffico TCP passante su di essa sia inoltrato prima verso host bridge all’indirizzo 10.10.1.3 e successivamente verso il servizio finale Telnet all’indirizzo 192.168.17.5 porta 20000.

ssh -N -L 20000:192.168.17.5:20000 user@hostbridge

L’opzione -N serve ad evitare l’esecuzione di comandi remoti. Invece, l’opzione -L serve ad aprire una porta locale TCP che accetti connessioni, inoltrando tutto il suo traffico verso un certo IP e porta, sulla parte remota.

Dalla macchina di sviluppo possiamo infine connetterci al servizio Telnet così facendo:

telnet localhost 20000

Di fatto ssh svolge la funzione di intermediario. Un mattoncino della piccola VPN è già stato realizzato, infatti:

Tftp

I dispositivi embedded possono essere programmati attraverso un server Tftp, che viene esposto dai loro bootloader per un tempo limitato in fase di avvio. Il codice del bootloader attende una eventuale comunicazione al boot e, se presente, procede con lo scaricamento del nuovo software e successiva programmazione della memoria interna della scheda. Visto che  vogliamo programmare le schede anche da remoto, è importante esporre questo servizio Tftp all’esterno.

Creare una VPN Tftp

In questo caso, il protocollo di trasporto è UDP, quindi si deve trovare un modo per far passare il traffico UDP all’interno di un tunnel ssh TCP, dal momento che ssh non contempla questa possibilità in modo nativo. Per questa funzione  viene in soccorso un altro strumento importante, quale socat:

  1. Apertura di una porta locale UDP 20569 sulla macchina di sviluppo 10.10.1.2, tramite socat.
  2. Apertura di un tunnel ssh TCP 20570 dalla macchina di sviluppo 10.10.1.2 verso la stessa porta 20570 su host bridge.
  3. Su macchina di sviluppo, trasferimento dati tramite socat da porta UDP 20569 verso TCP 20570 (e viceversa).
  4. Su host bridge, trasferimento dati tramite socat da porta TCP 20570 verso dispositivo 192.168.17.5 e porta UDP 69 (e viceversa).
  5. Complessivamente il passaggio dei dati deve essere bidirezionale.

L’apertura del tunnel ssh è simile al caso precedente:

ssh -N -L 20570:localhost:20570 user@hostbridge

Per quanto riguarda invece il passaggio da UDP a TCP, possiamo utilizzare il comando seguente sulla macchina di sviluppo:

socat -T5 udp4-listen:20569,reuseaddr,fork tcp4:localhost:20570

Il parametro -T5 imposta un timeout (di 5 secondi) per chiudere la comunicazione, specialmente in caso di trasporto UDP in cui non esiste un meccanismo di “disconnessione” da protocollo. Infatti, la direttiva udp4-listen permette di realizzare una pseudo connessione  con l’altra parte (anche se UDP di fatto è connectionless) in modo da poter gestire i dati in modo bidirezionale, ovvero anche le risposte, per ottenere una sorta di flusso stabile. Esiste anche una modalità di gestione a singolo pacchetto, ovvero udp4-recvfrom, ma sarebbe un impedimento nel nostro caso, dal momento che in un trasferimento Tftp i pacchetti sono logicamente correlati tra di loro e non indipendenti.

Infine, per ogni nuova comunicazione/flusso UDP, si stabilisce il trasferimento verso il socket TCP con la direttiva tcp4:localhost:20570. A questo punto i dati saranno inviati verso il tunnel ssh.

Invece, su host bridge dovremo eseguire l’operazione opposta, ovvero convertire i dati da TCP a UDP:

socat tcp4-listen:20570,reuseaddr,fork udp4-sendto:192.168.17.5:69

In questo caso la porta sorgente TCP 20570 sarà sempre aperta e disponibile, per via del tunnel ssh, quindi non è necessaria l’opzione -T5. Si stabilisce quindi una connessione UDP con l’IP finale e porta 69, attraverso la direttiva udp4-sendto:192.168.17.5:69.

Syslog e Discovery

L’ultima tipologia di servizio che vogliamo utilizzare da remoto riguarda il Syslog e il Discovery. Il metodo di gestione è adesso differente rispetto ai servizi visti in precedenza. Infatti qui la comunicazione spontanea avviene al contrario. In altre parole, non è il pc di sviluppo a iniziare la connessione, bensì esso deve rimanere in ascolto su una certa porta per la ricezione di dati. In entrambi i casi, i dispositivi inviano pacchetti UDP multicast su una determinata porta. Il servizio Syslog prevede l’invio di messaggi di log sulla porta UDP 514. Il Discovery, invece, è un meccanismo di announce, sulla porta UDP 23000, ma del tutto analogo al Syslog dal punto di vista di gestione.

Creare una VPN Syslog e Discovery

Per quanto riguarda ssh, adesso è necessario utilizzare -R affinché ogni connessione sulla porta remota TCP 20514 sia inoltrata all’indietro verso un processo in ascolto sulla macchina di sviluppo, sulla stessa porta TCP 20514:

ssh -N -R 20514:localhost:20514 user@hostbridge

Per quanto riguarda l’utilizzo di socat possiamo utilizzare comandi analoghi a quelli già visti, ma invertiti. Quindi utilizziamo la direttiva udp4-listen su host bridge, per poi inoltrare i pacchetti UDP nel tunnel TCP:

socat -T5 udp4-listen:514,reuseaddr,fork tcp4:localhost:20514

Sulla macchina di sviluppo, invece, dobbiamo restare in ascolto sulla porta TCP del tunnel ed inoltrare i dati di nuovo su UDP, nel sistema locale:

socat tcp4-listen:20514,reuseaddr,fork udp4-datagram:localhost:514

Da notare in quest’ultimo caso la direttiva udp4-datagram, che prevede l’invio di datagrammi UDP verso un indirizzo che potenzialmente potrebbe essere anche di tipo multicast o broadcast. Ciò significa che, anche in assenza di un processo in ascolto sulla porta locale 514, non saranno emessi errori di connessione.

Si osservi che questa procedura causa un effetto indesiderato per il servizio Syslog: l’IP sorgente sarà sempre lo stesso per tutti i messaggi di log. Infatti, tutti i messaggi di ogni scheda passano dallo stesso tunnel, perdendo l’informazione del sender effettivo. È un problema tuttavia risolvibile, se dovesse rappresentare un reale impedimento.

Realizzazione del tunnel

Dopo aver individuato i servizi d’interesse e capito il modo per accedervi da remoto, non resta che ingegnerizzare questa soluzione con uno script bash. Sembra essere una soluzione buona dal momento che dobbiamo solamente mettere in comunicazione processi diversi tra loro.

Ecco la versione completa dello script per l’apertura del tunnel:

tunnel.sh

#!/bin/bash

set -euo pipefail

cd "$(dirname "${0}")/../"

if [[ $# -eq 0 ]] || [[ "$1x" == "-hx" ]]; then
	echo "usage: $0 [-J <jump hosts>] <HOST>"
	exit 1
fi

HOST=${@:1}
CTL_SOCK=/tmp/tun_ctl_sock
NET_IP=192.168.17.0
BOARD_IP=(192.168.17.5 192.168.17.6)

TELNET_PORT=()
TFTP_PORT=()
FWD_PORT=()
for ip in ${BOARD_IP[@]}; do
	TELNET_PORT+=($(./ports.sh "$ip" --telnet))
	TFTP_PORT+=($(./ports.sh "$ip" --tftp-udp))
	FWD_PORT+=($(./ports.sh "$ip" --tftp-tcp))
done
HOST_PORT_TUN=()
HOST_PORT_SRV=(23000 514)
HOST_PORT_STR=("discovery" "syslog")
for name in ${HOST_PORT_STR[@]}; do
	HOST_PORT_TUN+=($(./ports.sh ${NET_IP} --${name}))
done

(
	set -m
	ssh -f -M -N -S ${CTL_SOCK} \
		-o ExitOnForwardFailure=yes \
		${HOST}
)

function cleanup {
	[ -S ${CTL_SOCK} ] && \
		ssh -S ${CTL_SOCK} -O exit ${HOST}
	kill 0
}
trap cleanup EXIT

# Tunnel for system-wide services.
for i in ${!HOST_PORT_STR[@]}; do
	ssh -S ${CTL_SOCK} \
		-O forward \
		-R ${HOST_PORT_TUN[$i]}:localhost:${HOST_PORT_TUN[$i]} \
		${HOST}

	(ssh -tt -S ${CTL_SOCK} ${HOST} \
		socat -T5 udp4-listen:${HOST_PORT_SRV[$i]},reuseaddr,fork tcp4:localhost:${HOST_PORT_TUN[$i]}) &

	(socat tcp4-listen:${HOST_PORT_TUN[$i]},reuseaddr,fork udp4-datagram:localhost:${HOST_PORT_SRV[$i]}) &
done

# Tunnel for board-specific services.
for i in ${!BOARD_IP[@]}; do
	ssh -S ${CTL_SOCK} \
		-O forward \
		-L ${FWD_PORT[$i]}:localhost:${FWD_PORT[$i]} \
		-L ${TELNET_PORT[$i]}:${BOARD_IP[$i]}:20000 \
		${HOST}

	(ssh -tt -S ${CTL_SOCK} ${HOST} \
		socat tcp4-listen:${FWD_PORT[$i]},reuseaddr,fork udp4-sendto:${BOARD_IP[$i]}:69) &

	(socat -T5 udp4-listen:${TFTP_PORT[$i]},reuseaddr,fork tcp4:localhost:${FWD_PORT[$i]}) &
done

wait -n

Si può notare l’utilizzo di un altro script di utilità, per la determinazione delle porte:

ports.sh

#!/bin/bash

set -euo pipefail

cd "$(dirname "${0}")/../"

if [ $# != 1 ] && [ $# != 2 ] || [ "x$1" == "x-h" ]; then
	echo "usage: $0 <IP> [[--telnet | --tftp-tcp | --tftp-udp | --discovery | --syslog]]" 1>&2
	exit 1
fi

N=$(echo $1 | cut -d. -f4)
[[ ! "${N}" =~ ^[0-9]+$ ]] || [ ${N} -lt 0 ] || [ ${N} -gt 99 ] \
	&& { echo "Invalid IP address $1, last octet ${N} not in range [0..99]" 1>&2; exit 1; }

N=$(printf "%02d" ${N##*0})

telnet=2${N}23
tftp_udp=2${N}69
tftp_tcp=2${N}70
discovery=2${N}98
syslog=2${N}14

case "${2-all}" in
"--telnet")
	echo ${telnet}
	;;
"--tftp-tcp")
	echo ${tftp_tcp}
	;;
"--tftp-udp")
	echo ${tftp_udp}
	;;
"--discovery")
	echo ${discovery}
	;;
"--syslog")
	echo ${syslog}
	;;
*)
	echo ${ssh}
	echo ${telnet}
	echo ${tftp_udp}
	echo ${tftp_tcp}
	echo ${discovery}
	echo ${syslog}
	;;
esac

Il tunnel si apre attraverso il comando:

./tunnel.sh user@hostbridge

Vediamo brevemente alcuni dettagli di questa implementazione.

Supporto per stesso servizio su schede diverse

Lo script tunnel.sh fornisce la possibilità di esportare gli stessi servizi visti in precedenza su più schede. Infatti, la variabile BOARD_IP è una lista, in cui possiamo indicare gli indirizzi IP delle varie schede. Per questo motivo è necessario assegnare porte diverse allo stesso servizio, su schede diverse. Tale compito viene assolto dallo script di appoggio ports.sh. In poche parole, supponendo che la sottorete privata sia 192.168.17.0/24, si definisce la regola 2${N}xx per determinare le porte a seconda dell’ultimo ottetto dell’indirizzo che identifica ogni scheda. Ad esempio, nel caso della scheda 192.168.17.5, il servizio telnet sarà associato alla porta 20523. Ciò implica che gli IP supportati da questa regola non superino il valore 99.

Supporto per servizi di sistema

Ci sono dei servizi che non dipendono dal numero di schede, come quello di logging. Infatti tutte le schede inviano i log su Syslog e vorremmo vedere tutti i messaggi insieme. Tale modalità è supportata tramite la lista HOST_PORT_SRV, contenente le porte UDP da aprire localmente. In particolare, dato che  sono servizi “condivisi”, si è scelto di passare allo script ports.sh proprio l’indirizzo IP della sottorete, ovvero 192.168.17.0.

Gestione ssh tramite socket di controllo

Si è scelto di avviare un’istanza master di ssh, in background, che accetti comandi attraverso un socket di controllo. Tale avvio si può notare dal primo comando ssh, a cui si passano le opzioni -f (avvio in background), -M (imposta modalità master) e -S (specifica del file per il socket di controllo). In riferimento a questa implementazione, utilizzare un socket di controllo porta i seguenti vantaggi:

Gestione errori di connessione e chiusura

Lo script tunnel.sh utilizza delle accortezze per gestire la corretta terminazione dello stesso, anche in presenza di errori. Le condizioni prese in considerazione sono:

Passaggio da host intermedi

Finora abbiamo considerato un collegamento diretto dalla macchina di sviluppo verso il sistema host bridge. Tuttavia, se mettiamo insieme le due condizioni di sottorete del banchino di test e lavoro da remoto, potrebbe non essere possibile realizzare il tunnel, per come è stato descritto. Infatti, non è detto che la sottorete di test sia accessibile dall’esterno. Tuttavia, se almeno un server aziendale è accessibile dall’esterno, possiamo sfruttare l’opzione -J di ssh, che consente di stabilire prima una connessione verso un host pubblico, dal quale sarà accessibile la sottorete di destinazione. Ad esempio:

Creare una VPN Hosto intermedi
./tunnel.sh -J user@public.company.com user@hostbridge

I servizi risulteranno esportati sulla macchina di sviluppo proprio come se il sistema host bridge fosse raggiungibile direttamente, in modo trasparente.

Conclusioni

L’implementazione di una tale rete privata personale porta alla luce la flessibilità di strumenti come ssh e socat, che non finiremo mai di imparare. Però si deve anche valutare l’impegno richiesto per l’implementazione ed i test necessari per realizzare una soluzione del genere. Rispetto a una rete VPN completa, sicuramente non è gestito il caso multiutente, cioè non è possibile aprire più volte lo stesso tunnel, da parte di utenti diversi (a meno di non gestire questa possibilità). Un’altra differenza è legata al numero di risorse a cui accedere: infatti una VPN completa permette di accedere a tutti i servizi, invece di doverli redirezionare uno ad uno. Da non dimenticare poi che alcuni di loro potrebbero essere complicati da gestire, oppure subire lievi modifiche (come nel caso dell’indirizzo sorgente per i messaggi Syslog). Tuttavia per il mio caso d’uso, il tunnel ssh+socat è risultato molto conveniente.