Я зробив свій PyPI-репозитарій з авторизацією та S3. На Nginx

У цій статті хочу поділитися досвідом роботи з NJS, інтерпретатора JavaScript для Nginx, що розробляється в компанії Nginx Inc, описав на реальному прикладі його основні можливості. NJS це підмножина ЯП JavaScript, що дозволяє розширити функціональність Nginx. На питання навіщо свій інтерпретатор? Детально відповів Дмитро Волинців. Якщо коротко: NJS це nginx-way, а JavaScript прогресивніший, «рідніший» і без GC на відміну від Lua.

Давним-давно…

На минулій роботі у спадок мені дістався gitlab з деякою кількістю різношерстих CI/CD-пайплайнів з docker-compose, dind та іншими принадами, які були переведені на рейки kaniko. Образи, які використовувалися раніше в CI, переїхали в первозданному вигляді. Працювали вони справно до того дня, поки наш gitlab не змінив IP і CI перетворився на гарбуз. Проблема була в тому, що в одному з docker-образів, який брав участь у CI, був git, який по ssh тягнув Python-модулі. Для ssh потрібен приватний ключ і … він був в образі разом із known_hosts. І будь-який CI завершувався помилкою перевірки ключа через розбіжність реального IP і вказаного в known_hosts. З наявних Dockfile-ів було швидко зібрано новий образ і додано опцію StrictHostKeyChecking no. Але неприємний присмак залишився і виникло бажання перенести либи в приватний PyPI-репозиторій. Додатковим бонусом після переходу на приватний PyPI ставав більш простий пайплайн і нормальний опис requirements.txt

Вибір зроблено, Господа!

Ми все крутимо у хмарах та Kubernetes і в підсумку хотілося отримати невеликий сервіс який уявляв собою stateless-контейнер із зовнішнім сховищем. Ну а оскільки ми використовуємо S3, то й пріоритет був за ним. І наскільки можна з аутентифікацією в gitlab (можна й самому дописати за необхідності).

Побіжний пошук дав кілька результатів s3pypi, pypicloud та варіант з «ручним» створенням html-файлів для ріпи. Останній варіант відпав сам-собою.

s3pypi: Це cli для використання хостингу на S3. Викладаємо файли, генеруємо html і заливаємо у той самий бакет. Для домашнього використання підійде.

pypicloud: Здавався цікавим проектом, але після прочитання доки прийшло розчарування. Незважаючи на хорошу документацію та можливості розширення під свої завдання, на ділі виявився надмірний та складний у налаштуванні. Виправити код під свої завдання, за тодішніми прикидками, зайняло б 3-5 днів. Також сервісу необхідна БД. Залишили його на випадок, якщо нічого не знайдемо.

Більш поглиблений пошук дав модуль Nginx, ngx_aws_auth. Результатом його тестування став XML, що відображається в браузері, за яким було видно вміст бакета S3. Останній коміт на момент пошуку був рік тому. Репозиторій виглядав покинутим.

Звернувшись до першоджерела та прочитавши ПЕП-503 зрозумів, що XML можна конвертувати в HTML нальоту та віддавати його pip. Ще трохи погуглив за словами Nginx і S3 натрапив на приклад автентифікації в S3 написаний на JS для Nginx. Так я познайомився із NJS.

Взявши за основу цей приклад, через годину спостерігав у своєму браузері той же XML, що й при використанні модуля ngx_aws_auth, але вже написано все було на JS.

Рішення на nginx мені дуже подобалося. По-перше хороша документація і безліч прикладів, по-друге ми отримуємо всі плюшки Nginx по роботі з файлами (з коробки), по-третє, будь-яка людина вміє писати конфіги для Nginx, зможе розібратися що до чого. Також плюсом для мене є мінімалізм, в порівнянні з Python або Go (якщо писати з нуля), не кажучи вже про nexus.

TL;DR Через 2 дні тестова версія PyPi вже була використана в CI.

Як це працює?

У Nginx підвантажується модуль ngx_http_js_module, включений в офіційний образ docker. Імпортуємо наш скрипт за допомогою директиви js_importу конфігурацію Nginx. Виклик функції здійснюється директивою js_content. Для встановлення змінних використовується директива js_setяка аргументом приймає тільки функцію описану в скрипті. А ось виконувати підзапити в NJS ми можемо тільки за допомогою Nginx, ні яких Вам XMLHttpRequest. Для цього в конфігурації Nginx має бути доданий відповідний локейшн. А в скрипті має бути описаний підзапит (subrequest) до цього локейшену. Щоб мати змогу звернутися до функції з конфіга Nginx, у самому скрипті ім'я функції необхідно експортувати 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}

При запиті у браузері http://localhost:8080/ ми потрапляємо в location /в якому директива js_content викликає функцію request описану в нашому скрипті script.js. У свою чергу у функції request здійснюється підзапит до location = /sub-query, з методом (у поточному прикладі GET) отриманим з аргументу (r), що неявно передається під час виклику цієї функції. Обробку відповіді підзапиту буде здійснено у функції call_back.

Пробуємо S3

Щоб зробити запит до приватного S3-сховища, нам потрібні:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Із використовуваного http-методу, поточна дата/час, S3_NAME та URI генерується певного виду рядок, який підписується (HMAC_SHA1) за допомогою SECRET_KEY. Далі рядок, виду AWS $ACCESS_KEY:$HASH, можна використовувати у заголовку авторизації. Та сама дата/час, що була використана для генерації рядка на попередньому кроці, має бути додана до заголовка X-amz-date. У коді це виглядає так:

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, переведений у статус deprecated)

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}

Небагато пояснення про _subrequest_uri: це змінна, яка в залежності від початкового uri формує запит до S3. Якщо потрібно отримати вміст «кореня», у такому разі необхідно сформувати uri-запит із зазначенням роздільника delimiter, який поверне список всіх xml-елементів CommonPrefixes, що відповідає директоріям (у разі PyPI, список всіх пакетів). Якщо потрібно отримати список вмісту в певній директорії (список всіх версій пакетів), тоді uri-запит повинен містити поле prefix з ім'ям директорії (пакета), що обов'язково закінчується на слеш /. В іншому випадку можливі колізії при запиті вмісту директорії, наприклад. Є директорії aiohttp-request та aiohttp-requests і якщо у запиті буде вказано /?prefix=aiohttp-requestтоді у відповіді буде вміст обох директорій. Якщо ж на кінці буде слеш, /?prefix=aiohttp-request/, то відповіді буде лише потрібна директорія. І якщо ми запитуємо файл, то результат uri не повинен відрізняти від початкового.

Зберігаємо, перезапускаємо Nginx. У браузері вводимо адресу нашого Nginx, результатом роботи запиту буде XML, наприклад:

Список директорій

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

Зі списку директорій знадобляться лише елементи CommonPrefixes.

Додавши в браузері до нашої адреси потрібну нам директорію, отримаємо її вміст так само у вигляді XML:

Список файлів у директорії

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

Зі списку файлів візьмемо тільки елементи Key.

Залишається отриманий XML розпарсувати і віддати як HTML, попередньо замінивши заголовок 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="/uk/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Пробуємо PyPI

Перевіряємо, що ніде і нічого не ламається на свідомо робочих пакетах.

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

Повторюємо із нашими либами.

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

У CI, створення та завантаження пакета виглядає так:

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

аутентифікація

У Gitlab можна використовувати JWT для аутентифікації/авторизації зовнішніх сервісів. Скориставшись директивою auth_request Nginx, перенаправимо автентифікаційні дані в підзапит містить виклик функції в скрипті. У скрипті буде зроблено ще один підзапит на url Gitlab і якщо автентифікаційні дані вказані були вірно, то Gitlab поверне код 200 і буде дозволено завантаження/завантаження пакета. Чому не скористатися одним підзапитом і відразу не надіслати дані до Gitlab? Тому що доведеться тоді правити файл конфігурації Nginx щоразу, як у нас будуть якісь зміни в авторизації, а це досить нудотне заняття. Так само, якщо в Kubernetes використовується політика read-only root filesystem, то це ще більше додає труднощів при заміні nginx.conf через configmap. І стає абсолютно неможлива конфігурація Nginx через configmap при одночасному використанні політик забороняючих підключення томів (pvc) і read-only root filesystem (таке теж буває).

Використовуючи проміжну ланку NJS, ми отримуємо можливість змінювати зазначені параметри в конфізі nginx за допомогою змінних оточення і робити якісь перевірки у скрипті (наприклад, невірно вказаного 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}

Швидше за все назріває питання: - А чому б не використовувати готові модулі? Адже там все вже зроблено! Наприклад, var AWS = require('aws-sdk') і не треба писати "велосипед" з S3-автентифікацією!

Перейдемо до мінусів

Для мене неможливість імпортування зовнішніх JS-модулів стало неприємною, але очікуваною особливістю. Описаний у прикладі вище require('crypto'), це build-in-модулі та require працює тільки для них. Так само немає можливості перевикористовувати код зі скриптів і доводиться копіювати його по різних файлах. Сподіваюся, що колись цей функціонал буде реалізовано.

Також для поточного проекту в Nginx має бути відключено стиснення gzip off;

Тому що немає gzip-модуля NJS і підключити його неможливо, відповідно немає можливості працювати зі стислими даними. Щоправда, не особливо це і мінус для цього кейсу. Тексту не багато, а файли, що передаються, вже стислі і додатковий стиск їм не особливо допоможе. Так само це не так навантажений чи критичний сервіс, щоб морочитися з віддачею контенту на кілька мілісекунд швидше.

Налагодження скрипта довге і можливе лише через «принти» в error.log. Залежно від виставленого рівня логування info, warn або error можливо використовувати 3 методи r.log, r.warn, r.error відповідно. Деякі скрипти намагаюся налагоджувати в Chrome (v8) або консольному тулзі njs, але не все можливо там перевірити. При налагодженні коду, ака функціональне тестування, history виглядає приблизно так:

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

та таких послідовностей може бути сотні.

Написання коду з використання підзапитів та змінних для них перетворюється на заплутаний клубок. Іноді починаєш метатися різними вікнами IDE, намагаючись розібратися в послідовності дій твого коду. Це не складно, але іноді сильно напружує.

Немає повноцінної підтримки ES6.

Може є ще якісь недоліки, але ні з чим я більше не стикався. Поділіться інформацією, якщо у Вас є негативний досвід експлуатації NJS.

Висновок

NJS - легковажний open-source інтерпретатор, що дозволяє реалізувати в Nginx різні сценарії на JavaScript. Під час його розробки було приділено велику увагу продуктивності. Звичайно, багато чого в ньому ще не вистачає, але проект розвивається силами невеликої команди і вони активно додають нові фічі і фіксують баги. Я ж сподіваюся, що колись NJS дозволить підключати зовнішні модулі, що зробить функціонал Nginx практично необмеженим. Але є NGINX Plus і якихось фіч швидше за все не буде!

Репозиторій із повним кодом до статті

njs-pypi з підтримкою AWS Sign v4

Опис директив модуля ngx_http_js_module

Офіційний репозиторій NJS и документація

Приклади використання NJS від Дмитра Волинцева

njs - рідний JavaScript-скриптинг у nginx / Виступ Дмитра Волнієва на Saint HighLoad 2019

NJS у production / Виступ Василя Сошнікова на HighLoad++ 2019

Підпис та автентифікація REST-запитів в AWS

Джерело: habr.com