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

U ovom članku želim podijeliti iskustvo rada s NJS-om, JavaScript interpreterom za Nginx koji razvija tvrtka Nginx Inc., opisujući njegove osnovne mogućnosti na stvarnom primjeru. NJS je podskup programskog jezika JavaScript koji omogućava proširenje funkcionalnosti Nginx-a. Na pitanje zašto vlastiti interpreter??? detaljno je odgovorio Dmitrij Volincev. U kratkim crtama: NJS je nginx-put, dok je JavaScript progresivniji, 'izvorni' i bez GC-a u usporedbi s Luom.

Prije mnogo vremena…

Na prethodnom poslu naslijedio sam gitlab s određenim brojem raznolikih CI/CD pipeline-ova s docker-compose, dindom i drugim čarima, koji su prebačeni na kaniko. Slike koje su se koristile ranije u CI, ostale su u svom izvorom obliku. Radili su bez greške sve do dana kada je našem gitlabu promijenjena IP adresa, a CI je postao neupotrebljiv. Problem je bio u tome što je u jednom od docker slika, koji je sudjelovao u CI, bio git koji je putem ssh preuzimao Python module. Za ssh potreban je privatni ključ i… on je bio u slici zajedno s known_hosts. I svaki CI završavao je greškom provjere ključa zbog neslaganja stvarne IP adrese i one navedene u known_hosts. Od dostupnih Dockfile-ova brzo je izrađena nova slika i dodana opcija StrictHostKeyChecking no. No, ostao je neugodan osjećaj i pojavila se želja da se biblioteke premjeste u privatni PyPI repozitorij. Dodatni bonus, nakon prelaska na privatni PyPI, bio je jednostavniji pipeline i normalan opis requirements.txt

Odabir je napravljen, gospodo!

Sve vrtimo u oblacima i Kubernetesu i na kraju smo željeli dobiti mali servis koji predstavlja stateless kontejner s vanjskim spremištem. Kako koristimo S3, prioritet je bio na njemu. A po mogućstvu s autentifikacijom u gitlabu (može se i samo dopisati prema potrebi).

Brzi pretraživanje donijelo je nekoliko rezultata s3pypi, pypicloud i opciju s 'ručnim' stvaranjem html datoteka za repozitorij. Potonja opcija sama je otpala.

s3pypi: Ovo je cli za korištenje hostinga na S3. Postavljamo datoteke, generiramo html i prenesemo u isti bucket. Za kućnu upotrebu odgovara.

Pypicloud: Činio se kao zanimljiv projekt, ali nakon što sam pročitao dokumentaciju, došao sam do razočaranja. Unatoč dobroj dokumentaciji i mogućnostima prilagodbe za vlastite potrebe, u praksi se pokazao kao suvišan i kompliciran za postavljanje. Uređivanje koda za vlastite potrebe, prema tadašnjim procjenama, trajalo bi 3-5 dana. Također, servisu je potrebna baza podataka. Ostavili smo ga za slučaj da ne nađemo ništa bolje.

Dublje pretraživanje dovelo je do modula za Nginx, ngx_aws_auth. Rezultat njegovog testiranja bio je XML prikazan u pregledniku, iz kojeg se moglo vidjeti sadržaj S3 spremnika. Posljednji commit, u trenutku pretraživanja, bio je prije godinu dana. Repozitorij je izgledao zapušteno.

Obraćajući se prvotnom izvoru i čitajući PEP-503 shvatio sam da se XML može pretvoriti u HTML u hodu i dostaviti ga pip. Nakon malo pretraživanja s ključnim riječima Nginx i S3, naišao sam na primjer autentifikacije u S3 napisan na JS za Nginx. Tako sam se upoznao s NJS.

Uzevši ovaj primjer kao osnovu, za sat vremena sam u svom pregledniku promatrao isti XML koji i kada se koristi modul ngx_aws_auth, ali sve je već bilo napisano na JS.

Rješenje na Nginxu mi se jako svidjelo. Prvo, dobra dokumentacija i mnoštvo primjera, drugo, dobivamo sve prednosti Nginxa za rad s datotekama (iz kutije), i treće, svatko tko zna pisati konfiguracije za Nginx, moći će razumjeti što je što. Također, plus za mene je minimalizam, u usporedbi s Python ili Go (ako se piše od nule), da ne govorimo o nexus.

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

Kako to radi?

U Nginxu se učitava modul ngx_http_js_module, uključen u službenu docker sliku. Uvozimo naš skript koristeći direktivu js_importu konfiguraciju Nginxa. Poziv funkcije ostvaruje se direktivom js_content. Za postavljanje varijabli koristi se direktiva js_set, koja kao argument prihvaća samo funkciju opisanu u skriptu. Međutim, izvođenje podupita u NJS možemo ostvariti samo uz pomoć Nginxa, nikakvog XMLHTTPRequesta. Za to, u konfiguraciju Nginxa mora biti dodana odgovarajuća lokacija. A u skripti mora biti opisan podupit (subrequest) toj lokaciji. Da bismo imali mogućnost pristupiti funkciji iz Nginx konfiguracije, u samom skriptu ime funkcije je potrebno eksportirati 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 zatraži u pregledniku http://localhost:8080/ dolazimo do location /u kojem je direktiva js_content poziva funkciju request opisana u našem skriptu script.js. S druge strane, u funkciji request izvodi se podupit na location = /sub-query, s metodom (u trenutnom primjeru GET) dobivenim iz argumenta (r), neizravno proslijeđenim pri pozivu ove funkcije. Obrada odgovora podupita odvija se u funkciji call_back.

Probamo S3

Da bismo napravili zahtjev privatnom S3 skladištu, potrebni su nam:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Iz korištenog http metode, trenutačni datum/vrijeme, S3_NAME i URI generira se određena vrsta niza, koji se potpisuje (HMAC_SHA1) koristeći SECRET_KEY. Zatim, niz, tipa AWS $ACCESS_KEY:$HASH, može se koristiti u autorizacijskom zaglavlju. Isti datum/vrijeme, koji je korišten za generiranje niza u prethodnom koraku, mora biti dodan 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/(?.*) {
    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(primjer autorizacije AWS Sign v2, prebačen u status 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}

Malo objašnjenje o _subrequest_uri: to je varijabla koja, ovisno o izvornoj uri, oblikuje zahtjev za S3. Ako je potrebno dobiti sadržaj 'korijena', u tom slučaju je potrebno oblikovati uri-zahtjev s navedenim razdjelnikom delimiter, koji će vratiti popis svih xml elemenata CommonPrefixes, što odgovara direktorijima (u slučaju s PyPI, popis svih paketa). Ako treba dobiti popis sadržaja u određenom direktoriju (popis svih verzija paketa), tada uri-zahtjev mora sadržavati polje prefix s imenom direktorija (paketa) koje se obavezno završava na kosu crtu \. U suprotnom, mogući su sukobi prilikom zahtjeva za sadržaj direktorija, na primjer. Postoje direktoriji aiohttp-request i aiohttp-requests i ako u zahtjevu bude navedeno /?prefix=aiohttp-request, tada će u odgovoru biti sadržaj obaju direktorija. Ako na kraju bude kosa crta, /?prefix=aiohttp-request/, odgovor će sadržavati samo potrebnu direktoriju. Ako zatražimo datoteku, rezultat URI ne smije se razlikovati od izvorne.

Spremamo, ponovno pokrećemo Nginx. U pregledniku upisujemo adresu našeg Nginx, a rezultat zahtjeva bit će XML, na primjer:

Popis direktorija

myback-space
  
  
  10000
  /
  false
  
    new/
  
  
    old/

Iz popisa direktorija potrebni su samo elementi CommonPrefixes.

Dodajući u preglednik potrebnu direktoriju, dobit ćemo njezin sadržaj također u obliku XML-a:

Popis datoteka u direktoriju

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

Iz popisa datoteka uzimamo samo elemente Ključ.

Preostaje analizirati dobiveni XML i prikazati ga u obliku HTML-a, unaprijed zamjenjujući zaglavlje Content-Type s 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>'
}

Pokušavamo PyPI

Provjeravamo da li igdje i ništa ne prestaje raditi s unaprijed provjerenim paketima.

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

Ponovno radimo s našim knjižnicama.

# Создаем для тестов новое окружение
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, izrada 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 Gitlab-u je moguće koristiti JWT za autentifikaciju/ovlastivanje vanjskih servisa. Korištenjem direktive auth_request u Nginx-u, preusmjeravamo autentifikacijske podatke u podupit koji sadrži poziv funkcije u skripti. U skripti će se napraviti još jedan podupit na URL Gitlab-a, a ako su autentifikacijski podaci ispravno navedeni, Gitlab će vratiti kod 200 i omogućiti učitavanje/preuzimanje paketa. Zašto ne koristiti jedan podupit i odmah ne poslati podatke u Gitlab? Zato što bismo tada morali svaki put uređivati datoteku s konfiguracijom Nginx-a kad imamo neke promjene u autorizaciji, a to je prilično zamorno. Također, ako se u Kubernetes-u koristi politika read-only root filesystem, to dodatno otežava zamjenu nginx.conf preko configmap-a. Postaje potpuno nemoguće konfigurirati Nginx putem configmap-a istovremeno koristeći politike koje onemogućavaju povezivanje volumena (pvc) i read-only root filesystem (to se također događa).

Korištenjem NJS-a kao posredničkog sloja, dobivamo mogućnost promjene navedenih parametara u konfiguraciji nginx-a putem varijabli okruženja i izvođenja nekih provjera u skripti (na primjer, neispravno navedeni 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}

Vjerojatno se postavlja pitanje: -Zašto ne koristiti gotove module? Tamo je već sve napravljeno! Na primjer, var AWS = require(‘aws-sdk’) i ne treba pisati "bicikl" s S3-autentifikacijom!

Pređimo na nedostatke

Za mene, nemogućnost uvoza vanjskih JS-modula postala je neprijatna, ali očekivana značajka. Opisani u gornjem primjeru require(‘crypto’), to je ugrađeni moduli i require radi samo za njih. Također, nema mogućnosti ponovne upotrebe koda iz skripti i mora se kopirati i umetati u različite datoteke. Nadam se da će ovaj funkcionalnost nekad biti realizirana.

Također, za trenutni projekt u Nginxu treba isključiti kompresiju gzip off;

Zato što u NJS-u nema gzip modula i nije ga moguće povezati, stoga nema mogućnosti rada sa komprimiranim podacima. Istina, to i nije baš mana za ovaj slučaj. Teksta nema puno, a prenosivi datoteke su već komprimirane i dodatna kompresija im ne bi puno pomogla. Ovo također nije toliko opterećen ili kritičan servis da bismo se brinuli o isporuci sadržaja za nekoliko milisekundi brže.

Debugiranje skripte traje dugo i moguće je samo putem "printa" u error.log. Ovisno o postavljenoj razini logiranja info, warn ili error, moguće je koristiti 3 metode r.log, r.warn, r.error. Neke skripte pokušavam debugirati u Chromeu (v8) ili u konzolnom alatu njs, ali ne može se sve provjeriti tamo. Prilikom debugiranja koda, odnosno funkcionalnog testiranja, povijest izgleda otprilike ovako:

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

i takvih sekvenci može biti stotine.

Pisanje koda s korištenjem podupita i varijabli za njih, pretvara se u zamršen splet. Ponekad se počneš kretati po raznim prozorima IDE-a pokušavajući shvatiti slijed radnji tvog koda. To nije teško, ali ponekad prilično iritira.

Nema potpune podrške za ES6.

Možda postoje i neki drugi nedostatci, ali s ničim drugim se nisam susreo. Podijelite informacije ako imate negativna iskustva s korištenjem NJS-a.

Zaključak

NJS je lagani open-source interpreter koji omogućuje implementaciju raznih scenarija u Nginxu koristeći programski jezik JavaScript. Tijekom njegove izrade posvećena je velika pažnja performansama. Naravno, mnogo toga još uvijek nedostaje, ali projekt se razvija snagom malog tima koji aktivno dodaje nove značajke i otklanja greške. Nadam se da će jednog dana NJS omogućiti povezivanje vanjskih modula, što će funkcionalnost Nginxa učiniti praktički neograničenom. No, tu je NGINX Plus i vjerojatno neće biti nekih značajki!

Repozitorij s punim kodom članka

njs-pypi s podrškom za AWS Sign v4

Opis direktiva modula ngx_http_js_module

Službeni repozitorij NJS-a i dokumentacija

Primjeri korištenja NJS-a od Dmitrija Volynceva

njs — izvorno JavaScript skriptiranje u nginxu / Выступление Дмитрия Волныева на Saint HighLoad++ 2019

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

Potpis i autentifikacija REST zahtjeva u AWS-u

Izvor: habr.com

Kupite pouzdan hosting za web stranice s DDoS zaštitom, VPS VDS servere 🔥 Kupite pouzdan hosting za web stranice s DDoS zaštitom, VPS VDS servere | ProHoster