Vaig fer el meu propi repositori PyPI amb autorització i S3. A Nginx

En aquest article m'agradaria compartir la meva experiència amb NJS, un intèrpret de JavaScript per a Nginx desenvolupat per Nginx Inc, descrivint les seves principals capacitats amb un exemple real. NJS és un subconjunt de JavaScript que us permet ampliar la funcionalitat de Nginx. A la pregunta per què el teu propi intèrpret??? Dmitry Volyntsev va respondre amb detall. En resum: NJS és nginx-way i JavaScript és més progressiu, "natiu" i sense GC, a diferència de Lua.

Fa molt de temps…

En el meu últim treball, vaig heretar gitlab amb una sèrie de pipelines CI/CD abigarrats amb docker-compose, dind i altres delícies, que es van transferir a rails kaniko. Les imatges que s'utilitzaven anteriorment a CI es van moure en la seva forma original. Van funcionar correctament fins al dia en què la nostra IP de gitlab va canviar i CI es va convertir en una carbassa. El problema va ser que una de les imatges docker que van participar en CI tenia git, que va treure mòduls de Python mitjançant ssh. Per a ssh necessiteu una clau privada i... estava a la imatge juntament amb els hosts_conegudes. I qualsevol CI ha fallat amb un error de verificació de clau a causa d'una discrepància entre la IP real i l'especificada a known_hosts. Es va reunir ràpidament una nova imatge a partir dels Dockfiles existents i es va afegir l'opció StrictHostKeyChecking no. Però el mal gust es va mantenir i es va voler traslladar les biblioteques a un repositori PyPI privat. Una bonificació addicional, després de canviar a PyPI privat, era una canalització més senzilla i una descripció normal de requirements.txt

L'elecció està feta, senyors!

Ho executem tot als núvols i Kubernetes, i al final volíem aconseguir un petit servei que fos un contenidor sense estat amb emmagatzematge extern. Bé, com que fem servir S3, se li va donar prioritat. I, si és possible, amb autenticació a gitlab (pots afegir-ho tu mateix si cal).

Una cerca ràpida va donar diversos resultats: s3pypi, pypicloud i una opció amb creació "manual" d'arxius html per a naps. L'última opció va desaparèixer per si sola.

s3pypi: aquest és un cli per utilitzar l'allotjament S3. Pugem els fitxers, generem l'html i el pengem al mateix bucket. Apte per a ús domèstic.

pypicloud: Semblava un projecte interessant, però després de llegir la documentació em va decebre. Malgrat la bona documentació i la capacitat d'ampliar-se per adaptar-se a les vostres necessitats, en realitat va resultar ser redundant i difícil de configurar. Corregir el codi per adaptar-lo a les vostres tasques, segons les estimacions en aquell moment, hauria trigat entre 3 i 5 dies. El servei també necessita una base de dades. El vam deixar per si no vam trobar res més.

Una cerca més detallada va donar un mòdul per a Nginx, ngx_aws_auth. El resultat de les seves proves va ser XML que es mostrava al navegador, que mostrava el contingut del bucket S3. L'última comesa en el moment de la recerca va ser fa un any. El dipòsit semblava abandonat.

Anant a la font i llegint PEP-503 Em vaig adonar que XML es pot convertir a HTML sobre la marxa i es pot donar a pip. Després de buscar a Google una mica més sobre Nginx i S3, em vaig trobar amb un exemple d'autenticació a S3 escrit en JS per a Nginx. Així va ser com vaig conèixer NJS.

Prenent aquest exemple com a base, una hora més tard vaig veure al meu navegador el mateix XML que quan feia servir el mòdul ngx_aws_auth, però tot ja estava escrit en JS.

Em va agradar molt la solució nginx. En primer lloc, una bona documentació i molts exemples, en segon lloc, obtenim totes les avantatges de Nginx per treballar amb fitxers (fora de la caixa), en tercer lloc, qualsevol persona que sàpiga com escriure configuracions per a Nginx podrà esbrinar què és què. El minimalisme també és un avantatge per a mi, en comparació amb Python o Go (si s'escriu des de zero), per no parlar de nexus.

TL;DR Després de 2 dies, la versió de prova de PyPi ja s'utilitzava a CI.

Com funciona?

El mòdul es carrega a Nginx ngx_http_js_module, inclòs a la imatge oficial de Docker. Importem el nostre script mitjançant la directiva js_importa la configuració de Nginx. La funció és cridada per una directiva js_content. La directiva s'utilitza per establir variables js_set, que només pren com a argument la funció descrita a l'script. Però podem executar subconsultes a NJS només utilitzant Nginx, no qualsevol XMLHttpRequest. Per fer-ho, cal afegir la ubicació corresponent a la configuració de Nginx. I l'script ha de descriure una subsol·licitud a aquesta ubicació. Per poder accedir a una funció des de la configuració de Nginx, el nom de la funció s'ha d'exportar al propi 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}

Quan ho sol·liciti al navegador http://localhost:8080/ ens endinsem location /en què la directiva js_content crida una funció request descrit al nostre guió script.js. Al seu torn, en la funció request es fa una subconsulta a location = /sub-query, amb un mètode (a l'exemple actual GET) obtingut a partir de l'argument (r), passat implícitament quan es crida aquesta funció. La resposta de la subsol·licitud es processarà a la funció call_back.

Provant S3

Per fer una sol·licitud a l'emmagatzematge privat S3, necessitem:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

A partir del mètode http utilitzat, la data/hora actuals, S3_NAME i URI, es genera un determinat tipus de cadena, que es signa (HMAC_SHA1) mitjançant SECRET_KEY. El següent és una línia com AWS $ACCESS_KEY:$HASH, es pot utilitzar a la capçalera d'autorització. La mateixa data/hora que es va utilitzar per generar la cadena al pas anterior s'ha d'afegir a la capçalera X-amz-date. En codi es veu així:

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(Exemple d'autorització AWS Sign v2, canviat a l'estat obsolet)

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 petita explicació sobre _subrequest_uri: aquesta és una variable que, en funció de l'uri inicial, forma una petició a S3. Si necessiteu obtenir el contingut de l'"arrel", haureu de crear una sol·licitud uri que indiqui el delimitador delimiter, que retornarà una llista de tots els elements xml de CommonPrefixes, corresponents als directoris (en el cas de PyPI, una llista de tots els paquets). Si necessiteu obtenir una llista de continguts en un directori específic (una llista de totes les versions del paquet), aleshores la sol·licitud uri ha de contenir un camp de prefix amb el nom del directori (paquet) que acabi necessàriament amb una barra inclinada /. En cas contrari, són possibles col·lisions quan es sol·licita el contingut d'un directori, per exemple. Hi ha directoris aiohttp-request i aiohttp-requests i si la petició ho especifica /?prefix=aiohttp-request, aleshores la resposta contindrà el contingut dels dos directoris. Si hi ha una barra al final, /?prefix=aiohttp-request/, aleshores la resposta només contindrà el directori requerit. I si sol·licitem un fitxer, l'uri resultant no hauria de diferir de l'original.

Desa i reinicia Nginx. Al navegador introduïm l'adreça del nostre Nginx, el resultat de la sol·licitud serà XML, per exemple:

Llista de directoris

<?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 llista de directoris només necessitareu els elements CommonPrefixes.

En afegir el directori que necessitem a la nostra adreça en el navegador, també rebrem el seu contingut en format XML:

Llista de fitxers d'un directori

<?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 llista de fitxers agafarem només elements Key.

Tot el que queda és analitzar l'XML resultant i enviar-lo com a HTML, després d'haver substituït primer la capçalera Content-Type per 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="/ca/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Provant PyPI

Comprovem que no es trenqui res als paquets que se sap que funcionen.

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

Repetim amb les nostres llibreries.

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

A CI, crear i carregar un paquet té aquest aspecte:

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ó

A Gitlab és possible utilitzar JWT per a l'autenticació/autorització de serveis externs. Utilitzant la directiva auth_request a Nginx, redirigirem les dades d'autenticació a una subsol·licitud que contingui una crida de funció a l'script. L'script farà una altra subsol·licitud a l'URL de Gitlab i si les dades d'autenticació s'han especificat correctament, Gitlab retornarà el codi 200 i es permetrà la càrrega/descàrrega del paquet. Per què no utilitzar una subconsulta i enviar immediatament les dades a Gitlab? Perquè aleshores haurem d'editar el fitxer de configuració de Nginx cada vegada que fem algun canvi en l'autorització, i aquesta és una tasca força tediosa. A més, si Kubernetes utilitza una política de sistema de fitxers arrel de només lectura, això afegeix encara més complexitat quan es substitueix nginx.conf mitjançant configmap. I es fa absolutament impossible configurar Nginx mitjançant el mapa de configuració mentre s'utilitzen simultàniament polítiques que prohibeixen la connexió de volums (pvc) i el sistema de fitxers arrel de només lectura (això també passa).

Utilitzant l'intermedi NJS, tenim l'oportunitat de canviar els paràmetres especificats a la configuració de nginx mitjançant variables d'entorn i fer algunes comprovacions a l'script (per exemple, un URL especificat incorrectament).

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}

El més probable és que la pregunta s'estigui: -Per què no utilitzar mòduls ja fets? Ja s'ha fet tot allà! Per exemple, var AWS = require('aws-sdk') i no cal escriure una "bicicleta" amb autenticació S3!

Passem als contres

Per a mi, la incapacitat d'importar mòduls JS externs es va convertir en una característica desagradable, però esperada. Es descriu a l'exemple anterior require('crypto') és mòduls integrats i només requereixen obres per a ells. Tampoc hi ha manera de reutilitzar el codi dels scripts i cal copiar-lo i enganxar-lo en diferents fitxers. Espero que algun dia aquesta funcionalitat s'implementarà.

La compressió també s'ha de desactivar per al projecte actual a Nginx gzip off;

Com que no hi ha cap mòdul gzip a NJS i és impossible connectar-lo; per tant, no hi ha manera de treballar amb dades comprimides. És cert que això no és realment un inconvenient per a aquest cas. No hi ha molt text i els fitxers transferits ja estan comprimits i la compressió addicional no els ajudarà gaire. A més, aquest no és un servei tan carregat o crític que us hagueu de preocupar de lliurar contingut uns quants mil·lisegons més ràpid.

La depuració de l'script triga molt de temps i només és possible mitjançant "impressions" a error.log. Depenent de la informació del nivell de registre establert, advertència o error, és possible utilitzar 3 mètodes r.log, r.warn, r.error respectivament. Intento depurar alguns scripts a Chrome (v8) o a l'eina de la consola njs, però no es pot comprovar tot allà. Quan es depura el codi, també conegut com a proves funcionals, l'historial té un aspecte semblant a això:

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

i hi pot haver centenars d'aquestes seqüències.

L'escriptura de codi utilitzant subconsultes i variables per a elles es converteix en un embolcall. De vegades comenceu a córrer per diferents finestres IDE intentant esbrinar la seqüència d'accions del vostre codi. No és difícil, però de vegades és molt molest.

No hi ha suport complet per a ES6.

Pot ser que hi hagi altres mancances, però no he trobat res més. Comparteix informació si tens una experiència negativa amb NJS.

Conclusió

NJS és un intèrpret lleuger de codi obert que us permet implementar diversos scripts JavaScript a Nginx. Durant el seu desenvolupament, es va prestar molta atenció al rendiment. Per descomptat, encara hi falten moltes coses, però el projecte està sent desenvolupat per un petit equip i estan afegint noves funcions de manera activa i arreglant errors. Espero que algun dia NJS us permeti connectar mòduls externs, cosa que farà que la funcionalitat de Nginx sigui gairebé il·limitada. Però hi ha NGINX Plus i molt probablement no hi haurà funcions!

Repositori amb el codi complet de l'article

njs-pypi amb compatibilitat amb AWS Sign v4

Descripció de les directives del mòdul ngx_http_js_module

Repositori oficial de NJS и documentació

Exemples d'ús de NJS de Dmitry Volintsev

njs - script natiu de JavaScript a nginx / Discurs de Dmitry Volnyev a Saint HighLoad++ 2019

NJS en producció / Discurs de Vasily Soshnikov a HighLoad++ 2019

Signatura i autenticació de sol·licituds REST a AWS

Font: www.habr.com