Jeg laget mitt PyPI-depot med autorisasjon og S3. På Nginx

I denne artikkelen vil jeg gjerne dele min erfaring med NJS, en JavaScript-tolk for Nginx utviklet av Nginx Inc, og beskriver hovedfunksjonene ved hjelp av et ekte eksempel. NJS er et undersett av JavaScript som lar deg utvide funksjonaliteten til Nginx. Til spørsmålet hvorfor din egen tolk??? Dmitry Volyntsev svarte i detalj. Kort sagt: NJS er nginx-måte, og JavaScript er mer progressivt, "native" og uten GC, i motsetning til Lua.

For lenge siden…

På min siste jobb arvet jeg gitlab med en rekke brokete CI/CD-pipelines med docker-compose, dind og andre herligheter, som ble overført til kaniko rails. Bildene som tidligere ble brukt i CI ble flyttet i sin opprinnelige form. De fungerte som det skal til den dagen da gitlab-IP-en vår endret seg og CI ble til et gresskar. Problemet var at et av docker-bildene som deltok i CI hadde git, som trakk Python-moduler via ssh. For ssh trenger du en privat nøkkel og... den var i bildet sammen med kjente_verter. Og enhver CI mislyktes med en nøkkelbekreftelsesfeil på grunn av et misforhold mellom den virkelige IP-en og den spesifisert i kjente_verter. Et nytt bilde ble raskt satt sammen fra de eksisterende Dockfiles og alternativet ble lagt til StrictHostKeyChecking no. Men den dårlige smaken forble, og det var et ønske om å flytte libs til et privat PyPI-lager. En ekstra bonus, etter å ha byttet til privat PyPI, var en enklere pipeline og en normal beskrivelse av requirements.txt

Valget er tatt, mine herrer!

Vi kjører alt i skyene og Kubernetes, og til slutt ønsket vi å få en liten tjeneste som var en statsløs container med ekstern lagring. Vel, siden vi bruker S3, ble den prioritert. Og, hvis mulig, med autentisering i gitlab (du kan legge det til selv om nødvendig).

Et raskt søk ga flere resultater: s3pypi, pypicloud og et alternativ med "manuell" oppretting av html-filer for neper. Det siste alternativet forsvant av seg selv.

s3pypi: Dette er en cli for bruk av S3-hosting. Vi laster opp filene, genererer html-en og laster den opp i samme bøtte. Egnet for hjemmebruk.

pypicloud: Det virket som et interessant prosjekt, men etter å ha lest dokumentasjonen ble jeg skuffet. Til tross for god dokumentasjon og muligheten til å utvide for å passe dine behov, viste det seg i realiteten å være overflødig og vanskelig å konfigurere. Å korrigere koden for å passe dine oppgaver, ifølge estimater på det tidspunktet, ville ha tatt 3-5 dager. Tjenesten trenger også en database. Vi forlot den i tilfelle vi ikke fant noe annet.

Et mer dyptgående søk ga en modul for Nginx, ngx_aws_auth. Resultatet av testingen hans var XML som ble vist i nettleseren, som viste innholdet i S3-bøtten. Den siste forpliktelsen på tidspunktet for søket var for et år siden. Depotet så forlatt ut.

Ved å gå til kilden og lese PEP-503 Jeg innså at XML kan konverteres til HTML i farten og gis til pip. Etter å ha googlet litt mer om Nginx og S3, kom jeg over et eksempel på autentisering i S3 skrevet i JS for Nginx. Det var slik jeg møtte NJS.

Med utgangspunkt i dette eksemplet, så jeg en time senere i nettleseren min den samme XML som ved bruk av ngx_aws_auth-modulen, men alt var allerede skrevet i JS.

Jeg likte virkelig nginx-løsningen. For det første, god dokumentasjon og mange eksempler, for det andre får vi alle godbitene til Nginx for å jobbe med filer (ut av esken), for det tredje vil alle som vet hvordan man skriver konfigurasjoner for Nginx kunne finne ut hva som er hva. Minimalisme er også et pluss for meg, sammenlignet med Python eller Go (hvis skrevet fra bunnen av), for ikke å snakke om nexus.

TL;DR Etter 2 dager ble testversjonen av PyPi allerede brukt i CI.

Hvordan virker det?

Modulen lastes inn i Nginx ngx_http_js_module, inkludert i det offisielle docker-bildet. Vi importerer skriptet vårt ved å bruke direktivet js_importtil Nginx-konfigurasjon. Funksjonen kalles av et direktiv js_content. Direktivet brukes til å sette variabler js_set, som tar som argument bare funksjonen beskrevet i skriptet. Men vi kan utføre underspørringer i NJS bare ved å bruke Nginx, ikke noen XMLHttpRequest. For å gjøre dette må den tilsvarende plasseringen legges til Nginx-konfigurasjonen. Og skriptet må beskrive en underforespørsel til denne plasseringen. For å få tilgang til en funksjon fra Nginx-konfigurasjonen, må funksjonsnavnet eksporteres i selve skriptet 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 du blir bedt om det i nettleseren http://localhost:8080/ vi kommer inn i location /der direktivet js_content kaller en funksjon request beskrevet i manuset vårt script.js. I sin tur i funksjonen request en underspørring gjøres til location = /sub-query, med en metode (i gjeldende eksempel GET) hentet fra argumentet (r), sendt implisitt når denne funksjonen kalles. Svaret på underforespørselen vil bli behandlet i funksjonen call_back.

Prøver S3

For å sende en forespørsel til privat S3-lagring trenger vi:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

Fra den brukte http-metoden, gjeldende dato/klokkeslett, S3_NAME og URI, genereres en bestemt type streng, som signeres (HMAC_SHA1) ved hjelp av SECRET_KEY. Neste er en linje som AWS $ACCESS_KEY:$HASH, kan brukes i autorisasjonsoverskriften. Den samme datoen/klokkeslettet som ble brukt til å generere strengen i forrige trinn må legges til i overskriften X-amz-date. I koden ser det slik ut:

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-autorisasjonseksempel, endret til utdatert status)

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}

En liten forklaring vedr _subrequest_uri: dette er en variabel som, avhengig av den opprinnelige urien, danner en forespørsel til S3. Hvis du trenger å få innholdet i "roten", må du lage en uri-forespørsel som indikerer skilletegnet delimiter, som vil returnere en liste over alle CommonPrefixes xml-elementer, tilsvarende kataloger (i tilfelle PyPI, en liste over alle pakker). Hvis du trenger å få en liste over innhold i en spesifikk katalog (en liste over alle pakkeversjoner), må uri-forespørselen inneholde et prefiksfelt med navnet på katalogen (pakken) som nødvendigvis slutter med en skråstrek /. Ellers er kollisjoner mulig når du for eksempel ber om innholdet i en katalog. Det er kataloger aiohttp-request og aiohttp-requests og hvis forespørselen spesifiserer /?prefix=aiohttp-request, så vil svaret inneholde innholdet i begge katalogene. Hvis det er en skråstrek på slutten, /?prefix=aiohttp-request/, vil svaret bare inneholde den nødvendige katalogen. Og hvis vi ber om en fil, bør den resulterende uri ikke avvike fra den opprinnelige.

Lagre og start Nginx på nytt. I nettleseren skriver vi inn adressen til vår Nginx, resultatet av forespørselen vil være XML, for eksempel:

Liste over kataloger

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

Fra listen over kataloger trenger du bare elementene CommonPrefixes.

Ved å legge til katalogen vi trenger til adressen vår i nettleseren, vil vi også motta innholdet i XML-form:

Liste over filer i en katalog

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

Fra listen over filer tar vi bare elementer Key.

Alt som gjenstår er å analysere den resulterende XML-en og sende den ut som HTML, etter først å ha erstattet Content-Type-overskriften med tekst/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="/no/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Prøver PyPI

Vi sjekker at ingenting går i stykker noe sted på pakker som er kjent for å fungere.

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

# Создаем для тестов новое окружение
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 oppretting og lasting av en pakke slik ut:

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

Autentisering

I Gitlab er det mulig å bruke JWT for autentisering/autorisering av eksterne tjenester. Ved å bruke auth_request-direktivet i Nginx, vil vi omdirigere autentiseringsdataene til en underforespørsel som inneholder et funksjonskall i skriptet. Skriptet vil sende en ny underforespørsel til Gitlab-url-en, og hvis autentiseringsdataene ble spesifisert riktig, vil Gitlab returnere kode 200 og opplasting/nedlasting av pakken vil bli tillatt. Hvorfor ikke bruke én underspørring og umiddelbart sende dataene til Gitlab? For da må vi redigere Nginx-konfigurasjonsfilen hver gang vi gjør endringer i autorisasjonen, og dette er en ganske kjedelig oppgave. Dessuten, hvis Kubernetes bruker en skrivebeskyttet rotfilsystempolicy, vil dette legge til enda mer kompleksitet når du erstatter nginx.conf via configmap. Og det blir absolutt umulig å konfigurere Nginx via configmap mens man samtidig bruker policyer som forbyr tilkobling av volumer (pvc) og skrivebeskyttet rotfilsystem (dette skjer også).

Ved å bruke NJS-mellomproduktet får vi muligheten til å endre de spesifiserte parameterne i nginx-konfigurasjonen ved å bruke miljøvariabler og gjøre noen kontroller i skriptet (for eksempel en feil spesifisert 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}

Mest sannsynlig er spørsmålet under brygging: -Hvorfor ikke bruke ferdige moduler? Alt er allerede gjort der! For eksempel var AWS = require('aws-sdk') og det er ikke nødvendig å skrive en "sykkel" med S3-autentisering!

La oss gå videre til ulempene

For meg ble manglende evne til å importere eksterne JS-moduler en ubehagelig, men forventet funksjon. Beskrevet i eksemplet ovenfor require('crypto') er innebygde moduler og krever bare arbeider for dem. Det er heller ingen måte å gjenbruke kode fra skript, og du må kopiere og lime den inn i forskjellige filer. Jeg håper at denne funksjonaliteten en dag vil bli implementert.

Komprimering må også være deaktivert for det nåværende prosjektet i Nginx gzip off;

Fordi det ikke er noen gzip-modul i NJS og det er umulig å koble den til; derfor er det ingen måte å jobbe med komprimerte data på. Riktignok er dette egentlig ikke et minus for denne saken. Det er ikke mye tekst, og de overførte filene er allerede komprimert, og ytterligere komprimering vil ikke hjelpe dem mye. Dette er heller ikke en så lastet eller kritisk tjeneste at du må bry deg med å levere innhold noen millisekunder raskere.

Å feilsøke skriptet tar lang tid og er kun mulig gjennom "prints" i error.log. Avhengig av innstilt loggingsnivåinfo, advarsel eller feil, er det mulig å bruke henholdsvis 3 metoder r.log, r.warn, r.error. Jeg prøver å feilsøke noen skript i Chrome (v8) eller njs-konsollverktøyet, men ikke alt kan sjekkes der. Når du feilsøker kode, aka funksjonell testing, ser historien omtrent slik ut:

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

og det kan være hundrevis av slike sekvenser.

Å skrive kode ved å bruke underspørringer og variabler for dem blir til en sammenfiltret floke. Noen ganger begynner du å haste rundt i forskjellige IDE-vinduer for å prøve å finne ut handlingssekvensen til koden din. Det er ikke vanskelig, men noen ganger er det veldig irriterende.

Det er ingen full støtte for ES6.

Det kan være noen andre mangler, men jeg har ikke støtt på noe annet. Del informasjon hvis du har negativ erfaring med bruk av NJS.

Konklusjon

NJS er en lett åpen kildekode-tolk som lar deg implementere ulike JavaScript-skript i Nginx. Under utviklingen ble det lagt stor vekt på ytelse. Selvfølgelig mangler det fortsatt mye, men prosjektet utvikles av et lite team og de legger aktivt til nye funksjoner og fikser feil. Jeg håper at NJS en dag vil tillate deg å koble til eksterne moduler, noe som vil gjøre Nginx-funksjonalitet nesten ubegrenset. Men det er NGINX Plus og mest sannsynlig vil det ikke være noen funksjoner!

Repository med full kode for artikkelen

njs-pypi med AWS Sign v4-støtte

Beskrivelse av direktivene til ngx_http_js_module-modulen

Offisielt NJS-depot и dokumentasjonen

Eksempler på bruk av NJS fra Dmitry Volintsev

njs - innebygd JavaScript-skripting i nginx / Tale av Dmitrij Volnyev på Saint HighLoad++ 2019

NJS i produksjon / Tale av Vasily Soshnikov på HighLoad++ 2019

Signering og autentisering av REST-forespørsler i AWS

Kilde: www.habr.com