Hice mi propio repositorio PyPI con autorización y S3. En Nginx

En este artículo me gustaría compartir mi experiencia con NJS, un intérprete de JavaScript para Nginx desarrollado por Nginx Inc, describiendo sus principales capacidades usando un ejemplo real. NJS es un subconjunto de JavaScript que le permite ampliar la funcionalidad de Nginx. A la pregunta ¿Por qué tu propio intérprete? Dmitry Volyntsev respondió en detalle. En resumen: NJS es al estilo nginx y JavaScript es más progresivo, "nativo" y sin GC, a diferencia de Lua.

Hace mucho tiempo…

En mi último trabajo, heredé gitlab con una serie de canales de CI/CD variados con docker-compose, dind y otras delicias, que se transfirieron a rieles kaniko. Las imágenes que se utilizaban anteriormente en CI se movieron en su forma original. Funcionaron correctamente hasta el día en que nuestra IP de gitlab cambió y CI se convirtió en una calabaza. El problema era que una de las imágenes de Docker que participaba en CI tenía git, que extraía módulos de Python a través de ssh. Para ssh necesitas una clave privada y... estaba en la imagen junto conknown_hosts. Y cualquier CI falló con un error de verificación de clave debido a una falta de coincidencia entre la IP real y la especificada enknown_hosts. Se armó rápidamente una nueva imagen a partir de los Dockfiles existentes y se agregó la opción StrictHostKeyChecking no. Pero el mal gusto persistió y existía el deseo de trasladar las bibliotecas a un repositorio privado de PyPI. Una ventaja adicional, después de cambiar a PyPI privado, fue una canalización más simple y una descripción normal de los requisitos.txt.

¡La elección está hecha, señores!

Ejecutamos todo en las nubes y en Kubernetes y, al final, queríamos obtener un pequeño servicio que fuera un contenedor sin estado con almacenamiento externo. Bueno, como usamos S3, se le dio prioridad. Y, si es posible, con autenticación en gitlab (puedes añadirla tú mismo si es necesario).

Una búsqueda rápida arrojó varios resultados: s3pypi, pypicloud y una opción con creación “manual” de archivos html para nabos. La última opción desapareció por sí sola.

s3pypi: este es un cli para usar alojamiento S3. Subimos los archivos, generamos el html y lo subimos al mismo depósito. Adecuado para uso doméstico.

pypicloud: Me pareció un proyecto interesante, pero después de leer la documentación me decepcioné. A pesar de la buena documentación y la posibilidad de ampliarlo para adaptarlo a sus necesidades, en realidad resultó redundante y difícil de configurar. Corregir el código para adaptarlo a sus tareas, según las estimaciones de la época, habría llevado entre 3 y 5 días. El servicio también necesita una base de datos. Lo dejamos por si no encontrábamos nada más.

Una búsqueda más profunda arrojó un módulo para Nginx, ngx_aws_auth. El resultado de sus pruebas fue un XML mostrado en el navegador, que mostraba el contenido del depósito S3. La última confirmación en el momento de la búsqueda fue hace un año. El depósito parecía abandonado.

Yendo a la fuente y leyendo pep-503 Me di cuenta de que XML se puede convertir a HTML sobre la marcha y pasarlo a pip. Después de buscar en Google un poco más sobre Nginx y S3, encontré un ejemplo de autenticación en S3 escrito en JS para Nginx. Así conocí a NJS.

Tomando como base este ejemplo, una hora después vi en mi navegador el mismo XML que cuando usaba el módulo ngx_aws_auth, pero ya estaba todo escrito en JS.

Realmente me gustó la solución nginx. En primer lugar, buena documentación y muchos ejemplos; en segundo lugar, obtenemos todas las ventajas de Nginx para trabajar con archivos (listos para usar); en tercer lugar, cualquiera que sepa cómo escribir configuraciones para Nginx podrá descubrir qué es qué. El minimalismo también es una ventaja para mí, en comparación con Python o Go (si se escribe desde cero), sin mencionar el nexo.

TL;DR Después de 2 días, la versión de prueba de PyPi ya se usaba en CI.

Como funciona?

El módulo está cargado en Nginx. ngx_http_js_module, incluido en la imagen oficial de Docker. Importamos nuestro script usando la directiva. js_importa la configuración de Nginx. La función es llamada por una directiva. js_content. La directiva se utiliza para establecer variables. js_set, que toma como argumento sólo la función descrita en el script. Pero podemos ejecutar subconsultas en NJS solo usando Nginx, no cualquier XMLHttpRequest. Para ello se debe agregar la ubicación correspondiente a la configuración de Nginx. Y el guión debe describir una subsolicitud a esta ubicación. Para poder acceder a una función desde la configuración de Nginx, el nombre de la función debe exportarse en el propio script export default.

nginx.conf

load_module modules/ngx_http_js_module.so;
http {
  js_import   imported_name  from script.js;

server {
  listen 8080;
  ...
  location = /sub-query {
    internal;

    proxy_pass http://upstream;
  }

  location / {
    js_content imported_name.request;
  }
}

script.js

function request(r) {
  function call_back(resp) {
    // handler's code
    r.return(resp.status, resp.responseBody);
  }

  r.subrequest('/sub-query', { method: r.method }, call_back);
}

export default {request}

Cuando se solicita en el navegador http://localhost:8080/ nos metemos en location /en el que la directiva js_content llama a una función request descrito en nuestro guión script.js. A su vez, en la función request se hace una subconsulta a location = /sub-query, con un método (en el ejemplo actual GET) obtenido del argumento (r), se pasa implícitamente cuando se llama a esta función. La respuesta a la subsolicitud se procesará en la función. call_back.

Probando S3

Para realizar una solicitud al almacenamiento privado de S3, necesitamos:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

A partir del método http utilizado, la fecha/hora actual, S3_NAME y URI, se genera un cierto tipo de cadena, que se firma (HMAC_SHA1) usando SECRET_KEY. La siguiente es una línea como AWS $ACCESS_KEY:$HASH, se puede utilizar en el encabezado de autorización. Se debe agregar al encabezado la misma fecha/hora que se utilizó para generar la cadena en el paso anterior. X-amz-date. En código se ve así:

nginx.conf

load_module modules/ngx_http_js_module.so;
http {
  js_import   s3      from     s3.js;

  js_set      $s3_datetime     s3.date_now;
  js_set      $s3_auth         s3.s3_sign;

server {
  listen 8080;
  ...
  location ~* /s3-query/(?<s3_path>.*) {
    internal;

    proxy_set_header    X-amz-date     $s3_datetime;
    proxy_set_header    Authorization  $s3_auth;

    proxy_pass          $s3_endpoint/$s3_path;
  }

  location ~ "^/(?<prefix>[w-]*)[/]?(?<postfix>[w-.]*)$" {
    js_content s3.request;
  }
}

s3.js(Ejemplo de autorización de AWS Sign v2, cambiado al estado obsoleto)

var crypt = require('crypto');

var s3_bucket = process.env.S3_BUCKET;
var s3_access_key = process.env.S3_ACCESS_KEY;
var s3_secret_key = process.env.S3_SECRET_KEY;
var _datetime = new Date().toISOString().replace(/[:-]|.d{3}/g, '');

function date_now() {
  return _datetime
}

function s3_sign(r) {
  var s2s = r.method + 'nnnn';

  s2s += `x-amz-date:${date_now()}n`;
  s2s += '/' + s3_bucket;
  s2s += r.uri.endsWith('/') ? '/' : r.variables.s3_path;

  return `AWS ${s3_access_key}:${crypt.createHmac('sha1', s3_secret_key).update(s2s).digest('base64')}`;
}

function request(r) {
  var v = r.variables;

  function call_back(resp) {
    r.return(resp.status, resp.responseBody);
  }

  var _subrequest_uri = r.uri;
  if (r.uri === '/') {
    // root
    _subrequest_uri = '/?delimiter=/';

  } else if (v.prefix !== '' && v.postfix === '') {
    // directory
    var slash = v.prefix.endsWith('/') ? '' : '/';
    _subrequest_uri = '/?prefix=' + v.prefix + slash;
  }

  r.subrequest(`/s3-query${_subrequest_uri}`, { method: r.method }, call_back);
}

export default {request, s3_sign, date_now}

Una pequeña explicación sobre _subrequest_uri: esta es una variable que, dependiendo del uri inicial, forma una solicitud a S3. Si necesita obtener el contenido de la "raíz", debe crear una solicitud de uri que indique el delimitador. delimiter, que devolverá una lista de todos los elementos xml de CommonPrefixes, correspondientes a directorios (en el caso de PyPI, una lista de todos los paquetes). Si necesita obtener una lista de contenidos en un directorio específico (una lista de todas las versiones del paquete), entonces la solicitud de uri debe contener un campo de prefijo con el nombre del directorio (paquete) que necesariamente termina con una barra diagonal /. De lo contrario, pueden producirse colisiones al consultar, por ejemplo, el contenido de un directorio. Hay directorios aiohttp-request y aiohttp-requests y si la solicitud lo especifica /?prefix=aiohttp-request, entonces la respuesta contendrá el contenido de ambos directorios. Si hay una barra al final, /?prefix=aiohttp-request/, entonces la respuesta contendrá solo el directorio requerido. Y si solicitamos un archivo, entonces el uri resultante no debería diferir del original.

Guarde y reinicie Nginx. En el navegador ingresamos la dirección de nuestro Nginx, el resultado de la solicitud será XML, por ejemplo:

Lista de directorios

<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <Name>myback-space</Name>
  <Prefix></Prefix>
  <Marker></Marker>
  <MaxKeys>10000</MaxKeys>
  <Delimiter>/</Delimiter>
  <IsTruncated>false</IsTruncated>
  <CommonPrefixes>
    <Prefix>new/</Prefix>
  </CommonPrefixes>
  <CommonPrefixes>
    <Prefix>old/</Prefix>
  </CommonPrefixes>
</ListBucketResult>

De la lista de directorios solo necesitarás los elementos CommonPrefixes.

Al agregar el directorio que necesitamos a nuestra dirección en el navegador, también recibiremos su contenido en formato XML:

Lista de archivos en un directorio

<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <Name> myback-space</Name>
  <Prefix>old/</Prefix>
  <Marker></Marker>
  <MaxKeys>10000</MaxKeys>
  <Delimiter></Delimiter>
  <IsTruncated>false</IsTruncated>
  <Contents>
    <Key>old/giphy.mp4</Key>
    <LastModified>2020-08-21T20:27:46.000Z</LastModified>
    <ETag>&#34;00000000000000000000000000000000-1&#34;</ETag>
    <Size>1350084</Size>
    <Owner>
      <ID>02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4</ID>
      <DisplayName></DisplayName>
    </Owner>
    <StorageClass>STANDARD</StorageClass>
  </Contents>
  <Contents>
    <Key>old/hsd-k8s.jpg</Key>
    <LastModified>2020-08-31T16:40:01.000Z</LastModified>
    <ETag>&#34;b2d76df4aeb4493c5456366748218093&#34;</ETag>
    <Size>93183</Size>
    <Owner>
      <ID>02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4</ID>
      <DisplayName></DisplayName>
    </Owner>
    <StorageClass>STANDARD</StorageClass>
  </Contents>
</ListBucketResult>

De la lista de archivos tomaremos solo elementos. Key.

Todo lo que queda es analizar el XML resultante y enviarlo como HTML, reemplazando primero el encabezado Content-Type con text/html.

function request(r) {
  var v = r.variables;

  function call_back(resp) {
    var body = resp.responseBody;

    if (r.method !== 'PUT' && resp.status < 400 && v.postfix === '') {
      r.headersOut['Content-Type'] = "text/html; charset=utf-8";
      body = toHTML(body);
    }

    r.return(resp.status, body);
  }
  
  var _subrequest_uri = r.uri;
  ...
}

function toHTML(xml_str) {
  var keysMap = {
    'CommonPrefixes': 'Prefix',
    'Contents': 'Key',
  };

  var pattern = `<k>(?<v>.*?)</k>`;
  var out = [];

  for(var group_key in keysMap) {
    var reS;
    var reGroup = new RegExp(pattern.replace(/k/g, group_key), 'g');

    while(reS = reGroup.exec(xml_str)) {
      var data = new RegExp(pattern.replace(/k/g, keysMap[group_key]), 'g');
      var reValue = data.exec(reS);
      var a_text = '';

      if (group_key === 'CommonPrefixes') {
        a_text = reValue.groups.v.replace(///g, '');
      } else {
        a_text = reValue.groups.v.split('/').slice(-1);
      }

      out.push(`<a href="/es/${reValue.groups.v}">${a_text}</a>`);
    }
  }

  return '<html><body>n' + out.join('</br>n') + 'n</html></body>'
}

Probando PyPI

Comprobamos que nada se rompa en ningún lugar de los paquetes que se sabe que funcionan.

# Создаем для тестов новое окружение
python3 -m venv venv
. ./venv/bin/activate

# Скачиваем рабочие пакеты.
pip download aiohttp

# Загружаем в приватную репу
for wheel in *.whl; do curl -T $wheel http://localhost:8080/${wheel%%-*}/$wheel; done

rm -f *.whl

# Устанавливаем из приватной репы
pip install aiohttp -i http://localhost:8080

Repetimos con nuestras libs.

# Создаем для тестов новое окружение
python3 -m venv venv
. ./venv/bin/activate

pip install setuptools wheel
python setup.py bdist_wheel
for wheel in dist/*.whl; do curl -T $wheel http://localhost:8080/${wheel%%-*}/$wheel; done

pip install our_pkg --extra-index-url http://localhost:8080

En CI, crear y cargar un paquete tiene este aspecto:

pip install setuptools wheel
python setup.py bdist_wheel

curl -sSfT dist/*.whl -u "gitlab-ci-token:${CI_JOB_TOKEN}" "https://pypi.our-domain.com/${CI_PROJECT_NAME}"

Autentificación

En Gitlab es posible utilizar JWT para autenticación/autorización de servicios externos. Usando la directiva auth_request en Nginx, redirigiremos los datos de autenticación a una subsolicitud que contiene una llamada de función en el script. El script realizará otra subsolicitud a la URL de Gitlab y si los datos de autenticación se especificaron correctamente, Gitlab devolverá el código 200 y se permitirá la carga/descarga del paquete. ¿Por qué no utilizar una subconsulta y enviar inmediatamente los datos a Gitlab? Porque entonces tendremos que editar el archivo de configuración de Nginx cada vez que hagamos algún cambio en la autorización, y esta es una tarea bastante tediosa. Además, si Kubernetes utiliza una política de sistema de archivos raíz de solo lectura, esto agrega aún más complejidad al reemplazar nginx.conf mediante configmap. Y se vuelve absolutamente imposible configurar Nginx a través de configmap mientras se usan simultáneamente políticas que prohíben la conexión de volúmenes (pvc) y el sistema de archivos raíz de solo lectura (esto también sucede).

Al utilizar el intermedio NJS, tenemos la oportunidad de cambiar los parámetros especificados en la configuración de nginx usando variables de entorno y realizar algunas comprobaciones en el script (por ejemplo, una URL especificada incorrectamente).

nginx.conf

location = /auth-provider {
  internal;

  proxy_pass $auth_url;
}

location = /auth {
  internal;

  proxy_set_header Content-Length "";
  proxy_pass_request_body off;
  js_content auth.auth;
}

location ~ "^/(?<prefix>[w-]*)[/]?(?<postfix>[w-.]*)$" {
  auth_request /auth;

  js_content s3.request;
}

s3.js

var env = process.env;
var env_bool = new RegExp(/[Tt]rue|[Yy]es|[Oo]n|[TtYy]|1/);
var auth_disabled  = env_bool.test(env.DISABLE_AUTH);
var gitlab_url = env.AUTH_URL;

function url() {
  return `${gitlab_url}/jwt/auth?service=container_registry`
}

function auth(r) {
  if (auth_disabled) {
    r.return(202, '{"auth": "disabled"}');
    return null
  }

  r.subrequest('/auth-provider',
                {method: 'GET', body: ''},
                function(res) {
                  r.return(res.status, "");
                });
}

export default {auth, url}

Lo más probable es que se esté gestando la pregunta: -¿Por qué no utilizar módulos ya preparados? ¡Allí ya se ha hecho todo! Por ejemplo, var AWS = require('aws-sdk') y no es necesario escribir una "bicicleta" con autenticación S3.

Pasemos a las desventajas.

Para mí, la imposibilidad de importar módulos JS externos se convirtió en una característica desagradable pero esperada. Descrito en el ejemplo anterior, require('crypto') es módulos incorporados y requieren sólo obras para ellos. Tampoco hay forma de reutilizar el código de los scripts y hay que copiarlo y pegarlo en diferentes archivos. Espero que algún día se implemente esta funcionalidad.

La compresión también debe estar deshabilitada para el proyecto actual en Nginx gzip off;

Porque no hay ningún módulo gzip en NJS y es imposible conectarlo, por lo tanto, no hay forma de trabajar con datos comprimidos. Es cierto que esto no es realmente un inconveniente en este caso. No hay mucho texto y los archivos transferidos ya están comprimidos y una compresión adicional no les ayudará mucho. Además, este no es un servicio tan cargado o crítico como para que tengas que molestarte en entregar contenido unos milisegundos más rápido.

La depuración del script lleva mucho tiempo y sólo es posible mediante "impresiones" en error.log. Dependiendo de la información del nivel de registro establecido, advertencia o error, es posible utilizar 3 métodos r.log, r.warn, r.error respectivamente. Intento depurar algunos scripts en Chrome (v8) o en la herramienta de consola njs, pero no se puede comprobar todo allí. Al depurar código, también conocido como prueba funcional, el historial se parece a esto:

docker-compose restart nginx
curl localhost:8080/
docker-compose logs --tail 10 nginx

y puede haber cientos de tales secuencias.

Escribir código utilizando subconsultas y variables para ellos se convierte en una maraña enredada. A veces empiezas a correr por diferentes ventanas del IDE intentando descubrir la secuencia de acciones de tu código. No es difícil, pero a veces resulta muy molesto.

No hay soporte completo para ES6.

Puede que haya otras deficiencias, pero no he encontrado nada más. Comparta información si tiene una experiencia negativa al utilizar NJS.

Conclusión

NJS es un intérprete liviano de código abierto que le permite implementar varios scripts de JavaScript en Nginx. Durante su desarrollo se prestó gran atención al rendimiento. Por supuesto, todavía falta mucho, pero el proyecto está siendo desarrollado por un pequeño equipo y están agregando activamente nuevas funciones y corrigiendo errores. Espero que algún día NJS le permita conectar módulos externos, lo que hará que la funcionalidad de Nginx sea casi ilimitada. ¡Pero existe NGINX Plus y lo más probable es que no haya funciones!

Repositorio con el código completo del artículo.

njs-pypi con soporte para AWS Sign v4

Descripción de las directivas del módulo ngx_http_js_module

Repositorio oficial de NJS и la documentación

Ejemplos de uso de NJS de Dmitry Volintsev

njs: secuencias de comandos JavaScript nativas en nginx / Discurso de Dmitry Volnyev en Saint HighLoad++ 2019

NJS en producción / Discurso de Vasily Soshnikov en HighLoad++ 2019

Firma y autenticación de solicitudes REST en AWS

Fuente: habr.com