Leer y escribir datos a través de una conexión de socket segura

Una conexión segura tiene su propio conjunto de funciones de intercambio de datos entre el cliente y el servidor. Los nombres y el concepto de operación de las funciones coinciden prácticamente con los de las funciones SocketRead y SocketSend consideradas anteriormente.

int SocketTlsRead(int socket, uchar &buffer[], uint maxlen)

La función SocketTlsRead lee datos de una conexión TLS segura abierta en el socket especificado. Los datos se introducen en el array buffer pasado por referencia. Si es dinámico, su tamaño se incrementará según la cantidad de datos pero no más de INT_MAX (2147483647) bytes.

El parámetro maxlen especifica el número de bytes descifrados que se recibirán (su número es siempre inferior a la cantidad de datos cifrados «en bruto» que llegan al búfer interno del socket). Los datos que no caben en el array permanecen en el socket y pueden ser recibidos por la siguiente llamada a SocketTlsRead.

La función se ejecuta hasta que recibe la cantidad de datos especificada o hasta que se produce el tiempo de espera especificado en SocketTimeouts.

En caso de éxito, la función devuelve el número de bytes leídos; en caso de error, devuelve -1, mientras que el código 5273 (ERR_NETSOCKET_IO_ERROR) se escribe en _LastError. La presencia de un error indica que la conexión ha finalizado.

int SocketTlsReadAvailable(int socket, uchar &buffer[], const uint maxlen)

La función SocketTlsReadAvailable lee todos los datos descifrados disponibles de una conexión TLS segura, pero no más de maxlen bytes. A diferencia de SocketTlsRead, SocketTlsReadAvailable no espera la presencia obligatoria de una cantidad determinada de datos y devuelve inmediatamente sólo los que están presentes. Así, si el búfer interno del socket está «vacío» (aún no se ha recibido nada del servidor, ya se ha leído o aún no se ha formado un bloque listo para el descifrado), la función devolverá 0 y no se registrará nada en el array de recepción buffer. Esta es una situación habitual.

El valor de maxlen debe estar comprendido entre 1 e INT_MAX (2147483647).

int SocketTlsSend(int socket, const uchar &buffer[], uint bufferlen)

La función SocketTlsSend envía datos desde el array buffer a través de una conexión segura abierta en el socket especificado. El principio de funcionamiento es el mismo que el de la función descrita anteriormente SocketSend, mientras que la única diferencia es el tipo de conexión.

Vamos a crear un nuevo script SocketReadWriteHTTPS.mq5 basado en el SocketReadWriteHTTP.mq5 anteriormente considerado, y a añadir flexibilidad en cuanto a la elección de un método HTTP (GET de manera predeterminada, no HEAD), estableciendo un tiempo de espera y admitiendo conexiones seguras. El puerto predeterminado es 443.

input string Method = "GET"// Method (HEAD,GET)
input string Server = "www.google.com";
input uint Port = 443;
input uint Timeout = 5000;

El servidor predeterminado es www.google.com. No olvide añadirlo (y cualquier otro servidor que introduzca) a la lista de permitidos en la configuración del terminal.

Para determinar si la conexión es segura o no, utilizaremos la función SocketTlsCertificate: si tiene éxito, el servidor ha proporcionado un certificado y el modo TLS está activo. Si la función devuelve false y lanza el código de error NETSOCKET_NO_CERTIFICATE(5275), significa que estamos utilizando una conexión normal pero el error puede ser ignorado y reiniciado, ya que estamos satisfechos con una conexión no segura.

void OnStart()
{
   PRTF(Server);
   PRTF(Port);
   const int socket = PRTF(SocketCreate());
   if(socket == INVALID_HANDLEreturn;
   SocketTimeouts(socketTimeoutTimeout);
   if(PRTF(SocketConnect(socketServerPortTimeout)))
   {
      string subjectissuerserialthumbprint
      datetime expiration;
      bool TLS = false;
      if(PRTF(SocketTlsCertificate(socketsubjectissuerserialthumbprintexpiration)))
      {
         PRTF(subject);
         PRTF(issuer);
         PRTF(serial);
         PRTF(thumbprint);
         PRTF(expiration);
         TLS = true;
      }
      ...

El resto de la función OnStart se ejecuta según el plan anterior: enviar una petición mediante la función HTTPSend y aceptar la respuesta mediante HTTPRecv. Pero esta vez, pasamos adicionalmente la bandera TLS a estas funciones, y deben implementarse de forma ligeramente diferente.

      if(PRTF(HTTPSend(socketStringFormat("%s / HTTP/1.1\r\nHost: %s\r\n"
         "User-Agent: MetaTrader 5\r\n\r\n"MethodServer), TLS)))
      {
         string response;
         if(PRTF(HTTPRecv(socketresponseTimeoutTLS)))
         {
            Print("Got "StringLen(response), " bytes");
            // for large documents, we will save to a file
            if(StringLen(response) > 1000)
            {
               int h = FileOpen(Server + ".htm"FILE_WRITE | FILE_TXT | FILE_ANSI0CP_UTF8);
               FileWriteString(hresponse);
               FileClose(h);
            }
            else
            {
               Print(response);
            }
         }
      }

Del ejemplo con HTTPSend, se puede ver que dependiendo de la bandera TLS, utilizamos SocketTlsSend o SocketSend.

bool HTTPSend(int socketconst string requestconst bool TLS)

   char req[];
   int len = StringToCharArray(requestreq0WHOLE_ARRAYCP_UTF8) - 1;
   if(len < 0return false;
   return (TLS ? SocketTlsSend(socketreqlen) : SocketSend(socketreqlen)) == len;
}

Las cosas son un poco más complicadas con HTTPRecv. Dado que ofrecemos la posibilidad de descargar toda la página (no sólo los encabezados), necesitamos alguna forma de saber si hemos recibido todos los datos. Incluso después de que se haya transmitido todo el documento, el socket suele dejarse abierto para optimizar futuras solicitudes previstas. Pero nuestro programa no sabrá si la transmisión se detuvo normalmente, o tal vez hubo una «congestión» temporal en algún lugar de la infraestructura de red (este tipo de carga de página relajada e intermitente puede observarse a veces en los navegadores). O viceversa, en caso de fallo de conexión, podemos creer erróneamente que hemos recibido el documento completo.

El hecho es que los propios sockets sólo actúan como medio de comunicación entre programas y trabajan con bloques abstractos de datos: desconocen el tipo de datos, su significado y su conclusión lógica. Todas estas cuestiones se gestionan mediante protocolos de aplicación como HTTP. Por lo tanto, tendremos que profundizar en las especificaciones e implementar las comprobaciones nosotros mismos.

bool HTTPRecv(int socketstring &resultconst uint timeoutconst bool TLS)
{
   uchar response[]; // accumulate the data as a whole (headers + body of the web document)
   uchar block[];    // separate read block
   int len;          // current block size (signed integer for error flag -1)
   int lastLF = -1;  // position of the last line feed found LF(Line-Feed)
   int body = 0;     // offset where document body starts
   int size = 0;     // document size according to title
   result = "";      // set an empty result at the beginning
   int chunk_size = 0chunk_start = 0chunk_n = 1;
   const static string content_length = "Content-Length:";
   const static string crlf = "\r\n";
   const static int crlf_length = 2;
   ...

El método más sencillo para determinar el tamaño de los datos recibidos se basa en analizar el encabezado «Content-Length:». Aquí necesitamos tres variables: lastLF, size y content_length. Sin embargo, este encabezado no siempre está presente, y operamos con «trozos»: se introducen las variables chunk_size, chunk_start, crlf y crlf_length para detectarlos.

Para demostrar diversas técnicas de recepción de datos, utilizamos en este ejemplo una función SocketTlsReadAvailable «no bloqueante». Sin embargo, no existe una función similar para una conexión insegura, por lo que tendremos que escribirla nosotros mismos (un poco más adelante). El esquema general del algoritmo es sencillo: se trata de un bucle con intentos de recibir nuevos bloques de datos de 1024 (o menos) bytes de tamaño. Si conseguimos leer algo, lo acumulamos en el array de respuesta. Si el búfer de entrada del socket está vacío, las funciones devolverán 0 y haremos una pequeña pausa. Por último, si se produce un error o se agota el tiempo de espera, el bucle se romperá.

   uint start = GetTickCount();
   do 
   {
      ResetLastError();
      if((len = (TLS ? SocketTlsReadAvailable(socketblock1024) :
         SocketReadAvailable(socketblock1024))) > 0)
      {
         const int n = ArraySize(response);
         ArrayCopy(responseblockn); // put all the blocks together
         ...
         // main operation here
      }
      else
      {
         if(len == 0Sleep(10); // wait a bit for the arrival of a portion of data
      }
   } 
   while(GetTickCount() - start < timeout && !IsStopped() && !_LastError);
   ...

En primer lugar, hay que esperar a que se complete la cabecera HTTP en el flujo de datos de entrada. Como ya hemos visto en el ejemplo anterior, los encabezados se separan del documento mediante una doble «línea nueva», es decir, mediante la secuencia de caracteres «\r\n\r\n». Es fácil de detectar por dos símbolos «\n» (LF) situados uno detrás de otro.

El resultado de la búsqueda será el desplazamiento en bytes desde el inicio de los datos, donde termina el encabezado y comienza el documento. Lo almacenaremos en la variable body.

         if(body == 0// look for the completion of the headers until we find it
         {
            for(int i = ni < ArraySize(response); ++i)
            {
               if(response[i] == '\n') // LF
               {
                  if(lastLF == i - crlf_length// found sequence "\r\n\r\n"
                  {
                     body = i + 1;
                     string headers = CharArrayToString(response0i);
                     Print("* HTTP-header found, header size: "body);
                     Print(headers);
                     const int p = StringFind(headerscontent_length);
                     if(p > -1)
                     {
                        size = (int)StringToInteger(StringSubstr(headers,
                           p + StringLen(content_length)));
                        Print("* "content_lengthsize);
                     }
                     ...
                     break// header/body boundary found
                  }
                  lastLF = i;
               }
            }
         }
         
         if(size == ArraySize(response) - body// entire document
         {
            Print("* Complete document");
            break;
         }
         ...

Esto busca inmediatamente el encabezado «Content-Length:» y extrae de él el tamaño. La variable size rellenada permite escribir una sentencia condicional adicional para salir del bucle de recepción de datos cuando se haya recibido todo el documento.

Algunos servidores dan el contenido en partes llamadas «trozos». En estos casos, la línea «Transfer-Encoding: chunked» está presente en el encabezado HTTP, y falta la línea «Content-Length:». Cada trozo comienza con un número hexadecimal que indica el tamaño del trozo, seguido de una nueva línea y el número especificado de bytes de datos. El trozo termina con otra nueva línea. El último trozo que marca el final del documento tiene un tamaño cero.

Tenga en cuenta que la división en dichos segmentos la realiza el servidor, basándose en sus propias «preferencias» actuales para optimizar el envío, y no tiene nada que ver con los bloques (paquetes) de datos en los que se divide la información a nivel de socket para su transmisión por la red. En otras palabras: los trozos tienden a fragmentarse arbitrariamente y el límite entre paquetes de red puede darse incluso entre dígitos de un tamaño de trozo.

Esquemáticamente, esto se puede representar de la siguiente manera (a la izquierda están los trozos del documento, y a la derecha, los bloques de datos del búfer del socket):

Fragmentación de un documento web durante la transmisión a los niveles HTTP y TCP

Fragmentación de un documento web durante la transmisión a los niveles HTTP y TCP

En nuestro algoritmo, los paquetes entran en el array block en cada iteración, pero no tiene sentido analizarlos uno por uno, y todo el trabajo principal va con el array de respuesta común.

Así, si el encabezado HTTP se recibe completamente pero no se encuentra en ella la cadena «Content-Length:», pasamos a la rama del algoritmo con el modo «Transfer-Encoding: chunked». Por la posición actual de body en el array response (inmediatamente después de la finalización de los encabezados HTTP), el fragmento de cadena se selecciona y convierte en un número asumiendo el formato hexadecimal: esto es realizado por la función auxiliar HexStringToInteger (véase el código fuente adjunto). Si realmente hay un número, lo escribimos en chunk_size, marcamos la posición como inicio del «trozo» en chunk_start y eliminamos los bytes con el número y las nuevas líneas de encuadre de response.

                  ...
                  if(lastLF == i - crlf_length// found sequence "\r\n\r\n"
                  {
                     body = i + 1;
                     ...
                     const int p = StringFind(headerscontent_length);
                     if(p > -1)
                     {
                        size = (int)StringToInteger(StringSubstr(headers,
                           p + StringLen(content_length)));
                        Print("* "content_lengthsize);
                     }
                     else
                     {
                        size = -1// server did not provide document length
                        // try to find chunks and the size of the first one
                        if(StringFind(headers"Transfer-Encoding: chunked") > 0)
                        {
                           // chunk syntax:
                           // <hex-size>\r\n<content>\r\n...
                           const string preview = CharArrayToString(responsebody20);
                           chunk_size = HexStringToInteger(preview);
                           if(chunk_size > 0)
                           {
                              const int d = StringFind(previewcrlf) + crlf_length;
                              chunk_start = body;
                              Print("Chunk: "chunk_size" start at "chunk_start" -"d);
                              ArrayRemove(responsebodyd);
                           }
                        }
                     }
                     break// header/body boundary found
                  }
                  lastLF = i;
                  ...

Ahora, para comprobar la integridad del documento, es necesario analizar no sólo la variable size (que, como hemos visto, en realidad puede desactivarse asignando -1 en ausencia de «Content-Length:»), sino también nuevas variables para los trozos: chunk_start y chunk_size. El esquema de actuación es el mismo que tras los encabezados HTTP: por desplazamiento en el array response, donde terminó el trozo anterior, aislamos el tamaño del siguiente «trozo». Continuamos el proceso hasta encontrar un trozo de tamaño cero.

         ...
         if(size == ArraySize(response) - body// entire document
         {
            Print("* Complete document");
            break;
         }
         else if(chunk_size > 0 && ArraySize(response) - chunk_start >= chunk_size)
         {
            Print("* "chunk_n" chunk done: "chunk_size" total: "ArraySize(response));
            const int p = chunk_start + chunk_size;
            const string preview = CharArrayToString(responsep20);
            if(StringLen(preview) > crlf_length              // there is '\r\n...\r\n' ?
               && StringFind(previewcrlfcrlf_length) > crlf_length)
            {
               chunk_size = HexStringToInteger(previewcrlf_length);
               if(chunk_size > 0)
               {                              // twice '\r\n': before and after chunk size
                  int d = StringFind(previewcrlfcrlf_length) + crlf_length;
                  chunk_start = p;
                  Print("Chunk: "chunk_size" start at "chunk_start" -"d);
                  ArrayRemove(responsechunk_startd);
                  ++chunk_n;
               }
               else
               {
                  Print("* Final chunk");
                  ArrayRemove(responsep5); // "\r\n0\r\n"
                  break;
               }
            } // otherwise wait for more data
         }

Así, proporcionamos una salida del bucle basada en los resultados del análisis del flujo entrante de dos formas diferentes (además de la salida por tiempo de espera y por error). Al final normal del bucle, convertimos esa parte del array en la cadena response, que comienza en la posición body y contiene todo el documento. De lo contrario, simplemente devolvemos todo lo que hemos conseguido obtener, junto con los encabezados, para su «análisis».

bool HTTPRecv(int socketstring &resultconst uint timeoutconst bool TLS)
{
   ...
   do 
   {
      ResetLastError();
      if((len = (TLS ? SocketTlsReadAvailable(socketblock1024) :
         SocketReadAvailable(socketblock1024))) > 0)
      {
         ... // main operation here - discussed above
      }
      else
      {
         if(len == 0Sleep(10); // wait a bit for the arrival of a portion of data
      }
   } 
   while(GetTickCount() - start < timeout && !IsStopped() && !_LastError);
      
   if(_LastErrorPRTF(_LastError);
   
   if(ArraySize(response) > 0)
   {
      if(body != 0)
      {
         // TODO: Desirable to check 'Content-Type:' for 'charset=UTF-8'
         result = CharArrayToString(responsebodyWHOLE_ARRAYCP_UTF8);
      }
      else
      {
         // to analyze wrong cases, return incomplete headers as is
         result = CharArrayToString(response);
      }
   }
   
   return StringLen(result) > 0;
}

La única función restante es SocketReadAvailable, que es el análogo de SocketTlsReadAvailable para conexiones no seguras.

int SocketReadAvailable(int socketuchar &block[], const uint maxlen = INT_MAX)
{
   ArrayResize(block0);
   const uint len = SocketIsReadable(socket);
   if(len > 0)
      return SocketRead(socketblockfmin(lenmaxlen), 10);
   return 0;
}

El script está listo para trabajar.

Nos costó bastante esfuerzo implementar una simple petición de página web utilizando sockets. Esto sirve para demostrar hasta qué punto la compatibilidad de los protocolos de red a bajo nivel suele ser una tarea ardua. Por supuesto, en el caso de HTTP, es más fácil y más correcto para nosotros utilizar la implementación integrada de WebRequest, pero no incluye todas las características de HTTP (además, hemos tocado HTTP 1.1 de pasada, pero también existe HTTP / 2), y el número de otros protocolos de aplicación es enorme. Por lo tanto, se requieren las funciones Socket para integrarlos en MetaTrader 5.

Vamos a ejecutar SocketReadWriteHTTPS.mq5 con la configuración por defecto.

Server=www.google.com / ok
Port=443 / ok
SocketCreate()=1 / ok
SocketConnect(socket,Server,Port,Timeout)=true / ok
SocketTlsCertificate(socket,subject,issuer,serial,thumbprint,expiration)=true / ok
subject=CN=www.google.com / ok
issuer=C=US, O=Google Trust Services LLC, CN=GTS CA 1C3 / ok
serial=00c9c57583d70aa05d12161cde9ee32578 / ok
thumbprint=1EEE9A574CC92773EF948B50E79703F1B55556BF / ok
expiration=2022.10.03 08:25:10 / ok
HTTPSend(socket,StringFormat(%s / HTTP/1.1
Host: %s
,Method,Server),TLS)=true / ok
* HTTP-header found, header size: 1080
HTTP/1.1 200 OK
Date: Mon, 01 Aug 2022 20:48:35 GMT
Expires: -1
Cache-Control: private, max-age=0
Content-Type: text/html; charset=ISO-8859-1
Server: gws
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN
Set-Cookie: 1P_JAR=2022-08-01-20; expires=Wed, 31-Aug-2022 20:48:35 GMT;
   path=/; domain=.google.com; Secure
...
Accept-Ranges: none
Vary: Accept-Encoding
Transfer-Encoding: chunked
Chunk: 22172 start at 1080 -6
* 1 chunk done: 22172 total: 24081
Chunk: 30824 start at 23252 -8
* 2 chunk done: 30824 total: 54083
* Final chunk
HTTPRecv(socket,response,Timeout,TLS)=true / ok
Got 52998 bytes

Como podemos ver, el documento se transfiere en trozos y se ha guardado en un archivo temporal (puede encontrarlo en MQL5/Files/www.mql5.com.htm).

Ahora vamos a ejecutar el script para el sitio «www.mql5.com» y el puerto 80. Por la sección anterior, sabemos que el sitio en este caso emite una redirección a su versión protegida, pero esta «redirección» no está vacía: tiene un documento stub, y ahora podemos obtenerlo completo. Lo que nos importa aquí es que el encabezado «Content-Length:» se utiliza correctamente en este caso.

Server=www.mql5.com / ok
Port=80 / ok
SocketCreate()=1 / ok
SocketConnect(socket,Server,Port,Timeout)=true / ok
HTTPSend(socket,StringFormat(%s / HTTP/1.1
Host: %s
,Method,Server),TLS)=true / NETSOCKET_NO_CERTIFICATE(5275)
* HTTP-header found, header size: 291
HTTP/1.1 301 Moved Permanently
Server: nginx
Date: Sun, 31 Jul 2022 19:28:57 GMT
Content-Type: text/html
Content-Length: 162
Connection: keep-alive
Location: https://www.mql5.com/
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Frame-Options: SAMEORIGIN
* Content-Length:162
* Complete document
HTTPRecv(socket,response,Timeout,TLS)=true / ok
<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>nginx</center>
</body>
</html>
 

Otro gran ejemplo del uso de sockets en la práctica lo consideraremos en el capítulo Proyectos.