Práctica 1 (Apéndice). Programación con sockets en Python

Objetivos

  • Familiarizarse con la API de sockets en Python.
  • Desarrollar esquemas básicos de sistemas cliente/servidor TCP y UDP utilizando Python.
  • Ser capaces de analizar el tráfico generado en una conexión TCP y UDP a través de Wireshark.
  • Diseñar un protocolo de capa de aplicación para simular una aplicación cliente/servidor utilizando TCP y UDP.
  • Observar la diferencia en tráfico generado para una misma aplicación utilizando TCP y UDP.
  • Implementar servidores multi-hilo en Python.

Nota

Ofrecemos esta práctica como anexo y referencia para complementar los contenidos ofrecidos en la Práctica 1. Su comprensión y desarrollo es opcional, aunque puede ser de utilidad para alumnos con poca experiencia en el lenguaje C o en programación con sockets, o para prototipar rápidamente sistemas cliente/servidor utilizando Python.

La API de sockets en Python

El módulo socket de Python proporciona una interfaz completa para trabajar con la API de sockets de Berkeley. En la presente práctica, trabajaremos exclusivamente con esta API para desarrollar aplicaciones cliente/servidor utilizando los protocolos TCP y UDP.

Las funciones y métodos principales de la API de sockets son:

  • socket() -
  • bind() -
  • listen() -
  • accept() -
  • connect() -
  • connect_ex() -
  • send() -
  • recv() -
  • close() -

Python prorpociona una API consistente y completa mapeada directamente a las anteriores llamadas al sistema, típicamente escritas en lenguaje C. Como parte de su biblioteca estándar, Python también proporciona clases que facilitan el trabajo con las funciones de bajo nivel. Aunque no lo cubriremos, el módulo socketserver proporciona una forma sencilla de crear servidores de red. Existen también numerosos módulos disponibles para implementar protocolos de alto nivel (por ejemplo HTTP o SMTP), véase .

Sockets TCP

En Python, los sockets TCP se crean en Python utilizando socket.socket(), especificando el tipo de socket como socket.SOCK_STREAM. El protocolo de control de transmisión (TCP) se caracteriza por dos rasgos principales:

  • Es confiable: se implementan mecanismos de detección de pérdidas en la red y reenvío de paquetes perdidos.

  • Garantiza una entrega de paquetes en orden: los datos se entregan a las capas superiores (aplicaciones) en el mismo orden en el que fueron enviados.

En contra, los sockets UDP se crean a través de socket.SOCK_DGRAM, y no son confiables ni garantizan la entrega de paquetes en orden. Por tanto, es el desarrollador de aplicaciones quien, en caso de así desearlo en el diseño de la aplicación, debe implementar estos mecanismos de forma específica.

En el siguiente diagrama se muestra la secuencia típica de invocaciones a la API de sockets para TCP:

foo

En la figura, la columna de la izquierda representa al servidor, mientras que la columna de la derecha representa al cliente en la conexión TCP. Observa las invocaciones necesarias para configurar un socket a la escucha de conexiones entrantes:

  • socket()
  • bind()
  • listen()
  • accept()

En este extremo, un socket escucha (listen) potenciales conexiones entrantes desde clientes. Cuando un cliente solicita conectar, el servidor acepta (accept) la conexión, completándola.

El cliente invoca a connect() para establecer una conexión con el servidor e inicia el proceso de conexión a tres vías (three-way connection).

Una vez establecida la conexión, los datos se intercambian entre cliente y servidor a través de invocaciones a send() y recv().

Finalmente, el socket se destruye (esto es, la conexión entre ambos extremos se cierra) a través de una invocación a close() en cada extremo.

Cliente/servidor echo TCP

Veamos un ejemplo sencillo para crear un par cliente-servidor. En este caso, el servidor simplemente responderá con la misma cadena que reciba desde el cliente.

Servidor echo

#!/usr/bin/env python3

#### servidor_echo.py

import socket

HOST = '127.0.0.1'  # Interfaz estándar de loopback (localhost)
PORT = 65432        # Puerto de escucha (los puertos mayores a 1023 son no privilegiados)

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, PORT))
    s.listen()
    conn, addr = s.accept()
    with conn:
        print('Conectado ', addr)
        while True:
            data = conn.recv(1024)
            if not data:
                break
            conn.sendall(data)

Nota

De momento, no importa si no entiendes todas las líneas en el anterior código. Simplemente se trata de un punto de partida para desarrollar un servidor sencillo. Sin embargo, es conveniente que copies el código en un fichero de texto (por ejemplo, llamado servidor_echo.py) para que podeamos probarlo.

Veamos línea a línea las partes más importantes del anterior código.

socket.socket() crea un objeto socket. Observa que, al crearse a través de una construcción with, no es necesario invocar explícitamente a s.close(), aunque debes tener en cuenta que el objeto es destruido al finalizar la construcción:

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    pass  # Es posible usar el socket win invocar a s.close().

Los argumentos que se proporcionan a socket() especifican la familia de direcciones (AF_INET) y tipo de socket (SOCK_STREAM). AF_INET es la familia de direcciones de Internet para IPv4. SOCK_STREAM es el tipo de socket que permite la creación de conexiones TCP.

bind() se utiliza para asociar el socket a una interfaz de red y número de puerto específicos:

HOST = '127.0.0.1'  # Interfaz estándar de loopback (localhost)
PORT = 65432        # Puerto de escucha (los puertos mayores a 1023 son no privilegiados)

# ...

s.bind((HOST, PORT))

Los valores proporcionados a bind() dependen de la familia de direcciones seleccionada para el socket. En este ejemplo, al utilizar AF_INET, espera una tupla con únicamente dos valores (host, puerto).

Para determinar el host, es posible utilizar un nombre de host, una dirección IP o una cadena vacía. Si utilizamos una dirección IP, ésta debe ser especificarse mediante una cadena que contenga una dirección IPv4 bien formada. La dirección 127.0.0.1 es la dirección IPv4 estándar para la interfaz de loopback, por lo que únicamente procesos que estén ejecutándose en el propio host podrán comunicar con el servidor. Si proporcionamos una cadena vacía, el servidro aceptará conexiones entrantes a través de todas las interfaces IPv4 disponibles en el sistema.

El número de puerto (port) se especifica con un valor entero entre 1 y 65535, y espcifica el puerto (en este caso, TCP) a través del cual el servidor aceptará conexiones desde los clientes. La mayoría de sistemas requieren permisos de superusuario para escuchar a través de los puertos (well-known), es decir, con valor inferior a 1024.

Continuando con el ejemplo, listen() posibilita que un servidor pueda, en el futuro, aceptar (accept()) conexiones entrantes. En otras palabras, pone a la escucha al socket:

s.listen()
conn, addr = s.accept()

La invocación a accept() bloquea el proceso y espera a una conexión entrante. Cuando un cliente conecta, devuelve un objeto socket que representa la conexión, así como una tupla (addr) que contiene la dirección del cliente. Concretamente, esta tupla contiene los valores (host, port) que almacenan la dirección IPv4 y puerto del cliente que solicita la conexión.

Observa que, en el ejemplo, conn es el objeto socket que usaremos para comunicar con el cliente:

conn, addr = s.accept()
with conn:
    print('Conectado ', addr)
    while True:
        data = conn.recv(1024)
        if not data:
            break
        conn.sendall(data)

Tras obtener el objeto devuelto por accept(), diseñamos el servidor como un bucle infinito que invoca repetidamente a llamadas bloqueantes a conn.recv(). Así, leemos los datos enviados por el cliente y los reenviamos sin modificación utilizando conn.sendall().

Si conn.recv() devuelve un objeto de tipo bytes vacío (b'') significa que el cliente cerró la conexión, en cuyo caso el bucle termina, destruyéndose el socket al salir de la sentencia with.

Cliente echo

Veamos a continuación la estructura general del cliente (puedes usar, por ejemplo, cliente_echo.py como nombre para el fichero):

#!/usr/bin/env python3

### cliente_echo.py

import socket

HOST = '127.0.0.1'  # IP del servidor
PORT = 65432        # Puerto de escucha del servidor

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((HOST, PORT))
    s.sendall(b'Hola, mundo')
    data = s.recv(1024)

print('Recibido ', repr(data))

En comparación con el servidor, la estructura del cliente es más simple; simplemente crea un nuevo objeto socket, conecta con el servidor e invoca a s.sendall() para enviar el mensaje. Finalmente, espera la recepción de la respuesta utilizando s.recv() y la imprime por pantalla.

Ejecución del cliente y servidor echo

A continuación, ejecutaremos cliente y servidor para observar el estado de las conexiones durante su ciclo de vida.

Ejecuta en una terminal el servidor:

$ python3 ./servidor_echo.py

Como ves, la terminal se bloquea (de hecho, el servidor permanece en estado bloqueado) en la invocación:

conn, addr = s.accept()

Realmente, el servidor está esperando a que haya conexiones entrantes por parte de un cliente. Abre otra terminal y ejecuta el cliente:

$ python3 cliente_echo.py
Recibido 'Hola, mundo'

En la ventana del servidor, deberías ver algo similar a:

$ python3 ./servidor_echo.py
Conectado ('127.0.0.1, 61234')

En esta salida, el servidor ha mostrado por pantalla la tupla devuelta por s.accept(), que incluye la dirección IP y el número de puerto TCP. Dicho número de puerto (en el ejemplo anterior, 61234) es seleccionado aleatoriamente por el sistema operativo y puede variar en tu ejecución.

Herramientas para observar el estado del socket

Podemos utilizar la herramienta netstat para observar el estado actual de los sockets en cualquier sistema operativo (macOS, Linux e incluso Windows). Por ejemplo, esta sería la salida de netstat en Linux tras ejecutar el servidor:

netstat -an | grep 65432
Conexiones activas de Internet (servidores y establecidos)
Proto  Recib Enviad Dirección local         Dirección remota       Estado
tcp        0      0 127.0.0.1:65432         0.0.0.0:*               ESCUCHAR

Observa que hemos filtrado la salida de la orden netcat según el número de puerto utilizado. Observa el valor de las columnas Proto, Dirección local y Estado.

Nota

Otra forma de observar el estado de las conexiones es a través de la orden lsof -i -n. Ejecútala y observa su salida.

Capturas de tráfico vía Wireshark

Wireshark es una herramienta de código abierto ampliamente utilizada para analizar protocolos de comunicación de red en cualquiera de las capas de la pila TCP/IP (como también en otros protocolos). Wireshark implementa un amplio abanico de filtros para definir criterios de búsqueda en las capturas de tráfico, aunque de momento, en nuestro caso, no será necesario utilizar filtros específicos.

Para arrancar Wireshark en la máquina virtual proporcionada (o en cualquier instalación básica Linux), teclea en tu terminal:

$ sudo wireshark

Tras el arranque, podemos comenzar una nueva captura de tráfico a través del menú Capture, opción Start. La pantalla de selección de interfaz nos permitirá definir en qué interfaz de red se realizará la captura. En nuestro caso, ya que vamos a comunicar dos procesos en la misma máquina, elegiremos la interfaz de Loopback (lo) y comenzaremos la captura.

Tarea

Arranca Wireshark y prepara una captura sobre la interfaz de loopback. Ejecuta el servidor echo TCP y el cliente correspondiente, y analiza el tráfico generado. Especialmente, fíjate en el proceso de establecimiento de conexión en tres vías, paquetes de Acknowledge tras el envío de cada mensaje y, en general, en cualquier otro aspecto que consideres de interés.

Sockets UDP

La creación y gestión de sockets UDP en Python resulta todavía más sencilla. Observa el siguiente código, que crea un servidor UDP utilizando la API de sockets Python:

import socket

udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_socket.bind(("localhost", 5005))

data = udp_socket.recv(512)
print(data)

Primero, importamos la biblioteca socket de recepción, igual que en el caso de TCP. Obviamente, en este caso el tipo de socket pasa a ser socket.DOCK_DGRAM, para indicar que deseamos utilizar UDP en la comunicación.

El programa espera a la recepción de un paquete utilizando el método bloqueante recv, cuyo único parámetro indica el número máximo de bytes que deseamos recibir. Cuando un paquete llega al socket, el metodo recv devolverá un array de bytes, que será almacenado en la variable que deseemos.

El envío de datos a través de un socket UDP es también sencillo:

import socket

udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_socket.bind(("localhost", 0))

data = b"Hola, mundo!"
udp_socket.sendto(data,("localhost", 5005))

Observa que, en este caso, asociamos (bind) el socket a un puerto especificado como 0. Este valor especial indica al sistema operativo que elija para la transimisión un puerto origen aleatorio de entre los disponibles en el sistema.

A continuación, creamos los datos a enviar y los enviamos utilizando el método sendto(). Este método tomados argumentos: datos a enviar, y precisamente la dirección de envío. Los datos enviados a través del socket deben formar parte de un array de bytes (por ello, la cadena a enviar viene precedida por el carácter b).

Tarea

Comprueba que, efectivamente, los códigos de envío y recepción a través de UDP funcionan como se espera.

Nota

Desde la versión 3 de Python, las cadenas se codifican utilizando Unicode. Al contrario que ASCII, conde cada caracter tiene una representación en byte directa, Unicode utiliza enteros par representar cada caracter, que deben ser codificados para obtener una representación en forma de byte. Uno de esos esquemas de codificación es UTF-8. Por ejemplo, el siguiente código muestra cómo codificar una cadena Unicode en una representación de bytes:

cadena= "Hola" data = cadena.encode("UTF-8") print(data, type(data))

lo cual genera

b"Hola" <class 'bytes'>

que puede ya ser enviado directamente por red.

Hasta este punto, los programas UDP han sido totalmente unidireccionales en el envío/recepción de datos, pero obviamente, un socket UDP es un canal de comunicación bidireccional.

Tarea

Implementa una funcionalidad similar al servidor echo que vimos para TCP, pero utilizando en este caso UDP. Realiza una captura de tráfico en Wireshark similar a la realizada en el caso de TCP, y observa las principales diferencias entre ellas a nivel de tráfico generado.

Envío de datos binarios a través de sockets

Hasta este punto, hemos visto únicamente cómo enviar cadenas de texto a través de sockets TCP o UDP, pero es muy probable que sea necesario (o conveniente), en ocasiones, enviar datos directamente en formato binario (por ejemplo, valores numéricos en punto flotante o enteros). Utilizando el módulo struct de Python podemos especificar qué tipo o tipos de datos se almacenan en una secuencia de bytes y cómo decodificarlos. También es posible especificar en qué lugar de la secuencia se alojan dichos datos, permitiendo el empaquetado de múltiples datos de distintos tipos de forma sencilla, y su posterior decodificación en el otro extremo de la comunicación.

Nota

Para todos los detalles del módulo struct, consulta la página oficial de documentación.

El módulo struct proporciona dos métodos de interés: pack y unpack.

La siguiente sentencia:

struct.pack(">iii", 1, 2, 3)

utiliza el método pack para realizar un empaquetado de datos. Concretamente, observa como el método recibe dos parámetros:

  • En primer lugar, el parámetro de formato ">iii". Define como debe codificarse cada valor en la secuencia de bytes. El primer carácter indica el endianness utilizado, en este caso big endian (utilizaríamos ">" para big endian, "<" para little endian y "=" para network (big) endian).

  • En segundo lugar, los valores a empaquetar.

Observa que el formato, además, incluye el número y tipo de los datos a empaquetar (en este caso, tres valores detipo entero). Para otros tipos de datos, consulta la documentación del módulo.

Desempaquetar los datos enviados en el extremo opuesto es intuitivo:

a, b, c = struct.unpack( ">iii" )

A continuación, mostramos un ejemplo de sistema cliente/servidor TCP que hace uso del módulo struct para realizar el envío de dos datos enteros y uno flotante entre un cliente y un servidor.


# Cliente

import binascii                                                                             
import socket                                                                               
import struct                                                                               
import sys                                                                                  

# Socket TCP                                                                                
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)                                    
server_address = ('localhost', 10001)                                                       
sock.connect(server_address)                                                                

packed_data = struct.pack("=iif", 1, 4, 2.7)                                                

try:                                                                                        
    # Envio de datos                                                                        
    print('Enviando "%s"' % binascii.hexlify(packed_data))                                  
    sock.sendall(packed_data)                                                               

finally:                                                                                    
    print('Cerrando socket')                                                                
    sock.close()                                                                            

# Servidor

import binascii                                                                             
import socket                                                                               
import struct                                                                               
import sys                                                                                  

# Socket TCP                                                                                
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)                                    
server_address = ('localhost', 10001)                                                       
sock.bind(server_address)                                                                   
sock.listen(1)                                                                              

while True:                                                                                 
    print('Esperando conexiones entrantes')                                                 
    connection, client_address = sock.accept()                                              
    try:                                                                                    
        data = connection.recv(1024)                                                        
        print('Recibido "%s"' % binascii.hexlify(data))                                     

        unpacked_data = struct.unpack("=iif", data)                                         
        print('Desempaquetado:', unpacked_data)                                             

    finally:                                                                                
        connection.close()

Tarea

Ejecuta el anterior sistema cliente servidor y analiza el tráfico generado, en busca de los datos binarios empaquetados. Experimenta con otros tipos de datos y endianess y observa las diferencias.

Tarea

Tarea

Se pide diseñar un sistema cliente/servidor programado en Python, que simule el envío de un conjunto de datos sensorizados desde un cliente hacia un servidor. El protocolo a utilizar (formato de datos enviado por la red a nivel de aplicación) debe ser propuesto por el propio alumno y descrito previamente al desarrollo. Se valorará el uso de múltiples tipos de datos tanto en el envío de datos sensorizados como de posibles respuestas por parte del servidor. Se desarrollará una versión utilizando TCP y otra equivalente usando UDP. El cliente enviará los datos de forma periódica y se éstos generarán de modo aleatorio.

A modo de entrega, se solicitan los códigos desarrollados, así como un análisis del tráfico generado, considerando la sobrecarga (en bytes reales enviados) introducida por cada protocolo de capa de transporte.

Ejemplo de sistema cliente/servidor multi-hilo

Los ejemplos anteriormente descritos, aunque funcionales, adolecen en su diseño de una característica esencial: el servidor deja de atender peticiones entrantes mientras trata cada nuevo envío por parte del cliente. Los siguientes ejemplos muestran implementaciones sencillas con soporte multi-hilo para un sistema cliente/servidor escrito en Python.

# Servidor TCP concurrente

import socket, threading

class ClientThread(threading.Thread):
    def __init__(self,clientAddress,clientsocket):
        threading.Thread.__init__(self)
        self.csocket = clientsocket
        print ("Nueva conexion anyadida: ", clientAddress)
    def run(self):
        print ("Conexion desde: ", clientAddress)
        #self.csocket.send(bytes("Hi, This is from Server..",'utf-8'))
        msg = ''
        while True:
            data = self.csocket.recv(2048)
            msg = data.decode()

            if msg=='bye':
              break

            print ("Desde el cliente", msg)
            self.csocket.send(bytes(msg,'UTF-8'))

        print ("Cliente ", clientAddress , " desconectado...")

LOCALHOST = "127.0.0.1"
PORT = 8080

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((LOCALHOST, PORT))

print("Servidor arrancado...")
print("Esperando petición de clientes...")

server.listen(1)

while True:
    clientsock, clientAddress = server.accept()
    newthread = ClientThread(clientAddress, clientsock)
    newthread.start()
# Cliente TCP. El envío de la cadena bye indica petición de desconexión.

import socket

SERVER = "127.0.0.1"
PORT = 8080

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect((SERVER, PORT))
client.sendall(bytes("Hola, soy un cliente!!",'UTF-8'))

while True:
  in_data =  client.recv(1024)

  print("Desde el servidor :" ,in_data.decode())
  out_data = input()
  client.sendall(bytes(out_data,'UTF-8'))

  if out_data=='end':
    break

client.close()

Tarea

Estudia el código del servidor concurrente y observa cómo gestiona la creación de hilos para atender cada petición entrante. Conecta simultáneamente múltiples clientes y observa el estado de los sockets mediante las herramientas correspondientes.

Tarea opcional

Tarea opcional

Modifica tu primer entregable para considerar una implementación multihilo del servidor TCP, siguiendo las directrices de los códigos de ejemplo anteriormente proporcionados.

Tarea opcional

Tarea opcional

Modifica el protocolo de envío para que tu aplicación cliente/servidor UDP garantice en la medida de lo posible la recepción de los paquetes enviados desde el cliente, así como su recepción en orden. Vuelve a analizar el tráfico necesario en este caso comparado con una comunicación básica basada en TCP (donde sí se garantizan, a nivel de transporte, dichas características).