Projeto final: implementando um servidor HTTP minimalista em C

1. Fundamentos do protocolo HTTP e arquitetura do servidor

1.1. Visão geral do HTTP/1.0 e HTTP/1.1

O protocolo HTTP (Hypertext Transfer Protocol) opera sobre TCP na porta 80. No HTTP/1.0, cada requisição abre uma nova conexão TCP, enquanto no HTTP/1.1 introduziu-se o conceito de conexões persistentes (keep-alive). Uma requisição HTTP típica contém método (GET, POST), URI, versão do protocolo e cabeçalhos no formato Chave: Valor.

1.2. Estrutura básica de um servidor TCP em C

O ciclo fundamental de um servidor TCP envolve quatro chamadas de sistema:

#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>

int server_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in address;
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8080);

bind(server_fd, (struct sockaddr*)&address, sizeof(address));
listen(server_fd, 10);

while (1) {
    int client_fd = accept(server_fd, NULL, NULL);
    // processar requisição
    close(client_fd);
}

1.3. Modelo de concorrência simplificado

Para este projeto, adotaremos um modelo single-thread com loop de eventos, suficiente para servir arquivos estáticos com baixa concorrência. O servidor aceita uma conexão, processa a requisição, envia a resposta e fecha o socket antes de aceitar a próxima conexão.

2. Parsing da requisição HTTP e tratamento de buffers

2.1. Leitura do socket com buffers de tamanho fixo

Utilizamos um buffer de 4096 bytes para ler dados parciais do socket. A função recv() retorna a quantidade de bytes efetivamente lidos:

#define BUFFER_SIZE 4096

char buffer[BUFFER_SIZE];
int bytes_read = recv(client_fd, buffer, BUFFER_SIZE - 1, 0);
if (bytes_read < 0) {
    perror("recv failed");
    return;
}
buffer[bytes_read] = '\0';

2.2. Análise manual da linha de requisição

Extraímos método, URI e versão do HTTP usando sscanf:

char method[16], uri[256], version[16];
if (sscanf(buffer, "%15s %255s %15s", method, uri, version) != 3) {
    send_error(client_fd, 400, "Bad Request");
    return;
}

2.3. Validação mínima de campos obrigatórios

Verificamos se o método é suportado (GET) e se a versão é HTTP/1.0 ou HTTP/1.1:

if (strcmp(method, "GET") != 0) {
    send_error(client_fd, 501, "Not Implemented");
    return;
}
if (strncmp(version, "HTTP/", 5) != 0) {
    send_error(client_fd, 400, "Bad Request");
    return;
}

3. Implementação do roteador e resposta estática

3.1. Mapeamento de URI para caminhos no sistema de arquivos

Sanitizamos o caminho para evitar ataques de path traversal:

char filepath[512];
snprintf(filepath, sizeof(filepath), "./www%s", uri);

// Prevenir path traversal
if (strstr(filepath, "..") != NULL) {
    send_error(client_fd, 400, "Bad Request");
    return;
}

3.2. Leitura de arquivos estáticos e construção do corpo da resposta

Abrimos o arquivo solicitado e lemos seu conteúdo:

FILE *file = fopen(filepath, "rb");
if (!file) {
    send_error(client_fd, 404, "Not Found");
    return;
}

fseek(file, 0, SEEK_END);
long file_size = ftell(file);
rewind(file);

char *body = malloc(file_size + 1);
fread(body, 1, file_size, file);
body[file_size] = '\0';
fclose(file);

3.3. Montagem manual do cabeçalho HTTP

Construímos a resposta completa com cabeçalhos essenciais:

char response[4096];
int header_len = snprintf(response, sizeof(response),
    "HTTP/1.1 200 OK\r\n"
    "Content-Type: text/html\r\n"
    "Content-Length: %ld\r\n"
    "Connection: close\r\n"
    "\r\n",
    file_size);

send(client_fd, response, header_len, 0);
send(client_fd, body, file_size, 0);
free(body);

4. Gerenciamento de conexões e tratamento de erros HTTP

4.1. Controle de keep-alive vs. close

Para simplicidade, nosso servidor sempre fecha a conexão após responder. O cabeçalho Connection: close informa ao cliente que não haverá reutilização do socket.

4.2. Respostas de erro padronizadas

Implementamos funções auxiliares para erros comuns:

void send_error(int client_fd, int status_code, const char *reason) {
    char body[256];
    int body_len = snprintf(body, sizeof(body),
        "<html><body><h1>%d %s</h1></body></html>", status_code, reason);

    char response[512];
    int header_len = snprintf(response, sizeof(response),
        "HTTP/1.1 %d %s\r\n"
        "Content-Type: text/html\r\n"
        "Content-Length: %d\r\n"
        "Connection: close\r\n"
        "\r\n%s",
        status_code, reason, body_len, body);

    send(client_fd, response, header_len, 0);
}

4.3. Limpeza de recursos

Garantimos que todo recurso alocado seja liberado antes de fechar o socket:

void handle_client(int client_fd) {
    char buffer[BUFFER_SIZE];
    int bytes_read = recv(client_fd, buffer, BUFFER_SIZE - 1, 0);
    if (bytes_read <= 0) {
        close(client_fd);
        return;
    }

    // processamento da requisição...

    close(client_fd);
}

5. Integração com limites de recursos (ulimit e cgroups)

5.1. Ajuste de ulimit para número máximo de arquivos abertos

Antes de executar o servidor, configuramos limites do sistema:

#include <sys/resource.h>

struct rlimit limit;
limit.rlim_cur = 1024;
limit.rlim_max = 4096;
setrlimit(RLIMIT_NOFILE, &limit);

5.2. Uso de cgroups para limitar memória e CPU

Em sistemas Linux, podemos verificar limites impostos por cgroups lendo arquivos em /sys/fs/cgroup/:

FILE *mem_limit = fopen("/sys/fs/cgroup/memory/memory.limit_in_bytes", "r");
if (mem_limit) {
    unsigned long limit;
    fscanf(mem_limit, "%lu", &limit);
    printf("Memory limit: %lu bytes\n", limit);
    fclose(mem_limit);
}

5.3. Verificação em tempo de execução

Consultamos limites ativos para ajustar o comportamento do servidor:

long max_fds = sysconf(_SC_OPEN_MAX);
printf("Maximum open file descriptors: %ld\n", max_fds);

6. Portabilidade e detecção de plataforma em compile-time

6.1. Macros condicionais para diferenciar plataformas

Usamos #ifdef para adaptar o código a diferentes sistemas operacionais:

#ifdef __linux__
    #include <sys/sendfile.h>
#elif defined(__APPLE__)
    #include <sys/uio.h>
#endif

6.2. Adaptação de funções de socket específicas

No Linux, podemos usar sendfile para enviar arquivos eficientemente; em outros sistemas, usamos write com buffer:

#ifdef __linux__
    off_t offset = 0;
    sendfile(client_fd, file_fd, &offset, file_size);
#else
    write(client_fd, body, file_size);
#endif

6.3. Inclusão condicional de cabeçalhos

Garantimos que apenas cabeçalhos disponíveis na plataforma sejam incluídos:

#ifdef __linux__
    #include <features.h>
#endif
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

7. Compilação, testes e considerações finais

7.1. Makefile simples

CC = gcc
CFLAGS = -Wall -Wextra -O2
LDFLAGS =

all: server

server: server.c
    $(CC) $(CFLAGS) -o server server.c $(LDFLAGS)

debug: CFLAGS += -g -DDEBUG
debug: server

clean:
    rm -f server

.PHONY: all debug clean

7.2. Testes manuais com curl

# Teste básico
curl -v http://localhost:8080/index.html

# Teste de erro 404
curl -v http://localhost:8080/nao-existe.html

# Teste com cabeçalhos
curl -H "Connection: close" http://localhost:8080/

7.3. Limitações e próximos passos

Este servidor minimalista não suporta:
- Requisições POST e outros métodos HTTP
- Conexões persistentes (keep-alive)
- HTTPS/TLS
- Multiplexação com select/poll/epoll

Para evoluir o projeto, implemente suporte a POST com parsing de corpo, adicione select() para multiplexação e integre OpenSSL para TLS.


Referências