Jag gjorde mitt eget PyPI-förråd med auktorisering och S3. På Nginx

I den här artikeln skulle jag vilja dela med mig av min erfarenhet av NJS, en JavaScript-tolk för Nginx utvecklad av Nginx Inc, och beskriva dess huvudsakliga funktioner med ett verkligt exempel. NJS är en delmängd av JavaScript som låter dig utöka funktionaliteten hos Nginx. Till frågan varför din egen tolk??? Dmitry Volyntsev svarade i detalj. Kort sagt: NJS är nginx-väg, och JavaScript är mer progressivt, "native" och utan GC, till skillnad från Lua.

För länge sedan…

På mitt senaste jobb ärvde jag gitlab med ett antal brokiga CI/CD-pipelines med docker-compose, dind och andra läckerheter, som överfördes till kaniko rails. Bilderna som tidigare användes i CI flyttades i sin ursprungliga form. De fungerade ordentligt tills den dagen då vår gitlab IP ändrades och CI förvandlades till en pumpa. Problemet var att en av docker-bilderna som deltog i CI hade git, som drog Python-moduler via ssh. För ssh behöver du en privat nyckel och... den fanns i bilden tillsammans med known_hosts. Och alla CI misslyckades med ett nyckelverifieringsfel på grund av en oöverensstämmelse mellan den verkliga IP-adressen och den som anges i known_hosts. En ny bild sattes snabbt ihop från de befintliga Dockfiles och alternativet lades till StrictHostKeyChecking no. Men den dåliga smaken kvarstod och det fanns en önskan att flytta libs till ett privat PyPI-förråd. En ytterligare bonus, efter att ha bytt till privat PyPI, var en enklare pipeline och en normal beskrivning av requirements.txt

Valet är gjort, mina herrar!

Vi kör allt i molnen och Kubernetes, och i slutändan ville vi få en liten tjänst som var en tillståndslös container med extern lagring. Tja, eftersom vi använder S3, prioriterades det. Och, om möjligt, med autentisering i gitlab (du kan lägga till det själv om det behövs).

En snabb sökning gav flera resultat: s3pypi, pypicloud och ett alternativ med "manuell" skapande av html-filer för kålrot. Det sista alternativet försvann av sig själv.

s3pypi: Detta är en cli för att använda S3-värd. Vi laddar upp filerna, genererar html och laddar upp den till samma hink. Lämplig för hemmabruk.

pypicloud: Det verkade som ett intressant projekt, men efter att ha läst dokumentationen blev jag besviken. Trots bra dokumentation och möjlighet att bygga ut för att passa dina behov visade det sig i verkligheten vara överflödigt och svårt att konfigurera. Att rätta koden för att passa dina uppgifter, enligt uppskattningar vid den tiden, skulle ha tagit 3-5 dagar. Tjänsten behöver också en databas. Vi lämnade den ifall vi inte hittade något annat.

En mer djupgående sökning gav en modul för Nginx, ngx_aws_auth. Resultatet av hans testning var XML som visades i webbläsaren, som visade innehållet i S3-hinken. Det senaste åtagandet vid tiden för sökningen var för ett år sedan. Förvaret såg övergivet ut.

Genom att gå till källan och läsa PEP-503 Jag insåg att XML kan konverteras till HTML i farten och ges till pip. Efter att ha googlat lite mer om Nginx och S3 kom jag över ett exempel på autentisering i S3 skrivet i JS för Nginx. Det var så jag träffade NJS.

Med det här exemplet som grund såg jag en timme senare i min webbläsare samma XML som när jag använde modulen ngx_aws_auth, men allt var redan skrivet i JS.

Jag gillade verkligen nginx-lösningen. För det första, bra dokumentation och många exempel, för det andra får vi alla godbitarna från Nginx för att arbeta med filer (utanför lådan), för det tredje kommer alla som vet hur man skriver konfigurationer för Nginx att kunna ta reda på vad som är vad. Minimalism är också ett plus för mig, jämfört med Python eller Go (om skrivet från början), för att inte tala om nexus.

TL;DR Efter 2 dagar användes testversionen av PyPi redan i CI.

Hur fungerar det?

Modulen laddas in i Nginx ngx_http_js_module, inkluderad i den officiella docker-bilden. Vi importerar vårt skript med hjälp av direktivet js_importtill Nginx-konfiguration. Funktionen anropas av ett direktiv js_content. Direktivet används för att ställa in variabler js_set, som endast tar den funktion som beskrivs i skriptet som argument. Men vi kan köra subqueries i NJS endast med Nginx, inte någon XMLHttpRequest. För att göra detta måste motsvarande plats läggas till i Nginx-konfigurationen. Och skriptet måste beskriva en underförfrågan till den här platsen. För att kunna komma åt en funktion från Nginx-konfigurationen måste funktionsnamnet exporteras i själva skriptet 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}

På begäran i webbläsaren http://localhost:8080/ vi kommer in location /där direktivet js_content anropar en funktion request beskrivs i vårt manus script.js. I sin tur i funktionen request en underfråga görs till location = /sub-query, med en metod (i det aktuella exemplet GET) erhållen från argumentet (r), skickas implicit när denna funktion anropas. Svaret på underförfrågan kommer att behandlas i funktionen call_back.

Testar S3

För att göra en begäran till privat S3-lagring behöver vi:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Från den använda http-metoden, aktuellt datum/tid, S3_NAME och URI, genereras en viss typ av sträng, som signeras (HMAC_SHA1) med SECRET_KEY. Nästa är en rad som AWS $ACCESS_KEY:$HASH, kan användas i auktoriseringshuvudet. Samma datum/tid som användes för att generera strängen i föregående steg måste läggas till i rubriken X-amz-date. I koden ser det ut så här:

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(Exempel på AWS Sign v2-auktorisering, ändrad till föråldrad 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}

En liten förklaring om _subrequest_uri: detta är en variabel som, beroende på den initiala uri, bildar en begäran till S3. Om du behöver få innehållet i "roten", måste du skapa en uri-förfrågan som anger avgränsaren delimiter, som returnerar en lista över alla CommonPrefixes xml-element, motsvarande kataloger (i fallet PyPI, en lista över alla paket). Om du behöver få en innehållsförteckning i en specifik katalog (en lista över alla paketversioner), måste uri-begäran innehålla ett prefixfält med namnet på katalogen (paketet) som nödvändigtvis slutar med ett snedstreck /. Annars är kollisioner möjliga när man till exempel begär innehållet i en katalog. Det finns kataloger aiohttp-request och aiohttp-requests och om begäran specificerar /?prefix=aiohttp-request, då kommer svaret att innehålla innehållet i båda katalogerna. Om det finns ett snedstreck i slutet, /?prefix=aiohttp-request/, då kommer svaret endast att innehålla den nödvändiga katalogen. Och om vi begär en fil, bör den resulterande uri inte skilja sig från den ursprungliga.

Spara och starta om Nginx. I webbläsaren anger vi adressen till vår Nginx, resultatet av begäran blir XML, till exempel:

Lista över kataloger

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

Från listan över kataloger behöver du bara elementen CommonPrefixes.

Genom att lägga till katalogen vi behöver till vår adress i webbläsaren får vi även dess innehåll i XML-form:

Lista över filer i en katalog

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

Från listan över filer tar vi bara element Key.

Allt som återstår är att analysera den resulterande XML-filen och skicka ut den som HTML, efter att först ha ersatt Content-Type-huvudet med 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="/sv/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Testar PyPI

Vi kontrollerar att inget går sönder någonstans på paket som man vet fungerar.

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

Vi upprepar med våra 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

I CI ser att skapa och ladda ett paket så här:

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

autentisering

I Gitlab är det möjligt att använda JWT för autentisering/auktorisering av externa tjänster. Genom att använda auth_request-direktivet i Nginx kommer vi att omdirigera autentiseringsdatan till en underförfrågan som innehåller ett funktionsanrop i skriptet. Skriptet kommer att göra ytterligare en underförfrågan till Gitlab-url:n och om autentiseringsdatan har angetts korrekt kommer Gitlab att returnera kod 200 och uppladdning/nedladdning av paketet kommer att tillåtas. Varför inte använda en underfråga och omedelbart skicka data till Gitlab? För då måste vi redigera Nginx-konfigurationsfilen varje gång vi gör några ändringar i auktorisationen, och detta är en ganska tråkig uppgift. Dessutom, om Kubernetes använder en skrivskyddad rotfilsystempolicy, så lägger detta till ännu mer komplexitet när nginx.conf ersätts via configmap. Och det blir absolut omöjligt att konfigurera Nginx via configmap samtidigt som man använder policyer som förbjuder anslutning av volymer (pvc) och skrivskyddat rotfilsystem (detta händer också).

Med hjälp av NJS-mellanprodukten får vi möjlighet att ändra de angivna parametrarna i nginx-konfigurationen med hjälp av miljövariabler och göra några kontroller i skriptet (till exempel en felaktigt angiven 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}

Troligtvis är frågan bryggning: -Varför inte använda färdiga moduler? Allt har redan gjorts där! Till exempel var AWS = require('aws-sdk') och det finns ingen anledning att skriva en "cykel" med S3-autentisering!

Låt oss gå vidare till nackdelarna

För mig blev oförmågan att importera externa JS-moduler en obehaglig, men förväntad funktion. Beskrivet i exemplet ovan require('crypto') är inbyggda moduler och kräver bara verk för dem. Det finns inte heller något sätt att återanvända kod från skript och du måste kopiera och klistra in den i olika filer. Jag hoppas att den här funktionen en dag kommer att implementeras.

Komprimering måste också inaktiveras för det aktuella projektet i Nginx gzip off;

Eftersom det inte finns någon gzip-modul i NJS och det är omöjligt att ansluta den; därför finns det inget sätt att arbeta med komprimerad data. Det är sant, detta är egentligen inte ett minus för det här fallet. Det finns inte mycket text, och de överförda filerna är redan komprimerade och ytterligare komprimering kommer inte att hjälpa dem mycket. Dessutom är det här inte en så laddad eller kritisk tjänst att du måste bry dig om att leverera innehåll några millisekunder snabbare.

Att felsöka skriptet tar lång tid och är endast möjligt genom "prints" i error.log. Beroende på inställd loggningsnivåinfo, varning eller fel, är det möjligt att använda 3 metoder r.log, r.warn, r.error respektive. Jag försöker felsöka några skript i Chrome (v8) eller njs-konsolverktyget, men allt går inte att kontrollera där. Vid felsökning av kod, aka funktionstestning, ser historien ut ungefär så här:

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

och det kan finnas hundratals sådana sekvenser.

Att skriva kod med hjälp av underfrågor och variabler för dem förvandlas till en trasslig härva. Ibland börjar du rusa runt i olika IDE-fönster för att försöka lista ut sekvensen av åtgärder för din kod. Det är inte svårt, men ibland är det väldigt irriterande.

Det finns inget fullt stöd för ES6.

Det kan finnas några andra brister, men jag har inte stött på något annat. Dela information om du har negativ erfarenhet av att använda NJS.

Slutsats

NJS är en lätt tolk med öppen källkod som låter dig implementera olika JavaScript-skript i Nginx. Under utvecklingen ägnades stor uppmärksamhet åt prestanda. Naturligtvis saknas det fortfarande mycket, men projektet utvecklas av ett litet team och de lägger aktivt till nya funktioner och fixar buggar. Jag hoppas att NJS en dag låter dig ansluta externa moduler, vilket kommer att göra Nginx-funktionaliteten nästan obegränsad. Men det finns NGINX Plus och troligen kommer det inte att finnas några funktioner!

Repository med fullständig kod för artikeln

njs-pypi med stöd för AWS Sign v4

Beskrivning av direktiven för modulen ngx_http_js_module

Officiellt NJS-förråd и dokumentationen

Exempel på användning av NJS från Dmitry Volintsev

njs - inbyggt JavaScript-skript i nginx / Tal av Dmitry Volnyev vid Saint HighLoad++ 2019

NJS i produktion / Tal av Vasily Soshnikov på HighLoad++ 2019

Signera och autentisera REST-förfrågningar i AWS

Källa: will.com