Naredil sem svoje PyPI repozitorij z avtorizacijo in S3. Na Nginxu

V tem članku bi rad delil svojo izkušnjo z NJS, tolmačem JavaScript za Nginx, ki ga je razvilo podjetje Nginx Inc, in opisal njegove glavne zmogljivosti na resničnem primeru. NJS je podnabor JavaScripta, ki vam omogoča razširitev funkcionalnosti Nginx. Na vprašanje zakaj svoj tolmač??? Dmitry Volyntsev je podrobno odgovoril. Na kratko: NJS je nginx-way, JavaScript pa je bolj napreden, "native" in brez GC, za razliko od Lua.

Dolgo časa nazaj…

Na zadnjem delovnem mestu sem podedoval gitlab s številnimi pestrimi CI/CD cevovodi z docker-compose, dind in drugimi dobrotami, ki so bile prenesene na kaniko raile. Slike, ki so bile prej uporabljene v CI, so bile premaknjene v izvirni obliki. Delovali so pravilno do dneva, ko se je naš gitlab IP spremenil in se je CI spremenil v bučo. Težava je bila v tem, da je ena od slik dockerja, ki je sodelovala pri CI, imela git, ki je potegnil module Python prek ssh. Za ssh potrebujete zasebni ključ in ... bil je na sliki skupaj z unknown_hosts. In kateri koli CI ni uspel z napako pri preverjanju ključa zaradi neujemanja med dejanskim IP in tistim, navedenim v unknown_hosts. Iz obstoječih Dockfiles je bila hitro sestavljena nova slika in dodana je bila možnost StrictHostKeyChecking no. Toda slab okus je ostal in pojavila se je želja, da bi libs premaknili v zasebni repozitorij PyPI. Dodaten bonus, po prehodu na zasebni PyPI, je bil preprostejši cevovod in normalen opis requirements.txt

Odločitev je bila narejena, gospodje!

Vse izvajamo v oblakih in Kubernetesu, na koncu pa smo želeli dobiti majhno storitev, ki je bila vsebnik brez stanja z zunanjim pomnilnikom. No, ker uporabljamo S3, smo mu dali prednost. In, če je mogoče, z avtentikacijo v gitlabu (po potrebi ga lahko dodate sami).

Hitro iskanje je dalo več rezultatov: s3pypi, pypicloud in možnost z “ročnim” ustvarjanjem html datotek za repo. Zadnja možnost je izginila sama od sebe.

s3pypi: To je cli za uporabo gostovanja S3. Naložimo datoteke, ustvarimo html in ga naložimo v isto vedro. Primeren za domačo uporabo.

pypicloud: Zdel se je zanimiv projekt, a sem bil po branju dokumentacije razočaran. Kljub dobri dokumentaciji in možnosti razširitve glede na vaše potrebe, se je v resnici izkazalo za odveč in težko za konfiguracijo. Popravljanje kode, da bi ustrezala vašim nalogam, bi po takratnih ocenah trajalo 3-5 dni. Storitev potrebuje tudi bazo podatkov. Pustili smo ga, če ne bi našli česa drugega.

Bolj poglobljeno iskanje je prineslo modul za Nginx, ngx_aws_auth. Rezultat njegovega testiranja je bil XML, prikazan v brskalniku, ki je pokazal vsebino vedra S3. Zadnja potrditev v času iskanja je bila pred enim letom. Skladišče je bilo videti zapuščeno.

Z odhodom do vira in branjem PEP-503 Spoznal sem, da je mogoče XML sproti pretvoriti v HTML in dati v pip. Ko sem še malo poguglal ​​o Nginxu in S3, sem naletel na primer avtentikacije v S3, napisan v JS za Nginx. Tako sem spoznal NJS.

Če vzamem ta primer za osnovo, sem uro kasneje v brskalniku videl enak XML kot pri uporabi modula ngx_aws_auth, vendar je bilo vse že napisano v JS.

Res mi je bila všeč rešitev nginx. Prvič, dobra dokumentacija in veliko primerov, drugič, dobimo vse dobrote Nginxa za delo z datotekami (izven škatle), tretjič, kdor zna pisati konfiguracije za Nginx, bo lahko ugotovil, kaj je kaj. Minimalizem mi je tudi plus, v primerjavi s Pythonom ali Go (če je napisan iz nule), da o nexusu sploh ne govorim.

TL;DR Po 2 dneh je bila testna različica PyPi že uporabljena v CI.

Kako deluje?

Modul se naloži v Nginx ngx_http_js_module, vključeno v uradno sliko dockerja. Naš skript uvozimo z uporabo direktive js_importna konfiguracijo Nginx. Funkcijo pokliče direktiva js_content. Direktiva se uporablja za nastavitev spremenljivk js_set, ki kot argument sprejme samo funkcijo, opisano v skriptu. Vendar lahko izvajamo podpoizvedbe v NJS samo z uporabo Nginxa, ne pa s kakršno koli XMLHttpRequest. Če želite to narediti, je treba ustrezno lokacijo dodati konfiguraciji Nginx. In skript mora opisati podpovpraševanje za to lokacijo. Za dostop do funkcije iz konfiguracije Nginx je treba ime funkcije izvoziti v samem 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 zahtevo v brskalniku http://localhost:8080/ pridemo v location /v kateri je direktiva js_content pokliče funkcijo request opisano v našem scenariju script.js. Po drugi strani pa v funkciji request se izvede podpoizvedba location = /sub-query, z metodo (v trenutnem primeru GET), pridobljeno iz argumenta (r), posredovan implicitno ob klicu te funkcije. Odgovor na podpovpraševanje bo obdelan v funkciji call_back.

Poskus S3

Za zahtevo po zasebni shrambi S3 potrebujemo:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Iz uporabljene http metode, trenutnega datuma/časa, S3_NAME in URI se generira določen tip niza, ki je podpisan (HMAC_SHA1) s SECRET_KEY. Naslednja je vrstica, podobna AWS $ACCESS_KEY:$HASH, lahko uporabite v avtorizacijski glavi. Isti datum/čas, ki je bil uporabljen za ustvarjanje niza v prejšnjem koraku, je treba dodati v glavo X-amz-date. V kodi je videti takole:

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(Primer avtorizacije AWS Sign v2, spremenjeno v zastarelo stanje)

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}

Majhna razlaga o _subrequest_uri: to je spremenljivka, ki glede na začetni uri oblikuje zahtevo za S3. Če želite pridobiti vsebino »root«, potem morate ustvariti zahtevo uri, ki označuje ločilo delimiter, ki bo vrnil seznam vseh xml elementov CommonPrefixes, ki ustrezajo imenikom (v primeru PyPI seznam vseh paketov). Če potrebujete seznam vsebine v določenem imeniku (seznam vseh različic paketov), ​​mora zahteva uri vsebovati polje s predpono z imenom imenika (paketa), ki se nujno konča s poševnico /. V nasprotnem primeru so na primer možna kolizija pri zahtevi po vsebini imenika. Obstajata imenika aiohttp-request in aiohttp-requests in če zahteva določa /?prefix=aiohttp-request, potem bo odgovor vseboval vsebino obeh imenikov. Če je na koncu poševnica, /?prefix=aiohttp-request/, bo odgovor vseboval samo zahtevani imenik. In če zahtevamo datoteko, se dobljeni uri ne sme razlikovati od prvotnega.

Shranite in znova zaženite Nginx. V brskalnik vnesemo naslov našega Nginxa, rezultat zahteve bo XML, na primer:

Seznam imenikov

<?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 seznama imenikov boste potrebovali samo elemente CommonPrefixes.

Z dodajanjem imenika, ki ga potrebujemo, k našemu naslovu v brskalniku, bomo prejeli tudi njegovo vsebino v obliki XML:

Seznam datotek v imeniku

<?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 seznama datotek bomo vzeli samo elemente Key.

Vse, kar ostane, je, da razčlenimo nastali XML in ga pošljemo kot HTML, pri čemer najprej zamenjamo glavo Content-Type z 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="/sl/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Preizkušam PyPI

Preverjamo, da se na paketih, za katere vemo, da delujejo, nikjer nič ne pokvari.

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

# Создаем для тестов новое окружение
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 ustvarjanje in nalaganje paketa izgleda takole:

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

Preverjanje pristnosti

V Gitlabu je mogoče uporabiti JWT za avtentikacijo/avtorizacijo zunanjih storitev. Z uporabo direktive auth_request v Nginxu bomo podatke za preverjanje pristnosti preusmerili na podzahtevo, ki vsebuje klic funkcije v skriptu. Skript bo naredil še eno podzahtevo na Gitlab url in če so bili podatki za preverjanje pristnosti podani pravilno, bo Gitlab vrnil kodo 200 in nalaganje/prenos paketa bo dovoljen. Zakaj ne bi uporabili ene podpoizvedbe in takoj poslali podatkov v Gitlab? Ker potem bomo morali urejati konfiguracijsko datoteko Nginx vsakič, ko naredimo kakršno koli spremembo avtorizacije, kar je precej dolgočasno opravilo. Poleg tega, če Kubernetes uporablja pravilnik korenskega datotečnega sistema samo za branje, je to še bolj zapleteno pri zamenjavi nginx.conf prek configmap. In postane absolutno nemogoče konfigurirati Nginx prek configmap ob hkratni uporabi pravilnikov, ki prepovedujejo povezovanje nosilcev (pvc) in korenskega datotečnega sistema samo za branje (tudi to se zgodi).

Z uporabo vmesnega vmesnika NJS dobimo možnost spreminjanja navedenih parametrov v konfiguraciji nginx z uporabo spremenljivk okolja in izvajanja nekaterih preverjanj v skriptu (na primer nepravilno podanega URL-ja).

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}

Najverjetneje se postavlja vprašanje: -Zakaj ne bi uporabili že pripravljenih modulov? Tam je že vse narejeno! Na primer, var AWS = require('aws-sdk') in ni potrebe po pisanju »kolesa« s preverjanjem pristnosti S3!

Pojdimo k slabostim

Zame je nezmožnost uvoza zunanjih modulov JS postala neprijetna, a pričakovana lastnost. Opisano v zgornjem primeru require('crypto') je vgradni moduli in zahtevajo samo dela zanje. Prav tako ni mogoče znova uporabiti kode iz skriptov in jo morate kopirati in prilepiti v različne datoteke. Upam, da bo nekoč ta funkcionalnost implementirana.

Stiskanje mora biti tudi onemogočeno za trenutni projekt v Nginxu gzip off;

Ker v NJS ni modula gzip in ga je nemogoče povezati, zato ni možnosti za delo s stisnjenimi podatki. Res je, da to ni ravno minus za ta primer. Besedila ni veliko, prenesene datoteke pa so že stisnjene in jim dodatno stiskanje ne bo kaj dosti pomagalo. Poleg tega to ni tako obremenjena ali kritična storitev, da bi se morali mučiti z nekaj milisekund hitrejšim pošiljanjem vsebine.

Razhroščevanje skripta traja dolgo in je možno le prek »izpisov« v error.log. Odvisno od nastavljene informacije o ravni beleženja, opozorilo ali napaka, je mogoče uporabiti 3 metode r.log, r.warn, r.error. Poskušam odpraviti napake v nekaterih skriptih v Chromu (v8) ali konzolnem orodju njs, vendar tam ni mogoče preveriti vsega. Pri odpravljanju napak v kodi, imenovanem tudi funkcionalno testiranje, je zgodovina videti nekako takole:

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

in takih zaporedij je lahko na stotine.

Pisanje kode z uporabo podpoizvedb in spremenljivk zanje se spremeni v zamotan klobčič. Včasih začnete hiteti po različnih oknih IDE in poskušati ugotoviti zaporedje dejanj vaše kode. Ni težko, je pa včasih zelo nadležno.

Za ES6 ni popolne podpore.

Morda so še kakšne druge pomanjkljivosti, vendar nisem naletel na nič drugega. Delite informacije, če imate negativne izkušnje z uporabo NJS.

Zaključek

NJS je lahek odprtokodni tolmač, ki vam omogoča implementacijo različnih skriptov JavaScript v Nginx. Pri razvoju je bila velika pozornost namenjena zmogljivosti. Seveda še veliko manjka, vendar projekt razvija majhna ekipa in aktivno dodaja nove funkcije in odpravlja napake. Upam, da vam bo nekoč NJS omogočil povezovanje zunanjih modulov, s čimer bo funkcionalnost Nginx skoraj neomejena. Vendar obstaja NGINX Plus in najverjetneje ne bo nobenih funkcij!

Repozitorij s celotno kodo za članek

njs-pypi s podporo za AWS Sign v4

Opis direktiv modula ngx_http_js_module

Uradno skladišče NJS и dokumentacijo

Primeri uporabe NJS Dmitrija Volinceva

njs - izvorno skriptiranje JavaScript v nginx / Govor Dmitrija Volnjeva na Saint HighLoad++ 2019

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

Podpisovanje in preverjanje pristnosti zahtev REST v AWS

Vir: www.habr.com