Ho creato il mio repository PyPI con autorizzazione e S3. Su Nginx

In questo articolo voglio condividere la mia esperienza con NJS, l'interprete JavaScript per Nginx sviluppato da Nginx Inc., descrivendo attraverso un esempio reale le sue principali funzionalità. NJS è un sottoinsieme del linguaggio di programmazione JavaScript che consente di estendere le funzionalità di Nginx. A questa domanda perché un proprio interprete??? ha risposto dettagliatamente Dmitry Volyncev. In breve: NJS è il modo di Nginx, mentre JavaScript è più progressivo, "nativo" e privo di GC a differenza di Lua.

C'era una volta…

Nel mio precedente lavoro, mi è stato lasciato un gitlab con una certa quantità di CI/CD pipeline eterogenee con docker-compose, dind e altre meraviglie, tutte convertite su kaniko. Le immagini usate in precedenza nel CI sono state trasferite intatte. Funzionavano perfettamente fino a quel giorno in cui il nostro gitlab ha cambiato IP e il CI è diventato inutilizzabile. Il problema era che in una delle immagini Docker, che partecipava al CI, era presente git, che scaricava moduli Python tramite ssh. Per ssh è necessaria una chiave privata e… questa si trovava nell'immagine insieme a known_hosts. Ogni CI si interrompeva con un errore di verifica della chiave a causa della discrepanza tra l'IP reale e quello specificato in known_hosts. Dai Dockfile esistenti, è stata rapidamente creata una nuova immagine e aggiunta l'opzione StrictHostKeyChecking no. Tuttavia, è rimasto un retrogusto sgradevole e è emersa la voglia di trasferire le librerie in un repository PyPI privato. Inoltre, dopo la migrazione su un PyPI privato, il pipeline diventava più semplice e la descrizione di requirements.txt era più chiara.

La scelta è fatta, signori!

Siamo tutti impegnati nei cloud e in Kubernetes e alla fine volevamo ottenere un piccolo servizio che fosse un contenitore stateless con storage esterno. E dato che utilizziamo S3, il nostro focus è stato su questo. E se possibile, con autenticazione in gitlab (si può implementare autonomamente se necessario).

Una rapida ricerca ha dato alcuni risultati s3pypi, pypicloud e un'opzione con la creazione "manuale" di file html per il repository. L'ultima opzione è stata scartata praticamente da sola.

s3pypi: È un cli per utilizzare l'hosting su S3. Carichiamo file, generiamo html e carichiamo nello stesso bucket. È adatto per uso domestico.

pypicloud: Sembra un progetto interessante, ma dopo aver letto la documentazione, ho avuto una delusione. Nonostante una buona documentazione e possibilità di estensione per le proprie esigenze, si è rivelato eccessivo e complicato da configurare. Modificare il codice per le proprie necessità, secondo le stime di quel periodo, avrebbe richiesto 3-5 giorni. Inoltre, il servizio richiede un DB. L'abbiamo tenuto nel caso non trovassimo altro.

Una ricerca più approfondita ha rivelato il modulo per Nginx, ngx_aws_auth. Il test con questo ha dato come risultato un XML visibile nel browser, che mostrava il contenuto del bucket S3. L'ultimo commit, al momento della ricerca, risaliva a un anno fa. Il repository sembrava abbandonato.

Rivolgendomi alla fonte originale e leggendo PEP-503 , ho capito che l'XML può essere convertito in HTML al volo e consegnato a pip. Dopo un'altra ricerca su Nginx e S3, ho trovato un esempio di autenticazione in S3 scritto in JS per Nginx. Così ho conosciuto NJS.

Prendendo questo esempio come base, dopo un'ora ho osservato nel mio browser lo stesso XML che usavo con il modulo ngx_aws_auth, ma ora tutto era scritto in JS.

La soluzione su nginx mi è piaciuta molto. Innanzitutto, buona documentazione e molti esempi; in secondo luogo, otteniamo tutti i vantaggi di Nginx nella gestione dei file (di default); in terzo luogo, chiunque sappia scrivere configurazioni per Nginx sarà in grado di capire come funziona. Inoltre, per me è un vantaggio il minimalismo, rispetto a Python o Go (se si scrive da zero), senza contare Nexus.

TL;DR Dopo 2 giorni, la versione di prova di PyPi era già in uso nel CI.

Come funziona?

In Nginx si carica il modulo ngx_http_js_module, incluso nell'immagine Docker ufficiale. Importiamo il nostro script con la direttiva js_importnella configurazione di Nginx. La chiamata della funzione avviene tramite la direttiva js_content. Per impostare variabili si utilizza la direttiva js_set, che come argomento accetta solo la funzione descritta nello script. Eseguire sottoquery in NJS possiamo farlo solo tramite Nginx, senza XMLHttpRequest. Per questo, nella configurazione di Nginx deve essere aggiunta la corrispondente location. E nello script deve essere descritta la sottoquery (subrequest) per questa location. Per poter accedere alla funzione dalla configurazione di Nginx, nel proprio script il nome della funzione deve essere esportato 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) {
    // codice del gestore
    r.return(resp.status, resp.responseBody);
  }

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

export default {request}

Quando si fa una richiesta nel browser http://localhost:8080/ si arriva in location /dove la direttiva js_content chiama la funzione request è quella descritta nel nostro script. script.jsA sua volta, nella funzione request si effettua la sottoquery a location = /sub-query, con il metodo (nell'esempio attuale GET) ottenuto dall'argomento (r), passato implicitamente durante la chiamata di questa funzione. L'elaborazione della risposta della sottoquery sarà gestita nella funzione call_back.

Proviamo S3

Per effettuare una richiesta a un archivio S3 privato, abbiamo bisogno di:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Dall'http-method utilizzato, la data/ora corrente, S3_NAME e URI viene generata una stringa di un certo tipo, che viene firmata (HMAC_SHA1) utilizzando il SECRET_KEY. Successivamente, la stringa, del tipo AWS $ACCESS_KEY:$HASH, può essere utilizzata nell'intestazione di autorizzazione. La stessa data/ora utilizzata per generare la stringa nel passaggio precedente deve essere aggiunta all'intestazione X-amz-date. Nel codice appare così:

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(esempio di autorizzazione AWS Sign v2, considerato 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}

Un po' di spiegazione su _subrequest_uri: è una variabile che, a seconda dell'uri originale, forma la richiesta a S3. Se è necessario ottenere il contenuto della 'radice', in tal caso è necessario formare un uri di richiesta specificando il delimitatore delimiter, che restituirà un elenco di tutti gli elementi xml CommonPrefixes, corrispondenti alle directory (nel caso di PyPI, l'elenco di tutti i pacchetti). Se si desidera ottenere l'elenco del contenuto in una directory specifica (l'elenco di tutte le versioni dei pacchetti), allora l'uri di richiesta deve contenere il campo prefix con il nome della directory (pacchetto) che deve terminare con una barra /. Altrimenti, potrebbero esserci conflitti durante la richiesta del contenuto della directory, ad esempio. Ci sono directory aiohttp-request e aiohttp-requests e se nella richiesta si indica /?prefix=aiohttp-request, allora nella risposta verrà il contenuto di entrambe le directory. Se invece alla fine c'è la barra, /?prefix=aiohttp-request/, allora nella risposta ci sarà solo la directory desiderata. E se stiamo richiedendo un file, l'uri risultante non deve differire dall'originale.

Salviamo, riavviamo Nginx. Nel browser, digitiamo l'indirizzo del nostro Nginx, il risultato della richiesta sarà XML, ad esempio:

Elenco delle directory

myback-space
  
  
  10000
  /
  false
  
    new/
  
  
    old/

Dall'elenco delle directory avremo bisogno solo degli elementi CommonPrefixes.

Aggiungendo, nel browser, l'indirizzo della directory necessaria, otterremo il suo contenuto anch'esso in XML:

Elenco dei file nella directory

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

Dall'elenco dei file prenderemo solo gli elementi Chiave.

Rimane da analizzare l'XML ottenuto e restituirlo come HTML, sostituendo preliminarmente l'intestazione Content-Type in 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>'
}

Proviamo PyPI

Verifichiamo che nulla si rompa su pacchetti già funzionanti.

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

Ripetiamo con le nostre librerie.

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

Nel CI, la creazione e il caricamento del pacchetto appare così:

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

Autenticazione

In Gitlab, it is possible to use JWT for authentication/authorization of external services. By utilizing the auth_request directive in Nginx, we can redirect authentication data into a subrequest that contains a function call in the script. The script will then make another subrequest to the Gitlab URL, and if the authentication data is correct, Gitlab will return a 200 code, allowing the upload/download of the package. Why not use a single subrequest and send the data directly to Gitlab? Because then we would have to modify the Nginx configuration file every time there are changes in authorization, which is quite a tedious task. Additionally, if a read-only root filesystem policy is used in Kubernetes, it adds further complexity when replacing nginx.conf through a configmap. Configuring Nginx via a configmap becomes absolutely impossible when also using policies that prohibit the connection of volumes (pvc) and a read-only root filesystem (this can also happen).

By using NJS as an intermediary, we gain the ability to change the specified parameters in the nginx config using environment variables and perform checks in the script (for example, for an incorrect URL).

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}

The question probably arises: - Why not use ready-made modules? Everything is already done there! For example, var AWS = require('aws-sdk') and you don't have to reinvent the wheel with S3 authentication!

Let's move on to the disadvantages

For me, the inability to import external JS modules was an unpleasant but expected feature. The require('crypto') described in the example above is build-in modules and require only works for them. There is also no possibility to reuse code from scripts, so it has to be copied and pasted across different files. I hope that someday this functionality will be implemented.

For the current project, gzip compression should be disabled in Nginx gzip off;

Because there is no gzip module in NJS and it cannot be enabled, there is accordingly no possibility to work with compressed data. However, this is not really a downside for this case. There's not much text, and the files being transferred are already compressed, so additional compression won't be of much help. Additionally, it is not such a heavily loaded or critical service to warrant worrying about delivering content a few milliseconds faster.

Debugging the script is lengthy and can only be done through 'prints' in error.log. Depending on the logging level set to info, warn, or error, you can use 3 methods: r.log, r.warn, r.error, respectively. I try to debug some scripts in Chrome (v8) or the njs console tool, but not everything can be verified there. During the debugging of the code, aka functional testing, the history looks something like this:

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

and such sequences can be hundreds.

Writing code using subrequests and variables for them turns into a tangled mess. Sometimes you find yourself darting between different IDE windows trying to understand the sequence of your code's actions. It's not difficult, but it can be very frustrating.

There is no full support for ES6.

There may be more downsides, but I haven't encountered any others. Please share information if you have negative experience with NJS.

Conclusione

NJS is a lightweight open-source interpreter that allows for implementing various scenarios in Nginx using the JavaScript programming language. Great attention was paid to performance during its development. Of course, it still lacks a lot, but the project is being developed by a small team and they are actively adding new features and fixing bugs. I hope that someday NJS will allow for connecting external modules, which will make Nginx's functionality nearly limitless. However, there is NGINX Plus and features are likely to not be available!

Repository with the full code of the article

njs-pypi with AWS Sign v4 support

Description of the ngx_http_js_module directives

Official NJS repository e documentazione

Examples of using NJS from Dmitry Volyntsev

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

NJS in production / Выступление Василия Сошникова на HighLoad++ 2019

Signing and authenticating REST requests in AWS

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, server VPS VDS 🔥 Acquista hosting affidabile per siti web con protezione DDoS, server VPS VDS | ProHoster