Zrobiłem moje repozytorium PyPI z autoryzacją i S3. Na Nginxie

W tym artykule chciałbym podzielić się swoimi doświadczeniami z NJS, interpreterem JavaScript dla Nginx opracowanym przez Nginx Inc, opisując jego główne możliwości na prawdziwym przykładzie. NJS to podzbiór JavaScript, który pozwala rozszerzyć funkcjonalność Nginx. Do pytania dlaczego własny tłumacz??? Dmitrij Wołyniec odpowiedział szczegółowo. W skrócie: NJS działa na sposób Nginx, a JavaScript jest bardziej progresywny, „natywny” i bez GC, w przeciwieństwie do Lua.

Dawno temu…

W mojej ostatniej pracy odziedziczyłem Gitlaba z wieloma pstrokatymi potokami CI/CD z funkcjami docker-compose, dind i innymi funkcjami, które zostały przeniesione na szyny kaniko. Obrazy, które były wcześniej używane w CI, zostały przeniesione w oryginalnej formie. Działały poprawnie aż do dnia, w którym zmieniło się IP naszego gitlaba i CI zamieniło się w dynię. Problem polegał na tym, że jeden z obrazów doków uczestniczących w CI miał git, który pobierał moduły Pythona przez ssh. Do ssh potrzebny jest klucz prywatny i... był on na obrazku wraz ze znanymi_hostami. Każdy element CI nie powiódł się z powodu błędu weryfikacji klucza z powodu niezgodności między prawdziwym adresem IP a adresem określonym w znanych_hostach. Szybko złożono nowy obraz z istniejących plików Dock i dodano tę opcję StrictHostKeyChecking no. Jednak zły gust pozostał i pojawiła się chęć przeniesienia bibliotek do prywatnego repozytorium PyPI. Dodatkowym bonusem, po przejściu na prywatne PyPI, był prostszy potok i normalny opis wymagań.txt

Wybór został dokonany, Panowie!

Uruchamiamy wszystko w chmurach i Kubernetesie, a docelowo chcieliśmy uzyskać małą usługę, która byłaby bezstanowym kontenerem z pamięcią zewnętrzną. Cóż, ponieważ używamy S3, nadano mu priorytet. I jeśli to możliwe, z uwierzytelnieniem w gitlabie (możesz dodać je samodzielnie, jeśli to konieczne).

Szybkie wyszukiwanie dało kilka wyników: s3pypi, pypicloud i opcję z „ręcznym” tworzeniem plików HTML dla rzepy. Ostatnia opcja zniknęła sama.

s3pypi: To jest CLI do korzystania z hostingu S3. Przesyłamy pliki, generujemy kod HTML i przesyłamy go do tego samego segmentu. Nadaje się do użytku domowego.

pypicloud: Wydawało się to ciekawym projektem, ale po przeczytaniu dokumentacji byłem rozczarowany. Pomimo dobrej dokumentacji i możliwości rozbudowy pod własne potrzeby, w rzeczywistości okazało się to zbędne i trudne w konfiguracji. Poprawienie kodu pod kątem Twoich zadań, według ówczesnych szacunków, zajęłoby 3-5 dni. Usługa potrzebuje również bazy danych. Zostawiliśmy to na wypadek, gdybyśmy nie znaleźli niczego innego.

Bardziej szczegółowe wyszukiwanie dało moduł dla Nginx, ngx_aws_auth. Efektem jego testów był wyświetlenie w przeglądarce XML, który pokazywał zawartość segmentu S3. Ostatnie zatwierdzenie w momencie wyszukiwania miało miejsce rok temu. Repozytorium wyglądało na opuszczone.

Sięgając do źródła i czytając PEP-503 Zdałem sobie sprawę, że XML można w locie przekonwertować na HTML i przekazać do pip. Po przejrzeniu trochę więcej informacji na temat Nginx i S3 natknąłem się na przykład uwierzytelnienia w S3 napisany w JS dla Nginx. Tak poznałem NJS.

Biorąc za podstawę ten przykład, godzinę później zobaczyłem w mojej przeglądarce ten sam XML, co przy użyciu modułu ngx_aws_auth, ale wszystko było już napisane w JS.

Bardzo spodobało mi się rozwiązanie nginx. Po pierwsze dobra dokumentacja i wiele przykładów, po drugie, dostajemy wszystkie gadżety Nginxa do pracy z plikami (od razu po wyjęciu z pudełka), po trzecie, każdy, kto wie, jak pisać konfiguracje dla Nginx, będzie mógł zorientować się, co jest co. Minimalizm też jest dla mnie plusem w porównaniu do Pythona czy Go (jeśli jest pisany od podstaw), o nexusie nie wspominając.

TL;DR Po 2 dniach wersja testowa PyPi była już używana w CI.

Jak to działa?

Moduł jest ładowany do Nginx ngx_http_js_module, zawarty w oficjalnym obrazie okna dokowanego. Importujemy nasz skrypt za pomocą dyrektywy js_importdo konfiguracji Nginx. Funkcja jest wywoływana przez dyrektywę js_content. Dyrektywa służy do ustawiania zmiennych js_set, który przyjmuje jako argument tylko funkcję opisaną w skrypcie. Ale podzapytania w NJS możemy wykonywać tylko przy użyciu Nginx, a nie żadnego XMLHttpRequest. Aby to zrobić, należy dodać odpowiednią lokalizację do konfiguracji Nginx. Skrypt musi opisywać podżądanie do tej lokalizacji. Aby móc uzyskać dostęp do funkcji z konfiguracji Nginx, nazwa funkcji musi zostać wyeksportowana w samym skrypcie 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 żądanie w przeglądarce http://localhost:8080/ wsiadamy location /w której dyrektywa js_content wywołuje funkcję request opisane w naszym skrypcie script.js. Z kolei w funkcji request wykonywane jest podzapytanie location = /sub-query, metodą (w bieżącym przykładzie GET) uzyskaną z argumentu (r), przekazywane niejawnie podczas wywoływania tej funkcji. Odpowiedź na żądanie zostanie przetworzona w funkcji call_back.

Próbuję S3

Aby złożyć wniosek o prywatną pamięć S3, potrzebujemy:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Z użytej metody http, bieżącej daty/godziny, S3_NAME i URI generowany jest określony typ ciągu znaków, który jest podpisany (HMAC_SHA1) przy użyciu SECRET_KEY. Dalej jest linia jak AWS $ACCESS_KEY:$HASH, można użyć w nagłówku autoryzacji. Do nagłówka należy dodać tę samą datę/godzinę, która została użyta do wygenerowania ciągu w poprzednim kroku X-amz-date. W kodzie wygląda to tak:

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(Przykład autoryzacji AWS Sign v2, status zmieniony na przestarzały)

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}

Małe wyjaśnienie dot _subrequest_uri: jest to zmienna, która w zależności od początkowego uri tworzy żądanie do S3. Jeśli chcesz uzyskać zawartość „rootu”, musisz utworzyć żądanie uri wskazujące ogranicznik delimiter, która zwróci listę wszystkich elementów xml CommonPrefixes, odpowiadających katalogom (w przypadku PyPI, listę wszystkich pakietów). Jeśli chcesz uzyskać listę zawartości określonego katalogu (listę wszystkich wersji pakietu), to żądanie uri musi zawierać pole prefiksu z nazwą katalogu (pakietu) koniecznie kończącą się ukośnikiem /. W przeciwnym razie możliwe są kolizje, na przykład podczas żądania zawartości katalogu. Istnieją katalogi aiohttp-request i aiohttp-requests oraz jeśli żądanie to określa /?prefix=aiohttp-request, wówczas odpowiedź będzie zawierać zawartość obu katalogów. Jeśli na końcu znajduje się ukośnik, /?prefix=aiohttp-request/, odpowiedź będzie zawierać tylko wymagany katalog. A jeśli poprosimy o plik, to wynikowy uri nie powinien różnić się od oryginalnego.

Zapisz i uruchom ponownie Nginx. W przeglądarce wpisujemy adres naszego Nginxa, efektem zapytania będzie XML, na przykład:

Lista katalogów

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

Z listy katalogów potrzebne będą tylko elementy CommonPrefixes.

Dodając potrzebny nam katalog pod nasz adres w przeglądarce, otrzymamy także jego zawartość w formie XML:

Lista plików w katalogu

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

Z listy plików pobierzemy tylko elementy Key.

Pozostaje tylko przeanalizować wynikowy plik XML i wysłać go w formacie HTML, po uprzednim zastąpieniu nagłówka Content-Type tekstem/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="/pl/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Próbuję PyPI

Sprawdzamy, czy w pakietach, o których wiadomo, że działają, nic się nie psuje.

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

Powtarzamy z naszymi bibliotekami.

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

W CI tworzenie i ładowanie pakietu wygląda następująco:

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

Uwierzytelnianie

W Gitlabie możliwe jest wykorzystanie JWT do uwierzytelniania/autoryzacji usług zewnętrznych. Korzystając z dyrektywy auth_request w Nginx, przekierujemy dane uwierzytelniające do podżądania zawierającego w skrypcie wywołanie funkcji. Skrypt złoży kolejne podżądanie do adresu URL Gitlaba i jeśli dane uwierzytelniające zostały określone poprawnie, Gitlab zwróci kod 200 i umożliwi wysyłanie/pobieranie pakietu. Dlaczego nie skorzystać z jednego podzapytania i od razu wysłać dane do Gitlaba? Ponieważ wtedy za każdym razem, gdy będziemy wprowadzać jakiekolwiek zmiany w autoryzacji, będziemy musieli edytować plik konfiguracyjny Nginx, a jest to dość żmudne zadanie. Ponadto, jeśli Kubernetes używa zasad głównego systemu plików tylko do odczytu, powoduje to jeszcze większą złożoność podczas zastępowania pliku nginx.conf poprzez mapę konfiguracyjną. I staje się całkowicie niemożliwe skonfigurowanie Nginxa za pomocą mapy konfiguracji przy jednoczesnym stosowaniu zasad zabraniających łączenia woluminów (pvc) i głównego systemu plików tylko do odczytu (to również się zdarza).

Korzystając z pośrednika NJS, otrzymujemy możliwość zmiany określonych parametrów w konfiguracji nginx za pomocą zmiennych środowiskowych i sprawdzenia w skrypcie (na przykład niepoprawnie określonego adresu 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}

Najprawdopodobniej pojawia się pytanie: -Dlaczego nie skorzystać z gotowych modułów? Tam już wszystko zostało zrobione! Na przykład var AWS = require('aws-sdk') i nie ma potrzeby zapisywania „roweru” z uwierzytelnieniem S3!

Przejdźmy do wad

Dla mnie brak możliwości importowania zewnętrznych modułów JS stał się nieprzyjemną, ale oczekiwaną funkcją. Opisane w powyższym przykładzie require('crypto') to wbudowane moduły i wymagają tylko dzieł dla nich. Nie ma też możliwości ponownego wykorzystania kodu ze skryptów i trzeba go skopiować i wkleić do różnych plików. Mam nadzieję, że kiedyś taka funkcjonalność zostanie wdrożona.

Kompresja musi być również wyłączona dla bieżącego projektu w Nginx gzip off;

Ponieważ w NJS nie ma modułu gzip i nie da się go podłączyć, dlatego nie ma możliwości pracy ze skompresowanymi danymi. To prawda, że ​​​​nie jest to tak naprawdę minus w tym przypadku. Tekstu nie jest dużo, a przesyłane pliki są już skompresowane i dodatkowa kompresja niewiele im pomoże. Poza tym nie jest to na tyle obciążona ani krytyczna usługa, aby trzeba było martwić się dostarczaniem treści o kilka milisekund szybciej.

Debugowanie skryptu zajmuje dużo czasu i jest możliwe jedynie poprzez „wydruki” w error.log. W zależności od ustawionego poziomu logowania info, warn lub error możliwe jest zastosowanie 3 metod odpowiednio r.log, r.warn, r.error. Próbuję debugować niektóre skrypty w przeglądarce Chrome (v8) lub narzędziu konsoli njs, ale nie wszystko da się tam sprawdzić. Podczas debugowania kodu, czyli testów funkcjonalnych, historia wygląda mniej więcej tak:

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

i takich sekwencji mogą być setki.

Pisanie kodu przy użyciu podzapytań i zmiennych dla nich zamienia się w splątaną plątaninę. Czasami zaczynasz biegać po różnych oknach IDE, próbując ustalić kolejność działań w kodzie. Nie jest to trudne, ale czasami jest bardzo irytujące.

Nie ma pełnego wsparcia dla ES6.

Być może są jeszcze jakieś niedociągnięcia, ale z niczym innym się nie spotkałem. Podziel się informacjami, jeśli masz negatywne doświadczenia z korzystaniem z NJS.

wniosek

NJS to lekki interpreter typu open source, który umożliwia implementację różnych skryptów JavaScript w Nginx. Podczas jego opracowywania dużą uwagę zwrócono na wydajność. Oczywiście jeszcze wiele brakuje, ale projekt jest rozwijany przez mały zespół, który aktywnie dodaje nowe funkcje i poprawia błędy. Mam nadzieję, że kiedyś NJS umożliwi podłączenie modułów zewnętrznych, co sprawi, że funkcjonalność Nginx będzie niemal nieograniczona. Ale jest NGINX Plus i najprawdopodobniej nie będzie żadnych funkcji!

Repozytorium z pełnym kodem artykułu

njs-pypi z obsługą AWS Sign v4

Opis dyrektyw modułu ngx_http_js_module

Oficjalne repozytorium NJS и dokumentacja

Przykłady użycia NJS od Dmitrija Volintseva

njs - natywny skrypt JavaScript w nginx / Przemówienie Dmitrija Volnyeva na Saint HighLoad++ 2019

NJS w produkcji / Wystąpienie Wasilija Sosznikowa na HighLoad++ 2019

Podpisywanie i uwierzytelnianie żądań REST w AWS

Źródło: www.habr.com