Ik heb mijn eigen PyPI-repository gemaakt met autorisatie en S3. Op Nginx

In dit artikel wil ik mijn ervaring delen met NJS, een JavaScript-interpreter voor Nginx ontwikkeld door Nginx Inc, waarbij ik de belangrijkste mogelijkheden ervan beschrijf aan de hand van een echt voorbeeld. NJS is een subset van JavaScript waarmee u de functionaliteit van Nginx kunt uitbreiden. Naar de vraag waarom je eigen tolk??? Dmitry Volyntsev antwoordde gedetailleerd. Kortom: NJS is nginx-manier en JavaScript is progressiever, ‘native’ en zonder GC, in tegenstelling tot Lua.

Een lange tijd geleden…

Bij mijn laatste baan erfde ik gitlab met een aantal bonte CI/CD-pijplijnen met docker-compose, dind en andere geneugten, die werden overgebracht naar kaniko-rails. De afbeeldingen die eerder in CI werden gebruikt, zijn in hun oorspronkelijke vorm verplaatst. Ze werkten goed tot de dag waarop ons gitlab IP veranderde en CI in een pompoen veranderde. Het probleem was dat een van de docker-images die deelnamen aan CI git had, die Python-modules via ssh ophaalde. Voor ssh heb je een privésleutel nodig en... deze stond in de afbeelding samen met bekende_hosts. En elk CI mislukte met een sleutelverificatiefout vanwege een mismatch tussen het echte IP-adres en het IP-adres dat is opgegeven in bekende_hosts. Er werd snel een nieuwe afbeelding samengesteld uit de bestaande Dockfiles en de optie werd toegevoegd StrictHostKeyChecking no. Maar de slechte smaak bleef bestaan ​​en er was een wens om de libs naar een privé PyPI-repository te verplaatsen. Een extra bonus, na de overstap naar private PyPI, was een eenvoudiger pijplijn en een normale beschrijving van vereisten.txt

De keuze is gemaakt, heren!

We draaien alles in de cloud en Kubernetes, en uiteindelijk wilden we een kleine dienst krijgen in de vorm van een staatloze container met externe opslag. Omdat we S3 gebruiken, werd er prioriteit aan gegeven. En indien mogelijk met authenticatie in gitlab (desgewenst kunt u dit zelf toevoegen).

Een snelle zoektocht leverde verschillende resultaten op: s3pypi, pypicloud en een optie met “handmatige” aanmaak van html-bestanden voor rapen. De laatste optie verdween vanzelf.

s3pypi: Dit is een cli voor het gebruik van S3-hosting. We uploaden de bestanden, genereren de html en uploaden deze naar dezelfde bucket. Geschikt voor thuisgebruik.

pypicloud: Het leek een interessant project, maar na het lezen van de documentatie was ik teleurgesteld. Ondanks goede documentatie en de mogelijkheid om uit te breiden naar jouw wensen, bleek het in werkelijkheid overbodig en lastig te configureren. Het corrigeren van de code om aan uw taken te voldoen zou, volgens de toenmalige schattingen, 3 tot 5 dagen hebben geduurd. De dienst heeft ook een database nodig. We hebben het achtergelaten voor het geval we niets anders zouden vinden.

Een meer diepgaande zoektocht leverde een module op voor Nginx, ngx_aws_auth. Het resultaat van zijn tests was XML die in de browser werd weergegeven en die de inhoud van de S3-bucket liet zien. De laatste commit ten tijde van de zoektocht was een jaar geleden. De opslagplaats zag er verlaten uit.

Door naar de bron te gaan en te lezen PEP-503 Ik realiseerde me dat XML direct naar HTML kan worden geconverteerd en aan pip kan worden gegeven. Nadat ik wat meer had gegoogled over Nginx en S3, kwam ik een voorbeeld van authenticatie in S3 tegen, geschreven in JS voor Nginx. Zo heb ik NJS leren kennen.

Met dit voorbeeld als basis zag ik een uur later in mijn browser dezelfde XML als bij het gebruik van de ngx_aws_auth-module, maar alles was al in JS geschreven.

Ik vond de nginx-oplossing erg leuk. Ten eerste goede documentatie en veel voorbeelden, ten tweede krijgen we al het goede van Nginx voor het werken met bestanden (out of the box), ten derde kan iedereen die weet hoe hij configuraties voor Nginx moet schrijven, uitzoeken wat wat is. Minimalisme is voor mij ook een pluspunt, vergeleken met Python of Go (indien helemaal opnieuw geschreven), om nog maar te zwijgen van nexus.

TL;DR Na 2 dagen was de testversie van PyPi al in CI gebruikt.

Hoe werkt het?

De module wordt in Nginx geladen ngx_http_js_module, opgenomen in de officiële docker-afbeelding. We importeren ons script met behulp van de richtlijn js_importnaar Nginx-configuratie. De functie wordt aangeroepen door een richtlijn js_content. De richtlijn wordt gebruikt om variabelen in te stellen js_set, dat alleen de functie neemt die in het script wordt beschreven als argument. Maar we kunnen subquery's in NJS alleen uitvoeren met Nginx, en niet met XMLHttpRequest. Om dit te doen, moet de bijbehorende locatie worden toegevoegd aan de Nginx-configuratie. En het script moet een subverzoek naar deze locatie beschrijven. Om toegang te krijgen tot een functie vanuit de Nginx-configuratie, moet de functienaam in het script zelf worden geëxporteerd 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}

Wanneer daarom wordt gevraagd in de browser http://localhost:8080/ wij stappen in location /waarin de richtlijn js_content roept een functie aan request beschreven in ons script script.js. Op zijn beurt in de functie request Er wordt een subquery gemaakt location = /sub-query, met een methode (in het huidige voorbeeld GET) verkregen uit het argument (r), impliciet doorgegeven wanneer deze functie wordt aangeroepen. Het antwoord op de subaanvraag wordt verwerkt in de functie call_back.

S3 proberen

Om een ​​verzoek in te dienen voor privé-S3-opslag hebben we het volgende nodig:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Uit de gebruikte http-methode, de huidige datum/tijd, S3_NAME en URI, wordt een bepaald type string gegenereerd, die wordt ondertekend (HMAC_SHA1) met SECRET_KEY. Het volgende is een regel zoals AWS $ACCESS_KEY:$HASH, kan worden gebruikt in de autorisatieheader. Dezelfde datum/tijd die werd gebruikt om de tekenreeks in de vorige stap te genereren, moet aan de header worden toegevoegd X-amz-date. In code ziet het er als volgt uit:

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(AWS Sign v2-autorisatievoorbeeld, gewijzigd in verouderde status)

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}

Een kleine uitleg over _subrequest_uri: dit is een variabele die, afhankelijk van de initiële uri, een verzoek vormt naar S3. Als u de inhoud van de “root” wilt ophalen, moet u een uri-verzoek maken waarin het scheidingsteken wordt aangegeven delimiter, die een lijst met alle CommonPrefixes xml-elementen retourneert, overeenkomend met mappen (in het geval van PyPI, een lijst met alle pakketten). Als u een lijst met inhoud in een specifieke map (een lijst van alle pakketversies) nodig heeft, moet het uri-verzoek een voorvoegselveld bevatten met de naam van de map (pakket) die noodzakelijkerwijs eindigt met een schuine streep /. Anders zijn er botsingen mogelijk bij het opvragen van de inhoud van bijvoorbeeld een directory. Er zijn mappen aiohttp-request en aiohttp-requests en als het verzoek dit specificeert /?prefix=aiohttp-request, dan bevat het antwoord de inhoud van beide mappen. Als er aan het einde een schuine streep staat, /?prefix=aiohttp-request/, dan bevat het antwoord alleen de vereiste map. En als we een bestand opvragen, mag de resulterende uri niet afwijken van de originele.

Bewaar en start Nginx opnieuw. In de browser voeren we het adres van onze Nginx in, het resultaat van het verzoek is XML, bijvoorbeeld:

Lijst met mappen

<?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>

Uit de lijst met mappen heeft u alleen de elementen nodig CommonPrefixes.

Door de map die we nodig hebben toe te voegen aan ons adres in de browser, ontvangen we de inhoud ook in XML-vorm:

Lijst met bestanden in een map

<?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>

Uit de lijst met bestanden nemen we alleen elementen Key.

Het enige dat overblijft is het ontleden van de resulterende XML en deze als HTML te verzenden, nadat eerst de Content-Type header is vervangen door 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="/nl/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

PyPI proberen

We controleren of er niets kapot gaat aan pakketten waarvan bekend is dat ze werken.

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

We herhalen met onze libs.

# Создаем для тестов новое окружение
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 ziet het maken en laden van een pakket er als volgt uit:

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

authenticatie

In Gitlab is het mogelijk om JWT te gebruiken voor authenticatie/autorisatie van externe diensten. Met behulp van de auth_request richtlijn in Nginx zullen we de authenticatiegegevens omleiden naar een subverzoek dat een functieaanroep in het script bevat. Het script zal nog een subverzoek doen aan de Gitlab-URL en als de authenticatiegegevens correct zijn opgegeven, retourneert Gitlab code 200 en is het uploaden/downloaden van het pakket toegestaan. Waarom zou u niet één subquery gebruiken en de gegevens onmiddellijk naar Gitlab sturen? Omdat we dan het Nginx-configuratiebestand elke keer moeten bewerken als we wijzigingen in de autorisatie aanbrengen, en dit is een nogal vervelende taak. Als Kubernetes een alleen-lezen rootbestandssysteembeleid gebruikt, voegt dit zelfs nog meer complexiteit toe bij het vervangen van nginx.conf via configmap. En het wordt absoluut onmogelijk om Nginx via configmap te configureren en tegelijkertijd beleid te gebruiken dat de verbinding van volumes (pvc) en alleen-lezen rootbestandssysteem verbiedt (dit gebeurt ook).

Met behulp van het NJS-tussenproduct krijgen we de mogelijkheid om de opgegeven parameters in de nginx-configuratie te wijzigen met behulp van omgevingsvariabelen en enkele controles in het script uit te voeren (bijvoorbeeld een onjuist opgegeven 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}

Hoogstwaarschijnlijk rijst de vraag: -Waarom geen kant-en-klare modules gebruiken? Daar is alles al gedaan! Bijvoorbeeld: var AWS = require('aws-sdk') en het is niet nodig om een ​​“bike” te schrijven met S3-authenticatie!

Laten we verder gaan met de nadelen

Voor mij werd het onvermogen om externe JS-modules te importeren een onaangename, maar verwachte functie. Beschreven in het bovenstaande voorbeeld require('crypto') is ingebouwde modules en vereisen alleen werken voor hen. Er is ook geen manier om code uit scripts opnieuw te gebruiken en je moet deze in verschillende bestanden kopiëren en plakken. Ik hoop dat deze functionaliteit ooit zal worden geïmplementeerd.

Compressie moet ook worden uitgeschakeld voor het huidige project in Nginx gzip off;

Omdat er geen gzip-module in NJS zit en het onmogelijk is om deze aan te sluiten, is er geen manier om met gecomprimeerde gegevens te werken. Toegegeven, dit is niet echt een minpunt voor dit geval. Er is niet veel tekst en de overgedragen bestanden zijn al gecomprimeerd en extra compressie zal niet veel helpen. Bovendien is dit niet zo’n beladen of kritische dienst dat je de moeite hoeft te nemen om content een paar milliseconden sneller aan te leveren.

Het debuggen van het script duurt lang en is alleen mogelijk via “prints” in error.log. Afhankelijk van het ingestelde logniveau info, warn of error, is het mogelijk om respectievelijk 3 methoden r.log, r.warn, r.error te gebruiken. Ik probeer sommige scripts te debuggen in Chrome (v8) of de njs-consoletool, maar niet alles kan daar worden gecontroleerd. Bij het debuggen van code, oftewel functioneel testen, ziet de geschiedenis er ongeveer zo uit:

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

en er kunnen honderden van dergelijke reeksen zijn.

Het schrijven van code met behulp van subquery's en variabelen daarvoor verandert in een kluwen. Soms begin je door verschillende IDE-vensters te rennen om de volgorde van acties van je code te achterhalen. Het is niet moeilijk, maar soms wel heel vervelend.

Er is geen volledige ondersteuning voor ES6.

Er kunnen nog meer tekortkomingen zijn, maar ik ben niets anders tegengekomen. Deel informatie als u negatieve ervaringen heeft met het gebruik van NJS.

Conclusie

NJS is een lichtgewicht open-source tolk waarmee u verschillende JavaScript-scripts in Nginx kunt implementeren. Tijdens de ontwikkeling is er veel aandacht besteed aan prestaties. Natuurlijk ontbreekt er nog veel, maar het project wordt ontwikkeld door een klein team en ze voegen actief nieuwe functies toe en repareren bugs. Ik hoop dat je met NJS op een dag externe modules kunt aansluiten, waardoor de Nginx-functionaliteit vrijwel onbeperkt wordt. Maar er is NGINX Plus en hoogstwaarschijnlijk zullen er geen functies zijn!

Repository met volledige code voor het artikel

njs-pypi met AWS Sign v4-ondersteuning

Beschrijving van de richtlijnen van de module ngx_http_js_module

Officiële NJS-repository и documentatie

Voorbeelden van het gebruik van NJS van Dmitry Volintsev

njs - native JavaScript-scripting in nginx / Toespraak door Dmitry Volnyev op Saint HighLoad++ 2019

NJS in productie / Toespraak door Vasily Soshnikov op HighLoad++ 2019

REST-verzoeken ondertekenen en authenticeren in AWS

Bron: www.habr.com