He creado mi repositorio de PyPI con autenticación y S3. En Nginx

En este artículo quiero compartir mi experiencia trabajando con NJS, un intérprete de JavaScript para Nginx desarrollado por Nginx Inc., describiendo a través de un ejemplo real sus principales capacidades. NJS es un subconjunto del lenguaje de programación JavaScript que permite ampliar la funcionalidad de Nginx. A la pregunta ¿por qué tener tu propio intérprete??? respondió en detalle Dmitry Volyntsev. En resumen: NJS es el enfoque de nginx, y JavaScript es más progresivo, 'nativo' y sin GC en comparación con Lua.

Hace mucho tiempo...

En mi trabajo anterior heredé un gitlab con una variedad de pipelines CI/CD con docker-compose, dind y otras maravillas, que fueron trasladadas al sistema kaniko. Las imágenes que se utilizaban anteriormente en CI se trasladaron en su estado original. Funcionaron correctamente hasta el día en que nuestro gitlab cambió de IP y CI se convirtió en una calabaza. El problema era que en uno de los contenedores docker involucrados en CI había git, que tiraba módulos de Python por ssh. Para ssh se necesita una clave privada y... estaba en el contenedor junto con known_hosts. Entonces, cualquier CI terminaba con un error de verificación de clave debido a la discrepancia entre la IP real y la que figuraba en known_hosts. A partir de los Dockfile disponibles, se construyó rápidamente una nueva imagen y se añadió la opción StrictHostKeyChecking no. Pero quedó un mal sabor y surgió el deseo de trasladar las librerías a un repositorio PyPI privado. Un beneficio adicional, después de pasar a un PyPI privado, fue tener un pipeline más simple y una descripción normal de requirements.txt.

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

Estamos moviendo todo en la nube y Kubernetes y, al final, queríamos obtener un pequeño servicio que representara un contenedor stateless con un almacenamiento externo. Así que, dado que usamos S3, la prioridad estaba en él. Y si es posible, con autenticación en gitlab (se puede añadir uno mismo si es necesario).

Una búsqueda rápida dio varios resultados: s3pypi, pypicloud y una opción de crear archivos html de forma 'manual' para el repositorio. La última opción se descartó por sí sola.

s3pypi: es un cli para usar el alojamiento en S3. Subimos archivos, generamos html y lo subimos al mismo bucket. Para uso doméstico es adecuado.

pypicloud: Parecía un proyecto interesante, pero después de leer la documentación, vino la decepción. A pesar de su buena documentación y las capacidades de extensión para nuestras necesidades, en realidad resultó ser excesivo y complejo de configurar. Ajustar el código para nuestras necesidades, según estimaciones previas, tomaría de 3 a 5 días. Además, el servicio requiere una base de datos. Lo dejamos como opción, en caso de que no encontráramos nada mejor.

Una búsqueda más profunda dio como resultado un módulo para Nginx, ngx_aws_auth. El resultado de su prueba fue un XML mostrado en el navegador, donde se podía ver el contenido del bucket S3. El último commit, en el momento de la búsqueda, había sido hace un año. El repositorio parecía abandonado.

Al dirigirme a la fuente original y leer PEP-503 me di cuenta de que el XML se puede convertir a HTML al vuelo y entregarlo a pip. Tras investigar un poco más sobre Nginx y S3, me topé con un ejemplo de autenticación en S3 escrito en JS para Nginx. Así conocí NJS.

Basándome en este ejemplo, después de una hora observé en mi navegador el mismo XML que al usar el módulo ngx_aws_auth, pero ya estaba escrito todo en JS.

Me gustaba mucho la solución en nginx. Primero, buena documentación y numerosos ejemplos; segundo, obtenemos todas las ventajas de Nginx para trabajar con archivos (de serie); y tercero, cualquier persona que sepa escribir configuraciones para Nginx podrá entender cómo funciona. También, el minimalismo es un plus para mí en comparación con Python o Go (si se escribiera desde cero), sin mencionar nexus.

TL;DR Al cabo de 2 días, la versión de prueba de PyPi ya se había utilizado en CI.

¿Cómo funciona?

En Nginx se carga el módulo ngx_http_js_module, incluido en la imagen oficial de docker. Importamos nuestro script utilizando la directiva js_importen la configuración de Nginx. La llamada a la función se realiza mediante la directiva js_content. Para establecer variables se utiliza la directiva js_set, que como argumento solo acepta la función definida en el script. Y así, realizar subconsultas en NJS solo podemos hacerlo a través de Nginx, nada de XMLHttpRequest. Para esto, en la configuración de Nginx debe añadirse la ubicación correspondiente. Y en el script debe describirse la subconsulta (subrequest) a esta ubicación. Para poder acceder a la función desde la configuración de Nginx, en el propio script el nombre de la función debe ser exportado. 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) {
    // código del manejador
    r.return(resp.status, resp.responseBody);
  }

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

export default {request}

Al realizar una solicitud en el navegador http://localhost:8080/ entramos en location /donde la directiva js_content llama a la función request que está descrita en nuestro script script.js. A su vez, en la función request se realiza una subconsulta a location = /sub-query, con el método (en el ejemplo actual GET) obtenido del argumento (r), que se pasa implícitamente al llamar a esta función. El manejo de la respuesta de la subconsulta se realizará en la función call_back.

Probamos S3

Para realizar una solicitud al almacenamiento S3 privado, necesitamos:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Con el método http utilizado, la fecha/hora actual, S3_NAME y URI se genera una cadena de cierto tipo que se firma (HMAC_SHA1) usando SECRET_KEY. Luego, la cadena de tipo AWS $ACCESS_KEY:$HASH, se puede utilizar en el encabezado de autorización. La misma fecha/hora que se utilizó para generar la cadena en el paso anterior debe añadirse al encabezado X-amz-date. En el 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/(?.*) {
    internal;

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

    proxy_pass          $s3_endpoint/$s3_path;
  }

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

s3.js(ejemplo de autorización AWS Sign v2, se ha declarado 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 aclaración sobre _subrequest_uri: esta es una variable que dependiendo del uri inicial forma la solicitud a S3. Si se necesita obtener el contenido de la "raíz", en tal caso es necesario formar el uri-solicitud especificando el delimitador delimiter, que devolverá una lista de todos los elementos xml-CommonPrefixes, lo que corresponde a los directorios (en el caso de PyPI, la lista de todos los paquetes). Si se necesita obtener una lista de contenidos en un directorio específico (la lista de todas las versiones de paquetes), entonces la uri-solicitud debe contener el campo prefix con el nombre del directorio (paquete) que debe terminar obligatoriamente en una barra diagonal /. De lo contrario, pueden ocurrir colisiones al solicitar el contenido del directorio, por ejemplo. Hay directorios aiohttp-request y aiohttp-requests y si en la solicitud se indica. /?prefix=aiohttp-request, entonces la respuesta contendrá el contenido de ambos directorios. Si termina con una barra, /?prefix=aiohttp-request/, la respuesta solo contendrá el directorio solicitado. Y si solicitamos un archivo, la URI resultante no debe diferir de la original.

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

Lista de directorios

myback-space
  
  
  10000
  /
  false
  
    new/
  
  
    old/

De la lista de directorios, solo necesitaremos los elementos CommonPrefixes.

Agregando, en el navegador, el directorio que necesitamos a nuestra dirección, obtendremos su contenido también en forma de XML:

Lista de archivos en el directorio

myback-space
  old/
  
  10000
  
  false
  
    old/giphy.mp4
    2020-08-21T20:27:46.000Z
    "00000000000000000000000000000000-1"
    1350084
    
      02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4
      
    
    SANDARD
  
  
    old/hsd-k8s.jpg
    2020-08-31T16:40:01.000Z
    "b2d76df4aeb4493c5456366748218093"
    93183
    
      02d6176db174dc93cb1b899f7c6078f08654445fe8cf1b6ce98d8855f66bdbf4
      
    
    SANDARD

De la lista de archivos tomaremos solo los elementos Clave.

Queda analizar el XML obtenido y devolverlo en formato HTML, cambiando previamente el encabezado Content-Type a 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="/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Probamos PyPI

Verificamos que nada se rompa en los paquetes funcionales.

# Создаем для тестов новое окружение
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 bibliotecas.

# Создаем для тестов новое окружение
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, la creación y carga del paquete se ve así:

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}"

Autenticación

En Gitlab es posible utilizar JWT para la autenticación/autorización de servicios externos. Aprovechando la directiva auth_request en Nginx, redirigiremos los datos de autenticación a una subconsulta que contiene la llamada a la función en el script. En el script se realizará otra subconsulta a la URL de Gitlab y, si los datos de autenticación se indican correctamente, Gitlab devolverá el código 200 y se permitirá la carga/descarga del paquete. ¿Por qué no aprovechar una sola subconsulta y enviar los datos directamente a Gitlab? Porque tendríamos que modificar el archivo de configuración de Nginx cada vez que se produzcan cambios en la autorización, lo que es una tarea bastante tediosa. Además, si en Kubernetes se utiliza una política de sistema de archivos raíz de solo lectura, esto añade aún más complicaciones al reemplazar nginx.conf a través de configmap. Y se vuelve absolutamente imposible configurar Nginx a través de configmap al mismo tiempo que se utilizan 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 ocurre).

Usando NJS como vínculo intermedio, obtenemos la capacidad de modificar los parámetros especificados en la configuración de nginx mediante variables de entorno y realizar algunas verificaciones en el script (por ejemplo, una URL incorrecta).

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(/true|yes|on|[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}

Seguramente surge la pregunta: - ¿Por qué no utilizar módulos listos? ¡Ahí ya está todo hecho! Por ejemplo, var AWS = require('aws-sdk') y no es necesario crear una "bicicleta" con la autenticación de S3.

Pasemos a los inconvenientes

Para mí, la imposibilidad de importar módulos JS externos se volvió una característica desagradable, pero esperada. El require('crypto') descrito en el ejemplo anterior, es módulos incorporados y require solo funciona para ellos. También no se puede reutilizar el código de los scripts y hay que copiarlo y pegarlo en diferentes archivos. Espero que algún día esta funcionalidad se implemente.

Además, para el proyecto actual en Nginx se debe desactivar la compresión gzip off;

Porque no hay el módulo gzip en NJS y no se puede conectar, por lo tanto no es posible trabajar con datos comprimidos. Sin embargo, esto no es realmente un inconveniente para este caso. No hay mucho texto, y los archivos transmitidos ya están comprimidos, así que una compresión adicional no ayudaría mucho. Además, este no es un servicio tan demandado o crítico como para preocuparse por entregar contenido unos pocos milisegundos más rápido.

La depuración de scripts es larga y solo es posible a través de "prints" en error.log. Dependiendo del nivel de registro establecido, info, warn o error, es posible usar 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 todo se puede verificar ahí. Durante la depuración del código, o pruebas funcionales, el historial se ve aproximadamente así:

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 ellas se convierte en un enredo. A veces comienzas a saltar entre diferentes ventanas de IDE tratando de entender la secuencia de acciones de tu código. No es difícil, pero a veces resulta muy estresante.

No hay soporte completo para ES6.

Puede que haya otros inconvenientes, pero no me he encontrado con nada más. Comparta información si tiene una experiencia negativa con NJS.

Conclusión

NJS es un intérprete liviano de código abierto que permite implementar varios escenarios en Nginx utilizando JavaScript. En su desarrollo se prestó mucha atención al rendimiento. Por supuesto, le faltan muchas cosas, pero el proyecto está evolucionando con un pequeño equipo y están agregando nuevas características y solucionando errores activamente. Espero que algún día NJS permita conectar módulos externos, lo que hará que la funcionalidad de Nginx sea prácticamente ilimitada. Pero existe NGINX Plus y probablemente no habrá ciertas 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 y documentación

Ejemplos de uso de NJS por Dmitry Volyncev

njs: scripting nativo en JavaScript en nginx / Выступление Дмитрия Волныева на Saint HighLoad++ 2019

NJS en producción / Выступление Василия Сошникова на HighLoad++ 2019

Firma y autenticación de solicitudes REST en AWS

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster