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

Históricamente, los sockets proporcionan transferencia de datos a través de una simple conexión por defecto. La transmisión de datos de forma abierta permite a los medios técnicos analizar todo el tráfico. En los últimos años, las cuestiones de seguridad se han tomado más en serio y, por ello, en casi todas partes se ha implantado la tecnología TLS (Transport Layer Security): proporciona cifrado sobre la marcha de todos los datos entre el remitente y el destinatario. En concreto, para las conexiones a Internet, la diferencia radica en los protocolos HTTP (conexión simple) y HTTPS (segura).

MQL5 proporciona diferentes conjuntos de funciones Socket para trabajar con conexiones simples y seguras. En esta sección nos familiarizaremos con el modo simple, y más adelante pasaremos al protegido.

Para leer datos de un socket, utilice la función SocketRead.

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

El descriptor de socket se obtiene de SocketCreate y se conecta a un recurso de red mediante Conexión de socket.

El parámetro buffer es una referencia al array en el que se leerán los datos. Si el array es dinámico, su tamaño aumenta en función del número de bytes leídos, pero no puede superar INT_MAX (2147483647). Puede limitar el número de bytes leídos en el parámetro maxlen. Los datos que no quepan permanecerán en el búfer interno del socket: pueden obtenerse mediante la siguiente llamada SocketRead. El valor de maxlen debe estar comprendido entre 1 e INT_MAX (2147483647).

El parámetro timeout especifica el tiempo (en milisegundos) que hay que esperar para que se complete la lectura. Si no se recibe ningún dato en este tiempo, los intentos terminan y la función sale con el resultado -1.

También se devuelve -1 en caso de error, mientras que el código de error en _LastError, por ejemplo, 5273 (ERR_NETSOCKET_IO_ERROR), significa que la conexión establecida a través de SocketConnect está interrumpida.

Si tiene éxito, la función devuelve el número de bytes leídos.

Cuando se ajusta el tiempo de espera de lectura a 0, se utiliza el valor por defecto de 120000 (2 minutos).

Para escribir datos en un socket, utilice la función SocketSend.

Por desgracia, los nombres de las funciones SocketRead y SocketSend no son «simétricos»: la operación inversa para «leer» es «escribir», y para «enviar» es «recibir». Esto puede resultar desconocido para los desarrolladores con experiencia que hayan trabajado con API de redes en otras plataformas.

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

El primer parámetro es un manejador de un socket previamente creado y abierto. Cuando se pasa un manejador no válido, _LastError recibe el error 5270 (ERR_NETSOCKET_INVALIDHANDLE). El array buffer contiene los datos que se van a enviar, cuyo tamaño se especifica en el parámetro maxlen (el parámetro se introdujo para facilitar el envío de parte de los datos de un array fijo).

La función devuelve el número de bytes escritos en el socket en caso de éxito, y -1 en caso de error.

Los errores a nivel de sistema (5273, ERR_NETSOCKET_IO_ERROR) indican una desconexión.

El script SocketReadWriteHTTP.mq5 demuestra cómo se pueden utilizar los sockets para implementar el trabajo sobre el protocolo HTTP, es decir, solicitar información sobre una página a un servidor web. Esta es una pequeña parte de lo que la función WebRequest hace por nosotros «entre bastidores».

Dejemos la dirección por defecto en los parámetros de entrada: el sitio «www.mql5.com». El número de puerto elegido es 80 porque es el valor por defecto para conexiones HTTP no seguras (aunque algunos servidores pueden utilizar un puerto diferente): 81, 8080, etc.). Los puertos reservados para conexiones seguras (en particular, el más popular, 443) no se admiten todavía en este ejemplo. Además, en el parámetro Server, es importante introducir el nombre del dominio y no una página concreta, ya que el script sólo puede solicitar la página principal, es decir, la ruta raíz «/».

input string Server = "www.mql5.com";
input uint Port = 80;

En la función principal del script crearemos un socket y abriremos una conexión en él con los parámetros especificados (el tiempo de espera es de 5 segundos).

void OnStart()
{
   PRTF(Server);
   PRTF(Port);
   const int socket = PRTF(SocketCreate());
   if(PRTF(SocketConnect(socketServerPort5000)))
   {
      ...
   }
}

Veamos cómo funciona el protocolo HTTP. El cliente envía las solicitudes en forma de encabezados especialmente diseñados (cadenas con nombres y valores predefinidos), que incluyen, en concreto, la dirección de la página web, y el servidor envía como respuesta la página web completa o el estado de la operación, utilizando también para ello encabezados especiales. El cliente puede solicitar una página web con una solicitud GET, enviar algunos datos con una solicitud POST o comprobar el estado de la página web con una frugal solicitud HEAD. En teoría, hay muchos más métodos HTTP: puede descubrirlos en la especificación del protocolo HTTP.

Así, el script debe generar y enviar un encabezado HTTP a través de la conexión de socket. En su forma más simple, la solicitud HEAD siguiente permite obtener metainformación sobre la página (podríamos sustituir HEAD por GET para solicitar la página completa, pero existen algunas complicaciones; hablaremos de ello más adelante).

HEAD / HTTP/1.1
Host: _server_
User-Agent: MetaTrader 5
                                     // <- two newlines in a row \r\n\r\n

La barra oblicua después de «HEAD» (u otro método) es la ruta más corta posible en cualquier servidor al directorio raíz, lo que suele dar lugar a que se muestre la página principal. Si quisiéramos una página web concreta, podríamos escribir algo como «GET /en/forum/ HTTP/1.1» y obtener la tabla de contenidos de los foros en inglés de mql5.com. Especifique un dominio real en lugar de la cadena «_server_».

Aunque la presencia de «User-Agent:» es opcional, permite al programa «presentarse» al servidor, sin lo cual algunos servidores pueden rechazar la solicitud.

Fíjese en las dos líneas vacías: marcan el final del encabezado. En nuestro script es conveniente formar el título con la siguiente expresión:

StringFormat("HEAD / HTTP/1.1\r\nHost: %s\r\n\r\n"Server)

Ahora sólo tenemos que enviarlo al servidor. Para ello, hemos escrito una sencilla función HTTPSend, que recibe un descriptor de socket y una línea de encabezado.

bool HTTPSend(int socketconst string request)

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

Internamente, convertimos la cadena en un array de bytes y llamamos a SocketSend.

A continuación, necesitamos aceptar la respuesta del servidor, para lo cual hemos escrito la función HTTPRecv. También espera un descriptor de socket y una referencia a una cadena donde colocar los datos, pero es más complejo.

bool HTTPRecv(int socketstring &resultconst uint timeout)

   char response[];
   int len;         // signed integer needed for error flag -1
   uint start = GetTickCount();
   result = "";
   
   do 
   {
      ResetLastError();
      if(!(len = (int)SocketIsReadable(socket)))
      {
         Sleep(10); // wait for data or timeout
      }
      else          // read the data in the available volume
      if((len = SocketRead(socketresponselentimeout)) > 0)
      {
         result += CharArrayToString(response0len); // NB: without CP_UTF8 only 'HEAD'
         const int p = StringFind(result"\r\n\r\n");
         if(p > 0)
         {
            // HTTP header ends with a double newline, use this
            // to make sure the entire header is received
            Print("HTTP-header found");
            StringSetLength(resultp); // cut off the body of the document (in case of a GET request)
            return true;
         }
      }
   } 
   while(GetTickCount() - start < timeout && !IsStopped() && !_LastError);
   
   if(_LastErrorPRTF(_LastError);
   
   return StringLen(result) > 0;
}

Aquí estamos comprobando en un bucle la aparición de datos dentro del tiempo de espera especificado y leyéndolos en el búfer response. La aparición de un error pone fin al bucle.

Los bytes del búfer se convierten inmediatamente en una cadena y se concatenan en una respuesta completa en la variable result. Es importante tener en cuenta que sólo podemos utilizar la función CharArrayToString con la codificación por defecto para el encabezado HTTP, ya que en ella sólo se permiten letras latinas y unos pocos caracteres especiales de ANSI.

Para recibir un documento web completo, que, por regla general, tiene codificación UTF-8 (pero potencialmente tiene otra no latina, que se indica sólo en el encabezado HTTP), será necesario un procesamiento más complicado: primero, hay que recoger todos los bloques enviados en un búfer común y luego convertirlo todo en una cadena que indique CP_UTF8 (de lo contrario, cualquier carácter codificado en dos bytes puede «cortarse» al ser enviado, y llegará en bloques diferentes; por eso no podemos esperar un flujo de bytes UTF-8 correcto en fragmento individual). Mejoraremos este ejemplo en las secciones siguientes.

Teniendo las funciones HTTPSend y HTTPRecv, completamos el código OnStart.

void OnStart()
{
      ...
      if(PRTF(HTTPSend(socketStringFormat("HEAD / HTTP/1.1\r\nHost: %s \r\n"
         "User-Agent: MetaTrader 5\r\n\r\n"Server))))
      {
         string response;
         if(PRTF(HTTPRecv(socketresponse5000)))
         {
            Print(response);
         }
      }
      ...
}

En el encabezado HTTP recibido del servidor pueden resultar interesantes las siguientes líneas:

  • 'Content-Length:' - la longitud total del documento en bytes
  • 'Content-Language:' - idioma del documento (por ejemplo, «de-DE, ru»)
  • 'Content-Type:' - codificación del documento (por ejemplo, «text/html; charset=UTF-8»)
  • 'Last-Modified:' - la hora de la última modificación del documento, para no descargar lo que ya está (en principio, podemos añadir el encabezado 'If-Modified-Since:' en nuestra solicitud HTTP).

Hablaremos de averiguar la longitud del documento (tamaño de los datos) con más detalle porque casi todos los encabezados son opcionales, es decir, el servidor los comunica a voluntad y, en su ausencia, se utilizan mecanismos alternativos. El tamaño es importante para saber cuándo hay que cerrar la conexión, es decir, para asegurarse de que se han recibido todos los datos.

La ejecución del script con los parámetros por defecto produce el siguiente resultado:

Server=www.mql5.com / ok
Port=80 / ok
SocketCreate()=1 / ok
SocketConnect(socket,Server,Port,5000)=true / ok
HTTPSend(socket,StringFormat(HEAD / HTTP/1.1
Host: %s
,Server))=true / ok
HTTP-header found
HTTPRecv(socket,response,5000)=true / ok
HTTP/1.1 301 Moved Permanently
Server: nginx
Date: Sun, 31 Jul 2022 10:24:00 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

Tenga en cuenta que este sitio, como la mayoría de los sitios actuales, redirige nuestra solicitud a una conexión segura: esto se consigue con el código de estado «301 Moved Permanently» y la nueva dirección «Location: https://www.mql5.com/» (el protocolo es importante aquí «https»). Para reintentar una solicitud habilitada para TLS, se deben utilizar otras funciones, que discutiremos más adelante.