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.
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.
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:
- Telnet
- Tftp
- Syslog
- 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.
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:
- La comunicazione tra macchina di sviluppo e host bridge è sicura, grazie all’utilizzo di chiavi/password.
- Dalla macchina di sviluppo si può accedere a un servizio remoto.
- La configurazione è resa semplice tramite il solo strumento
ssh
. È ovviamente richiesta anche l’installazione del serverssh
su host bridge, l’aggiunta di chiavi pubbliche etc..
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.
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
:
- Apertura di una porta locale UDP 20569 sulla macchina di sviluppo 10.10.1.2, tramite
socat
. - Apertura di un tunnel
ssh
TCP 20570 dalla macchina di sviluppo 10.10.1.2 verso la stessa porta 20570 su host bridge. - Su macchina di sviluppo, trasferimento dati tramite
socat
da porta UDP 20569 verso TCP 20570 (e viceversa). - Su host bridge, trasferimento dati tramite
socat
da porta TCP 20570 verso dispositivo 192.168.17.5 e porta UDP 69 (e viceversa). - 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.
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:
- Lentezza di connessione solo la prima volta.
- Solitamente ogni comando
ssh
è lento perché si deve stabilire la connessione. Tuttavia questa lentezza si osserva solo quando si avvia l’istanza master. Invece, gli altri comandissh
tramite socket di controllo risultano veloci, quindi non si paga una penalità evidente se li eseguiamo all’interno di un ciclo.
- Solitamente ogni comando
- Apertura porte modulare.
- Si possono aprire porte in modalità lazy, abilitando la possibilità di gestire un numero variabile di schede. Questo si può fare passando ad
ssh
l’opzione-O forward
e l’opzione-L
oppure-R
.
- Si possono aprire porte in modalità lazy, abilitando la possibilità di gestire un numero variabile di schede. Questo si può fare passando ad
- Chiusura di tutte le connessioni del tunnel.
- Siccome sul sistema host bridge ci sono delle istanze di
socat
avviate tramite comandossh
, esse si potranno chiudere automaticamente nel momento in cui si ferma l’istanza masterssh
.
- Siccome sul sistema host bridge ci sono delle istanze di
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:
- Avvio dell’istanza master
ssh
in un process group separato. - Si ottiene avviando l’istanza master da una subshell configurata con
set -m
. Non è strettamente necessario, ma in questo modo si gestisce il flusso di chiusura via CTRL-C (che causa l’invio del segnale SIGINT) all’interno della funzionecleanup
, che a sua volta si occupa di fermare l’istanza master.
- Chiusura in caso di errore di port forwarding.
- Si ottiene tramite l’opzione
-o ExitOnForwardFailure=yes
passata all’’istanza masterssh
. È giusto che il tunnel si interrompa sotto questo tipo di errore.
- Si ottiene tramite l’opzione
- Gestione chiusura processo base.
- Lo script viene avviato solitamente da linea di comando ed interrotto tramite CTRL-C, ovvero tramite segnale SIGINT. L’interruzione dello script causa la chiamata alla funzione
cleanup
che termina l’istanza master e poi invia un segnale di terminazione a tutti i processi del gruppo tramitekill 0
.
- Lo script viene avviato solitamente da linea di comando ed interrotto tramite CTRL-C, ovvero tramite segnale SIGINT. L’interruzione dello script causa la chiamata alla funzione
- Avvio di processi in background tramite subshell.
- I processi avviati in background saranno associati allo stesso process group di partenza. Quindi il segnale di terminazione può essere trasmesso a tutti i job, senza lasciare processi attivi all’uscita.
- Interruzione della connessione verso host bridge.
- Questo errore provoca la chiusura delle sessioni
ssh
, di conseguenza l’istruzione finalewait -n
esce, causando la terminazione dello script. Infatti, l’ultima istruzione attende la terminazione di almeno un job.
- Questo errore provoca la chiusura delle sessioni
- Terminazione dei comandi su host bridge.
- L’opzione
-tt
dissh
causa la terminazione dei comandisocat
su host bridge, evitando di lasciare processi in esecuzione quando iltunnel.sh
si chiude.
- L’opzione
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:
./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.