Programación de redes en Linux: Sockets en C

Introducción: Los sockets

Programar aplicaciones de red quiere decir hacer aplicaciones que de alguna manera puedan enviar o recibir bytes de otras aplicaciones a través de una red. Y eso de enviar o recibir bytes es análogo a escribir o leer un archivo. En el caso de un archivo los bytes van a parar al disco rígido, en el caso de la red, van a parar a quién-sabe-dónde.

Lo interesante de esta analogía es que en Linux (y en UNIX en general) una conexión de red termina siendo casi igual a un archivo, y leemos y escribimos con las mismas funciones (read, write). Y eso es porque son simplemente file descriptors (lo mismo que se obtiene al abrir un archivo). Lo que cambia es como se obtienen estos descriptores de archivo: no se usa la función open con la que abríamos un archivo, sino que existe toda una API nueva.

A los descriptores de archivo destinados a mover bytes por una red se los conoce como sockets. Y la función para crear un socket se llama, justamente, socket. Esta función se usa tanto para clientes como para servidores. Su prototipo es:

int socket(int dominio, int tipo, int protocolo);

Y sus parámetros son:

dominio
La familia de protocolos que se piensa utilizar. Para redes hoy en día debe ser siempre AF_INET6.
tipo
Si se piensa usar una conexión SOCK_STREAM, para solamente enviar paquetes aislados SOCK_DGRAM.
protocolo
Por lo general siempre se pone 0, porque con los otros parámetros Linux ya sabe qué protocolo usar (ej. TCP o UDP).

Va a quedar más claro viendo los ejemplos que pongo más adelante.

Direcciones

Ya sea para hacer un cliente, o para hacer un servidor, es necesario manejar "direcciones". Las direcciones se manejan en unos structs medio extraños, y son medio extraños porque esta API fue diseñada para poder ser utilizada con protocolos muy diferentes, y que cada uno de esos protocolos tenga un esquema de manejo de direcciones completamente distinto. Afortunadamente ya no es necesario meterse en cómo son esos structs y en sus extrañezas y podemos manejarlas como un puntero (de tipo struct sockaddr*) a algo que no necesitamos saber qué es, y una variable indicando el tamaño de ese bloque de memoria.

La principal función para obtener una dirección es getaddrinfo. Este es su prototipo:

int getaddrinfo(const char *nodo, const char *servicio, const struct addrinfo *hints, struct addrinfo **res);

nodo
La representación textual de una dirección, o sea: www.example.com o 190.2.3.5. En el primer caso la función automaticamente hará la resolución DNS.
servicio
El nombre de un servicio o el número de puerto. El nombre de servicio se usa para buscar el número de puerto en /etc/services, por ejemplo si ponemos telnet, busca en ese archivo y usa 23.
hints
Una estructura con más indicaciones.

Esta función, y su contraparte getnameinfo no participan del mencanismo normal de errores de Linux (perror, strerror). Los códigos de error pueden ser convertidos a mensajes inteligibles mediante la función específica gai_strerror.

La función getaddrinfo es más avanzada que gethostbyname y la reemplaza, por lo que esta última puede considerarse obsoleta. Otras funciones que quedan obsoletas son inet_aton, inet_pton, e inet_addr.

A no desesperar! Más abajo en los casos concretos se va a entender mejor cómo se usa todo esto

Cómo hacer un cliente

Con la palabra cliente llamamos a la aplicación que inicia la conexión, y por lo tanto elige con quién conectarse, y cuándo.

Para conectarse sólo hay que obtener el socket con la función que ya dije y después "conectarlo" con el destino, con la función connect. Pero vamos paso por paso, pongámonos en modo "tutorial" =b. Primero declaremos variables que después usaremos:

	struct addrinfo hints, *res;
	int rc, s;
	char buf[8192];
	ssize_t n;

Ahora, para llamar a connect necesitamos primero tener la dirección a la que conectarnos. Eso lo conseguimos con la función getaddrinfo. En este caso, se necesita preparar el parámetro hints (la estructura) de la siguiene manera:

	memset(&hints, 0, sizeof(hints));
	hints.ai_socktype = SOCK_STREAM; 

Si en vez de necesitar una conexión se requiere enviar simples paquetes (UDP) se debe usar SOCK_DGRAM en vez de SOCK_STREAM. Ahora hacemos la llamada:

	rc = getaddrinfo("www.reloco.com.ar", "www", &hints, &res);
	if(rc)
	{
		fprintf(stderr, "%s: %s", argv[0], gai_strerror(rc));
		return EXIT_FAILURE;
	}

El "www" que aparece se busca en /etc/services y entonces se usa 80 como puerto de destino (el puerto estándar del protocolo HTTP). Fijate como el mensaje de error se obtiene con gai_strerror.

En este punto, la variable res apunta a una lista enlazada de estructuras. Cada una de ellas corresponde a una posible dirección con la que podemos conectarnos. Supuestamente debemos preferir la primera, con lo que la mayoría de las veces se puede ignorar el que sea una lista (Aunque idealmente se deberían ir probando todas las de la lista hasta que la conexión se realice). Ahora simplemente debemos conectarnos:

	rc = connect(s, res->ai_addr, res->ai_addrlen);
	if(rc < 0)
	{
		perror("connect");
		return EXIT_FAILURE;
	}

	/* Liberemos "res", ya no lo necesitamos */
	freeaddrinfo(res);

En este punto ya estamos conectados al servidor y podemos usar el descriptor de archivo s para leer y escribir!

#define M "GET / HTTP/1.1\r\nHost: www.debian.org\r\nConnection: close\r\n\r\n"
	write(s, M, strlen(M));

	while((n = read(s, buf, sizeof(buf))))
	{
		if(n < 0)
		{
			perror("read");
			return EXIT_FAILURE;
		}
		printf("recibí: %.*s", (int)n, buf);
	}
	
	close(s);

Este código escribe un requerimiento HTTP, y muestra el resultado en pantalla. Ahora vamos a meternos un poco en cómo programar el otro lado, cómo hacer un servidor.

Cómo hacer un servidor

Un servidor es una aplicación que se queda esperando que otra se conecte. Por lo general se espera que un servidor atienda a varios clientes a la vez, esto se puede lograr de varias maneras:

La opción que voy a demostrar en este artículo es quizá la más flexible de todas, que es la que utiliza threads. Cada uno de los threads terminará ejecutando una función, que implementará el "protocolo". Un ejemplo:

void *atender_cliente(void *arg)
{
	int cl = (long)arg;
	write(cl, "hola!\r\n", 7);
	close(cl);
	return NULL;
}

Definamos ahora las variables que usarán en la función principal de este ejemplo:

	struct addrinfo hints, *res;
	int rc, s, val = 1;

Primero uno debe preparar la dirección en la que debe escuchar. ¿Qué quiere decir esto? Por lo general no nos interesa por qué dirección llegan las conexiones. En IP eso se indica escuchando en la dirección especial "0.0.0.0". Si una máquina tiene varias placas de red, se puede forzar que un servidor atienda por sólo una de esas placas indicando la dirección asignada aa esa placa. Un uso interesante es para la creación de servidores que solo deban aceptar conexiones desde la máquina en la que corren, en ese caso, se usa "localhost". Para el caso normal, en el que queremos escuchar cualquier conexión entrante, hacemos así:

	memset(&hints, 0, sizeof(hints));
	hints.ai_socktype = SOCK_STREAM;
	hints.ai_flags = AI_PASSIVE;

	rc = getaddrinfo(NULL, "12345", &hints, &res);
	if(rc)
	{
		fprintf(stderr, "%s: %s", argv[0], gai_strerror(rc));
		return EXIT_FAILURE;
	}

En este ejemplo, 12345 es el puerto por el que deberán entrar las conexiones que quieran ser atendidas por este programa.

También necesitamos un soket para un servidor, vamos a obtenerlo:

	s = socket(res->ai_family, SOCK_STREAM, res->ai_protocol);
	if(s < 0)
	{
		perror("socket");
		return EXIT_FAILURE;
	}

	/* Configuración típica del socket que se hace en serviores.
	 * La variable val es un int con el valor "1".
	 */
	setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &val, sizeof(val));

Antes habíamos obtenido la dirección en la que escucharíamos, ahora es el momento de indicarle al socket cuál es esa dirección. Incluso aunque no querramos escuchar en ninguna en particular, debemos indicarle al socket sa dirección. Para esto utilizamos la operación bind para especificar el puerto en el que se desea escuchar.

	rc = bind(s, res->ai_addr, res->ai_addrlen);
	if(rc < 0)
	{
		perror("connect");
		return EXIT_FAILURE;
	}

Pero este socket no es para leer y escribir. Por eso, en lugar de llamar a la función connect, vamos a convertirlo a otro tipo especial de sockets, a un socket servidor, y eso se hace con la función listen.

	rc = listen(s, 10);
	if(rc < 0)
	{
		perror("listen");
		return EXIT_FAILURE;
	}

Luego, mediante la función accept sobre este socket se van recibiendo, por cada conexión entrante, sockets para comunicarse con los clientes.

	while(1)
	{
		int cl;
		struct sockaddr addr;
		socklen_t addrlen = sizeof(addr);
		pthread_t thread;

		cl = accept(s, &addr, &addrlen);
		if(cl < 0)
		{
			perror("accept");
			return EXIT_FAILURE;
		}
		pthread_create(&thread, NULL, atender_cliente, (void*)(long)cl);
	}

En este ciclo podemos ver un par de cosas. La función accept nos devuelve, no sólo la conexión con el cliente, sino la dirección desde la cuál el cliente se está conectando. Esta dirección es un puntero a un bloque de memoria, cuyo tamaño lo recibimos también (lo guardo en addrlen). Para convertir esta dirección en un string imprimible (por ejemplo para guardarlo en un log) podemos usar la función getnameinfo de esta manera:

	char host[NI_MAXHOST];

	/* ... */

	getnameinfo(&addr, addrlen, host, sizeof(host)
		, NULL, 0, NI_NUMERICHOST);

Si en vez de NI_NUMERICHOST ponemos un 0, la función intentará resolver el nombre (lo que puede tardar un poco de tiempo, así que hay que tener cuidado con eso). Antes se usaban inet_ntop, o inet_ntoa, pero esta es una manera más moderna.

La otra cuestión imporante aquí es el uso de pthread_create. Luego de llamar a esta función, el código de atender_cliente empieza a ejecutarse "de fondo", de esta manera este thread puede volver rápidamente al accept para esperar a una nueva conexión. Mientras tanto, atender_cliente puede pasarse todo el tiempo que quiera dialogando con su cliente. Como este código usa threads es importante recordar que debe ser compilado y enlazado con la opción -pthread del GCC para que las funciones estándar sepan que deben ser "threadsafe".

Uno de los parámetros de pthread_create es un puntero que será pasado tal cual a la función que corre en el nuevo thread. Yo no quiero pasar un puntero a nada, pero uso esa variable para pasar el socket. Lo "casteo" desde y a "long" porque long es un tipo que suele tener la misma cantidad de bits que los punteros de la plataforma (int puede ser más chico).

Que no te de miedo usar threads, para servidores es una opción bastante sensata. Y si los threads no comparten cosas entre sí tampoco hay demasiado riesgo de meter la pata. Si comparten de alguna manera recursos y variables es necesario meterse un poco con mecanismos de sincronización, tales como mútexes, semáforos, etc.

Una cosa que se puede querer hacer comunmente es enviar un archivo por el socket. Eso es lo que haría un servidor web, por ejemplo. Para hacer eso más rápido, Linux tiene una función específica, llamanda sendfile. La ventaja de utilizar esta función es que el kernel evita copiar los datos: Los mueve directo, de la memoria en la que lo entrega el filesystem, a la placa de red. Consultá cómo se usa en la página de manual.


Mis otros artículos sobre programación en Linux.


Por Nicolás Lichtmaier. Cualquier comentario o pedido de mayor claridad o extensión será bien recibido.