Am creat un depozit PyPI cu autentificare și S3. Pe Nginx

În acest articol vreau să împărtășesc experiența mea cu NJS, interpretatorul JavaScript pentru Nginx dezvoltat de compania Nginx Inc, descriind printr-un exemplu real principalele sale capabilități. NJS este un subset al limbajului de programare JavaScript care permite extinderea funcționalității Nginx. La întrebarea de ce un interpret propriu??? a răspuns în detaliu Dmitri Volîntsev. Pe scurt: NJS este metoda nginx, iar JavaScript este mai avansat, „nativ” și fără GC, spre deosebire de Lua.

Cu mult timp în urmă...

La fostul loc de muncă, am moștenit un gitlab cu un anumit număr de pipeline-uri CI/CD diverse cu docker-compose, dind și alte minunății, care au fost traduse pe căile kaniko. Imaginile care au fost folosite anterior în CI au fost mutate în forma lor originală. Au funcționat corect până în ziua în care gitlab-ul nostru și-a schimbat IP-ul și CI-ul s-a transformat într-o dovleacă. Problema era că într-unul dintre imaginile docker, implicate în CI, era git, care prin ssh descărca module Python. Pentru ssh este necesar o cheie privată și… aceasta era în imagine împreună cu known_hosts. Și orice CI se încheia cu o eroare de verificare a cheii din cauza neconcordanței dintre IP-ul real și cel specificat în known_hosts. Din Dockfile-urile existente a fost creat rapid o nouă imagine și a fost adăugată opțiunea StrictHostKeyChecking no. Dar a rămas un gust neplăcut și a apărut dorința de a muta bibliotecile într-un repository privat PyPI. Un bonus suplimentar, după trecerea la PyPI privat, a fost pipeline-ul mai simplu și o descriere normală a requirements.txt.

Alegerea a fost făcută, domnilor!

Facem totul în nori și Kubernetes, iar în cele din urmă ne-am dorit un mic serviciu care să reprezinte un container stateless cu un stoc extern. Așadar, având în vedere că folosim S3, prioritatea a fost dată acestuia. Și, dacă este posibil, cu autentificare în gitlab (se poate adăuga și de unul singur, dacă este necesar).

O căutare rapidă a dat câteva rezultate s3pypi, pypicloud și o variantă cu crearea „manuală” a fișierelor html pentru repo. Ultima variantă s-a eliminat de la sine.

s3pypi: Acesta este un cli pentru utilizarea hostingului pe S3. Încărcăm fișiere, generăm html și încărcăm în același bucket. Este potrivit pentru utilizare acasă.

pypicloud: Părea un proiect interesant, dar după ce am citit documentația, am fost dezamăgit. În ciuda documentației bune și a opțiunilor de extindere pentru nevoile mele, s-a dovedit a fi excesiv și complicat de configurat. Ajustarea codului pentru nevoile mele ar fi necesitat, conform estimărilor de atunci, 3-5 zile. De asemenea, serviciul necesită o bază de date. L-am lăsat pe cazul în care nu găsim nimic altceva.

O căutare mai aprofundată a dus la un modul pentru Nginx, ngx_aws_auth. Rezultatul testării sale a fost un XML afisat în browser, din care se putea observa conținutul bucket-ului S3. Ultima actualizare, în momentul căutării, a fost acum un an. Repozitoriul părea abandonat.

Adresându-mă sursei originale și citind PEP-503 am realizat că XML-ul poate fi convertit în HTML în timp real și livrat prin pip. După o mică căutare cu cuvintele Nginx și S3, am dat peste un exemplu de autentificare în S3 scris în JS pentru Nginx. Așa am descoperit NJS.

Luând ca bază acest exemplu, după o oră am observat în browserul meu același XML ca și în cazul modulului ngx_aws_auth, dar totul era deja scris în JS.

Soluția pe nginx mi-a plăcut foarte mult. În primul rând, documentație bună și multe exemple, în al doilea rând, obținem toate avantajele Nginx în lucrul cu fișiere (din cutie), în al treilea rând, oricine știe să scrie configurații pentru Nginx, va putea să înțeleagă ce și cum. De asemenea, un alt avantaj pentru mine este minimalismul, în comparație cu Python sau Go (dacă scrii de la zero), să nu mai vorbim despre nexus.

TL;DR După 2 zile, versiunea de testare PyPi a fost deja utilizată în CI.

Cum funcționează?

În Nginx se încarcă modulul ngx_http_js_module, inclus în imaginea oficială Docker. Importăm scriptul nostru folosind directiva js_importîn configurația Nginx. Apelul funcției se face prin directiva js_content. Pentru stabilirea variabilelor se folosește directiva js_set, care acceptă ca argument doar funcția descrisă în script. Totuși, pentru a efectua sub-cereri în NJS putem face asta doar prin Nginx, nu există XMLHttpRequest acolo. Pentru aceasta, în configurația Nginx trebuie adăugat un locație corespunzător. Iar în script trebuie descrisă o sub-cerere (subrequest) către această locație. Pentru a avea posibilitatea de a accesa funcția din configurația Nginx, în scriptul propriu-zis numele funcției trebuie exportat. 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}

Când se face o solicitare în browser http://localhost:8080/ ajungem la location /în care directiva js_content apelează funcția ) cu datele. Iată linia pe care o puteți insera pur și simplu în câmpul de adresă al browser-ului și apoi să actualizați pagina (asta trebuie făcut o singură dată): este descrisă în scriptul nostru script.js. La rândul său, în funcție ) cu datele. Iată linia pe care o puteți insera pur și simplu în câmpul de adresă al browser-ului și apoi să actualizați pagina (asta trebuie făcut o singură dată): se realizează o subsolicitare către location = /sub-query, cu metoda (în exemplul curent GET) obținută din argumentul (r), transmisă implicit la apelarea acestei funcții. Procesarea răspunsului subsolicitării va fi realizată în funcția call_back.

Încercăm S3

Pentru a face o solicitare către un depozit S3 privat, avem nevoie de:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Din metoda HTTP utilizată, data/oră curentă, S3_NAME și URI se generează un șir de un anumit tip care este semnat (HMAC_SHA1) folosind SECRET_KEY. Apoi, șirul, de tipul AWS $ACCESS_KEY:$HASH, poate fi folosit în header-ul de autorizare. Aceeași dată/oră care a fost utilizată pentru a genera șirul în pasul anterior trebuie adăugată în header-ul X-amz-date. În cod, arată așa:

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(exemplu de autorizare AWS Sign v2, este acum depreciat)

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}

Puțin despre _subrequest_uri: acesta este o variabilă care, în funcție de URI-ul inițial, formează solicitarea către S3. Dacă dorim să obținem conținutul „rădăcinii”, în acest caz trebuie să formăm un URI de solicitare specificând delimiter-ul delimiter, care va returna o listă a tuturor elementelor xml-CommonPrefixes, care corespund directorilor (în cazul PyPI, lista tuturor pachetelor). Dacă dorim să obținem o listă de conținut într-un anumit director (lista tuturor versiunilor pachetelor), atunci URI-ul de solicitare trebuie să conțină câmpul prefix cu numele directorului (pachetului) care se termină neapărat cu un slash /. În caz contrar, pot apărea coliziuni la solicitarea conținutului unui director, de exemplu. Există directoare aiohttp-request și aiohttp-requests și dacă în solicitare se va specifica /?prefix=aiohttp-request, atunci răspunsul va conține conținutul ambelor directoare. Dacă în schimb va fi un slash la final, /?prefix=aiohttp-request/, în răspuns va fi doar directorul necesar. Și dacă solicităm un fișier, URI-ul rezultat nu trebuie să difere de cel inițial.

Salvăm, repornim Nginx. Introducem în browser adresa Nginx-ului nostru, iar rezultatul cererii va fi XML, de exemplu:

Lista directorilor

myback-space
  
  
  10000
  /
  false
  
    new/
  
  
    old/

Din lista directorilor, vor fi necesare doar elementele CommonPrefixes.

Adăugând, în browser, directorul necesar la adresa noastră, vom obține conținutul său tot în format XML:

Lista fișierelor din director

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

Din lista fișierelor, vom lua doar elementele Cheie.

Rămâne să parsăm XML-ul obținut și să-l returnăm sub formă de HTML, în prealabil schimbând antetul Content-Type în 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>'
}

Încercăm PyPI

Verificăm că nimic nu se strică în pachetele deja funcționale.

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

Repetăm cu bibliotecile noastre.

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

În CI, crearea și încărcarea pachetului arată astfel:

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

Autentificare

În Gitlab este posibil să utilizați JWT pentru autentificarea/autorizarea serviciilor externe. Folosind directiva auth_request în Nginx, vom redirecționa datele de autentificare în subcereri care conțin apeluri ale funcțiilor din script. În script va fi efectuată o altă subcerere pe URL-ul Gitlab-ului și, dacă datele de autentificare au fost corecte, Gitlab va returna codul 200 și va permite descărcarea/pachetului. De ce să nu folosim o subcerere și să nu trimitem imediat datele în Gitlab? Din cauza că va trebui să modificăm fișierul de configurare Nginx de fiecare dată când avem modificări în autorizare, iar acest lucru este destul de complicat. De asemenea, dacă în Kubernetes se utilizează o politică de filesystem root de tip read-only, aceasta adaugă și mai multe complicări în ceea ce privește înlocuirea nginx.conf prin configmap. Și devine complet imposibilă configurarea Nginx prin configmap, în timp ce utilizăm simultan politici care interzic conectarea volumelor (pvc) și filesystem root de tip read-only (aceasta se poate întâmpla).

Folosind NJS ca legătură intermediară, obținem posibilitatea de a modifica parametrii specificați în configurația nginx folosind variabile de mediu și de a efectua diverse verificări în script (de exemplu, o adresă URL incorectă).

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 ~ "^/(?[w-]*)[/?]?(?[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}

Cel mai probabil apare întrebarea: - De ce să nu folosim modulele gata făcute? Acolo totul este deja realizat! De exemplu, var AWS = require('aws-sdk') și nu trebuie să reinventăm roata cu autentificarea S3!

Să trecem la dezavantaje

Pentru mine, imposibilitatea de a importa module JS externe a fost o caracteristică neplăcută, dar așteptată. Require(‘crypto’) descris în exemplul de mai sus este module încorporate și require funcționează doar pentru ele. De asemenea, nu există posibilitatea de a reutiliza codul din scripturi, așa că trebuie să-l copiez și să-l inserez în diverse fișiere. Sper că, într-o bună zi, această funcționalitate va fi implementată.

De asemenea, pentru proiectul actual în Nginx, compresia trebuie dezactivată gzip off;

Pentru că nu există modul gzip în NJS și nu poate fi conectat, deci nu există posibilitatea de a lucra cu date comprimate. Totuși, nu este un dezavantaj major pentru acest caz. Textul nu este mult, iar fișierele transmise sunt deja comprimate și o compresie suplimentară nu va ajuta prea mult. De asemenea, acesta nu este un serviciu atât de încărcat sau critic încât să ne îngrijorăm în legătură cu livrarea conținutului cu câteva milisecunde mai repede.

Debugging-ul scriptului este lung și se poate face doar prin „printuri” în error.log. În funcție de nivelul de logging setat, info, warn sau error, este posibil să folosești cele 3 metode r.log, r.warn, r.error. Încerc să deboghez unele scripturi în Chrome (v8) sau în unealta de consolă njs, dar nu totul poate fi verificat acolo. În timpul debugging-ului codului, adică testarea funcțională, istoricul arată cam așa:

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

și astfel de secvențe pot fi sute.

Scrierea codului folosind subinterogări și variabile pentru acestea devine un ghem complicat. Uneori încep să mă mișc între diferite feronerie IDE încercând să înțeleg secvența acțiunilor din codul meu. Nu este greu, dar uneori poate fi stresant.

Nu există suport complet pentru ES6.

Poate că există și alte deficiențe, dar nu am întâmpinat nimic altceva. Vă rog să împărtășiți informații dacă aveți experiențe negative cu NJS.

Concluzie

NJS este un interpret open-source ușor, care permite implementarea diferitelor scenarii în Nginx folosind limbajul JavaScript. În dezvoltarea sa, s-a pus accent pe performanță. Desigur, îi lipsesc multe caracteristici, dar proiectul evoluează cu ajutorul unei echipe mici și ei adaugă activ noi funcții și remediază bug-uri. Sper că, într-o zi, NJS va permite conectarea modulelor externe, ceea ce va face funcționalitatea Nginx practic nelimitată. Dar există NGINX Plus și probabil că nu vor fi anumite caracteristici!

Repository cu codul complet al articolului

njs-pypi cu suport pentru AWS Sign v4

Descrierea directivelor modulului ngx_http_js_module

Repository-ul oficial NJS și documentația

Exemple de utilizare a NJS de la Dmitri Volynțev

njs — scripting JavaScript nativ în nginx / Выступление Дмитрия Волныева на Saint HighLoad++ 2019

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

Semnătura și autentificarea cererilor REST în AWS

Sursa: habr.com

Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS 🔥 Cumpără un hosting fiabil pentru site-uri cu protecție DDoS, servere VPS VDS | ProHoster