Vytvoril som svoj PyPI repozitár s autorizáciou a S3. Na Nginx

V tomto článku sa chcem podeliť o skúsenosti s NJS, JavaScriptovým interpretom pre Nginx vyvíjaným spoločnosťou Nginx Inc, popisujúc na reálnych príkladoch jeho hlavné možnosti. NJS je podmnožina programovacieho jazyka JavaScript, ktorá umožňuje rozšíriť funkčnosť Nginx. Na otázku prečo mať svoj interpret??? podrobne odpovedal Dmitrij Volyncev. Ak by som to mal zhrnúť: NJS je nginx-way, zatiaľ čo JavaScript je progresívnejší, 'prirodzený' a bez GC na rozdiel od Lua.

Kedysi dávno…

Na mojej predchádzajúcej práci som zdedil gitlab s množstvom rôznorodých CI/CD pipeline s docker-compose, dind a inými skvostmi, ktoré boli prevedené na kaniko. Obrazy, ktoré sa predtým používali v CI, sa presťahovali v nezmenenej podobe. Fungovali bez problémov, kým sa náš gitlab nezmenil na IP a CI sa nezmenil na tekvicu. Problém bol v tom, že v jednom z docker obrazov, ktorý sa zúčastnil CI, bol git, ktorý cez ssh ťahal Python moduly. Pre ssh potrebujete privátny kľúč a… bol v obraze spolu s known_hosts. A akýkoľvek CI končil chybou overovania kľúča pre nesúlad skutočnej IP a tej, ktorá bola uvedená v known_hosts. Z dostupných Dockfile-ov bol rýchlo zostavený nový obraz a pridaná možnosť StrictHostKeyChecking no. Zostal nepríjemný pocit a objavila sa túžba presunúť knižnice do privátneho PyPI repozitára. Ďalším bonusom, po prechode na privátny PyPI, bol jednoduchší pipeline a normálne zobrazenie requirements.txt

Výber je hotový, páni!

Všetko točíme v cloudoch a Kubernetes a nakoniec sme chceli získať malú službu, ktorá predstavovala stateless kontajner s externým úložiskom. A pretože používame S3, tak priorita bola na ňom. A pokiaľ je to možné, s autentifikáciou do gitlabu (môže si to aj niekto naprogramovať podľa potreby).

Rýchly prieskum dal niekoľko výsledkov s3pypi, pypicloud a variantou s 'manuálnym' vytváraním html súborov pre repo. Posledný variant sám odpadol.

s3pypi: Toto je cli na používanie hostingu na S3. Ukladáme súbory, generujeme html a nahrávame do toho istého bucketu. Pre domáce použitie je to vhodné.

pypicloud: Zdalo sa, že je to zaujímavý projekt, ale po prečítaní dokumentácie prišlo sklamanie. Napriek dobrej dokumentácii a možnostiam rozšírenia pod vlastné úlohy sa v praxi ukázal byť prebytočný a zložitý na nastavenie. Upravovanie kódu pre svoje potreby, podľa vtedajších odhadov, by trvalo 3-5 dní. Službe bola taktiež potrebná databáza. Nechali sme ho, ak nenájdeme nič iné.

Hlbšie vyhľadávanie prinieslo modul pre Nginx, ngx_aws_auth. Výsledkom jeho testovania bol XML zobrazený v prehliadači, podľa ktorého bolo vidieť obsah zásobníka S3. Posledný commit, v čase hľadania, bol pred rokom. Repozitár vyzeral opustený.

Obrátil som sa na prvotný zdroj a prečítal PEP-503 , pochopil som, že XML možno naletu konvertovať na HTML a posielať ho pip. Ešte chvíľu som googlil slová Nginx a S3, keď som narazil na príklad autentifikácie v S3 napísaný v JS pre Nginx. Tak som sa zoznámil s NJS.

Vzal som tento príklad za základ a za hodinu som videl vo svojom prehliadači rovnaké XML, ako pri použití modulu ngx_aws_auth, ale už všetko bolo napísané v JS.

Riešenie na Nginx sa mi veľmi páčilo. Po prvé, dobrá dokumentácia a množstvo príkladov, po druhé, dostávame všetky výhody Nginx pri práci s súbormi (z krabice), po tretie, každý, kto vie napísať konfigurácie pre Nginx, sa zorientuje, čo a ako. Takisto pre mňa je pozitívom minimalizmus v porovnaní s Python alebo Go (ak písať od nuly), nieto už o nexus.

TL;DR Po 2 dňoch bola testovacia verzia PyPi už použitá v CI.

Ako to funguje?

Do Nginx sa načíta modul ngx_http_js_module, zahrnutý v oficiálnom docker obraze. Importujeme náš skript pomocou direktívy js_importdo konfigurácie Nginx. Volanie funkcie sa realizuje direktívou js_content. Pre nastavovanie premenných sa používa direktíva js_set, ktorá akceptuje iba funkciu opísanú v skripte ako argument. A podzápisy v NJS môžeme vykonávať iba pomocou Nginx, žiadne XMLHttpRequest. Pre toto musí byť v konfigurácii Nginx pridaný zodpovedajúci location. A v skripte musí byť opísaný podzápis (subrequest) k tomuto location. Aby sme mali možnosť odkázať sa na funkciu z konfigurácie Nginx, v samotnom skripte je potrebné exportovať názov funkcie. 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}

Pri požiadavke v prehliadači http://localhost:8080/ sa dostávame do location /, v ktorom direktíva js_content vyvoláva funkciu request popísanú v našom skripte script.js. Vo funkcii request sa vykonáva podžiadosť na location = /sub-query, s metódou (v aktuálnom príklade GET) získanou z argumentu (r), ktorý je implicitne prenášaný pri volaní tejto funkcie. Spracovanie odpovede podžiadosti bude vykonané vo funkcii call_back.

Skúšame S3

Na vykonanie požiadavky na súkromné S3 úložisko potrebujeme:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Z použitého http-metódu, aktuálna dátum/čas, S3_NAME a URI sa generuje určitý typ reťazca, ktorý sa podpisuje (HMAC_SHA1) pomocou SECRET_KEY. Následne reťazec vo forme AWS $ACCESS_KEY:$HASH, môže byť použitý v hlavičke autorizácie. Tá istá dátum/čas, ktorá bola použitá na generovanie reťazca v predchádzajúcom kroku, musí byť pridaná do hlavičky X-amz-date. V kóde to vyzerá 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/(?.*) {
    internal;

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

    proxy_pass          $s3_endpoint/$s3_path;
  }

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

s3.js(príklad autorizácie AWS Sign v2, preložená do stavu 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}

Trochu vysvetlenia o _subrequest_uri: je to premenná, ktorá v závislosti od pôvodného uri formuluje požiadavku na S3. Ak potrebujete získať obsah „korena“, v takom prípade je potrebné formulovať uri-požiadavku s určením oddeľovača delimiter, ktorý vráti zoznam všetkých xml prvkov CommonPrefixes, čo zodpovedá adresárom (v prípade PyPI, zoznam všetkých balíkov). Ak potrebujete získať zoznam obsahu v určitej adresári (zoznam všetkých verzií balíkov), potom must uri-požiadavka obsahovať pole prefix s menom adresára (balíka), ktoré končí na lomku /. V opačnom prípade môžu nastať kolízie pri požiadavke na obsah adresára, napríklad. Existujú adresáre aiohttp-request a aiohttp-requests a ak bude v požiadavke uvedené /?prefix=aiohttp-request, potom v odpovedi bude obsah oboch adresárov. Ak na konci bude lomka, /?prefix=aiohttp-request/, v odpovedi bude iba požadovaný adresár. A ak požadujeme súbor, výsledný uri sa nemá líšiť od pôvodného.

Ukladáme, reštartujeme Nginx. V prehliadači zadáme adresu nášho Nginx, výsledkom žiadosti bude XML, napríklad:

Zoznam adresárov

myback-space
  
  
  10000
  /
  false
  
    new/
  
  
    old/

Zoznam adresárov potrebujeme iba prvky CommonPrefixes.

Pridáme sa do prehliadača, k našej adrese potrebného adresára, získame jeho obsah takisto vo forme XML:

Zoznam súborov v adresári

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

Zoznam súborov vezmeme iba prvky Kľúč.

Zostáva získaný XML spracovať a odovzdať vo forme HTML, predtým nahradíme hlavičku 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>'
}

Skúšame PyPI

Kontrolujeme, že nikde a nič neprestavuje na známych funkčných balíkoch.

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

# Создаем для тестов новое окружение
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, vytvorenie a nahranie balíka vyzerá 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}"

Autentifikácia

V Gitlabe je možné používať JWT na autentifikáciu a autorizáciu externých služieb. Využitím direktívy auth_request v Nginx presmerujeme autentifikačné údaje do podžiadosti obsahujúcej volanie funkcie v skripte. V skripte bude vykonaná ďalšia podžiadosť na URL Gitlaba a ak boli autentifikačné údaje zadané správne, Gitlab vráti kód 200 a povolí nahrávanie/prevzatie balíka. Prečo nevyužiť jednu podžiadosť a hneď neposlať údaje do Gitlaba? Pretože by sme museli každý raz meniť konfiguračný súbor Nginx, keď sa vyskytne nejaká zmena v autorizačných údajoch, a to je dosť zdĺhavé. Taktiež, ak sa v Kubernetes používa politika read-only root filesystem, ešte viac to pridáva komplikácie pri výmene nginx.conf prostredníctvom configmap. A konfigurácia Nginx cez configmap sa stáva nemožnou pri súčasnom použití politík zakazujúcich pripojenie objemov (pvc) a read-only root filesystem (to sa tiež stáva).

Použitím intermediárneho spoja NJS získavame možnosť meniť uvedené parametre v konfigurácii nginx pomocou premenných prostredia a vykonávať rôzne kontroly v skripte (napríklad, ak je zadané nesprávne 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}

Skôr či neskôr sa vynára otázka: -A prečo nevyužiť hotové moduly? Veď tam je už všetko hotové! Napríklad, var AWS = require('aws-sdk') a nemusíme písať 'bicykel' s S3 autentifikáciou!

Prejdime k nevýhodám

Pre mňa bola nemožnosť importovať externé JS-moduly nepríjemnou, ale očakávanou vlastnosťou. Opísané v príklade vyššie require('crypto') je vstavané moduly a require funguje len pre ne. Taktiež nie je možné opätovne používať kód zo skriptov, a musíme ho kopírovať a vkladať do rôznych súborov. Dúfam, že raz bude táto funkcia realizovaná.

Takisto pre aktuálny projekt v Nginx musí byť vypnuté kompresia gzip off;

Pretože v NJS nie je gzip modul a nie je možné ho pripojiť, preto nie je možné pracovať so stlačenými dátami. Pravda, nie je to až taký problém pre tento prípad. Textu nie je veľa a prenášané súbory sú už stlačené, takže dodatočné stlačenie im príliš nepomôže. Takisto to nie je natoľko zaťažená alebo kritická služba, aby sme sa zaoberali dodávaním obsahu o niekoľko milisekúnd rýchlejšie.

Ladenie skriptu je dlhé a možné iba cez "print" v error.log. V závislosti od nastaveného úrovne logovania info, warn alebo error je možné použiť 3 metódy r.log, r.warn, r.error. Niektoré skripty sa snažím ladiť v Chrome (v8) alebo konzolovom nástroji njs, ale nie všetko je tam možné skontrolovať. Pri ladení kódu, teda funkčnom testovaní, má história približne takúto podobu:

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

a takých sekvencií môže byť stovky.

Písanie kódu s použitím poddotazov a premenných pre ne sa stáva zložitým chaodom. Niekedy začnete blúdiť po rôznych oknách IDE snažiac sa pochopiť sekvenciu krokov vo vašom kóde. Nie je to ťažké, ale niekedy to značne zaťažuje.

Neexistuje plná podpora ES6.

Možno existujú aj nejaké ďalšie nedostatky, ale s ničím iným som sa už nestretol. Podeľte sa o informácie, ak máte negatívne skúsenosti s používaním NJS.

Záver

NJS je ľahký open-source interpreter, ktorý umožňuje realizovať v Nginx rôzne scenáre v programovacom jazyku JavaScript. Pri jeho vývoji sa kládol veľký dôraz na výkon. Samozrejme, veľa vecí mu ešte chýba, ale projekt sa vyvíja silami malej tímu a aktívne pridávajú nové funkcie a opravujú chyby. Dúfam, že NJS umožní neskôr pripojiť externé moduly, čím sa funkčnosť Nginx stane prakticky neobmedzenou. Avšak existuje NGINX Plus a nejaké funkcie pravdepodobne nebudú!

Úložisko s kompletným kódom k článku

njs-pypi s podporou AWS Sign v4

Popis direktív modulu ngx_http_js_module

Oficiálne úložisko NJS a dokumentácia

Príklady použitia NJS od Dmitrija Volynceva

njs — natívne JavaScript skriptovanie v nginx / Выступление Дмитрия Волныева на Saint HighLoad++ 2019

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

Podpis a autentifikácia REST žiadostí v AWS

Zdroj: habr.com

Kúpte si spoľahlivý hosting pre webové stránky s ochranou proti DDoS, VPS VDS servery 🔥 Kúpte si spoľahlivý hosting pre webové stránky s ochranou proti DDoS, VPS VDS servery | ProHoster