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

In questo articolo voglio condividere la mia esperienza con NJS, un interprete JavaScript per Nginx sviluppato dalla società Nginx Inc, descrivendo attraverso un esempio reale le sue principali funzionalità. NJS è un sottoinsieme del linguaggio di programmazione JavaScript che consente di estendere le capacità di Nginx. Alla domanda perché avere un proprio interprete??? ha risposto in modo dettagliato Dmitrij Volyncev. In breve: NJS è l'approccio nginx, mentre JavaScript è più progressivo, 'nativo' e senza GC a differenza di Lua.

Tempo fa…

Nel mio lavoro precedente mi è stato lasciato un gitlab con un certo numero di pipeline CI/CD variegate con docker-compose, dind e altre meraviglie, che erano state migrate su kaniko. Le immagini utilizzate in precedenza nel CI sono rimaste nel loro stato originale. Funzionavano correttamente fino a quel giorno in cui il nostro gitlab ha cambiato IP e il CI si è trasformato in una zucca. Il problema era che in una delle immagini docker partecipanti al CI c'era git, che traeva i moduli Python tramite ssh. Per ssh è necessaria una chiave privata e… era nell'immagine insieme a known_hosts. E qualsiasi CI terminava con un errore di verifica della chiave a causa della non corrispondenza tra l'IP reale e quello indicato in known_hosts. Dai Dockfile disponibili è stata rapidamente ricostruita una nuova immagine e aggiunta l'opzione StrictHostKeyChecking no. Tuttavia, è rimasto un sapore sgradevole e nasceva il desiderio di trasferire le lib a un repository PyPI privato. Un ulteriore vantaggio, dopo il passaggio a PyPI privato, è diventato un pipeline più semplice e una normale descrizione di requirements.txt.

La scelta è fatta, Signori!

Siamo tutti immersi nelle nuvole e Kubernetes e alla fine volevamo ottenere un piccolo servizio che fosse un contenitore stateless con un'archiviazione esterna. E poiché utilizziamo S3, il nostro focus è stato su di esso. E possibilmente con autenticazione in gitlab (si può anche scrivere da soli se necessario).

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

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

pypicloud: Sembrava un progetto interessante, ma dopo aver letto la documentazione, è arrivata la delusione. Nonostante una buona documentazione e la possibilità di personalizzazione, si è rivelato troppo complesso e difficile da configurare. Modificare il codice per le mie esigenze, secondo le mie stime, richiederebbe 3-5 giorni. Inoltre, il servizio ha bisogno di un database. L'abbiamo lasciato in caso non trovassimo nulla di meglio.

Una ricerca più approfondita ha portato a un modulo per Nginx, ngx_aws_auth. Il risultato dei suoi test è stato un XML visualizzato nel browser, dal quale era possibile vedere 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 passato a pip. Ancora un po' di ricerca con le parole Nginx e S3, ho trovato un esempio di autenticazione in S3 scritto in JS per Nginx. Così ho conosciuto NJS.

Prendendo spunto da questo esempio, dopo un'ora ho osservato nel mio browser lo stesso XML, proprio come con il modulo ngx_aws_auth, ma tutto era già scritto in JS.

La soluzione su nginx mi piaceva molto. In primo luogo, ottima documentazione e molti esempi; in secondo luogo, otteniamo tutti i vantaggi di Nginx per la gestione dei file (out of the box); in terzo luogo, chiunque sappia scrivere configurazioni per Nginx, può capire come funziona. Inoltre, per me è un vantaggio il minimalismo, rispetto a Python o Go (se scrivessi da zero), per non parlare di nexus.

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

Come funziona?

In Nginx viene caricato il modulo ngx_http_js_module, incluso nell'immagine docker ufficiale. Importiamo il nostro script utilizzando la direttiva js_importnella configurazione di Nginx. La chiamata della funzione avviene tramite la direttiva js_content. Per impostare le variabili si utilizza la direttiva js_set, che accetta come argomento solo la funzione definita nello script. Tuttavia, può eseguire sottoricheste in NJS solo con Nginx, niente XMLHttpRequest. Per questo, nella configurazione di Nginx deve essere aggiunta la relativa posizione. E nello script deve essere definita una sottorichiesta (subrequest) a questa posizione. Per poter accedere a una 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 effettuato una richiesta nel browser http://localhost:8080/ ci troviamo in location /in cui direttiva js_content chiama la funzione request è descritta nel nostro script script.js. A sua volta, nella funzione request viene effettuata una sotto-richiesta a location = /sub-query, con il metodo (nell'esempio attuale GET) ottenuto dall'argomento (r), passato implicitamente al momento della chiamata di questa funzione. L'elaborazione della risposta della sotto-richiesta sarà gestita nella funzione call_back.

Proviamo S3

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

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Dall'http-method utilizzato, la data/ora attuale, S3_NAME e URI viene generata una stringa di un certo tipo, che è firmata (HMAC_SHA1) usando il SECRET_KEY. Successivamente, questa 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 nell'intestazione X-amz-date. In 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, convertito in stato deprecato)

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 breve spiegazione su _subrequest_uri: è una variabile che, a seconda dell'uri di partenza, genera la richiesta a S3. Se occorre ottenere il contenuto "della radice", in tal caso è necessario formare una uri-richiesta con l'indicazione del 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 occorre ottenere un elenco del contenuto in una specifica directory (elenco di tutte le versioni dei pacchetti), allora la uri-richiesta deve contenere il campo prefix con il nome della directory (pacchetto) che termina obbligatoriamente con una barra \/ . In caso contrario, sono possibili conflitti nella richiesta del contenuto della directory, ad esempio. Ci sono directory aiohttp-request e aiohttp-requests e se nella richiesta viene specificato /?prefix=aiohttp-request, allora nella risposta sarà presente il contenuto di entrambe le directory. Se, invece, alla fine ci sarà una barra, /?prefix=aiohttp-request/, quindi nella risposta ci sarà solo la directory richiesta. E se chiediamo un file, l'uri risultante non deve differire da quello originale.

Salviamo, riavviamo Nginx. Nel browser inseriamo 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, ci serviranno solo gli elementi CommonPrefixes.

Aggiungendo nel browser la directory richiesta al nostro indirizzo, otteniamo il suo contenuto anch'esso in formato 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 prendiamo solo gli elementi Key.

Rimane solo da analizzare l'XML ottenuto e restituirlo in formato HTML, sostituendo precedentemente l'intestazione 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="/${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 notoriamente 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

In CI, la creazione e il caricamento di un pacchetto avviene 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 è possibile utilizzare JWT per l'autenticazione/autorizzazione di servizi esterni. Utilizzando la direttiva auth_request in Nginx, reindirizzeremo i dati di autenticazione in una sotto-richiesta che contiene la chiamata a una funzione nello script. Nello script verrà effettuata un'altra sotto-richiesta all'url di Gitlab e se i dati di autenticazione sono stati forniti correttamente, Gitlab restituirà il codice 200 e sarà consentito il caricamento/scaricamento del pacchetto. Perché non utilizzare una sola sotto-richiesta e inviare direttamente i dati a Gitlab? Perché bisognerebbe modificare il file di configurazione di Nginx ogni volta che ci sono cambiamenti nell'autenticazione, il che è un'attività piuttosto noiosa. Inoltre, se in Kubernetes viene utilizzata la politica di filesystem root in modalità di sola lettura, questo aggiunge ulteriori complicazioni alla sostituzione di nginx.conf tramite configmap. E diventa assolutamente impossibile configurare Nginx tramite configmap utilizzando contemporaneamente politiche che vietano il collegamento di volumi (pvc) e il filesystem root in modalità di sola lettura (questo può succedere).

Utilizzando NJS come intermediario, otteniamo la possibilità di modificare i parametri specificati nella configurazione di nginx tramite variabili d'ambiente e di eseguire alcune verifiche nello script (ad esempio, un URL fornito in modo errato).

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 ~ "^/(?[w-]*)[/?]?(?[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}

È probabile che sorga la domanda: - Perché non utilizzare i moduli pronti? Lì è già tutto fatto! Ad esempio, var AWS = require(‘aws-sdk’) e non bisogna scrivere "bicicletta" con l'autenticazione S3!

Passiamo ai contro.

Per me, l'impossibilità di importare moduli JS esterni è stata una caratteristica sgradevole, ma attesa. Il require(‘crypto’) descritto nell'esempio precedente è moduli incorporati e il require funziona solo per essi. Inoltre, non è possibile riutilizzare il codice dagli script e si è costretti a copiarlo e incollarlo in file diversi. Spero che un giorno questa funzionalità venga implementata.

Anche per l'attuale progetto in Nginx la compressione deve essere disattivata gzip off;

Perché manca il modulo gzip in NJS e non è possibile collegarlo, di conseguenza non c'è possibilità di lavorare con dati compressi. In verità, non è un grande svantaggio per questo caso specifico. Il testo non è molto e i file trasferiti sono già compressi, quindi una compressione aggiuntiva non sarà molto utile. Inoltre, non è un servizio così sovraccarico o critico da preoccuparsi di fornire contenuti qualche millisecondo più velocemente.

Il debug dello script è lungo e possibile solo tramite "print" in error.log. A seconda del livello di logging impostato (info, warn o error), è possibile utilizzare 3 metodi: r.log, r.warn, r.error, rispettivamente. Alcuni script cerco di debuggare in Chrome (v8) o nello strumento console njs, ma non tutto può essere verificato lì. Durante il debug del codice, ovvero il testing funzionale, la cronologia appare circa così:

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

e tali sequenze possono essere centinaia.

Scrivere codice utilizzando sottoquery e variabili per esse diventa un intricato groviglio. A volte inizio a saltare tra diverse finestre dell'IDE per cercare di capire la sequenza delle azioni del mio codice. Non è difficile, ma talvolta è davvero stressante.

Non c'è supporto completo per ES6.

Potrebbero esserci anche altre mancanze, ma non ho avuto ulteriori problemi. Condividi informazioni se hai avuto esperienze negative con NJS.

Conclusione

NJS è un interprete open-source leggero che consente di implementare vari scenari in Nginx utilizzando il linguaggio JavaScript. Durante il suo sviluppo, è stata prestata grande attenzione alle prestazioni. Certo, manca ancora molto, ma il progetto è in continua evoluzione grazie a un piccolo team che attivamente aggiunge nuove funzionalità e corregge bug. Spero che un giorno NJS consentirà di collegare moduli esterni, rendendo le funzionalità di Nginx praticamente illimitate. Ma c'è NGINX Plus e alcune funzionalità probabilmente non saranno disponibili!

Repository con il codice completo dell'articolo

njs-pypi con supporto per AWS Sign v4

Descrizione delle direttive del modulo ngx_http_js_module

Repository ufficiale di NJS e documentazione

Esempi di utilizzo di NJS di Dmitry Volynets

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

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

Firma e autenticazione delle richieste REST in AWS

Fonte: habr.com

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