J'ai créé mon dépôt PyPI avec autorisation et S3. Sur Nginx

Dans cet article, je souhaite partager mon expérience avec NJS, l'interpréteur JavaScript pour Nginx développé par Nginx Inc., en décrivant ses principales capacités à travers un exemple concret. NJS est un sous-ensemble du langage de programmation JavaScript, qui permet d'étendre la fonctionnalité de Nginx. À la question pourquoi avoir son propre interpréteur??? Dmitry Volyntsev a répondu en détail. En résumé : NJS est la manière nginx, tandis que JavaScript est plus progressif, « natif » et sans GC contrairement à Lua.

Il y a longtemps…

Lors de mon ancien emploi, j'ai hérité d'un gitlab avec un certain nombre de pipelines CI/CD hétéroclites utilisant docker-compose, dind et d'autres fonctionnalités, qui ont été adaptés pour utiliser kaniko. Les images utilisées auparavant dans le CI ont été transférées dans leur état original. Elles fonctionnaient correctement jusqu'au jour où notre gitlab a changé d'IP et que le CI s'est transformé en citrouille. Le problème était qu'une des images docker, participant au CI, contenait git, qui tirait des modules Python via ssh. Pour ssh, une clé privée est nécessaire et... elle se trouvait dans l'image avec known_hosts. Et chaque CI se terminait par une erreur de vérification de clé en raison de l'incompatibilité entre l'IP réelle et celle spécifiée dans known_hosts. À partir des Dockerfiles disponibles, une nouvelle image a été rapidement construite et l'option StrictHostKeyChecking noa été ajoutée. Mais un goût amer est resté et l'envie de transférer les bibliothèques dans un dépôt PyPI privé est apparue. Un autre avantage de la transition vers un PyPI privé était un pipeline plus simple et une description correcte de requirements.txt.

Le choix est fait, mesdames et messieurs !

Nous tournons tout dans le cloud et Kubernetes et au final, nous voulions obtenir un petit service qui représente un conteneur stateless avec un stockage externe. Comme nous utilisons S3, la priorité était donc pour lui. Et idéalement avec une authentification dans gitlab (il est possible d'ajouter cela si nécessaire).

Une recherche rapide a donné plusieurs résultats s3pypi, pypicloud et une option de création « manuelle » de fichiers html pour le repo. La dernière option a été abandonnée d'elle-même.

s3pypi : C'est un cli pour utiliser l'hébergement sur S3. Nous téléchargeons des fichiers, générons html et les mettons dans le même bucket. Cela convient pour un usage domestique.

pypicloud : Cela semblait être un projet intéressant, mais après avoir lu la documentation, j'ai été déçu. Malgré une bonne documentation et des possibilités d'adaptation à mes besoins, il s'est avéré être trop complexe et difficile à configurer. Adapter le code à mes besoins aurait, selon des estimations initiales, pris entre 3 et 5 jours. De plus, le service nécessite une base de données. Nous l'avons laissé au cas où nous ne trouverions rien d'autre.

Une recherche plus approfondie a révélé un module pour Nginx, ngx_aws_auth. Le résultat de son test était un XML affiché dans le navigateur, montrant le contenu du bac S3. Le dernier commit, au moment de la recherche, remontait à un an. Le dépôt semblait abandonné.

En me rapprochant de la source et en lisant PEP-503 , j'ai compris que le XML pouvait être converti en HTML à la volée et remis à pip. En cherchant un peu plus sur les mots Nginx et S3, je suis tombé sur un exemple d'authentification S3 écrit en JS pour Nginx. C'est ainsi que j'ai découvert NJS.

En prenant cet exemple comme base, après une heure, je voyais dans mon navigateur le même XML que celui utilisé avec le module ngx_aws_auth, mais tout était écrit en JS.

La solution sur Nginx m'a beaucoup plu. D'abord, il y a une bonne documentation et de nombreux exemples, ensuite, nous bénéficions de tous les avantages de Nginx en matière de gestion de fichiers (out-of-the-box) et enfin, quiconque sait écrire des configurations pour Nginx pourra comprendre ce qu'il faut faire. De plus, le minimalisme par rapport à Python ou Go (si écrit de zéro) est un atout, sans parler de nexus.

TL;DR : Après 2 jours, la version test de PyPi était déjà utilisée dans CI.

Comment cela fonctionne ?

Dans Nginx, on charge le module ngx_http_js_module, inclus dans l'image Docker officielle. Nous importons notre script grâce à la directive js_importdans la configuration de Nginx. L'appel de la fonction s'effectue par la directive js_content. Pour définir des variables, on utilise la directive js_set, qui prend en argument uniquement la fonction décrite dans le script. En revanche, nous ne pouvons effectuer des sous-requêtes dans NJS qu'avec l'aide de Nginx, pas de XMLHttpRequest à cet endroit. Pour cela, un emplacement correspondant doit être ajouté dans la configuration de Nginx. Dans le script, une sous-requête (subrequest) vers cet emplacement doit être décrite. Afin de pouvoir appeler la fonction depuis la config Nginx, le nom de la fonction doit être exporté dans le 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) {
    // code du gestionnaire
    r.return(resp.status, resp.responseBody);
  }

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

export default {request}

Lors d'une requête dans le navigateur http://localhost:8080/ nous arrivons à location /dans laquelle la directive js_content appelle la fonction request décrite dans notre script script.js. En retour, dans la fonction request une sous-requête est réalisée vers location = /sub-query, avec la méthode (dans l'exemple actuel GET) obtenue de l'argument (r), transmis implicitement lors de l'appel de cette fonction. Le traitement de la réponse de la sous-requête sera effectué dans la fonction call_back.

Essayons S3

Pour faire une demande à un stockage S3 privé, nous avons besoin de :

ACCESS_KEY

SECRET_KEY

S3_BUCKET

À partir de la méthode http utilisée, la date/heure actuelle, S3_NAME et URI, une chaîne de ce type est générée, qui est signée (HMAC_SHA1) à l'aide de SECRET_KEY. Ensuite, la chaîne, du type AWS $ACCESS_KEY:$HASH, peut être utilisée dans l'en-tête d'autorisation. La même date/heure utilisée pour générer la chaîne à l'étape précédente doit être ajoutée à l'en-tête X-amz-date. Dans le code, cela ressemble à ceci :

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(exemple d'autorisation AWS Sign v2, mis en statut obsolète)

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}

Quelques explications sur _subrequest_uri: il s'agit d'une variable qui, en fonction de l'uri d'origine, forme la demande à S3. Si vous devez obtenir le contenu de la "racine", dans ce cas, il faut former une uri-demande en indiquant le séparateur delimiter, qui renverra la liste de tous les éléments xml CommonPrefixes, correspondant aux répertoires (dans le cas de PyPI, la liste de tous les paquets). Si vous avez besoin d'obtenir la liste du contenu dans un répertoire spécifique (la liste de toutes les versions des paquets), alors l'uri-demande doit contenir le champ prefix avec le nom du répertoire (paquet), se terminant impérativement par une barre oblique /. Sinon, il peut y avoir des collisions lors de la demande du contenu du répertoire, par exemple. Il y a les répertoires aiohttp-request et aiohttp-requests et si dans la demande il est indiqué /?prefix=aiohttp-request, ensuite, la réponse contiendra le contenu des deux répertoires. Si, à la fin, il y a un slash, /?prefix=aiohttp-request/, alors la réponse ne contiendra que le répertoire requis. Et si nous demandons un fichier, l’URI résultant ne doit pas différer de l'original.

Nous enregistrons, redémarrons Nginx. Dans le navigateur, nous saisissons l'adresse de notre Nginx, le résultat de la requête sera un XML, par exemple :

Liste des répertoires

myback-space
  
  
  10000
  /
  false
  
    new/
  
  
    old/

Dans la liste des répertoires, seuls les éléments CommonPrefixes.

En ajoutant, dans le navigateur, le répertoire requis à notre adresse, nous obtiendrons son contenu également sous forme de XML :

Liste des fichiers dans le répertoire

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

Dans la liste des fichiers, prenons uniquement les éléments Clé.

Il ne reste plus qu'à analyser le XML obtenu et à le renvoyer sous forme de HTML, après avoir remplacé l'en-tête Content-Type par 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>'
}

Essayons PyPI

Vérifions que tout fonctionne correctement avec des packages bien établis.

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

Répétons cela avec nos bibliothèques.

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

Dans CI, la création et le téléchargement d'un package se déroulent comme suit :

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

Authentification

Dans Gitlab, il est possible d'utiliser JWT pour l'authentification/autorisation des services externes. En utilisant la directive auth_request dans Nginx, nous allons rediriger les données d'authentification dans une sous-requête contenant un appel à une fonction dans le script. Dans le script, une autre sous-requête sera effectuée sur l'URL de Gitlab et si les données d'authentification sont correctes, Gitlab renverra le code 200 et la téléchargement/téléchargement du package sera autorisé. Pourquoi ne pas utiliser une seule sous-requête et envoyer directement les données à Gitlab ? Parce qu'il faudra alors modifier le fichier de configuration Nginx à chaque fois qu'il y aura des changements dans l'authentification, ce qui est une tâche assez laborieuse. De plus, si Kubernetes utilise une politique de système de fichiers racine en mode lecture seule, cela complique encore plus le remplacement de nginx.conf via configmap. Et il devient absolument impossible de configurer Nginx via configmap tout en utilisant des politiques interdisant la connexion des volumes (pvc) et le système de fichiers racine en mode lecture seule (ce qui arrive aussi).

En utilisant NJS comme intermédiaire, nous avons la possibilité de modifier les paramètres spécifiés dans le fichier de configuration nginx à l'aide de variables d'environnement et de réaliser certaines vérifications dans le script (par exemple, une URL incorrecte).

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}

La question se pose probablement : - Pourquoi ne pas utiliser des modules prêts à l'emploi ? Tout y est déjà fait ! Par exemple, var AWS = require('aws-sdk') et pas besoin de réinventer la roue avec l'authentification S3 !

Passons aux inconvénients

Pour moi, l'incapacité d'importer des modules JS externes a été une caractéristique désagréable, mais attendue. Le require('crypto') mentionné dans l'exemple ci-dessus, c'est des modules intégrés et le require ne fonctionne que pour eux. Il n'est également pas possible de réutiliser le code des scripts et il faut le copier-coller dans différents fichiers. J'espère qu'un jour cette fonctionnalité sera réalisée.

De plus, pour le projet actuel, la compression doit être désactivée dans Nginx. gzip off;

Parce qu'il n'y a pas de module gzip dans NJS et qu'il est impossible de l'ajouter, il n'est donc pas possible de travailler avec des données compressées. Cela dit, ce n'est pas vraiment une lacune dans ce cas. Il n'y a pas beaucoup de texte, et les fichiers transmis sont déjà compressés, donc une compression additionnelle ne serait pas d'une grande aide. De plus, ce n'est pas un service trop chargé ou critique pour se soucier de la livraison de contenu quelques millisecondes plus rapidement.

Le débogage du script est long et ne peut se faire que par des 'print' dans error.log. En fonction du niveau de journalisation défini, info, warn ou error, il est possible d'utiliser respectivement 3 méthodes : r.log, r.warn, r.error. J'essaie de déboguer certains scripts dans Chrome (v8) ou l'outil console njs, mais il n'est pas possible de tout vérifier là-bas. Lors du débogage du code, c'est-à-dire les tests fonctionnels, l'historique ressemble à peu près à cela :

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

et de telles séquences peuvent être présentes par centaines.

Écrire du code en utilisant des sous-requêtes et des variables pour celles-ci devient un véritable casse-tête. Parfois, on commence à naviguer entre différentes fenêtres de l'IDE pour essayer de comprendre la séquence des actions de votre code. Ce n'est pas difficile, mais cela peut parfois s'avérer très frustrant.

Aucune prise en charge complète de ES6.

Il peut y avoir d'autres défauts, mais je n'en ai pas rencontré d'autres. Partagez des informations si vous avez eu une expérience négative avec NJS.

Conclusion

NJS est un interpréteur open-source léger permettant de réaliser divers scénarios en JavaScript dans Nginx. Une grande attention a été portée à la performance lors de son développement. Bien sûr, il lui manque encore beaucoup de choses, mais le projet progresse grâce à une petite équipe qui ajoute activement de nouvelles fonctionnalités et corrige des bugs. J'espère qu'un jour, NJS permettra d'ajouter des modules externes, ce qui rendra les fonctionnalités de Nginx pratiquement illimitées. Mais il existe NGINX Plus, donc certaines fonctionnalités ne seront probablement pas proposées !

Dépôt avec le code complet de l'article

njs-pypi avec support de AWS Sign v4

Description des directives du module ngx_http_js_module

Dépôt officiel de NJS et documentation

Exemples d'utilisation de NJS par Dmitry Volynets

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

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

Signature et authentification des requêtes REST dans AWS

Source : habr.com

Acheter un hébergement fiable pour les sites avec protection DDoS, serveurs VPS VDS 🔥 Acheter un hébergement fiable pour les sites avec protection DDoS, serveurs VPS VDS | ProHoster