Intro

Vamos a crear un proxy de la capa HTTP en NodeJs sin necesidad de ninguna librería. Existen varios blogs en internet sobre como implementar un proxy y la mayoria utilizan librerías que proveen la funcionalidad o la implementación no es suficiente clara bajo mi punto de vista.

Es interesante desde el punto de vista de desarrollo como organizar en código las peticiones del cliente y las que redirige el proxy en su lugar.

Diseño

Un proxy es una pieza de software que se encarga de hacer de intermediario entre las peticiones de una máquina a otra. En nuestro caso el proxy hará de intermediario entre las peticiones del cliente hacia internet.

proxy_diagram

Vamos a utilizar las palabras del diagrama para representar los conceptos en el código. De esta manera se vuelve más sencillo de trabajar. Aparte de poder consultar el diagrama y entenderlo más fácilmente.

El vocabulario es:

  • Proxy Server
  • Client Request
  • Client Response
  • Proxy Request
  • Proxy Response

Como hemos dicho el objetivo es un servidor proxy HTTP, por lo tanto las peticiones esperables son los de esta capa. Las peticiones que el servidor espera recibir son peticiones comunes HTTP y peticiones HTTPS.

HTTP

El formato de peticiones HTTP son las estándar de HTTP. Generalmente el método utilizado es el GET, aunque haremos el código para que soporte cualquier método a través del proxy.

El formato estándar de una petición HTTP es el que todos conocemos:

  1. Método HTTP + Recurso Solicitado + Versión del protocolo \r\n
  2. Cabeceras \n
  3. Cuerpo del mensaje

La primera línea con el verbo HTTP seguida del recurso soliticado y la versión del protocolo. Luego sigue las cabeceras HTTP y finalmente el texto del recurso solicitado, que suele ser un HTML si pedimos una página web.

Podemos hacer un curl a cualquier web y ver la salida con curl:

$ curl -vv www.hardfloat.es

> GET / HTTP/1.1
> Host: www.hardfloat.es
> User-Agent: curl/7.84.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 302 Moved Temporarily
< Server: nginx/1.21.6
< Date: Fri, 08 Jul 2022 23:24:46 GMT
< Content-Type: text/html
< Content-Length: 145
< Connection: keep-alive
< Location: https://www.hardfloat.es/
< 
<html>
<head><title>302 Found</title></head>
<body>
<center><h1>302 Found</h1></center>
<hr><center>nginx/1.21.6</center>
</body>
</html>

HTTPS

Las peticiones HTTPS al ser encriptadas utilizan el método CONNECT mediante el cual el cliente solicita al proxy que redireccione la petición TCP al destino solicitado por el cliente. Sólo la primera petición es HTTP y luego el resto de la comunicación ocurre en a través de la conexión TCP. De esta manera el tráfico permanece encriptado a pesar de que el proxy esté en medio de la comunicación. A esto se le conoce como HTTP Tunnel

Una vez el proxy haya recibido el método CONNECT ha de devolver un 200 al cliente para poder establecer de forma correcta el tunel.

La petición CONNECT se parece a una petición cualquiera de HTTP pero con el método CONNECT.

> CONNECT hardfloat.es:443 HTTP/1.1
> user-agent': 'PostmanRuntime/7.29.0
> accept: '*/*'
> accept-encoding': 'gzip, deflate, br'
> connection: keep-alive
> host: 'hardfloat.es:443'

Por lo tanto necesitamos un servidor que atienda a peticiones HTTP. Para el caso de peticiones estándar HTTP se realiza la petición en nombre del cliente y se le devuelve el resultado. Para el caso de peticiones HTTPS se atiende al método CONNECT para establecer un enlace TCP y redigir la conversación entre el cliente y el destino.

Github

Si quieres ver el código directamente en Github aqui dejo el enlace:

Implementación

Según se ha visto en la parte de diseño queremos respetar el vocabulario del diagrama.

Lo primero es crear un servidor HTTP estándar que reciba peticiones. En NodeJs los eventos son los siquientes:

  • Evento ‘Request’: Ocurre cuando se recibe una petición
  • Evento ‘Connect’: NodeJs crea un evento especial para el método CONNECT. Además de pasar diferentes parámetros en este evento.
const http = require('node:http');
const { http_proxy, https_proxy} = require('./proxy');

const PORT = 8080 || process.env.PORT;
const proxyServer = http.createServer();

proxyServer.listen(PORT, () => {
    console.log(`proxyServer started at ${PORT}`);
});

//HTTP requests
proxyServer.on('request', (clientRequest, clientResponse) => {
});

//HTTPS request are done through CONNECT method
proxyServer.on('connect', (clientRequest, clientSocket, head) => {
});

proxyServer.on('clientError', (error, socket) => {
    console.log('proxyServer::clientError');
    socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});

Ya tenemos un servidor escuchando peticiciones HTTP. Ahora queda montar la parte central del proxy, esto es, realizar las peticiones del cliente en su nombre y devolverle la respuesta. Debido a que Node sin frameworks hace uso extensivo de callbacks, vamos a abstraer la lógica del proxy en funciones. A continuación pongo un fragmento de código:

//HTTP requests
proxyServer.on('request', (clientRequest, clientResponse) => {
    http_proxy(clientRequest, clientResponse);
});

//HTTPS request are done through CONNECT method
proxyServer.on('connect', (clientRequest, clientSocket, head) => {
    https_proxy(clientRequest, clientSocket, head);
});

Vamos a ver al detalle la función http_proxy que se encarga de realizar la petición del cliente (clientRequest) al destino y devolverle la respuesta (clientResponse)

function append_x_forwarded_for_header(clientRequest) {
    clientRequest.headers['X-Forwarded-For'] = clientRequest
                                                .socket
                                                .remoteAddress;
}

function removePortFromURL(host) {
    return host.substring(0, host.indexOf(':'));
}

function get_options(clientRequest) {
    const clientRequestURL = new URL(clientRequest.url);
    return {
        host: removePortFromURL(clientRequestURL.host),
        port: clientRequestURL.port,
        path: clientRequestURL.pathname,
        auth: clientRequestURL.auth,
        method: clientRequestURL.method,
        headers: clientRequest.headers
    }
}

function http_proxy(clientRequest, clientResponse) {
    append_x_forwarded_for_header(clientRequest);
    const options = get_options(clientRequest);
    const proxyRequest = http.request(options, (proxyResponse) => {
        clientResponse.writeHead(proxyResponse.statusCode, proxyResponse.headers);
        proxyResponse.pipe(clientResponse, {end: true});
    });
    proxyRequest.end();
    function end_proxy_request(error) {
        proxyRequest.end()
    }
    proxyRequest.on('error', end_proxy_request);
    proxyRequest.on('close', end_proxy_request);
    clientRequest.on('close', end_proxy_request);
    clientRequest.on('error', end_proxy_request);
}

Como se puede observar en el código, NodeJs provee el método pipe que sirve para devolver la respuesta del servidor. Este método es bastante conveniente. Por otro lado la gestión de errores es parecida: desde que haya un error cerrar la petición del proxy.

La función https_proxy es ligeramente diferente pero sigue el mismo concepto.

function https_proxy(clientRequest, clientSocket, head) {
    append_x_forwarded_for_header(clientRequest);
    const { port, hostname } = new URL(`http://${clientRequest.url}`);

    const proxyRequest = net.connect(port, hostname, () => {
        clientSocket.write('HTTP/1.1 200 Connection Established\r\n' +
            'Proxy-agent: Node.js-Proxy\r\n' +
            '\r\n');
        //send the client headers
        proxyRequest.write(head);
        //pipe connection between parties
        proxyRequest.pipe(clientSocket, {end: true});
        clientSocket.pipe(proxyRequest, {end: true});

        function end_proxy_request(error) {
            proxyRequest.end()
        }
        //handle error or connection close
        proxyRequest.on('error', end_proxy_request);
        proxyRequest.on('close', end_proxy_request);
        clientRequest.on('error', end_proxy_request);
        clientRequest.on('close', end_proxy_request);
        clientSocket.on('error', end_proxy_request);
    });
}

Como vemos al recibir la petición CONNECT devolvemos al cliente un 200 y procedemos a establecer la conexión con pipe.

La mayor parte del tráfico del proxy irá por esta via ya que prácticamente la totalidad de las peticiones se hacen por https. No difiere mucho de la función anterior. Con la información obtenida de la petición CONNECT realizamos una conexión TCP al destino y redirigimos los datos de conexión hacia el cliente con los métodos pipe. En este caso NodeJs nos provee de un objeto socket que ace referencia a la conexión TCP del cliente para que podamos hacer el tunneling.

Testing

Postman

Durante el desarrollo he utilizado Postman que provee de una utilidad que permite configurar un proxy http de las peticiones que se hagan a través de el. Nos viene perfecto para nuestro caso ya que podemos configurar una petición a Google en Postman y en lugar de hacerla directamente la pasa por nuestro proxy, de esta forma tenemos un punto de entrada para comenzar el desarrollo. Asi no tenemos que montar la petición a mano que solicite un recurso a Google pero que la envíe al proxy en su lugar.

La parte más interesante está en el método CONNECT, ya que configurando en Postman que solicite un recurso a traves de HTTPS, Postman va a realizar el método CONNECT a nuestro proxy en su lugar.

Firefox

Aquí está la parte que prueba de forma real el proxy que hemos desarrollado. Si vamos a la pestaña de configuración podems configurar un proxy para que el navegador realice las peticiones a través de nosotros. Los datos que tenemos que rellenar son:

  • HTTP Proxy: localhost
  • HTTP Proxy Port: 8080
  • Habilitar la opción: “Also use this proxy for HTTPS”

Tests unitarios

Luego de realizar las pruebas usando los puntos anteriores me dispuse a a pensar sobre como regresionar el proxy. Para poder regresionarlo de forma automática es necesario levantar un servidor HTTP que recibirá las peticiones del cliente a través del proxy.

Este servidor le he llamado Mock Server y la idea es que en cada caso de prueba se establece de antemano la respuesta del Mock Server asi como el código de error y las cabeceras de forma que es posible poder establecer la respuesta esperada que debería recibir el cliente a través del proxy.

mock_server

Se puede ver el código en el respositorio de Github

Docker

Como bola extra también he escrito Dockerfile para desplegar la aplicación en contenedores:

FROM node:18
WORKDIR /app
COPY ./src/* package.json package-lock.json ./
RUN npm install
CMD ["node", "index.js" ]
docker build -t andrew7293/proxyjs .
docker run -p 8080:8080 andrew7293/proxyjs