Napravio sam vlastiti PyPI repozitorij s autorizacijom i S3. Na Nginxu

U ovom članku želio bih podijeliti svoje iskustvo rada s NJS-om, JavaScript interpreterom za Nginx koji je razvio Nginx Inc, opisujući njegove glavne mogućnosti na stvarnom primjeru. NJS je podskup JavaScripta koji vam omogućuje proširenje funkcionalnosti Nginxa. Na pitanje zašto vlastiti prevoditelj??? Dmitry Volyntsev je detaljno odgovorio. Ukratko: NJS je nginx-way, a JavaScript je progresivniji, “native” i bez GC-a, za razliku od Lue.

Davno ...

Na posljednjem poslu naslijedio sam gitlab s brojnim šarolikim CI/CD cjevovodima s docker-compose, dind i ostalim užicima, koji su prebačeni na kaniko rails. Slike koje su prethodno korištene u CI premještene su u izvornom obliku. Radili su ispravno sve do dana kada se naš gitlab IP promijenio i CI pretvorio u bundevu. Problem je bio u tome što je jedna od docker slika koje su sudjelovale u CI-ju imala git, koji je izvlačio Python module putem ssh-a. Za ssh vam treba privatni ključ i... bio je na slici zajedno s known_hosts. I svaki CI nije uspio s greškom provjere ključa zbog neusklađenosti između stvarnog IP-a i onog navedenog u unknown_hosts. Nova slika je brzo sastavljena iz postojećih Dockfiles i dodana je opcija StrictHostKeyChecking no. Ali loš okus je ostao i postojala je želja da se libs premjesti u privatni PyPI repozitorij. Dodatni bonus, nakon prelaska na privatni PyPI, bio je jednostavniji cjevovod i normalan opis requirements.txt

Izbor je napravljen, gospodo!

Sve pokrećemo u oblaku i Kubernetesu, a na kraju smo htjeli dobiti mali servis koji bi bio stateless kontejner s eksternom pohranom. Pa pošto koristimo S3, njemu je dat prioritet. I, ako je moguće, s autentifikacijom u gitlabu (možete ga sami dodati ako je potrebno).

Brza pretraga je dala nekoliko rezultata: s3pypi, pypicloud i opcija s “ručnim” kreiranjem html datoteka za repu. Posljednja opcija je nestala sama od sebe.

s3pypi: Ovo je cli za korištenje S3 hostinga. Učitavamo datoteke, generiramo html i učitavamo ga u istu kantu. Pogodan za kućnu upotrebu.

pypicloud: Činilo se kao zanimljiv projekt, ali nakon čitanja dokumentacije bio sam razočaran. Unatoč dobroj dokumentaciji i mogućnosti proširenja prema vašim potrebama, u stvarnosti se pokazalo suvišnim i teškim za konfiguriranje. Ispravljanje koda kako bi odgovarao vašim zadacima, prema tadašnjim procjenama, trajalo bi 3-5 dana. Usluga također treba bazu podataka. Ostavili smo ga u slučaju da ne nađemo ništa drugo.

Detaljnije pretraživanje dalo je modul za Nginx, ngx_aws_auth. Rezultat njegovog testiranja bio je XML prikazan u pregledniku, koji je prikazivao sadržaj S3 spremnika. Posljednji commit u vrijeme pretraživanja bio je prije godinu dana. Spremište je izgledalo napušteno.

Odlaskom na izvor i čitanjem PEP-503 Shvatio sam da se XML može pretvoriti u HTML u hodu i dati u pip. Nakon što sam malo više guglao o Nginxu i S3, naišao sam na primjer autentifikacije u S3 napisan u JS za Nginx. Tako sam upoznao NJS.

Uzimajući ovaj primjer kao osnovu, sat vremena kasnije u pregledniku sam vidio isti XML kao i kod korištenja modula ngx_aws_auth, ali sve je već bilo napisano u JS-u.

Jako mi se svidjelo nginx rješenje. Prvo, dobra dokumentacija i mnogo primjera, drugo, dobivamo sve prednosti Nginxa za rad s datotekama (iz kutije), treće, svatko tko zna pisati konfiguracije za Nginx moći će shvatiti što je što. Minimalizam mi je također plus, u usporedbi s Pythonom ili Goom (ako je napisan od nule), a o nexusu da i ne govorim.

TL;DR Nakon 2 dana testna verzija PyPi već je korištena u CI.

Kako se to radi?

Modul se učitava u Nginx ngx_http_js_module, uključen u službenu docker sliku. Uvozimo našu skriptu pomoću direktive js_importna Nginx konfiguraciju. Funkcija se poziva direktivom js_content. Direktiva se koristi za postavljanje varijabli js_set, koji kao argument uzima samo funkciju opisanu u skripti. Ali podupiti u NJS-u možemo izvršiti samo koristeći Nginx, a ne bilo koji XMLHttpRequest. Da biste to učinili, odgovarajuća lokacija mora biti dodana u Nginx konfiguraciju. I skripta mora opisati podzahtjev za ovu lokaciju. Da biste mogli pristupiti funkciji iz Nginx konfiguracije, naziv funkcije mora biti eksportiran u samu 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 zahtjev u pregledniku http://localhost:8080/ ulazimo u location /u kojoj je direktiva js_content poziva funkciju request opisano u našoj skripti script.js. Zauzvrat, u funkciji request napravljen je podupit za location = /sub-query, s metodom (u trenutnom primjeru GET) dobivenom iz argumenta (r), proslijeđen implicitno kada se ova funkcija pozove. Odgovor na podzahtjev bit će obrađen u funkciji call_back.

Isprobavanje S3

Za podnošenje zahtjeva za privatnu S3 pohranu potrebno nam je:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Iz korištene http metode, trenutnog datuma/vremena, S3_NAME i URI-ja, generira se određena vrsta stringa, koji je potpisan (HMAC_SHA1) korištenjem SECRET_KEY. Sljedeći je redak poput AWS $ACCESS_KEY:$HASH, može se koristiti u zaglavlju autorizacije. Isti datum/vrijeme koji je korišten za generiranje niza u prethodnom koraku mora se dodati u zaglavlje X-amz-date. U kodu to izgleda ovako:

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 primjer autorizacije, promijenjen u zastarjeli status)

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}

Malo objašnjenje o _subrequest_uri: ovo je varijabla koja, ovisno o početnom uri-ju, oblikuje zahtjev prema S3. Ako trebate dobiti sadržaj "korijena", tada morate stvoriti uri zahtjev koji označava graničnik delimiter, koji će vratiti popis svih CommonPrefixes xml elemenata, koji odgovaraju direktorijima (u slučaju PyPI-ja, popis svih paketa). Ako trebate dobiti popis sadržaja u određenom direktoriju (popis svih verzija paketa), tada uri zahtjev mora sadržavati polje prefiksa s nazivom direktorija (paketa) koji obavezno završava kosom crtom /. U suprotnom, mogući su sukobi kada se, na primjer, traži sadržaj imenika. Postoje direktoriji aiohttp-request i aiohttp-requests i ako zahtjev navodi /?prefix=aiohttp-request, tada će odgovor sadržavati sadržaj oba direktorija. Ako je kosa crta na kraju, /?prefix=aiohttp-request/, tada će odgovor sadržavati samo traženi imenik. A ako zatražimo datoteku, onda se rezultirajući uri ne bi trebao razlikovati od izvornog.

Spremite i ponovno pokrenite Nginx. U preglednik unosimo adresu našeg Nginxa, rezultat zahtjeva bit će XML, na primjer:

Popis imenika

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

S popisa imenika trebat će vam samo elementi CommonPrefixes.

Dodavanjem imenika koji nam je potreban našoj adresi u pregledniku, dobit ćemo i njegov sadržaj u XML obliku:

Popis datoteka u direktoriju

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

Iz popisa datoteka uzet ćemo samo elemente Key.

Sve što preostaje je raščlaniti dobiveni XML i poslati ga kao HTML, prethodno zamijenivši zaglavlje Content-Type tekstom/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="/hr/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Isprobavanje PyPI

Provjeravamo da se nigdje ništa ne pokvari na paketima za koje se zna da djeluju.

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

Ponavljamo s udovima.

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

U CI-ju kreiranje i učitavanje paketa izgleda ovako:

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

ovjera

U Gitlabu je moguće koristiti JWT za autentifikaciju/autorizaciju vanjskih usluga. Koristeći direktivu auth_request u Nginxu, preusmjerit ćemo podatke za autentifikaciju na podzahtjev koji sadrži poziv funkcije u skripti. Skripta će napraviti još jedan podzahtjev Gitlab url-u i ako su podaci za provjeru autentičnosti navedeni ispravno, tada će Gitlab vratiti kod 200 i dopušteno je upload/download paketa. Zašto ne upotrijebiti jedan podupit i odmah poslati podatke Gitlabu? Zato što ćemo tada morati uređivati ​​Nginx konfiguracijsku datoteku svaki put kada napravimo bilo kakve promjene u autorizaciji, a to je prilično dosadan zadatak. Također, ako Kubernetes koristi pravilo korijenskog datotečnog sustava samo za čitanje, onda to dodaje još veću složenost prilikom zamjene nginx.conf putem configmapa. I postaje apsolutno nemoguće konfigurirati Nginx putem configmapa dok istovremeno koristite politike koje zabranjuju povezivanje volumena (pvc) i korijenskog datotečnog sustava samo za čitanje (ovo se također događa).

Korištenjem NJS posrednika dobivamo priliku promijeniti navedene parametre u konfiguraciji nginxa pomoću varijabli okruženja i izvršiti neke provjere u skripti (na primjer, netočno naveden 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}

Najvjerojatnije se postavlja pitanje: -Zašto ne koristiti gotove module? Tamo je već sve napravljeno! Na primjer, var AWS = require('aws-sdk') i nema potrebe pisati “bicikl” sa S3 autentifikacijom!

Prijeđimo na nedostatke

Za mene je nemogućnost uvoza vanjskih JS modula postala neugodna, ali očekivana značajka. Opisano u gornjem primjeru require('crypto') je ugradbeni moduli i zahtijevaju samo djela za njih. Također ne postoji način za ponovno korištenje koda iz skripti i morate ga kopirati i zalijepiti u različite datoteke. Nadam se da će jednog dana ova funkcionalnost biti implementirana.

Kompresija također mora biti onemogućena za trenutni projekt u Nginxu gzip off;

Budući da u NJS-u nema gzip modula i nemoguće ga je spojiti, stoga ne postoji način rada s komprimiranim podacima. Istina, to baš i nije minus za ovaj slučaj. Nema puno teksta, a prenesene datoteke su već komprimirane i dodatna kompresija im neće puno pomoći. Također, ovo nije toliko opterećena ili kritična usluga da se morate mučiti s isporukom sadržaja nekoliko milisekundi bržim.

Otklanjanje pogrešaka u skripti traje dugo i moguće je samo putem "ispisa" u error.log. Ovisno o postavljenim informacijama o razini zapisivanja, upozorenju ili pogrešci, moguće je koristiti 3 metode r.log, r.warn, r.error. Pokušavam debugirati neke skripte u Chromeu (v8) ili alatu konzole njs, ali tamo se ne može sve provjeriti. Prilikom otklanjanja pogrešaka koda, tj. funkcionalnog testiranja, povijest izgleda otprilike ovako:

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

a takvih nizova može biti na stotine.

Pisanje koda pomoću podupita i varijabli za njih pretvara se u zamršeno klupko. Ponekad počnete juriti oko različitih IDE prozora pokušavajući shvatiti redoslijed radnji vašeg koda. Nije teško, ali ponekad je jako neugodno.

Ne postoji puna podrška za ES6.

Možda postoje neki drugi nedostaci, ali nisam naišao ni na što drugo. Podijelite informacije ako imate negativno iskustvo s korištenjem NJS-a.

Zaključak

NJS je lagani open-source interpreter koji vam omogućuje implementaciju različitih JavaScript skripti u Nginx. Tijekom razvoja velika je pažnja posvećena performansama. Naravno, još puno toga nedostaje, ali projekt razvija mali tim koji aktivno dodaje nove značajke i ispravlja greške. Nadam se da će vam NJS jednog dana omogućiti povezivanje vanjskih modula, što će funkcionalnost Nginxa učiniti gotovo neograničenom. Ali postoji NGINX Plus i najvjerojatnije neće biti značajki!

Repozitorij s punim kodom za članak

njs-pypi s podrškom za AWS Sign v4

Opis direktiva modula ngx_http_js_module

Službeno spremište NJS и dokumentaciju

Primjeri korištenja NJS od Dmitrija Volintseva

njs - izvorno JavaScript skriptiranje u nginxu / Govor Dmitrija Volnjeva na Saint HighLoad++ 2019

NJS u proizvodnji / Govor Vasilija Sošnjikova na HighLoad++ 2019

Potpisivanje i autentifikacija REST zahtjeva u AWS-u

Izvor: www.habr.com