Vytvořil jsem si vlastní PyPI repozitář s autorizací a S3. Na Nginx

V tomto článku bych se rád podělil o své zkušenosti s NJS, interpretem JavaScriptu pro Nginx vyvinutý společností Nginx Inc, a popsal jeho hlavní schopnosti na reálném příkladu. NJS je podmnožina JavaScriptu, která vám umožňuje rozšířit funkčnost Nginx. K otázce proč vlastní tlumočník??? Dmitrij Volyncev odpověděl podrobně. Stručně řečeno: NJS je nginx-way a JavaScript je progresivnější, „nativní“ a bez GC, na rozdíl od Lua.

Před dávnými časy…

Při své poslední práci jsem zdědil gitlab s řadou pestrých CI/CD pipelines s docker-compose, dind a dalšími lahůdkami, které byly přeneseny na kaniko raily. Obrázky, které byly dříve použity v CI, byly přesunuty ve své původní podobě. Fungovaly správně až do dne, kdy se naše IP adresa gitlabu změnila a CI se proměnila v dýni. Problém byl v tom, že jeden z dockerů, které se účastnily CI, měl git, který stahoval moduly Pythonu přes ssh. Pro ssh potřebujete soukromý klíč a... byl na obrázku spolu se známými_hostiteli. A jakékoli CI se nezdařilo s chybou ověření klíče kvůli nesouladu mezi skutečnou IP a IP uvedenou ve známých_hostitelích. Nový obrázek byl rychle sestaven z existujících Dockfiles a byla přidána možnost StrictHostKeyChecking no. Ale špatný vkus zůstal a objevila se touha přesunout knihovny do soukromého úložiště PyPI. Dalším bonusem po přechodu na privátní PyPI bylo jednodušší potrubí a běžný popis požadavku.txt

Volba byla učiněna, pánové!

Vše provozujeme v cloudu a Kubernetes a nakonec jsme chtěli získat malou službu, kterou byl bezstavový kontejner s externím úložištěm. Protože používáme S3, byla mu dána přednost. A pokud možno s autentizací v gitlabu (v případě potřeby si ji můžete přidat sami).

Rychlé vyhledávání přineslo několik výsledků: s3pypi, pypicloud a možnost s „ručním“ vytvářením html souborů pro tuřín. Poslední možnost zmizela sama.

s3pypi: Toto je cli pro použití S3 hostingu. Nahrajeme soubory, vygenerujeme html a nahrajeme ho do stejného bucketu. Vhodné pro domácí použití.

pypicloud: Vypadalo to jako zajímavý projekt, ale po přečtení dokumentace jsem byl zklamán. Navzdory dobré dokumentaci a možnosti rozšíření podle vašich potřeb se ve skutečnosti ukázalo, že je nadbytečný a obtížně konfigurovatelný. Oprava kódu, aby vyhovoval vašim úkolům, by podle tehdejších odhadů trvala 3–5 dní. Služba také potřebuje databázi. Nechali jsme to pro případ, že bychom nenašli nic jiného.

Hlubší hledání přineslo modul pro Nginx, ngx_aws_auth. Výsledkem jeho testování bylo XML zobrazené v prohlížeči, které ukazovalo obsah S3 bucketu. Poslední závazek v době hledání byl před rokem. Úložiště vypadalo opuštěně.

Tím, že půjdete ke zdroji a přečtete si PEP-503 Uvědomil jsem si, že XML lze za běhu převést na HTML a dát do pipu. Poté, co jsem si trochu více vygoogloval o Nginx a S3, narazil jsem na příklad autentizace v S3 napsaný v JS pro Nginx. Tak jsem potkal NJS.

Vezmu-li tento příklad jako základ, o hodinu později jsem ve svém prohlížeči viděl stejné XML jako při použití modulu ngx_aws_auth, ale vše již bylo napsáno v JS.

Řešení nginx se mi opravdu líbilo. Za prvé, dobrá dokumentace a mnoho příkladů, za druhé získáme všechny vychytávky Nginxu pro práci se soubory (z krabice), za třetí, každý, kdo ví, jak psát konfigurace pro Nginx, bude schopen zjistit, co je co. Minimalismus je pro mě také plus oproti Pythonu nebo Go (pokud je psán od začátku), nemluvě o nexusu.

TL;DR Po 2 dnech byla testovací verze PyPi již používána v CI.

Jak to funguje?

Modul se nahraje do Nginx ngx_http_js_module, který je součástí oficiálního obrázku dockeru. Náš skript importujeme pomocí směrnice js_importdo konfigurace Nginx. Funkce je volána direktivou js_content. Direktiva slouží k nastavení proměnných js_set, který bere jako argument pouze funkci popsanou ve skriptu. Ale můžeme provádět poddotazy v NJS pouze pomocí Nginx, nikoli pomocí XMLHttpRequest. K tomu je třeba přidat odpovídající umístění do konfigurace Nginx. A skript musí popsat dílčí požadavek na toto umístění. Aby bylo možné přistupovat k funkci z konfigurace Nginx, musí být název funkce exportován do samotného skriptu 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}

Na požádání v prohlížeči http://localhost:8080/ dostaneme se do location /ve které směrnice js_content volá funkci request popsané v našem scénáři script.js. Na druhé straně ve funkci request je vytvořen poddotaz location = /sub-query, s metodou (v aktuálním příkladu GET) získanou z argumentu (r), předaná implicitně při volání této funkce. Odpověď na dílčí požadavek bude zpracována ve funkci call_back.

Zkouším S3

K zadání požadavku na soukromé úložiště S3 potřebujeme:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Z použité http metody, aktuálního data/času, S3_NAME a URI se vygeneruje určitý typ řetězce, který se podepíše (HMAC_SHA1) pomocí SECRET_KEY. Další je řádek jako AWS $ACCESS_KEY:$HASH, lze použít v autorizační hlavičce. Do záhlaví musí být přidáno stejné datum/čas, které bylo použito pro vygenerování řetězce v předchozím kroku X-amz-date. V kódu to vypadá takto:

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(Příklad autorizace AWS Sign v2 se změnil na zastaralý stav)

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}

Malé vysvětlení o _subrequest_uri: toto je proměnná, která v závislosti na počátečním uri tvoří požadavek na S3. Pokud potřebujete získat obsah „kořenu“, musíte vytvořit požadavek uri označující oddělovač delimiter, který vrátí seznam všech CommonPrefixes xml prvků, odpovídajících adresářům (v případě PyPI seznam všech balíčků). Pokud potřebujete získat seznam obsahu v konkrétním adresáři (seznam všech verzí balíčku), pak požadavek uri musí obsahovat pole předpony s názvem adresáře (balíčku) nutně končícího lomítkem /. V opačném případě jsou možné kolize například při požadavku na obsah adresáře. Existují adresáře aiohttp-request a aiohttp-requests a pokud to požadavek specifikuje /?prefix=aiohttp-request, pak bude odpověď obsahovat obsah obou adresářů. Pokud je na konci lomítko, /?prefix=aiohttp-request/, pak bude odpověď obsahovat pouze požadovaný adresář. A pokud požadujeme soubor, pak by se výsledné uri nemělo lišit od původního.

Uložte a restartujte Nginx. V prohlížeči zadáme adresu našeho Nginxu, výsledkem požadavku bude XML, například:

Seznam adresářů

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

Ze seznamu adresářů budete potřebovat pouze prvky CommonPrefixes.

Přidáním adresáře, který potřebujeme na naši adresu v prohlížeči, obdržíme také jeho obsah ve formě XML:

Seznam souborů v adresáři

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

Ze seznamu souborů vezmeme pouze prvky Key.

Vše, co zbývá, je analyzovat výsledný XML a odeslat jej jako HTML, přičemž nejprve nahradil záhlaví Content-Type 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="/cs/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Zkouším PyPI

Kontrolujeme, že se nikde nic nerozbije na balíčcích, o kterých je známo, že fungují.

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

Opakujeme s našimi libami.

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

V CI vypadá vytvoření a načtení balíčku takto:

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

Ověřování

V Gitlabu je možné použít JWT pro autentizaci/autorizaci externích služeb. Pomocí direktivy auth_request v Nginx přesměrujeme autentizační data na dílčí požadavek obsahující volání funkce ve skriptu. Skript provede další dílčí požadavek na url Gitlabu a pokud byly autentizační údaje zadány správně, Gitlab vrátí kód 200 a bude povoleno nahrávání/stahování balíčku. Proč nepoužít jeden poddotaz a rovnou neposílat data do Gitlabu? Protože pak budeme muset upravovat konfigurační soubor Nginx pokaždé, když uděláme nějaké změny v autorizaci, a to je poměrně zdlouhavý úkol. Také, pokud Kubernetes používá politiku kořenového souborového systému pouze pro čtení, pak to přidává ještě větší složitost při nahrazení nginx.conf přes configmap. A stává se absolutně nemožným konfigurovat Nginx přes configmap při současném používání zásad zakazujících připojení svazků (pvc) a kořenového souborového systému pouze pro čtení (to se také stává).

Pomocí meziproduktu NJS získáme možnost změnit zadané parametry v konfiguraci nginx pomocí proměnných prostředí a provést nějaké kontroly ve skriptu (například nesprávně zadaná 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}

Nejpravděpodobnější je otázka: -Proč nepoužít hotové moduly? Už se tam všechno udělalo! Například var AWS = require('aws-sdk') a není třeba psát „kolo“ s autentizací S3!

Pojďme k nevýhodám

Nepříjemnou, ale očekávanou funkcí se pro mě stala nemožnost importovat externí JS moduly. Popsáno ve výše uvedeném příkladu require('crypto') is vestavěné moduly a vyžadují pouze práce pro ně. Neexistuje také žádný způsob, jak znovu použít kód ze skriptů a musíte jej zkopírovat a vložit do různých souborů. Doufám, že jednou bude tato funkce implementována.

Komprese musí být také zakázána pro aktuální projekt v Nginx gzip off;

Protože v NJS není modul gzip a není možné jej připojit, nelze tedy jak pracovat s komprimovanými daty. Pravda, pro tento případ to opravdu není mínus. Textu je málo a přenášené soubory jsou již komprimované a dodatečná komprese jim příliš nepomůže. Také to není tak nabitá nebo kritická služba, abyste se museli obtěžovat s doručováním obsahu o několik milisekund rychleji.

Ladění skriptu trvá dlouho a je možné pouze prostřednictvím „tisků“ v error.log. V závislosti na nastavené úrovni logování info, varování nebo chyba je možné použít 3 metody r.log, r.warn, r.error resp. Zkouším odladit nějaké skripty v Chrome (v8) nebo konzolovém nástroji njs, ale ne vše lze zkontrolovat tam. Při ladění kódu, neboli funkčního testování, vypadá historie asi takto:

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

a takových sekvencí mohou být stovky.

Psaní kódu pomocí poddotazů a pro ně proměnných se změní ve spletitou spleť. Někdy začnete spěchat po různých oknech IDE a snažíte se zjistit posloupnost akcí vašeho kódu. Není to těžké, ale někdy je to velmi nepříjemné.

Neexistuje žádná plná podpora pro ES6.

Mohou tam být nějaké další nedostatky, ale na nic jiného jsem nenarazil. Sdílejte informace, pokud máte negativní zkušenosti s používáním NJS.

Závěr

NJS je lehký open-source interpret, který vám umožňuje implementovat různé skripty JavaScriptu v Nginx. Při jeho vývoji byla věnována velká pozornost výkonu. Samozřejmě toho ještě hodně chybí, ale projekt vyvíjí malý tým a aktivně přidávají nové funkce a opravují chyby. Doufám, že vám někdy NJS umožní připojit externí moduly, díky čemuž bude funkčnost Nginx téměř neomezená. Ale je tu NGINX Plus a s největší pravděpodobností nebudou žádné funkce!

Úložiště s úplným kódem pro článek

njs-pypi s podporou AWS Sign v4

Popis direktiv modulu ngx_http_js_module

Oficiální úložiště NJS и dokumentaci

Příklady použití NJS od Dmitrije Volintseva

njs - nativní skriptování JavaScriptu v nginx / Projev Dmitrije Volnyeva na Saint HighLoad++ 2019

NJS ve výrobě / Projev Vasilije Soshnikova na HighLoad++ 2019

Podepisování a ověřování požadavků REST v AWS

Zdroj: www.habr.com