Client/server TCP e UDP in Python

Dalle porte e dai socket al confronto tra TCP e UDP, fino al laboratorio con Netcat e Wireshark.

1. Preparazione

Realizzare due coppie di programmi Python: un client e un server TCP, poi un client e un server UDP. I programmi si scambiano un messaggio e una risposta sulla stessa VM Linux. Wireshark permette di confrontare il traffico prodotto nei due casi.

Servono Python 3, Wireshark e due terminali. Il modulo socket è incluso in Python: non occorre installare librerie aggiuntive. Salvare i quattro file nella stessa cartella e aprire i terminali in quella cartella.

  • Indirizzo del server: 127.0.0.1, cioè la stessa macchina.
  • Porta TCP: 5000.
  • Porta UDP: 5001.
  • Interfaccia di cattura: Loopback: lo.

Chiudere eventuali server Netcat ancora attivi sulla porta 5000. I server Python gestiscono un solo scambio e poi terminano: riavviarli prima di ogni nuova prova. Per richiamare i concetti di base consultare TCP e UDP: il livello di trasporto.

Aprire Wireshark dal menu delle applicazioni oppure da un terzo terminale:

wireshark

Avviare la cattura su lo prima di lanciare il client. I filtri indicati sotto vanno inseriti nella barra del filtro di visualizzazione e applicati con Invio.

2. Esempio 1 — Client/server TCP

AF_INET seleziona IPv4 e SOCK_STREAM crea un socket TCP. Il server associa il socket a IP e porta con bind(), passa in ascolto con listen() e attende un client con accept().

accept() restituisce un nuovo socket, connessione, usato per lo scambio con quel client. Il socket server resta quello di ascolto. Il valore 1 in listen(1) riguarda la coda delle connessioni in attesa: questo programma serve un solo client perché chiama accept() una sola volta.

server_tcp.py

import socket

# 1. Creazione socket TCP (IPv4 + Stream)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(("127.0.0.1", 5000))
server.listen(1)

print("Server TCP in ascolto...")
connessione, indirizzo = server.accept() # Si blocca qui fino alla connessione del client

# 2. Ricezione e Invio
messaggio = connessione.recv(1024).decode("utf-8")
print(f"Ricevuto da {indirizzo}: {messaggio}")

connessione.sendall("Messaggio ricevuto!".encode("utf-8"))

# 3. Chiusura
connessione.close()
server.close()

client_tcp.py

import socket

# 1. Creazione e Connessione (qui scatta l'Handshake TCP!)
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("127.0.0.1", 5000))

# 2. Invio e Ricezione
testo = input("Inserisci messaggio: ")
client.sendall(testo.encode("utf-8"))

risposta = client.recv(1024).decode("utf-8")
print(f"Risposta del server: {risposta}")

# 3. Chiusura
client.close()

connect() viene eseguito prima di input(): quando compare la richiesta di digitare il messaggio, la connessione TCP è già aperta. encode("utf-8") converte il testo in byte; decode("utf-8") esegue la conversione inversa.

sendall() continua l’invio fino a consegnare tutti i byte allo stack locale, oppure segnala un errore. Non garantisce che tutto venga trasportato in un unico segmento.

3. Avvio TCP e apertura della connessione

Nel terminale server:

python3 server_tcp.py

Il programma stampa Server TCP in ascolto... e resta in attesa su accept(). Avviare la cattura Wireshark e applicare:

tcp.port == 5000

Nel terminale client:

python3 client_tcp.py

Lasciare per un momento il client fermo sulla richiesta di input. Wireshark mostra già i tre segmenti dell’handshake: SYN, SYN, ACK e ACK. In questa fase non è stato ancora inviato il testo.

Server e client Python TCP avviati; il client attende il testo e Wireshark mostra i tre segmenti SYN, SYN ACK e ACK tra le porte 34986 e 5000.
TCP: la connessione è aperta prima di digitare il messaggio. Nella cattura il client usa la porta effimera 34986 e il server la porta 5000.

I due endpoint hanno lo stesso IP, 127.0.0.1, ma porte diverse. La porta client viene scelta dal sistema operativo e può cambiare a ogni esecuzione. Nei numeri relativi mostrati da Wireshark il SYN parte da Seq 0; viene confermato con Ack 1 perché SYN occupa una posizione nello spazio di sequenza.

4. Messaggio, risposta e payload TCP

Nel client scrivere:

Messaggio di esempio dal client

Le schermate TCP sono state acquisite con la risposta Messaggio ricevuto correttamente dal server, di 43 byte. Il codice qui riportato risponde con Messaggio ricevuto!, di 19 byte: nella nuova prova cambiano le lunghezze e i numeri di conferma relativi alla risposta, mentre le fasi dello scambio restano le stesse.

Premere Invio. Il server visualizza il testo e invia la risposta Messaggio ricevuto!. A differenza dell’esperimento con Netcat, input() elimina il fine riga: questo codice non aggiunge \n al testo inviato.

Terminali Python con il messaggio e la risposta; selezionato in Wireshark il segmento client verso server, Seq 1 e Len 31.
Richiesta TCP: il messaggio contiene 31 byte. Il segmento selezionato ha Seq=1 e Len=31; la conferma successiva del server indica Ack=32.

Espandere Transmission Control Protocol e Data. Il riquadro dei byte permette di leggere il testo in esadecimale e nella rappresentazione testuale. La colonna Length indica la lunghezza del frame catturato, mentre tcp.len misura il payload TCP.

Nelle schermate compare anche una riga mDNS: non appartiene allo scambio dei programmi Python. Per isolare la conversazione applicare nuovamente tcp.port == 5000 con Invio; scrivere un filtro nella barra senza applicarlo non cambia l’elenco mostrato.

Risposta TCP dalla porta 5000 alla porta 34986, con flag PSH ACK, Seq 1, Ack 32 e Len 43; visibile il testo della risposta del server.
Risposta del server: 43 byte, Seq=1, Ack=32. Il client conferma con Ack=44. I numeri di sequenza delle due direzioni sono indipendenti.

I flag PSH, ACK sono impostati insieme nei segmenti mostrati. La presenza dei dati si verifica con il payload e tcp.len, non dal solo PSH. La risposta testuale è un messaggio applicativo; gli ACK TCP sono conferme gestite dallo stack di rete.

Per mostrare soltanto i segmenti contenenti dati:

tcp.port == 5000 && tcp.len > 0

Con un clic destro su un pacchetto scegliere Follow → TCP Stream per ricostruire il dialogo. Non c’è una corrispondenza obbligatoria tra una chiamata Python di invio e un singolo segmento: la disposizione vista qui è quella di questa cattura.

5. Chiusura TCP

Dopo lo scambio i programmi chiamano close(). Ogni direzione della connessione deve essere chiusa; gli ACK possono essere combinati con i FIN.

Wireshark evidenzia gli ultimi tre segmenti TCP: FIN ACK dal server, FIN ACK dal client e ACK finale del server.
In questa cattura chiude prima il server. La chiusura occupa tre segmenti: il FIN del client contiene anche la conferma del FIN del server.
Server → Client: FIN, ACK   Seq=44  Ack=32
Client → Server: FIN, ACK   Seq=32  Ack=45
Server → Client: ACK        Seq=45  Ack=33

Anche FIN occupa una posizione nello spazio di sequenza. I valori qui riportati si riferiscono alle schermate; in altre esecuzioni ordine dei segmenti, temporizzazione e raggruppamento delle conferme possono cambiare.

6. Esempio 2 — Client/server UDP

Con SOCK_DGRAM il socket usa UDP. Il server associa IP e porta con bind(), poi attende un datagramma con recvfrom(). Non usa listen() o accept().

server_udp.py

import socket

# Creazione socket UDP (SOCK_DGRAM)
server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server.bind(("127.0.0.1", 5001))

print("Server UDP in ascolto...")

# Ricezione: restituisce sia i dati che l'indirizzo del mittente
messaggio, indirizzo = server.recvfrom(1024)
print(f"Ricevuto da {indirizzo}: {messaggio.decode('utf-8')}")

# Risposta diretta all'indirizzo del client
server.sendto("Messaggio UDP ricevuto!".encode("utf-8"), indirizzo)

server.close()

recvfrom() restituisce i dati e l’indirizzo del mittente. Il server passa quell’indirizzo a sendto() per rispondere al client corretto.

client_udp.py

import socket

client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

testo = input("Inserisci messaggio UDP: ")

# Invia direttamente senza fare connect()
client.sendto(testo.encode("utf-8"), ("127.0.0.1", 5001))

# Riceve la risposta
risposta, indirizzo = client.recvfrom(1024)
print(f"Risposta: {risposta.decode('utf-8')}")

client.close()

Il client indica la destinazione in sendto(). La chiamata recvfrom() resta in attesa finché arriva una risposta. Se non arriva, interrompere il client con Ctrl+C e controllare che il server sia attivo. Il buffer di 1024 byte è sufficiente per i brevi messaggi dell’esercizio; un datagramma più grande può essere troncato in ricezione.

7. Avvio UDP e cattura dei datagrammi

Avviare una nuova cattura su lo e applicare il filtro:

udp.port == 5001

Nel terminale server:

python3 server_udp.py

Nel terminale client:

python3 client_udp.py
Server Python UDP in attesa sulla porta 5001, client fermo sull’input e nessun pacchetto visualizzato dal filtro udp.port == 5001.
UDP: il server attende un datagramma e il client attende il testo. Non c’è uno scambio preliminare di apertura della connessione.

Scrivere nel client il messaggio della schermata e premere Invio:

Ciao, questo è un messaggio dal client.
Cattura UDP con richiesta dalla porta 59163 alla porta 5001 di 40 byte e risposta nella direzione opposta di 22 byte.
Due datagrammi: richiesta del client e risposta Messaggio UDP ricevuto. La porta effimera è 59163 in questa esecuzione.

La schermata UDP mostra la risposta Messaggio UDP ricevuto, senza punto esclamativo. Il codice aggiornato invia Messaggio UDP ricevuto!: il payload della risposta è quindi di 23 byte e UDP Length vale 31.

La cattura mostra Len=40 nella richiesta e Len=22 nella risposta: sono le dimensioni dei payload. Il campo UDP Length, che include gli 8 byte di header, vale rispettivamente 48 e 30.

Il messaggio Ciao, questo è un messaggio dal client. contiene 39 caratteri e 40 byte UTF-8: il carattere è occupa due byte, c3 a8. La risposta della schermata contiene 22 caratteri ASCII e quindi 22 byte. Anche qui input() non include il fine riga nei dati inviati.

UDP non scambia SYN, ACK o FIN. La risposta del server è un datagramma generato dal programma Python, non un ACK UDP. La chiusura del socket libera la risorsa locale senza avviare una procedura di chiusura sul trasporto.

Per osservare sulla porta 5001 anche l’eventuale traffico TCP, senza nasconderlo con il solo filtro UDP:

udp.port == 5001 || tcp.port == 5001

Ricevere entrambe le risposte su loopback non dimostra che UDP garantisca la consegna: in questi programmi non ci sono ritrasmissioni né recupero delle perdite.

8. Dal codice ai pacchetti

Operazioni dei programmi e comportamento osservabile nel trasporto.
OperazioneTCPUDP
Creazione del socketSOCK_STREAM; da sola non avvia l’handshake.SOCK_DGRAM; nessun handshake.
Avvio dello scambioconnect() richiede l’apertura TCP; il server usa accept().sendto() invia il datagramma alla destinazione indicata.
Trasferimentosendall() e recv() lavorano sul flusso di byte; lo stack gestisce Seq e ACK.sendto() e recvfrom() lavorano sui datagrammi.
Risposta applicativaTesto inviato dal server, distinto dagli ACK TCP.Un secondo datagramma: non è una conferma automatica del trasporto.
ChiusuraNello scambio ordinato si osservano FIN e ACK.Nessun FIN o ACK UDP.

Se il client TCP segnala Connection refused, controllare che il server sia stato avviato sulla porta corretta. Se il server segnala Address already in use, verificare che non sia ancora attivo un altro processo sulla stessa porta. Per ripetere la prova riavviare entrambi i programmi.

Documentazione: modulo socket di Python e programmazione con i socket.