Skip to content

Desarrollo técnico

Tormenta74 edited this page Mar 7, 2018 · 1 revision

Aproximación inicial

Comenzamos el desarrollo de nuestro servidor HTTP con un proceso iterativo de construcción de servidores. La primera aproximación estaba basada en código procedente de la misma práctica de esta asignatura.

Comenzamos resolviendo los problemas de las librerías:

  1. libtcp Esta librería actúa como capa de contacto entre las funciones de bajo nivel de sockets, como socket, recv, bind, etc., para proporcionar una interfaz consistente con estas funciones.
  2. libconcurrent Con esta librería añadimos una capa más simple al uso de los diferentes items de la librería pthread, de la que usamos tanto los mutex como los hilos.
  3. libdaemon Esta miniatura es simplemente una caja para la función daemonize que utilizamos para poner los procesos servidores en segundo plano.

Empezamos desarrollando unas funciones "esqueleto" para servidores, donde configuramos la dirección IP y el puerto en el que estamos escuchando, atando un socket a dicho puerto, y lanzando el servidor en modo demonio (src/server.c:server_setup). La segunda función, fundamental para el desarrollo, es src/server.c:server_accept_loop. En una primera instancia, esta función se basaba en el funcionamiento de select sobre un set de sockets abiertos, de forma que usábamos el tipo fd_set para almacenar los sockets a los que están conectados los clientes, y despachábamos hilos de atención a cada cliente ya conectado o usábamos la función accept para conectarles a un nuevo socket, dependiendo del número de socket. Sin embargo, esta aproximación era compleja incluso de leer, así que la descartamos por el siguiente esquema multihilo:

while(active):
	tcp_accept(&new_socket)	   // llamada bloqueante
	print(addr)    // mostramos la IP y el puerto del cliente
	launch(attention_routine, new_socket)    // lanzamos un hilo que atiende a dicho cliente

Donde attention_routine es un puntero a una función compatible con los hilos de pthread que se lanza como hilo.

A partir de aquí, podemos implementar el protocolo que queramos en la rutina, así que como primer prototipo implementamos un sencillo eco:

echo(socket):
	tcp_recv(socket, buffer)    // bloqueante
	tcp_send(socket, buffer)    // devolvemos el mismo mensaje
	tcp_close(socket)

Una vez probado este esquema, probamos con un servidor de ficheros:

file_contents = read(file_pointer, file_size)

[...]

file(socket):
	tcp_recv(socket, buffer)    // esperamos a que hable el cliente
	tcp_send(socket, file_contents)
	tcp_close(socket)

Módulo de configuración

Para una mayor flexibilidad de actuación del servidor, introducimos un fichero de configuración así como un mecanismo de lectura.

Las opciones que el servidor soporta son:

  • server_root: Especifica la ruta de la base del servidor, a partir de la cual se buscarán los recursos solicitados por los clientes.
  • server_signature: "Nombre" del servidor. Influencia tanto su presencia en el sistema operativo como las cabeceras de las respuestas HTTP que provee.
  • max_clients: Número máximo de conexiones concurrentes que puede procesar (limita también el número de hilos que el proceso servidor puede crear).
  • listen_port: Número de puerto en el que el servidor escucha para conexiones entrantes.
  • daemon: (opcional) Para mayor flexibilidad en el desarrollo, esta opción evita que el proceso vaya a segundo plano para mejorar el proceso de debug.
  • iterative: (opcional) Misma intención que la opción anterior, donde marcamos que cada cliente debe ser atendido en orden de llegada, y hasta que no sea procesado, no se podrá aceptar al siguiente.

En el fichero server.conf definimos las opciones que se establecen por defecto. Como alternativa, en el programa principal se implementa una opción en la que se puede indicar como parámetro (flag) un fichero de configuración alternativo, usando "--config path/to/file". Este fichero se leerá de la misma manera que el por defecto.

Nota: En el fichero de configuración pueden aparecer líneas vacías (que empiecen con un salto de línea), pero si apareciesen líneas con espacios, el parseado de las opciones fallaría.

Parseo de peticiones HTTP

El servidor, una vez ha iniciado la conexión satisfactoriamente con el cliente, debe atender e interpretar las peticiones que éste requiera, y en nuestro caso se debe construir un interpretador de peticiones HTTP. Para ello, el primer paso es saber traducirlas y parsearlas, para lo cual se utiliza la función phr_parse_request de la librería PicoHTTPParser.

Encapsulamos la función anterior en otra propia que formatee los resultados de una forma que resulte más cómoda para trabajar con ellos posteriormente. Se construye así http_request_parse, en la que se obtienen los resultados de phr_parse_request y se formatean y devuelven de la forma deseada. En una primera aproximación, todos los datos requeridos se pasaban a través de referencias introducidas en la función como argumentos, pero finalmente se decidió que resultaría más cómodo crear una estructura que contuviera todos los parámetros, http_req_data:

  • int version: Versión HTTP de la petición
  • char *method: Método (verbo) de la petición
  • char *path: URI del recurso solicitado en la petición
  • size_t path_len: Tamaño de URI
  • char *body: Cuerpo de la petición
  • size_t body_len: Tamaño del cuerpo
  • int num_headers: Número de cabeceras
  • struct http_pairs: Estructura que contiene a las cabeceras (pares {nombre, valor})

Junto con esta función, se incluye también http_response_body, que realiza la función de devolver el cuerpo de la petición, tarea no completada por la función inicial de PicoHTTPParser.

Además, existe una característica de las peticiones HTTP con método GET, y es que, en caso de necesitar argumentos, estos se incluyen en la URI de la petición, y la función nativa phr_parse_request las incluye en el campo path. Como para realizar la búsqueda y la ejecución del recurso solicitado necesitamos separar la URI en dos partes (path real y argumentos), se ha construido la función http_request_get_split, que se encarga específicamente de esta tarea.

Como utilidades extra de cara al parseo de peticiones HTTP, se incluyen las funciones http_request_data_print (impresión de los datos de una petición HTTP por pantalla) y http_request_data_free (liberación de la memoria reservada tras la construcción de una estructura http_req_data).

Por último, se citan algunas funciones que se han desarrollado pero han quedado sin uso u obsoletas:

  • http_request_parse_old: Citada anteriormente, no usaba la estructura http_req_data
  • response_parser: Parseador de respuestas de servidor. Como estamos en el punto de vista del servidor, es evidente que no resulta necesaria.
  • request_argument_parser y argument_parser: Parseadores de cadenas con argumentos del estilo key1=value1&key2=value. Finalmente esta funcionalidad se implementa en los propios scripts que se ejecuten con estos argumentos.

Procesamiento de peticiones y construcción de respuestas HTTP

Una vez se ha iniciado la conexión con un cliente, el servidor entra en un estado de pausa donde se espera que el cliente realice alguna petición. Cuando el cliente se comunica y su petición es correctamente descompuesta, el procede a responder adecuadamente.

Errores del cliente / HTTP 4xx

Existen diferentes condiciones en las que el servidor contestará con una respuesta predefinida: una breve página HTML con una cabecera con uno de los códigos de error HTTP en el rango 400-499.

  • 400 - Bad Request: Si el servidor no ha fallado internamente procesando la petición del cliente, pero se percibe un fallo igualmente en el parseo, se asume que la petición contiene errores de sintaxis.
  • 404 - Not Found: El servidor procesa correctamente la petición, pero el recurso solicitado es un directorio o no existe.
  • 415 - Unsupported Media Type: El servidor encuentra el recurso solicitado, pero el tipo del recurso no está soportado en la implementación (ver código de src/finder.c para una lista completa de los tipos soportados).

Errores del servidor / HTTP 5xx

Llegados al punto en el que sabemos que la petición del cliente es correcta, aún es posible que no podamos servir el recurso solicitado.

  • 500 - Internal Server Error: Nuestra implementación utiliza este código para indicar timeout en la ejecución de un script.
  • 501 - Not Implemented: El verbo de la petición no está implementado.

Los verbos implementados por el servidor son GET, HEAD, POST y OPTIONS. Los tres primeros resultan en la carga y envío (en el caso de HEAD, con el cuerpo de la respuesta truncado) de un recurso del servidor, ya sea un fichero o el resultado de un script en Python o PHP.

Una de las tareas que debe realizar el servidor al construir una respuesta ante cualquiera de las peticiones anteriores es crear cabeceras con información que se quiera mandar. En este caso, dichas cabeceras serán las siguientes:

  • Date: Fecha de envío de la respuesta
  • Server: Nombre del servidor
  • Allow (sólo para peticiones OPTIONS): Métodos soportados por el servidor
  • Content-Type (sólo para peticiones GET o POST): Tipo/extensión del recurso solicitado
  • Content-Lenght (sólo para peticiones GET o POST, o en respuestas de errores): Tamaño del recurso solicitado
  • Last-Modified (sólo para peticiones GET o POST): Fecha de última modificación del recurso solicitado

La construcción de estas cabeceras es tarea de la función header_build, cuyas decisiones acerca de qué cabeceras incluir se basan en dos banderas, check_flag y options_flag. La primera de ellas se ha explicado ya en la sección del procesamiento de peticiones HTTP, y la segunda de ellas, como su nombre indica, sólo determina si se está construyendo una cabecera para responder a una petición OPTIONS.

Una vez se dispone de toda la información necesaria para construir la respuesta HTTP del servidor, la función http_response_build se encarga de darle estructura y formato. Recibe la versión, el código de respuesta, el mensaje asociado a dicho código, las cabeceras y el cuerpo de la respuesta, y devuelve una cadena en la que se contiene la respuesta construida. Vamos a detallar, entonces, el proceso para construir respuestas para cada método soportado:

  • OPTIONS

La construcción de una respuesta tras una petición OPTIONS es la más sencilla. Con header_build se crean las cabeceras correspondientes (fecha, nombre del servidor y métodos soportados); se llama posteriormente a http_response_build con código 200, mensaje "OK" y sin cuerpo, y se devuelve la cadena generada.

  • POST

En este caso hay que manejar la búsqueda y entrega del recurso solicitado en la petición. Se sigue el siguiente esquema:

post(socket):
	create_real_path(server_root, path)
	finder_load(real_path)
	header_build(real_path, content_type, content_length)
	http_response_build(version, code, message, headers, body)
	tcp_send(socket, buffer)

En primer lugar, se concatenan las cadenas server_root y path para generar el directorio real donde se encuentra el recurso solicitado, y se usa finder_load para gestionar su búsqueda y entrega. Si dicho recurso no existe, se crea una respuesta con código de error 404; y si existe, se devuelve para incluirlo en el cuerpo de la respuesta.

Tras la construcción de las cabeceras y de la respuesta, se ejecuta tcp_send para entregarle el recurso al cliente, como hemos mencionado, con una respuesta HTTP en la que el fichero se entregue dentro del cuerpo. Si el tamaño de la respuesta fuera demasiado grande como para que la función lo enviara de una sola vez, se entra en un bucle de envío hasta que se envía por completo.

  • GET

La creación de respuestas para este método sigue un esquema casi idéntico al de la petición POST, con la diferencia de que el manejo de argumentos aquí es diferente (se incluyen en la URI en lugar de en el cuerpo):

get(socket):
	http_request_get_split(path)
	create_real_path(server_root, file_path)
	finder_load(real_path)
	header_build(real_path, content_type, content_length)
	http_response_build(version, code, message, headers, body)
	tcp_send(socket, buffer)

En primer lugar separamos la cadena path en dos, el directorio real correspondiente al recurso y los argumentos para la ejecución de scripts. Una vez tenemos el directorio real, los siguientes pasos son exactamente los mismos que para la respuesta a una petición POST.

  • HEAD

La implementación de este verbo es literalmente una copia de la implementación de GET, con la única diferencia de que la respuesta se construye sin cuerpo.

Ejecución de scripts en PHP y Python

Para la ejecución de scripts inicialmente se desarrolla un módulo que probamos en src/testcgi.c, donde se hace uso de expresiones regulares para determinar el lenguaje del script solicitado y se ejecuta script en un "entorno controlado".

Usando expresiones regulares determinamos la extensión del fichero solicitado (inicialmente implementamos .py y .php). En caso de encontrar alguna de ellas, iniciamos un proceso de fork + exec donde lanzamos el intérprete adecuado.

Usando un proceso hijo y un algoritmo externo redirigimos el input y output estándar del hijo hacia unas tuberías (pipe) que controla el padre, sobre las cuales podemos mandar los parámetros deseados y leer el output del script. El padre leerá el output del script hasta que aparezca en la secuencia una línea cuyo único contenido sean los caracteres de control \r\n, que indicará el final del script.

(Los scripts implementados inicialmente se encuentran en el directorio scripts/. hellothere.php lee el input estándar e imprime una cadena con un saludo, si lo encuentra asociado a la clave "name". fahrenheit.py intenta parsear un número en coma flotante y la escala desde la que se quiere convertir almacenadas en las claves "temp" y "scale" respectivamente, y lo convierte a la cantidad de grados Fahrenheit o Celsius equivalente. endless.py simplemente se ejecuta indefinidamente sin imprimir por el output estándar: sirve para probar el timeout implementado en el módulo CGI.)