Post

Sistema P2P de intercambio de archivos

Desarrollo de un sistema P2P de intercambio de archivos que incorpora un logger con ONC-RPC y servidor (indexador) en C, asi como un Web Service y el cliente en Python.

Sistema P2P de intercambio de archivos

Sobre el proyecto

Este proyecto se desarrollo como proyecto final para la asignatura Sistemas Distribuidos. El objetivo del proyecto era desarrollar un sistema distribuido que permitiera a los usuarios compartir archivos entre ellos.

El sistema se compone de varios componentes, un servidor principal que actúa como indexador, un servidor RPC que actúa como logger del servidor (de manera que se pueda auditar las acciones de los usuarios y mantener un registro de las mismas en un servidor ajeno al principal), un servicio web que proporciona la hora y fecha actual y un cliente que permite a los usuarios interactuar con el servidor y entre ellos para compartir archivos.

El proyecto se puede encontrar en el siguiente repositorio de GitHub

Arquitectura del Sistema

RPC Service (Logger)

El servicio RPC actúa como un sistema de registro centralizado que captura y almacena las operaciones realizadas por los usuarios. Utilizando el modelo ONC-RPC y el lenguaje C, este componente define interfaces consistentes para las llamadas y respuestas entre el servidor principal y el servidor de registro, garantizando así una auditoría efectiva de las actividades del sistema.

Server

El servidor maneja todas las interacciones dentro de la red del sistema, incluyendo el registro, la conexión, y la publicación de contenidos. Está implementado en C como un servidor concurrente multihilo que utiliza sockets TCP para manejar múltiples conexiones concurrentemente, facilitando así una comunicación eficiente entre los clientes.

Web Service

Este servicio web proporciona la hora y fecha actuales para que el cliente las añada a las operaciones que envía al servidor. Implementado en Python, el servicio es simple y puede ser desplegado tanto localmente como en un servidor remoto, ofreciendo un formato de cadena para la integración fácil con otras partes del sistema.

Client

El cliente permite a los usuarios emitir comandos para interactuar con el servidor, realizar publicaciones de archivos, y descargar archivos directamente de otros clientes. Desarrollado en Python, el cliente opera a través de un intérprete de comandos, gestionando también las conexiones directas para la transferencia de archivos.

Actúa a su vez de cliente en la comunicación con el servidor y como “servidor” en un paradigma p2p cuando se comunica con otros clientes, para la comunicación p2p se comporta como un servidor concurrente multihilo que soporta enviar varios archivos simultáneamente a distintos clientes (recibir solo uno).

Estructura de mensajes por componente

Se ha seguido la estructura de mensajes que se especificaba en el enunciado. Todos los códigos de error son un entero de 8 bits y todos los demás datos se envían en formato de string, marcando el final de la misma un \0.

Debido a la decisión de que el código de resultado sea 1 byte y los demás mensajes estén todos en formato string, no es necesario el uso de funciones ntoh/hton para convertir a big endian/little endian pues todo se procesa byte a byte. Lo mismo ocurre con el contenido de los archivos.

RPC Service (Logger)

Los mensajes enviados al servicio RPC consisten en un único string el cual contiene el nombre del usuario, la operación ejecutada y la fecha y hora de la operación. El servidor RPC procesa estos mensajes y responde con un código de estado, típicamente un entero que indica éxito o fallo (donde 0 es éxito y -1 error). 

Es responsabilidad del “cliente” enviar los mensajes en un formato correcto.

Server

El servidor utiliza un protocolo de comunicación basado en TCP, manejando mensajes específicos como REGISTER, UNREGISTER, CONNECT, DISCONNECT, PUBLISH, y DELETE. Cada mensaje enviado por el cliente incluye un comando seguido de la fecha y hora del mensaje y el usuario que lo envía. Además de los parámetros necesarios, como el nombre de usuario o el nombre del archivo. El servidor responde con un código (entero de 8 bits) de estado que indica el éxito o el tipo de error ocurrido, permitiendo a los clientes manejar adecuadamente los resultados de sus solicitudes.

Para ciertas operaciones el servidor enviará uno o varios mensajes tras el código de resultado, estos son los posibles mensajes con los que puede responder el servidor:

REGISTER, UNREGISTER, CONNECT, DISCONNECT, PUBLISH y DELETE

En estas operaciones únicamente se enviará un código de resultado, que simboliza si la operación se ha ejecutado correctamente o no. El 0 siempre simboliza éxito y los demás códigos simbolizan distintos tipos de error en función de la operación.

LIST USERS

En caso de éxito se enviará una cadena que codifica el número de usuarios cuya información se va a enviar.

Si se recibe la cadena ”N”, el servidor a continuación enviará la información asociada a N clientes. Por cada cliente enviará 3 cadenas de caracteres codificadas con el nombre del usuario, la dirección IP y el puerto.

LIST CONTENT

En caso de éxito se enviará una cadena que codifica el número de nombre de ficheros publicados. Si se reci-

be la cadena ”N”, el servidor a continuación enviará la información asociada a N nombres de ficheros. Por cada fichero el servidor enviara una cadena de caracteres con el nombre del fichero (el tamaño maximo del fichero es de 256 bytes) y a continuación otra cadena con la descripcíon asociada (tamaño máximo: 256bytes)

Web Service

Este componente maneja una sola solicitud: recibir una petición HTTP y responder con la fecha y hora actual en formato de cadena de texto. No hay interacciones complejas o múltiples tipos de mensajes involucrados en este servicio. Su simplicidad permite que cualquier cliente que pueda realizar peticiones HTTP lo utilice sin configuraciones adicionales, facilitando así la integración con otros componentes que necesiten marcas de tiempo.

Client

El cliente se comunica con el servidor a través de un protocolo basado en TCP, enviando mensajes estructurados según los comandos que se desean ejecutar. Cada mensaje enviado incluye un comando específico (en forma de string), como REGISTER, UNREGISTER, CONNECT, DISCONNECT, PUBLISH, DELETE, LIST_USERS, y LIST_CONTENT. Tras enviar el código de la operación se envía la fecha y hora de la operación en formato string y se envía el nombre de usuario que realiza la operación. 

En algunas operaciones tras enviar estos datos necesarios se envían argumentos:

REGISTER/UNREGISTER, CONNECT/DISCONNECT y LIST USERS

Únicamente envían el código, la fecha y el usuario a registrar/eliminar/…

PUBLISH

Envía código, fecha, usuario conectado, nombre del archivo a publicar (máx 256) y descripción del archivo a publicar (máx 256).

DELETE

Tras los argumentos básicos, se envía el nombre del archivo a eliminar

LIST CONTENT

Tras los argumentos básicos se envía el nombre del usuario cuyos archivos se quiere listar.

Además de los mensajes que puede enviar al servidor también puede comunicarse con otros usuarios (p2p) con el comando get_file, este primero envía un list users al servidor, y obtiene la ip de el usuario destino del resultado. Después conecta con la ip y puerto obtenidos y envía al otro cliente el código de operación (“GET_FILE”) y el nombre del archivo a obtener. 

Cuando el cliente recibe una petición de GET_FILE responde con un código de resultado (entero 8 bits), en caso de éxito (código 0) envía en binario byte a byte todo el contenido del archivo solicitado.

Protocolo de aplicaciones

El protocolo de cada aplicación es distinto, es por ello que voy a dividir este apartado en pares de aplicaciones por su interacción (cliente-servidor, cliente-cliente, servidor-logger, etc).

Cliente - Servidor

El protocolo de comunicación entre el cliente y el servidor en el sistema descrito sigue un paradigma de cliente-servidor, solicitud-respuesta sobre TCP. Aquí está cómo funciona y por qué se elige este enfoque:

Conexión TCP: 

El cliente inicia una conexión TCP con el servidor. TCP es elegido por su fiabilidad, orden de entrega y control de flujo, lo que es crucial para operaciones que necesitan garantía de entrega y secuencia correcta de los mensajes.

Envío de Solicitud: 

El cliente envía una solicitud al servidor. Esta solicitud puede ser cualquier comando soportado. Tras el envío de la operación se envían N mensajes con todos los argumentos necesarios para la misma.

Procesamiento en el Servidor: 

El servidor recibe la solicitud, la procesa según la lógica de la aplicación, y prepara una respuesta. Durante este proceso el servidor se comunica con otros componentes como el RPCLogger.

Envío de Respuesta: 

El servidor envía una respuesta al cliente. Esta respuesta incluye un código de estado que indica si la operación fue exitosa o no, y puede incluir datos adicionales como listados de usuarios o archivos, dependiendo del tipo de solicitud.

Cierre de Conexión: 

Una vez que la interacción específica ha concluido la conexión se cierra, es los clientes no están constantemente conectados al servidor, si no únicamente durante el tiempo que se tarda en procesar la operación. 

Esto tiene la ventaja de que no es necesario mantener la conexión abierta siendo más eficiente, pero presenta problemas para verificar si un cliente está realmente conectado o no. 

Es decir, en la operación CONNECT el servidor almacena los datos del cliente y asume que sigue conectado a en esa dirección hasta que le llegue un mensaje de DISCONNECT. Por ello si un cliente se conecta y se “apaga” sin notificar al servidor este no será consciente de ello, es por ello que estaría bien implementar un sistema de “heartbeat” para comprobar que el cliente sigue respondiendo en esa dirección. Esto no se ha implementado, sin embargo, se ha implementado en el lado cliente el que en caso de cierre por señal del sistema se envíe un DISCONNECT al servidor.

Cliente - Cliente

La comunicación directa entre clientes se utiliza para la transferencia de archivos. El protocolo de comunicación cliente-cliente sigue un paradigma peer-to-peer (P2P) utilizando TCP como protocolo subyacente. Es importante que sea TCP pues los ficheros se pueden corromper si pierden bytes de su contenido.

Establecimiento de Conexión: 

Un cliente (denominado cliente solicitante) establece una conexión TCP directa con otro cliente (denominado cliente proveedor) que posee el archivo deseado. La dirección IP y el puerto del cliente proveedor son obtenidos previamente a través del servidor central, que coordina la localización de archivos y la información de conectividad entre los pares.

Solicitud de Archivo: 

Una vez establecida la conexión, el cliente solicitante envía una solicitud para descargar un archivo específico. Esta solicitud incluye detalles como el nombre del archivo deseado.

Transferencia de Archivo: 

Si el cliente proveedor tiene el archivo, comienza la transferencia del archivo. Los datos del archivo se envían a través de la conexión TCP establecida

Confirmación de Recepción: 

Una vez que el cliente solicitante ha recibido el archivo, cierra la conexión.

Servidor - RPC Logger

El protocolo de comunicación entre el servidor principal y el servidor RPC Logger en la aplicación descrita se basa en el paradigma de llamada a procedimiento remoto (RPC, por sus siglas en inglés), utilizando el modelo ONC-RPC para estructurar las comunicaciones. 

Realización de la Llamada RPC: 

Cuando el servidor principal necesita registrar una acción (como un registro de usuario o una publicación de archivo), realiza una llamada RPC al servidor RPC Logger. Esta llamada es transparente para el servidor (que en esta comunicación se comporta como un cliente), ya que se comporta como si estuviera llamando a una función local.

Cliente - Web Service

El servicio web expone una interfaz SOAP (Simple Object Access Protocol), que es un protocolo basado en XML para el intercambio de mensajes estructurados. El cliente se comunica con el servicio web utilizando el protocolo SOAP sobre HTTP.

Compilación y ejecución 

El archivo README.md en la carpeta raíz del proyecto describe todo el proceso de compilación y ejecución. Se incluye un script de Python (setup.py) que automatiza el proceso y también ofrece una guía paso a paso para la ejecución manual. Para más detalles o para compilar y ejecutar manualmente, se recomienda consultar el README.

El script toma 3 posibles argumentos:

  • --clean|-c: Borra todos los archivos generados por la compilación.

  • --build|-b: Compila los archivos de server, rpc_service e instala las dependencias necesarias.

  • --run|-r: Ejecuta el rpc service, el web server y el servidor. De esta forma solo queda por ejecutar tantos clientes como se desee.

Una ejecucion tipica (la recomendada si se va a ejecutar todos los componentes del servidor en la misma máquina) del script sería:

$ python3 setup.py -cbr

Esto hará una compilación limpia y ejecutará el servidor, el rpc_service y el web_server.

La salida de este script será algo similar a:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
... ## Salida de la limpieza y compilación
Server, logger and web service running.
Server PID: 23442
Logger PID: 23441
Web service PID: 23443

Server IP: 192.168.1.49
Server Port: 9090
Web service URL: http://192.168.1.49:8000
Web service WSDL URL: http://192.168.1.49:8000/?wsdl
Para ejecutar el cliente establece la variable de entorno 'WSDL_URL' a 'http://192.168.1.49:8000/?wsdl'
Press Ctrl+C to stop the processes.

Server output will be shown in server.out file.
Web service output will be shown in ws_time_service.out file.
Logger output will be shown in this terminal.

Una vez terminado el script (el cual se mantendrá ejecutándose hasta que se presione CTRL+C), en una terminal diferente se puede ejecutar el cliente siguiendo los siguientes pasos.

  1. Establecer la variable de entorno WSDL_URL con la URL del servicio web. En el caso anterior seria: export WSDL_URL=”http://192.168.1.49:8000/?wsdl”

  2. Para ejecutar el cliente se deben proporcionar como argumentos la dirección del servidor y el puerto del servidor. Siguiendo el ejemplo anterior: python3 client/client.py -s 192.168.1.49 -p 9090

Batería de pruebas

La batería de pruebas se ha implementado mediante unittest de python, pues posibilita el simular respuestas de distintos procesos y forzar estados del sistema que de otra forma no sería posible.

test_client_invalids.py

Este archivo contiene pruebas unitarias para verificar el comportamiento del cliente en caso de errores o entradas no válidas. 

Se utiliza el módulo unittest.mock para crear mocks de las conexiones de socket. Esto permite simular diferentes respuestas del servidor sin la necesidad de ejecutar un proceso real del servidor. Mediante el uso de patch, se reemplaza la función socket.socket() con un MagicMock que devuelve valores predefinidos para simular los diferentes casos de error. Además se emplea un mock para el web service evitando así tener que desplegar los procesos reales.

 Las pruebas incluyen:

  • test_register_error1: Prueba el registro de un usuario con un nombre que ya está en uso.

  • test_register_error2: Prueba el registro de un usuario con un nombre vacío.

  • test_unregister_error1: Prueba la eliminación de un usuario que no existe.

  • test_unregister_error2: Prueba la eliminación de un usuario cuando ocurre un error.

  • test_connect_error1: Prueba la conexión de un usuario que no existe.

  • test_connect_error2: Prueba la conexión de un usuario que ya está conectado.

  • test_connect_error3: Prueba la conexión cuando ocurre un error.

  • test_publish_error1: Prueba la publicación de un archivo por parte de un usuario que no existe.

  • test_publish_error2: Prueba la publicación de un archivo por parte de un usuario que no está conectado.

  • test_publish_error3: Prueba la publicación de un archivo que ya está publicado.

  • test_publish_error4: Prueba la publicación de un archivo cuando ocurre un error.

  • test_delete_error1: Prueba la eliminación de un archivo por parte de un usuario que no existe.

  • test_delete_error2: Prueba la eliminación de un archivo por parte de un usuario que no está conectado.

  • test_delete_error3: Prueba la eliminación de un archivo que no está publicado.

  • test_delete_error4: Prueba la eliminación de un archivo cuando ocurre un error.

  • test_list_users_error1: Prueba la obtención de la lista de usuarios por parte de un usuario que no existe.

  • test_list_users_error2: Prueba la obtención de la lista de usuarios por parte de un usuario que no está conectado.

  • test_list_users_error3: Prueba la obtención de la lista de usuarios cuando ocurre un error.

  • test_list_content_error1: Prueba la obtención de la lista de archivos de un usuario que no existe.

  • test_list_content_error2: Prueba la obtención de la lista de archivos por parte de un usuario que no está conectado.

  • test_list_content_error3: Prueba la obtención de la lista de archivos de un usuario remoto que no existe.

  • test_list_content_error4: Prueba la obtención de la lista de archivos cuando ocurre un error.

  • test_disconnect_error1: Prueba la desconexión de un usuario que no existe.

  • test_disconnect_error2: Prueba la desconexión de un usuario que no está conectado.

  • test_disconnect_error3: Prueba la desconexión cuando ocurre un error.

test_client_valids.py

Este archivo contiene pruebas unitarias para verificar el comportamiento correcto del cliente en casos válidos. 

Al igual que en el archivo anterior, se utiliza unittest.mock para crear mocks de las conexiones de socket y simular las respuestas del servidor. Además, se utiliza un mock del objeto zeep.Client para evitar errores de conexión con el servicio web. Además se emplea un mock para el web service evitando asi tener que desplegar los procesos reales.

Las pruebas incluyen:

  • test_register: Prueba el registro de un nuevo usuario.

  • test_unregister: Prueba la eliminación de un usuario que no existe.

  • test_connect: Prueba la conexión de un usuario existente.

  • test_disconnect: Prueba la desconexión de un usuario conectado.

  • test_publish: Prueba la publicación de un archivo por parte de un usuario conectado.

  • test_delete: Prueba la eliminación de un archivo que no está publicado.

  • test_listusers: Prueba la obtención de la lista de usuarios.

  • test_listcontent: Prueba la obtención de la lista de archivos de un usuario existente.

  • test_getfile: Prueba la descarga de un archivo desde otro cliente.

test_multiclient.py

Este archivo contiene pruebas para verificar el comportamiento del sistema con múltiples clientes concurrentes. 

En este archivo, se ejecutan procesos reales del servidor y del cliente. Se utiliza la clase subprocess.Popen para iniciar el proceso del servidor y, luego, se crean instancias del cliente para interactuar con el servidor. Esto permite probar el comportamiento del sistema con múltiples clientes concurrentes en un entorno real. 

El web service se simula mediante un MagicMock.

Las pruebas incluyen:

  • test_getfile: Prueba la descarga concurrente de un archivo desde un cliente por parte de dos clientes diferentes.

  • test_register_concurrent: Prueba el registro concurrente de dos usuarios con el mismo nombre.

  • test_connect_concurrent: Prueba la conexión concurrente de dos usuarios con el mismo nombre.

  • test_getfile_concurrent: Prueba la descarga concurrente de un archivo grande desde un cliente por parte de dos clientes diferentes.

  • test_unregister_connected: Prueba la eliminación de un usuario que está conectado.

test_server.py

Este archivo contiene pruebas para verificar el comportamiento correcto del servidor. 

Este archivo también ejecuta procesos reales del servidor y del cliente. Se utiliza subprocess.Popen para iniciar el proceso del servidor y se crean instancias del cliente para realizar las pruebas. En este caso, se utiliza unittest.mock para crear mocks del objeto zeep.Client y evitar errores de conexión con el servicio web.

Las pruebas incluyen:

  • test_register: Prueba el registro de un nuevo usuario.

  • test_register_fail1: Prueba el registro de un usuario con un nombre que ya está en uso.

  • test_register_fail2: Prueba el registro de un usuario con un nombre vacío.

  • test_connect: Prueba la conexión de un usuario existente.

  • test_connect_fail1: Prueba la conexión de un usuario que no existe.

  • test_connect_fail2: Prueba la conexión de un usuario que ya está conectado.

  • test_publish: Prueba la publicación de un archivo por parte de un usuario conectado.

  • test_publish_fail1: Prueba la publicación de un archivo por parte de un usuario que no está conectado.

  • test_publish_fail2: Prueba la publicación de un archivo que ya está publicado.

  • test_delete: Prueba la eliminación de un archivo publicado.

  • test_delete_fail1: Prueba la eliminación de un archivo que no está publicado.

  • test_listusers: Prueba la obtención de la lista de usuarios.

  • test_listusers_fail1: Prueba la obtención de la lista de usuarios por parte de un usuario que no está conectado.

  • test_listcontent: Prueba la obtención de la lista de archivos de un usuario existente.

  • test_listcontent_fail1: Prueba la obtención de la lista de archivos por parte de un usuario que no está conectado.

  • test_listcontent_fail2: Prueba la obtención de la lista de archivos de un usuario remoto que no existe.

  • test_disconnect: Prueba la desconexión de un usuario conectado.

  • test_disconnect_fail1: Prueba la desconexión de un usuario que no está conectado.

Estas pruebas cubren una amplia gama de casos de uso, tanto válidos como inválidos, para asegurar el correcto funcionamiento del sistema distribuido y verificar que se manejen adecuadamente los errores y las condiciones inesperadas.

Las pruebas al web service se han realizado manualmente, conectando un cliente desde la misma máquina, en remoto y haciendo solicitudes. 

Ejecución de los tests

Para ejecutar los tests se debe seguir los siguientes pasos:

  1. Compilar todos los programas, siguiendo los pasos de la sección de compilación.

  2. NO ejecutar ningún programa ya que los tests se encargan de ejecutar los programas necesarios.

  3. Ejecutar los tests:

1
2
3
4
python3 tests/test_client_valids.py
python3 tests/test_client_invalids.py
python3 tests/test_server.py
python3 tests/test_multiclient.py

#

Consideraciones adicionales

Me gustaría mencionar que hay muchas funcionalidades que me habría gustado implementar en esta práctica que ya sea por cuestiones de tiempo, por mantener la interfaz especificada en el enunciado o por simplicidad no se han llegado a implementar. Algunas ideas que he pensado que podrian hacer falta en el programa son:

  • Mejorar el feedback en el cliente

    • Indicar cuando otro cliente está solicitando un archivo a tu cliente.

    • Indicar el tamaño del archivo antes de empezar a transmitirlo para así poder mostrar barras de progreso en las descargas.

    • Modificar algunos errores y dar más contexto para que sean más explicativos.

  • Mejorar la seguridad del sistema (arreglar vulnerabilidades que se han encontrado)

    • Hacer comprobaciones sobre ficheros públicos y ficheros privados antes de enviarlos en una solicitud GET_FILE

    • Empleo de métodos de autenticación para conectar usuarios, etc. Junto con métodos criptográficos durante las comunicaciones.

  • Uso de ficheros por parte del servidor para conservar el estado entre apagados. Ya que actualmente al utilizar únicamente variables en memoria, se pierden todos los datos entre apagados.

  • Mejorar la compartimentalización de los componentes

    • No me parece una solución elegante que para compilar el servidor sea necesario compilar el RPC en la misma máquina para generar liblogger.so aun que después el RPC se vaya a desplegar en otra máquina.
  • Mejora en el manejo de conexiones en el servidor.

    • Implementar un sistema de heartbeat en la interacción cliente-servidor para evitar fallos si un cliente se desconecta sin notificar.

Conclusiones

Considero que este ha sido un proyecto desafiante y muy interesante que ha puesto en práctica casi todos los conocimientos adquiridos en la asignatura de Sistemas Distribuidos.

This post is licensed under CC BY 4.0 by the author.