Napravio sam svoje PyPI spremište sa autorizacijom i S3. Na Nginxu

U ovom članku želim podijeliti svoje iskustvo sa NJS-om, JavaScript interpretatorom za Nginx koji je razvio Nginx Inc, opisujući njegove glavne mogućnosti koristeći pravi primjer. NJS je podskup JavaScripta koji vam omogućava da proširite funkcionalnost Nginxa. Na pitanje zasto tvoj prevodilac??? Dmitrij Volyncev je detaljno odgovorio. Ukratko: NJS je nginx-way, a JavaScript je progresivniji, "native" i bez GC-a, za razliku od Lua.

Prije mnogo vremena…

Na svom posljednjem poslu naslijedio sam gitlab sa nizom šarolikih CI/CD pipelinea sa docker-compose, dind i drugim užicima, koji su prebačeni na kaniko rails. Slike koje su se ranije koristile u CI premještene su u izvornom obliku. Radili su kako treba do dana kada se naš gitlab IP promijenio i CI se pretvorio u bundevu. Problem je bio u tome što je jedna od docker slika koja je učestvovala u CI imala git, koji je povukao Python module preko ssh-a. Za ssh vam je potreban privatni ključ i... bio je na slici zajedno sa poznatim_hostovima. I bilo koji CI nije uspio s greškom u verifikaciji ključa zbog nepodudarnosti između stvarnog IP-a i onog specificiranog u known_hosts. Nova slika je brzo sastavljena iz postojećih Dock fajlova i dodana je opcija StrictHostKeyChecking no. Ali loš ukus je ostao i postojala je želja da se libovi premjeste u privatno PyPI spremište. Dodatni bonus, nakon prelaska na privatni PyPI, bio je jednostavniji kanal i normalan opis zahtjeva.txt

Izbor je napravljen, gospodo!

Sve pokrećemo u oblaku i Kubernetes-u, a na kraju smo željeli dobiti mali servis koji je bio kontejner bez stanja sa eksternom pohranom. Pa, pošto koristimo S3, prioritet je dat njemu. I, ako je moguće, sa autentifikacijom u gitlabu (možete je sami dodati ako je potrebno).

Brza pretraga je dala nekoliko rezultata: s3pypi, pypicloud i opciju sa “ručnim” kreiranjem html fajlova za repu. Poslednja opcija je nestala sama od sebe.

s3pypi: Ovo je kli za korištenje S3 hostinga. Učitavamo fajlove, generišemo html i postavljamo ga u istu kantu. Pogodno za kućnu upotrebu.

pypicloud: Činilo se zanimljivim projektom, ali nakon čitanja dokumentacije bio sam razočaran. Unatoč dobroj dokumentaciji i mogućnosti proširenja prema vašim potrebama, u stvarnosti se pokazalo da je suvišan i težak za konfiguraciju. Ispravljanje koda kako bi odgovarao vašim zadacima, prema tadašnjim procjenama, trajalo bi 3-5 dana. Servisu je potrebna i baza podataka. Ostavili smo ga u slučaju da nismo našli ništa drugo.

Detaljnija pretraga je dala modul za Nginx, ngx_aws_auth. Rezultat njegovog testiranja bio je XML prikazan u pretraživaču, koji je pokazao sadržaj S3 bucketa. Posljednje izvršenje u vrijeme pretrage bilo je prije godinu dana. Skladište je izgledalo napušteno.

Odlaskom do izvora i čitanjem PEP-503 Shvatio sam da se XML može konvertovati 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-u za Nginx. Tako sam upoznao NJS.

Uzimajući ovaj primjer kao osnovu, sat kasnije vidio sam u svom pretraživaču isti XML kao kada sam koristio modul ngx_aws_auth, ali je sve već bilo napisano u JS-u.

Zaista mi se svidjelo nginx rješenje. Prvo, dobra dokumentacija i mnogo primjera, drugo, dobijamo sve dobrote Nginxa za rad sa fajlovima (iz kutije), treće, svako ko zna da piše konfiguracije za Nginx moći će da shvati šta je šta. Minimalizam je takođe plus za mene, u poređenju sa Python-om ili Go-om (ako je napisano od nule), da ne spominjem nexus.

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

Как это работает?

Modul se učitava u Nginx ngx_http_js_module, uključen u službenu sliku dockera. Uvozimo našu skriptu koristeći direktivu js_importna Nginx konfiguraciju. Funkcija se poziva putem direktive js_content. Direktiva se koristi za postavljanje varijabli js_set, koji kao argument uzima samo funkciju opisanu u skripti. Ali možemo izvršiti potupite u NJS-u 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 izvezen 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}

Kada se to zatraži u pretraživaču http://localhost:8080/ ulazimo u location /u kojoj je direktiva js_content poziva funkciju request opisano u našem scenariju script.js. Zauzvrat, u funkciji request postavlja se potupit location = /sub-query, s metodom (u trenutnom primjeru GET) dobivenom iz argumenta (r), proslijeđen implicitno kada se ova funkcija pozove. Odgovor na podzahtjev će se obraditi u funkciji call_back.

Pokušavam sa 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đeni tip stringa koji se potpisuje (HMAC_SHA1) korištenjem SECRET_KEY. Sljedeća je linija 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 status zastarjelih)

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-u, formira zahtjev za S3. Ako trebate dobiti sadržaj "root", tada morate kreirati uri zahtjev koji ukazuje na graničnik delimiter, koji će vratiti listu svih CommonPrefixes xml elemenata, koji odgovaraju direktorijumima (u slučaju PyPI, listu svih paketa). Ako trebate dobiti listu sadržaja u određenom direktoriju (lista svih verzija paketa), onda uri zahtjev mora sadržavati prefiks polje s imenom direktorija (paketa) koje se obavezno završava kosom crtom /. Inače, kolizije su moguće kada se traži sadržaj direktorija, na primjer. Postoje direktoriji aiohttp-request i aiohttp-requests i ako zahtjev specificira /?prefix=aiohttp-request, tada će odgovor sadržavati sadržaj oba direktorija. Ako postoji kosa crta na kraju, /?prefix=aiohttp-request/, tada će odgovor sadržavati samo traženi direktorij. A ako zatražimo datoteku, onda se rezultirajući uri ne bi trebao razlikovati od originalnog.

Sačuvajte i ponovo pokrenite Nginx. U pretraživač unosimo adresu našeg Nginxa, rezultat zahtjeva će biti XML, na primjer:

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

Sa liste direktorija trebat će vam samo elementi CommonPrefixes.

Dodavanjem direktorija koji nam je potreban na našu adresu u pretraživaču, također ćemo dobiti njegov sadržaj u XML obliku:

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

Sa liste fajlova ćemo uzeti samo elemente Key.

Sve što preostaje je da raščlanite rezultirajući XML i pošaljete ga kao HTML, nakon što ste prvo zamijenili zaglavlje Content-Type sa 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="/bs/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Probam PyPI

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

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

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

Autentifikacija

U Gitlabu je moguće koristiti JWT za autentifikaciju/autorizaciju eksternih servisa. Koristeći auth_request direktivu u Nginxu, preusmjerit ćemo podatke o autentifikaciji na podzahtjev koji sadrži poziv funkcije u skripti. Skripta će napraviti još jedan podzahtjev za Gitlab url i ako su podaci za autentifikaciju ispravno navedeni, Gitlab će vratiti kod 200 i upload/download paketa će biti dozvoljen. Zašto ne koristite jedan podupit i odmah pošaljete podatke u Gitlab? Jer tada ćemo morati uređivati ​​Nginx konfiguracijsku datoteku svaki put kada napravimo bilo kakve promjene u autorizaciji, a to je prilično zamoran zadatak. Takođe, ako Kubernetes koristi politiku osnovnog sistema datoteka samo za čitanje, to dodaje još više složenosti prilikom zamjene nginx.conf putem configmap-a. I postaje apsolutno nemoguće konfigurirati Nginx putem configmap-a dok istovremeno koristite politike koje zabranjuju povezivanje volumena (pvc) i root fajl sistema samo za čitanje (ovo se takođe dešava).

Koristeći NJS intermediate, dobijamo priliku da promijenimo specificirane parametre u nginx konfiguraciji koristeći varijable okruženja i izvršimo neke provjere u skripti (na primjer, neispravno specificiran 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}

Najvjerovatnije se postavlja pitanje: -Zašto ne koristiti gotove module? Tamo je sve već urađeno! Na primjer, var AWS = require('aws-sdk') i nema potrebe za pisanjem "bicikla" sa S3 autentifikacijom!

Pređimo na nedostatke

Za mene je nemogućnost uvoza eksternih JS modula postala neugodna, ali očekivana karakteristika. Opisano u gornjem primjeru require('crypto') je ugrađeni moduli i zahtijevaju samo radove za njih. Takođe ne postoji način da ponovo koristite kod 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 projekat u Nginxu gzip off;

Budući da u NJS-u nema gzip modula i nemoguće ga je povezati, stoga ne postoji način rada sa 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ćen ili kritičan servis da biste se morali mučiti s isporukom sadržaja nekoliko milisekundi brže.

Otklanjanje grešaka u skripti traje dugo i moguće je samo putem “ispisa” u error.log. U zavisnosti od postavljenih informacija nivoa evidencije, upozorenja ili greške, moguće je koristiti 3 metode r.log, r.warn, r.error. Pokušavam da otklonim greške u nekim skriptama u Chrome-u (v8) ili njs konzolnom alatu, ali ne može se sve provjeriti tamo. Prilikom otklanjanja grešaka koda, odnosno funkcionalnog testiranja, historija izgleda otprilike ovako:

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

i može postojati stotine takvih sekvenci.

Pisanje koda pomoću potupita i varijabli za njih pretvara se u zamršenu petlju. Ponekad počnete da jurite oko različitih IDE prozora pokušavajući da shvatite redosled akcija vašeg koda. Nije teško, ali ponekad je jako neugodno.

Ne postoji puna podrška za ES6.

Možda ima još nekih nedostataka, ali nisam naišao ni na šta drugo. Podijelite informacije ako imate negativno iskustvo korištenja NJS-a.

zaključak

NJS je lagani tumač otvorenog koda koji vam omogućava da implementirate različite JavaScript skripte u Nginx. Prilikom njegovog razvoja velika pažnja je posvećena performansama. Naravno, još mnogo toga nedostaje, ali projekat razvija mali tim i oni aktivno dodaju nove funkcije i ispravljaju greške. Nadam se da će vam NJS jednog dana omogućiti povezivanje eksternih modula, što će Nginx funkcionalnost učiniti gotovo neograničenom. Ali postoji NGINX Plus i najvjerovatnije neće biti funkcija!

Repozitorijum sa punim kodom za članak

njs-pypi sa podrškom za AWS Sign v4

Opis direktiva modula ngx_http_js_module

Zvanični NJS repozitorij и dokumentaciju

Primjeri korištenja NJS-a od Dmitrija Volintseva

njs - izvorni JavaScript skript u nginxu / Govor Dmitrija Volnjeva na Saint HighLoad++ 2019

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

Potpisivanje i provjera autentičnosti REST zahtjeva u AWS-u

izvor: www.habr.com