Ich habe mein PyPI-Repository mit Authentifizierung und S3 erstellt. Auf Nginx

In diesem Artikel möchte ich meine Erfahrungen mit NJS, dem JavaScript-Interpreter für Nginx, der von der Firma Nginx Inc. entwickelt wird, teilen und an einem konkreten Beispiel die wichtigsten Möglichkeiten vorstellen. NJS ist eine Teilmenge der Programmiersprache JavaScript, die es ermöglicht, die Funktionalität von Nginx zu erweitern. Auf die Frage warum einen eigenen Interpreter??? hat Dmitrij Wolynzew ausführlich geantwortet. Kurz gesagt: NJS ist der nginx-way, während JavaScript fortschrittlicher, „heimischer“ und ohne GC ist, im Gegensatz zu Lua.

Vor langer Zeit…

In meinem vorherigen Job hatte ich ein GitLab übernommen, das eine Vielzahl verschiedener CI/CD-Pipelines mit Docker-Compose, DIND und anderen Annehmlichkeiten beinhaltete, die auf Kaniko umgestellt wurden. Die Images, die zuvor in CI verwendet wurden, blieben unverändert. Sie funktionierten zuverlässig bis zu dem Tag, an dem sich die IP unseres GitLab änderte und CI zu einem Kürbis wurde. Das Problem war, dass in einem der Docker-Images, das an CI beteiligt war, Git installiert war, das über SSH Python-Module bezog. Für SSH wird ein privater Schlüssel benötigt, und … dieser war im Image zusammen mit known_hosts. Und jeder CI wurde aufgrund eines Fehlers beim Schlüsselcheck über die Diskrepanz zwischen der realen IP und der in known_hosts angegebenen IP abgebrochen. Aus den vorhandenen Dockerfiles wurde schnell ein neues Image erstellt und die Option StrictHostKeyChecking nohinzugefügt. Doch der unangenehme Nachgeschmack blieb und der Wunsch entstand, die Bibliotheken in ein privates PyPI-Repository zu übertragen. Ein zusätzlicher Bonus war, dass die Pipeline nach der Umstellung auf PyPI einfacher wurde und eine ordentliche Beschreibung der requirements.txt ermöglicht wurde.

Die Entscheidung ist gefallen, meine Herren!

Wir drehen alles in der Cloud und Kubernetes, und letztendlich wollte ich einen kleinen Dienst erhalten, der aus einem stateless Container mit externem Speicher besteht. Da wir S3 verwenden, hatte es auch Priorität. Und wenn möglich mit Authentifizierung in GitLab (kann bei Bedarf selbst hinzugefügt werden).

Eine schnelle Recherche ergab mehrere Ergebnisse: s3pypi, pypicloud und eine Option mit „manuellem“ Erstellen von HTML-Dateien für das Repository. Letztere Option fiel von selbst weg.

s3pypi: das ist ein CLI zur Nutzung des Hostings auf S3. Wir laden Dateien hoch, generieren HTML und laden es in dasselbe Bucket hoch. Für den Heimgebrauch ist es geeignet.

pypicloud: Es schien ein interessantes Projekt zu sein, aber nach dem Lesen der Dokumentation stellte sich Enttäuschung ein. Trotz guter Dokumentation und Erweiterungsmöglichkeiten für eigene Anforderungen erwies es sich als überflüssig und kompliziert einzurichten. Den Code für die eigenen Ziele anzupassen, würde schätzungsweise 3-5 Tage in Anspruch nehmen. Zudem benötigt der Dienst eine Datenbank. Wir haben es für den Fall aufbewahrt, dass wir sonst nichts finden.

Eine vertiefte Suche ergab ein Modul für Nginx, ngx_aws_auth. Das Ergebnis seiner Testung war ein im Browser angezeigtes XML, aus dem der Inhalt des S3-Buckets sichtbar war. Der letzte Commit, zum Zeitpunkt der Suche, war vor einem Jahr. Das Repository wirkte verlassen.

Nachdem ich die Quelle konsultiert und gelesen hatte PEP-503 , verstand ich, dass XML im Handumdrehen in HTML konvertiert und von pip bereitgestellt werden kann. Noch ein wenig mit den Worten Nginx und S3 gegoogelt, stieß ich auf ein Beispiel zur Authentifizierung in S3, das in JS für Nginx geschrieben war. So lernte ich NJS kennen.

Anhand dieses Beispiels sah ich nach einer Stunde im eigenen Browser dasselbe XML, das beim Einsatz des Moduls ngx_aws_auth angezeigt wurde, jedoch war alles bereits in JS verfasst.

Die Lösung auf Nginx gefiel mir sehr gut. Erstens, gute Dokumentation und zahlreiche Beispiele, zweitens erhalten wir alle Vorteile von Nginx bei der Arbeit mit Dateien (out of the box), drittens kann jeder, der in der Lage ist, Konfigurationen für Nginx zu schreiben, verstehen, was zu tun ist. Ein weiterer Vorteil für mich ist der Minimalismus im Vergleich zu Python oder Go (wenn man von Grund auf schreibt), ganz zu schweigen von Nexus.

TL;DR: Nach 2 Tagen war die Testversion von PyPi bereits im CI eingesetzt.

Wie funktioniert das?

In Nginx wird das Modul ngx_http_js_module, in das offizielle Docker-Image eingebunden. Wir importieren unser Script mithilfe der Direktive js_import, in die Nginx-Konfiguration. Der Funktionsaufruf erfolgt mit der Direktive js_content. Zur Einstellung von Variablen wird die Direktive js_set, die nur eine Funktion, die im Script beschrieben ist, als Argument akzeptiert. Unteranfragen in NJS können wir nur mit Nginx ausführen, keine XMLHttpRequest dort. Dazu muss in der Nginx-Konfiguration der entsprechende Standort hinzugefügt werden. Im Script muss eine Unteranfrage (subrequest) zu diesem Standort beschrieben werden. Damit wir auf die Funktion aus der Nginx-Konfiguration zugreifen können, muss im Script der Funktionsname exportiert werden. 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 des Handlers
    r.return(resp.status, resp.responseBody);
  }

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

export default {request}

Bei einer Anfrage im Browser http://localhost:8080/ gelangen wir zu location /in dem die Direktive js_content die Funktion request in unserem Skript beschrieben wird script.js. In der Funktion request wird eine Unteranfrage an location = /sub-query, mit der Methode (im aktuellen Beispiel GET), die aus dem Argument (r), das implizit beim Aufruf dieser Funktion übergeben wird. Die Verarbeitung der Antwort der Unteranfrage erfolgt in der Funktion call_back.

Wir versuchen S3

Um eine Anfrage an ein privates S3-Speicher zu stellen, benötigen wir:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Basierend auf der verwendeten HTTP-Methode, dem aktuellen Datum/Zeit, S3_NAME und URI wird eine bestimmte Art von Zeichenfolge generiert, die mit SECRET_KEY signiert wird (HMAC_SHA1). Diese Zeichenfolge, die das Format AWS $ACCESS_KEY:$HASH, kann im Autorisierungsheader verwendet werden. Das gleiche Datum/Zeit, das zur Generierung der Zeichenfolge im vorherigen Schritt verwendet wurde, muss im Header X-amz-datehinzugefügt werden. Der Code sieht folgendermaßen aus:

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(Beispiel für die Autorisierung AWS Sign v2, in den Status deprecated übersetzt)

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}

Ein paar Erklärungen zu _subrequest_uri: Das ist eine Variable, die je nach ursprünglichem URI eine Anfrage an S3 formuliert. Um den Inhalt des „Wurzelverzeichnisses“ zu erhalten, muss eine URI-Anfrage mit dem Trenner delimiter, der eine Liste aller XML-Elemente CommonPrefixes zurückgibt, die den Verzeichnissen entsprechen (im Fall von PyPI, die Liste aller Pakete), gebildet werden. Um eine Liste des Inhalts in einem bestimmten Verzeichnis (die Liste aller Versionspakete) zu erhalten, muss die URI-Anfrage das Feld prefix mit dem Verzeichnisnamen (Paket) beinhalten, das unbedingt mit einem Schrägstrich endet. Andernfalls können bei der Abfrage des Inhalts eines Verzeichnisses Kollisionen auftreten. Zum Beispiel gibt es die Verzeichnisse aiohttp-request und aiohttp-requests, und wenn in der Anfrage angegeben wird /?prefix=aiohttp-request, dann wird die Antwort den Inhalt beider Verzeichnisse enthalten. Wenn jedoch am Ende ein Schrägstrich steht, /?prefix=aiohttp-request/, dann wird die Antwort nur das benötigte Verzeichnis enthalten. Und wenn wir eine Datei anfordern, sollte die resultierende URI sich nicht vom ursprünglichen unterscheiden.

Wir speichern, starten Nginx neu. Im Browser geben wir die Adresse unseres Nginx ein, das Ergebnis der Anfrage wird XML sein, zum Beispiel:

Verzeichnisliste

myback-space
  
  
  10000
  /
  false
  
    new/
  
  
    old/

Aus der Liste der Verzeichnisse benötigen wir nur die Elemente CommonPrefixes.

Fügen wir im Browser unser benötigtes Verzeichnis an die Adresse an, erhalten wir dessen Inhalt ebenfalls in Form von XML:

Dateiliste im Verzeichnis

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

Aus der Liste der Dateien nehmen wir nur die Elemente Schlüssel.

Wir müssen das erhaltene XML parsen und als HTML ausgeben, nachdem wir den Content-Type-Header auf text/html geändert haben.

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

Wir versuchen PyPI

Wir überprüfen, dass an allen Stellen und in keinerlei Paketen, von denen wir wissen, etwas nicht bricht.

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

Wir wiederholen es mit unseren Bibliotheken.

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

Im CI sieht die Erstellung und der Upload des Pakets so aus:

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

Authentifizierung

In Gitlab ist es möglich, JWT für die Authentifizierung/Autorisierung externer Dienste zu verwenden. Mit der Direktive auth_request in Nginx leiten wir die Authentifizierungsdaten an eine Unteranfrage weiter, die einen Funktionsaufruf im Skript enthält. Im Skript wird eine weitere Unteranfrage an die URL von Gitlab gestellt, und wenn die Authentifizierungsdaten korrekt angegeben sind, gibt Gitlab den Code 200 zurück und der Upload/Download des Pakets wird erlaubt. Warum nicht einfach eine Unteranfrage verwenden und die Daten direkt an Gitlab senden? Weil wir dann die Nginx-Konfigurationsdatei jedes Mal anpassen müssten, wenn es Änderungen in der Authentifizierung gibt, und das ist eine ziemlich mühsame Aufgabe. Auch wenn in Kubernetes eine Politik für ein schreibgeschütztes Wurzel-Dateisystem verwendet wird, macht das die Ersetzung der nginx.conf über configmap noch komplizierter. Und die Konfiguration von Nginx über configmap wird völlig unmöglich, wenn gleichzeitig Politiken gelten, die den Zugriff auf Volumes (pvc) und das schreibgeschützte Wurzel-Dateisystem verbieten (das kommt vor).

Durch die Verwendung von NJS als Zwischenverbindung erhalten wir die Möglichkeit, die angegebenen Parameter in der Nginx-Konfiguration mithilfe von Umgebungsvariablen zu ändern und im Skript verschiedene Prüfungen vorzunehmen (zum Beispiel bei einer falsch angegebenen 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 ~ "^/(?[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}

Wahrscheinlich stellt sich die Frage: -Warum nicht fertige Module verwenden? Dort ist doch alles bereits erledigt! Zum Beispiel var AWS = require(‘aws-sdk’) und man muss keinen "neuen Fahrrad" mit S3-Authentifizierung schreiben!

Lassen Sie uns zu den Nachteilen übergehen.

Für mich war die Unmöglichkeit, externe JS-Module zu importieren, eine unangenehme, aber zu erwartende Eigenschaft. Der im obigen Beispiel beschriebene require(‘crypto’) ist eingebaute Module und require funktioniert nur für diese. Es besteht auch keine Möglichkeit, Code aus Skripten wiederzuverwenden, sodass man ihn in verschiedene Dateien kopieren und einfügen muss. Ich hoffe, dass diese Funktionalität irgendwann umgesetzt wird.

Für das aktuelle Projekt muss in Nginx die Kompression deaktiviert werden. gzip off;

Weil das gzip-Modul in NJS nicht vorhanden ist und nicht eingebunden werden kann, gibt es keine Möglichkeit, mit komprimierten Daten zu arbeiten. Das ist in diesem Fall allerdings nicht so schlimm. Der Text ist nicht umfangreich, und die übertragbaren Dateien sind bereits komprimiert, zusätzliche Kompression wird ihnen nicht viel helfen. Es handelt sich auch nicht um einen besonders belasteten oder kritischen Dienst, sodass es sich nicht lohnt, den Inhalt nur um ein paar Millisekunden schneller auszuliefern.

Das Debuggen des Skripts ist langwierig und kann nur über "Prints" in error.log erfolgen. Je nach eingestelltem Protokollierungsgrad info, warn oder error kann man die Methoden r.log, r.warn, r.error jeweils verwenden. Einige Skripte versuche ich in Chrome (v8) oder im Konsolen-Tool njs zu debuggen, aber nicht alles lässt sich dort überprüfen. Bei der Code-Debugging, besser bekannt als funktionale Tests, sieht der Verlauf etwa so aus:

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

und solche Sequenzen können Hunderte sein.

Das Schreiben von Code unter Verwendung von Subqueries und Variablen für diese wird zu einem verworrenen Knäuel. Manchmal springt man zwischen verschiedenen Fenstern der IDE hin und her und versucht, die Reihenfolge der Aktionen des Codes zu verstehen. Es ist nicht schwierig, aber manchmal sehr belastend.

Es gibt keine vollständige Unterstützung für ES6.

Möglicherweise gibt es noch weitere Nachteile, aber mit denen bin ich nicht konfrontiert worden. Teilen Sie mir Ihre Erfahrungen mit, wenn Sie negative Erfahrungen mit NJS gemacht haben.

Fazit

NJS ist ein leichter Open-Source-Interpreter, der es ermöglicht, in Nginx verschiedene Szenarien in der Programmiersprache JavaScript umzusetzen. Bei seiner Entwicklung wurde viel Wert auf die Leistung gelegt. Natürlich fehlen noch viele Dinge, aber das Projekt entwickelt sich mit einem kleinen Team und sie fügen aktiv neue Funktionen hinzu und beheben Bugs. Ich hoffe, dass NJS eines Tages die Einbindung externer Module ermöglicht, was die Funktionalität von Nginx nahezu unbegrenzt machen würde. Aber es gibt NGINX Plus und einige Funktionen werden wahrscheinlich nicht verfügbar sein!

Repository mit dem vollständigen Code zum Artikel

njs-pypi mit Unterstützung für AWS Sign v4

Beschreibung der Direktiven des Moduls ngx_http_js_module

Offizielles Repository von NJS und Dokumentation

Beispiele zur Verwendung von NJS von Dmitri Wolynezew

njs — nativer JavaScript-Scripting in Nginx / Выступление Дмитрия Волныева на Saint HighLoad++ 2019

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

Signatur und Authentifizierung von REST-Anfragen in AWS

Quelle: habr.com

60GB SSD 8Gb DDR4