Jeg har lavet mit eget PyPI-repository med autentificering og S3. På Nginx

I denne artikel vil jeg dele mine erfaringer med NJS, en JavaScript-fortolker til Nginx udviklet af Nginx Inc, og beskrive dets grundlæggende funktioner med et konkret eksempel. NJS er et underafsnit af programmeringssproget JavaScript, som gør det muligt at udvide funktionaliteten af Nginx. Til spørgsmålet hvorfor have sin egen fortolker??? har Dmitry Volyntsev givet et detaljeret svar. Kort sagt: NJS er nginx-way, mens JavaScript er mere progressivt, "indfødt" og uden GC i modsætning til Lua.

For længe siden…

På mit tidligere job arvede jeg et gitlab med et vis antal brogede CI/CD-pipelines med docker-compose, dind og andre skønheder, der var blevet konverteret til kaniko. De billeder, der tidligere blev brugt i CI, blev overført i deres oprindelige form. De fungerede fint, indtil vores gitlab ændrede IP, og CI blev til en græskarlampe. Problemet var, at en af docker-billederne, der deltog i CI, havde git, som via ssh trak Python-moduler. For ssh kræves en privat nøgle, og … den var i billedet sammen med known_hosts. Og enhver CI afsluttedes med en fejl ved nøglekontrollen på grund af uoverensstemmelse mellem den faktiske IP og den angivne i known_hosts. Fra de eksisterende Dockfile blev der hurtigt sammensat et nyt billede og tilføjet en mulighed StrictHostKeyChecking no. Men den ubehagelige følelse hængte stadig ved, og der opstod et ønske om at flytte bibliotekerne til et privat PyPI-repository. En ekstra bonus ved overgangen til privat PyPI blev en enklere pipeline og en normal beskrivelse af requirements.txt

Valget er truffet, mine damer og herrer!

Vi arbejder alt sammen i cloud og Kubernetes, og i sidste ende ønskede vi at få en lille tjeneste, som var en stateless-container med ekstern lager. Da vi bruger S3, var prioriteten også her. Og om muligt med autentificering i gitlab (man kan også selv skrive det, hvis nødvendigt).

En hurtig søgning gav flere resultater s3pypi, pypicloud og en mulighed med "manuelt" at skabe html-filer til repoen. Den sidste mulighed blev hurtigt udelukket.

s3pypi: Dette er cli til brug af hosting på S3. Vi uploader filer, genererer html og uploader til samme bucket. Det vil være passende til hjemmebrug.

pypicloud: Det virkede som et interessant projekt, men efter at have læst dokumentationen kom skuffelsen. På trods af den gode dokumentation og mulighederne for at tilpasse det til egne behov, viste det sig at være overflødigt og kompliceret at konfigurere. At justere koden til egne behov ville efter dengang estimater tage 3-5 dage. Derudover kræver tjenesten en database. Vi beholdt det til tilfælde af, at vi ikke finder noget andet.

En mere grundig søgning førte til en modul til Nginx, ngx_aws_auth. Resultatet af dens test var XML vist i browseren, som hvor man kunne se indholdet af S3-bucketen. Det sidste commit, på tidspunktet for søgningen, var for et år siden. Repositoryet så forladt ud.

Ved at henvende mig til kilden og at have læst PEP-503 , forstod jeg, at XML kan konverteres til HTML on-the-fly og gives til pip. Efter lidt mere googling på ordene Nginx og S3, fandt jeg et eksempel på autentifikation i S3 skrevet i JS til Nginx. Sådan blev jeg bekendt med NJS.

Ved at tage udgangspunkt i dette eksempel, kunne jeg efter en time se det samme XML i min browser, som ved brug af modulet ngx_aws_auth, men nu var det hele skrevet i JS.

Løsningen på nginx kunne jeg virkelig lide. For det første god dokumentation og mange eksempler, for det andet får vi alle Nginx' fordele ved filhåndtering (ud af boksen), og for det tredje vil enhver person, der kan skrive konfigurationer for Nginx, kunne finde ud af, hvad der sker. Minimalisme er også et plus for mig i forhold til Python eller Go (hvis man skriver fra bunden), for ikke at nævne nexus.

TL;DR: Efter 2 dage blev testversionen af PyPi allerede brugt i CI.

Hvordan fungerer det?

I Nginx bliver modulet ngx_http_js_module, inkluderet i den officielle docker-image. Vi importerer vores script ved hjælp af direktivet js_importi Nginx-konfigurationen. Funktionen kaldes ved hjælp af direktivet js_content. For at sætte variabler benyttes direktivet js_set, som kun accepterer funktionen beskrevet i scriptet som argument. Vi kan kun udføre underforespørgsler i NJS ved hjælp af Nginx; der er ingen XMLHttpRequest. For dette skal den passende location tilføjes i Nginx-konfigurationen. Scripts skal beskrive en underforespørgsel (subrequest) til denne location. For at kunne tilgå funktionen fra Nginx-konfigurationen skal navnet på funktionen eksporteres i selve scriptet. 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}

Når en forespørgsel laves i browseren http://localhost:8080/ ender vi op i location /hvor direktivet js_content kalder funktionen request beskrevet i vores script script.js. I sin tur udfører funktionen request et underopkald til location = /sub-query, med metoden (i dette eksempel GET) hentet fra argumentet (r), der implicit sendes ved kaldet af denne funktion. Behandlingen af svaret fra underopkaldet vil finde sted i funktionen call_back.

Vi prøver S3

For at lave en forespørgsel til et privat S3-lager har vi brug for:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Fra den anvendte http-metode, den aktuelle dato/tid, S3_NAME og URI genereres en streng af en bestemt type, som underskrives (HMAC_SHA1) med SECRET_KEY. Derefter kan strengen, af typen AWS $ACCESS_KEY:$HASH, bruges i autorisationshovedet. Den samme dato/tid, som blev brugt til at generere strengen i det forrige trin, skal tilføjes til hovedet X-amz-date. I koden ser det sådan ud:

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(eksempel på AWS Sign v2 autorisation, oversat til 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}

Lidt forklaring om _subrequest_uri: dette er en variabel, som afhængig af den oprindelige uri danner en forespørgsel til S3. Hvis man skal have indholdet af "roden", skal der dannes en uri-forespørgsel med angivelse af separatoren delimiter, som vil returnere en liste over alle xml-elementer CommonPrefixes, der svarer til mapperne (i tilfælde af PyPI, listen over alle pakker). Hvis du skal have en liste over indholdet i en bestemt mappe (listen over alle versionspakker), skal uri-forespørgslen indeholde feltet prefix med navnet på mappen (pakken), som bestemt skal slutte med skråstregen /. Ellers kan der opstå kollisioner ved forespørgslen af indholdet af mappen, for eksempel. Der er mapper aiohttp-request og aiohttp-requests, og hvis forespørgslen er angivet /?prefix=aiohttp-request, så vil svaret indeholde indholdet af begge mapper. Hvis der derimod er et skråstreg til sidst, /?prefix=aiohttp-request/, så vil svaret kun indeholde den relevante mappe. Og hvis vi anmoder om en fil, må den resulterende URI ikke adskille sig fra den oprindelige.

Vi gemmer, genstarter Nginx. I browseren indtaster vi adressen til vores Nginx, og resultatet af forespørgslen vil være XML, for eksempel:

Liste over mapper

myback-space
  
  
  10000
  /
  false
  
    new/
  
  
    old/

Fra liste over mapperne vil vi kun have brug for elementerne CommonPrefixes.

Ved at tilføje den ønskede mappe til vores adresse i browseren, vil vi få dens indhold også i XML-format:

Liste over filer i mappen

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

Fra liste over filer vil vi kun tage elementerne Nøgle.

Det tilbageværende XML skal parses og leveres som HTML, efter at have ændret Content-Type-headeren til 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>'
}

Vi prøver PyPI

Vi kontrollerer, at der ikke er noget, der går i stykker på kendte fungerende pakker.

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

Vi gentager med vores biblioteker.

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

I CI ser oprettelse og upload af pakken således ud:

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

Godkendelse

I Gitlab er det muligt at bruge JWT til autentificering/autorise af eksterne tjenester. Ved at bruge direktivet auth_request i Nginx videresender vi autentificeringsoplysningerne i et underanmodning, der indeholder et funktionskald i scriptet. I scriptet vil der blive foretaget endnu en underanmodning til Gitlab-URL'en, og hvis autentificeringsoplysningerne er korrekt angivet, returnerer Gitlab kode 200, og indlæsning/udlæsning af pakken vil blive tilladt. Hvorfor ikke bruge én underanmodning og straks sende oplysningerne til Gitlab? Fordi vi så ville skulle ændre Nginx-konfigurationsfilen hver gang der er ændringer i autentifikationen, og det er et ret besværligt arbejde. Desuden, hvis der i Kubernetes anvendes politikken read-only root filesystem, så tilføjer det endnu flere komplikationer ved at ændre nginx.conf via configmap. Og det bliver absolut umuligt at konfigurere Nginx via configmap samtidig med at der anvendes politikker, der forbyder tilslutning af volumer (pvc) og read-only root filesystem (det sker også).

Ved at bruge NJS som et mellemled får vi mulighed for at ændre de angivne parametre i Nginx-konfigurationen ved hjælp af miljøvariabler og udføre nogle kontroller i scriptet (f.eks. forkert angivet 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 ~ "^/(?[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}

Man spørger måske: - Hvorfor ikke bruge færdige moduler? Der er jo allerede gjort alt for dig! For eksempel, var AWS = require('aws-sdk') og du behøver ikke at skrive "cyklen" med S3-autentificering!

Lad os se på ulemperne

For mig blev det en ubehagelig, men forventet egenskab, at det ikke er muligt at importere eksterne JS-moduler. Den nævnte require('crypto') i eksemplet ovenfor, er indbyggede moduler og require fungerer kun for dem. Derudover er det ikke muligt at genbruge kode fra scripts, og man må kopiere og indsætte den i forskellige filer. Jeg håber, at denne funktionalitet vil blive implementeret en dag.

Desuden skal kompression være deaktiveret i Nginx for det nuværende projekt. gzip off;

Fordi der ikke er nogen gzip-modul i NJS, og det kan ikke tilsluttes, er der derfor ikke mulighed for at arbejde med komprimerede data. Sandheden er, at dette ikke er så stort et problem for dette tilfælde. Der er ikke meget tekst, og de overførte filer er allerede komprimerede, så yderligere kompression vil ikke hjælpe meget. Det er heller ikke et så belastet eller kritisk service, at der er behov for at bekymre sig om at levere indholdet hurtigere med et par millisekunder.

Fejlfinding af scriptet tager lang tid og er kun muligt via "prints" i error.log. Afhængigt af det indstillede logningsniveau info, warn eller error kan man bruge de 3 metoder r.log, r.warn, r.error henholdsvis. Nogle scripts forsøger jeg at fejlsøge i Chrome (v8) eller den konsolværktøj njs, men ikke alt kan verificeres der. Under fejlfinding af koden, også kaldet funktionel test, ser historikken omtrent sådan ud:

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

og sådanne sekvenser kan være hundrede.

At skrive kode med brug af underforespørgsler og variabler til dem bliver til en forvirrende klud. Nogle gange begynder man at skynde sig rundt i forskellige IDE-vinduer for at finde ud af rækkefølgen af handlingerne i ens kode. Det er ikke svært, men det kan være meget frustrerende.

Der er ingen fuld støtte til ES6.

Måske er der andre ulemper, men jeg har ikke stødt på andet. Del venligst information, hvis du har haft negative erfaringer med at bruge NJS.

Konklusion

NJS er en letvægts open-source fortolker, der gør det muligt at implementere forskellige scenarier i Nginx på JavaScript. Der er blevet lagt stor vægt på ydeevne under udviklingen. Selvfølgelig mangler der stadig meget, men projektet udvikles af et lille team, og de tilføjer aktivt nye funktioner og udbedrer fejl. Jeg håber, at NJS en dag vil tillade tilslutning af eksterne moduler, hvilket vil gøre funktionaliteten i Nginx næsten ubegribelig. Men der er NGINX Plus, og nogle funktioner vil sandsynligvis ikke være tilgængelige!

Repository med den fulde kode til artiklen

njs-pypi med støtte til AWS Sign v4

Beskrivelse af direktiverne for modulet ngx_http_js_module

Det officielle repository for NJS og dokumentation

Eksempler på brug af NJS af Dmitry Volynets

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

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

Signatur og autentifikation af REST-anmodninger i AWS

Kilde: habr.com

Køb pålidelig webhosting med DDoS-beskyttelse, VPS VDS-servere 🔥 Køb pålidelig webhosting med DDoS-beskyttelse, VPS VDS-servere | ProHoster