Stworzyłem mój repozytorium PyPI z autoryzacją i S3. Na Nginx

W tym artykule chciałbym podzielić się doświadczeniem pracy z NJS, interpretatorem JavaScript dla Nginx opracowanym przez firmę Nginx Inc., opisując na prawdziwym przykładzie jego podstawowe możliwości. NJS to podzbiór Języka Programowania JavaScript, który pozwala na rozszerzenie funkcjonalności Nginx. Na pytanie dlaczego własny interpreter??? szczegółowo odpowiedział Dmitrij Wołyncew. Jeśli w skrócie: NJS to sposób nginx, a JavaScript jest bardziej postępowy, 'rodzimy' i nie ma GC w przeciwieństwie do Lua.

Dawno, dawno temu...

W poprzedniej pracy odziedziczyłem gitlaba z pewną ilością różnorodnych CI/CD-pipelines z docker-compose, dind i innymi przyjemnościami, które zostały przeniesione na tory kaniko. Obrazy, które były używane wcześniej w CI, zostały przeniesione w pierwotnej postaci. Działały one poprawnie do dnia, w którym nasz gitlab zmienił IP, a CI zamieniło się w dyńkę. Problem polegał na tym, że w jednym z obrazów docker, uczestniczących w CI, był git, który przez ssh pobierał moduły Pythona. Do ssh potrzebny jest klucz prywatny i… był on w obrazie razem z known_hosts. I każdy CI kończył się błędem weryfikacji klucza z powodu niezgodności rzeczywistego IP z tym, co było w known_hosts. Z istniejących Dockfile'ów szybko zbudowano nowy obraz i dodano opcję StrictHostKeyChecking no. Ale niemiły posmak pozostał i pojawiła się chęć przeniesienia bibliotek do prywatnego repozytorium PyPI. Dodatkowym bonusem, po przejściu na prywatny PyPI, stał się prostszy pipeline i poprawny opis requirements.txt.

Decyzja podjęta, Panowie!

Wszystko kręcimy w chmurach i Kubernetes, a ostatecznie chcieliśmy uzyskać małą usługę, która stanowiła stateless-kontener z zewnętrznym przechowalnią. A ponieważ używamy S3, to priorytet był dla niego. I, jeśli to możliwe, z autoryzacją w gitlabie (można samemu dopisać w razie potrzeby).

Pobieżne poszukiwania dały kilka wyników: s3pypi, pypicloud i opcja 'ręcznego' tworzenia plików html dla repozytorium. Ostatnia opcja odpadła sama z siebie.

s3pypi: To cli do korzystania z hostingu na S3. Umieszczamy pliki, generujemy html i wgrywamy do tego samego bucketu. Do użytku domowego będzie odpowiedni.

pypicloud: Wyglądał na interesujący projekt, ale po przeczytaniu dokumentacji zawiodłem się. Mimo dobrej dokumentacji i możliwości dostosowania do własnych potrzeb, okazał się w praktyce zbyt rozbudowany i trudny w konfiguracji. Dostosowanie kodu do własnych zadań, według wstępnych szacunków, zajmie 3-5 dni. Ponadto serwis wymaga bazy danych. Zostawiliśmy go na wypadek, gdybyśmy nie znaleźli nic lepszego.

Bardziej szczegółowe poszukiwania przyniosły moduł dla Nginx, ngx_aws_auth. Wynikiem jego testów był XML wyświetlany w przeglądarce, w którym można było zobaczyć zawartość koszyka S3. Ostatni commit w momencie poszukiwania miał miejsce rok temu. Repozytorium wyglądało na porzucone.

Zwracając się do pierwotnego źródła i czytając PEP-503 zrozumiałem, że XML można na bieżąco konwertować na HTML i przesyłać go przez pip. Po dalszym googlowaniu haseł Nginx i S3 natknąłem się na przykład autoryzacji w S3 napisany w JS dla Nginx. W ten sposób poznałem NJS.

Biorąc ten przykład jako podstawę, po godzinie obserwowałem w swojej przeglądarce ten sam XML, co przy użyciu modułu ngx_aws_auth, ale wszystko było już napisane w JS.

Rozwiązanie na nginx bardzo mi się podobało. Po pierwsze, dobra dokumentacja i wiele przykładów, po drugie, otrzymujemy wszystkie plusy Nginx w pracy z plikami (z pudełka), po trzecie, każda osoba potrafiąca pisać konfiguracje dla Nginx, będzie mogła zrozumieć co i jak. Również zaletą dla mnie jest minimalizm, w porównaniu z Pythonem czy Go (jeśli piszesz od zera), nie mówiąc już o nexus.

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

Jak to działa?

W Nginx załadowany jest moduł ngx_http_js_module, włączony w oficjalny obraz docker. Importujemy nasz skrypt za pomocą dyrektywy js_importdo konfiguracji Nginx. Wywołanie funkcji odbywa się za pomocą dyrektywy js_content. Do ustawiania zmiennych używamy dyrektywy js_set, która jako argument przyjmuje tylko funkcję opisaną w skrypcie. A wykonanie podzapytań w NJS możemy zrealizować tylko za pomocą Nginx, żadnych tam XMLHttpRequest. Z tego powodu w konfiguracji Nginx musi być dodany odpowiedni lokalizacja. A w skrypcie musi być opisany podzapytanie (subrequest) do tej lokalizacji. Aby mieć możliwość odwołania się do funkcji z konfiguracji Nginx, w samym skrypcie trzeba wyeksportować nazwę funkcji 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}

Przy żądaniu w przeglądarce http://localhost:8080/ docieramy do location /w którym dyrektywa js_content wywołuje funkcję request opisana w naszym skrypcie script.js. Z kolei w funkcji request realizowany jest podzapor do location = /sub-query, z metodą (w obecnym przykładzie GET) uzyskaną z argumentu (r), niejawnie przekazywaną podczas wywołania tej funkcji. Obsługa odpowiedzi podzaporu będzie realizowana w funkcji call_back.

Próbujemy S3

Aby wykonać żądanie do prywatnego magazynu S3, potrzebujemy:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Na podstawie używanej metody http, bieżącej daty/czasu, S3_NAME i URI generowany jest łańcuch o określonym formacie, który jest podpisywany (HMAC_SHA1) z użyciem SECRET_KEY. Następnie łańcuch o formacie AWS $ACCESS_KEY:$HASH, można użyć w nagłówku autoryzacji. Ta sama data/czas, która została użyta do wygenerowania łańcucha w poprzednim kroku, musi być dodana do nagłówka 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/(?.*) {
    internal;

    proxy_set_header    X-amz-date     $s3_datetime;
    proxy_set_header    Authorization  $s3_auth;

    proxy_pass          $s3_endpoint/$s3_path;
  }

  location ~ "^(?P[w-]*)[/]?(?P[w-.]*)$" {
    js_content s3.request;
  }
}

s3.js(przykład autoryzacji AWS Sign v2, przetłumaczony jako 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}

Trochę wyjaśnienia na temat _subrequest_uri: jest to zmienna, która w zależności od początkowego uri formuje żądanie do S3. Jeśli potrzebujesz uzyskać zawartość „korzenia”, w takim przypadku musisz sformułować żądanie uri z określonym separatorem delimiter, który zwróci listę wszystkich elementów xml CommonPrefixes, odpowiadających katalogom (w przypadku PyPI, lista wszystkich pakietów). Jeśli musisz uzyskać listę zawartości w określonym katalogu (lista wszystkich wersji pakietów), wtedy żądanie uri powinno zawierać pole prefix z nazwą katalogu (pakietu) koniecznie zakończoną ukośnikiem /. W przeciwnym razie mogą wystąpić kolizje przy żądaniu zawartości katalogu, np. Istnieją katalogi aiohttp-request i aiohttp-requests i jeśli w żądaniu będzie wskazane /?prefix=aiohttp-request, wtedy w odpowiedzi będzie zawartość obu katalogów. Jeśli jednak na końcu będzie ukośnik, /?prefix=aiohttp-request/, w odpowiedzi będzie tylko potrzebny katalog. Jeśli natomiast żądamy pliku, to uzyskany uri nie powinien różnić się od początkowego.

Zapisujemy, restartujemy Nginx. W przeglądarce wpisujemy adres naszego Nginx, a wynikiem zapytania będzie XML, na przykład:

Lista katalogów

myback-space
  
  
  10000
  /
  false
  
    new/
  
  
    old/

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

Dodając w przeglądarce do naszego adresu potrzebny katalog, otrzymamy jego zawartość również w postaci XML:

Lista plików w katalogu

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

Z listy plików weźmiemy tylko elementy Klucz.

Pozostaje sparsować otrzymany XML i zwrócić w formacie HTML, wcześniejszym krokiem wymieniając nagłówek Content-Type na 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="/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Spróbujmy PyPI

Sprawdzamy, czy nic nie psuje się w znanych działających pakietach.

# Создаем для тестов новое окружение
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 przesyłanie pakietu wygląda tak:

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

Autoryzacja

W GitLab można używać JWT do uwierzytelniania/autoryzacji zewnętrznych usług. Korzystając z dyrektywy auth_request w Nginx, przekazujemy dane uwierzytelniające do podzapytań zawierających wywołanie funkcji w skrypcie. W skrypcie nastąpi kolejne podzapytanie do URL GitLab-a, a jeśli dane uwierzytelniające były podane poprawnie, GitLab zwróci kod 200 i umożliwi pobranie/pobranie pakietu. Dlaczego nie skorzystać z jednego podzapytania i od razu nie wysłać danych do GitLab? Ponieważ wówczas trzeba byłoby za każdym razem modyfikować plik konfiguracyjny Nginx, gdy pojawią się jakiekolwiek zmiany w autoryzacji, a to dość żmudne zajęcie. Jeśli w Kubernetes używana jest polityka read-only root filesystem, to jeszcze bardziej komplikuje to proces podmiany nginx.conf przez configmap. Konfiguracja Nginx przez configmap staje się całkowicie niemożliwa przy równoczesnym stosowaniu polityk zabraniających podłączania wolumenów (pvc) oraz read-only root filesystem (co również się zdarza).

Używając NJS jako elementu pośredniego, zyskujemy możliwość zmiany określonych parametrów w konfiguracji Nginx za pomocą zmiennych środowiskowych oraz przeprowadzania jakichkolwiek kontroli w skrypcie (na przykład błędnie podanego URL-a).

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}

Najprawdopodobniej pojawia się pytanie: - A dlaczego nie skorzystać z gotowych modułów? Tam wszystko jest już zrobione! Na przykład, var AWS = require('aws-sdk') i nie trzeba pisać 'roweru' z uwierzytelnianiem S3!

Przejdźmy do minusów

Dla mnie, niemożność importowania zewnętrznych modułów JS była nieprzyjemną, ale oczekiwaną cechą. Opisany w powyższym przykładzie require('crypto') to wbudowane moduły i require działa tylko dla nich. Nie ma również możliwości ponownego użycia kodu z skryptów, co sprawia, że muszę go kopiować i wklejać do różnych plików. Mam nadzieję, że ten funkcjonalność zostanie kiedykolwiek wdrożona.

W bieżącym projekcie w Nginx należy wyłączyć kompresję gzip off;

Ponieważ w NJS nie ma modułu gzip i nie można go podłączyć, nie ma możliwości pracy ze skompresowanymi danymi. Jednak w tym przypadku nie jest to szczególnie minus. Tekstu nie ma dużo, a przesyłane pliki są już skompresowane, więc dodatkowa kompresja nie pomoże znacząco. To nie jest także tak obciążająca ani krytyczna usługa, aby martwić się o dostarczanie treści z opóźnieniem kilku milisekund.

Debugowanie skryptu jest długie i możliwe tylko poprzez 'print' w error.log. W zależności od ustawionego poziomu logowania info, warn lub error można używać 3 metod: r.log, r.warn, r.error odpowiednio. Niektóre skrypty próbuję debugować w Chrome (v8) lub w konsolowej narzędzi njs, ale nie wszystko można tam sprawdzić. Podczas debugowania kodu, czyli testowania funkcjonalnego, historia wygląda mniej więcej tak:

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

i takich sekwencji może być setki.

Pisanie kodu z użyciem podzapytań i dla nich zmiennych staje się skomplikowane. Czasami zaczynam przeskakiwać między różnymi oknami IDE, próbując zrozumieć sekwencję działań swojego kodu. To nie jest trudne, ale czasami mocno frustruje.

Brakuje pełnego wsparcia dla ES6.

Może są też inne wady, ale z niczym innym się nie spotkałem. Podziel się informacjami, jeśli masz negatywne doświadczenia z użytkowaniem NJS.

Podsumowanie

NJS to lekki interpreter open-source, który pozwala na realizację różnych scenariuszy w Nginx za pomocą języka JavaScript. Podczas jego tworzenia zwrócono dużą uwagę na wydajność. Oczywiście wiele rzeczy w nim jeszcze brakuje, ale projekt rozwija się dzięki małej ekipie i aktywnie dodają nowe funkcje oraz poprawiają błędy. Mam nadzieję, że pewnego dnia NJS pozwoli na podłączanie zewnętrznych modułów, co uczyni funkcjonalność Nginx praktycznie nieograniczoną. Ale jest NGINX Plus, więc niektóre funkcje prawdopodobnie nie będą dostępne!

Repozytorium z pełnym kodem do artykułu

njs-pypi z obsługą AWS Sign v4

Opis dyrektyw modułu ngx_http_js_module

Oficjalne repozytorium NJS i 文档

Przykłady użycia NJS od Dmitrija Wołyncowa

njs — natywne skryptowanie JavaScript w nginx / Выступление Дмитрия Волныева на Saint HighLoad++ 2019

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

Podpis i uwierzytelnienie żądań REST w AWS

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster