Fiz meu repositório PyPI com autorização e S3. No Nginx

Neste artigo gostaria de compartilhar minha experiência com NJS, um interpretador JavaScript para Nginx desenvolvido pela Nginx Inc, descrevendo suas principais capacidades usando um exemplo real. NJS é um subconjunto de JavaScript que permite estender a funcionalidade do Nginx. Para a pergunta por que seu próprio intérprete??? Dmitry Volyntsev respondeu detalhadamente. Resumindo: NJS é nginx-way, e JavaScript é mais progressivo, “nativo” e sem GC, ao contrário de Lua.

A muito tempo atrás…

No meu último trabalho, herdei o gitlab com vários pipelines heterogêneos de CI/CD com docker-compose, dind e outras delícias, que foram transferidos para kaniko rails. As imagens que eram utilizadas anteriormente no CI foram movidas em sua forma original. Eles funcionaram corretamente até o dia em que nosso IP do gitlab mudou e o CI se transformou em uma abóbora. O problema era que uma das imagens do docker que participava do CI tinha git, que extraía módulos Python via ssh. Para ssh você precisa de uma chave privada e... ela estava na imagem junto comknown_hosts. E qualquer CI falhou com um erro de verificação de chave devido a uma incompatibilidade entre o IP real e aquele especificado emknown_hosts. Uma nova imagem foi rapidamente montada a partir dos Dockfiles existentes e a opção foi adicionada StrictHostKeyChecking no. Mas o mau gosto permaneceu e houve o desejo de mover as bibliotecas para um repositório PyPI privado. Um bônus adicional, após a mudança para PyPI privado, foi um pipeline mais simples e uma descrição normal de requisitos.txt

A escolha foi feita, senhores!

Rodamos tudo nas nuvens e no Kubernetes e, no final, queríamos um pequeno serviço que fosse um contêiner sem estado com armazenamento externo. Bom, como usamos o S3, foi dada prioridade a ele. E, se possível, com autenticação no gitlab (você mesmo pode adicioná-lo, se necessário).

Uma busca rápida rendeu vários resultados: s3pypi, pypicloud e uma opção com criação “manual” de arquivos html para nabos. A última opção desapareceu sozinha.

s3pypi: Este é um cli para usar hospedagem S3. Fazemos upload dos arquivos, geramos o html e enviamos para o mesmo bucket. Adequado para uso doméstico.

pypicloud: Parecia um projeto interessante, mas depois de ler a documentação fiquei decepcionado. Apesar da boa documentação e da capacidade de expansão para atender às suas necessidades, na realidade revelou-se redundante e difícil de configurar. Corrigir o código para se adequar às suas tarefas, de acordo com as estimativas da época, levaria de 3 a 5 dias. O serviço também precisa de um banco de dados. Deixamos para o caso de não encontrarmos mais nada.

Uma pesquisa mais aprofundada resultou em um módulo para Nginx, ngx_aws_auth. O resultado de seus testes foi o XML exibido no navegador, que mostrou o conteúdo do bucket S3. O último commit no momento da pesquisa foi há um ano. O repositório parecia abandonado.

Indo à fonte e lendo PEP-503 Percebi que o XML pode ser convertido em HTML instantaneamente e fornecido ao pip. Depois de pesquisar um pouco mais sobre Nginx e S3 no Google, me deparei com um exemplo de autenticação em S3 escrito em JS para Nginx. Foi assim que conheci o NJS.

Tomando este exemplo como base, uma hora depois vi no meu navegador o mesmo XML de quando uso o módulo ngx_aws_auth, mas tudo já estava escrito em JS.

Gostei muito da solução nginx. Em primeiro lugar, boa documentação e muitos exemplos, em segundo lugar, obtemos todas as vantagens do Nginx para trabalhar com arquivos (prontos para uso), em terceiro lugar, qualquer pessoa que saiba como escrever configurações para o Nginx será capaz de descobrir o que é o quê. O minimalismo também é uma vantagem para mim, comparado ao Python ou Go (se escrito do zero), sem falar no nexo.

TL;DR Após 2 dias, a versão de teste do PyPi já estava em uso no CI.

Como isso funciona?

O módulo é carregado no Nginx ngx_http_js_module, incluído na imagem oficial do docker. Importamos nosso script usando a diretiva js_importpara a configuração do Nginx. A função é chamada por uma diretiva js_content. A diretiva é usada para definir variáveis js_set, que leva como argumento apenas a função descrita no script. Mas podemos executar subconsultas em NJS apenas usando Nginx, não qualquer XMLHttpRequest. Para fazer isso, o local correspondente deve ser adicionado à configuração do Nginx. E o script deve descrever uma subsolicitação para esse local. Para poder acessar uma função a partir da configuração do Nginx, o nome da função deve ser exportado no próprio script 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}

Quando solicitado no navegador http://localhost:8080/ nós entramos location /em que a directiva js_content chama uma função request descrito em nosso script script.js. Por sua vez, na função request uma subconsulta é feita para location = /sub-query, com um método (no exemplo atual GET) obtido do argumento (r), passado implicitamente quando esta função é chamada. A resposta da subsolicitação será processada na função call_back.

Tentando S3

Para fazer uma solicitação ao armazenamento S3 privado, precisamos:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

A partir do método http utilizado, data/hora atual, S3_NAME e URI, é gerado um determinado tipo de string, que é assinado (HMAC_SHA1) usando SECRET_KEY. A seguir está uma linha como AWS $ACCESS_KEY:$HASH, pode ser usado no cabeçalho de autorização. A mesma data/hora que foi usada para gerar a string na etapa anterior deve ser adicionada ao cabeçalho X-amz-date. No código fica assim:

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(Exemplo de autorização do AWS Sign v2, alterado para status obsoleto)

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}

Uma pequena explicação sobre _subrequest_uri: é uma variável que, dependendo do uri inicial, forma uma solicitação ao S3. Se você precisar obter o conteúdo da “raiz”, será necessário criar uma solicitação uri indicando o delimitador delimiter, que retornará uma lista de todos os elementos xml CommonPrefixes, correspondentes aos diretórios (no caso do PyPI, uma lista de todos os pacotes). Se você precisar obter uma lista do conteúdo de um diretório específico (uma lista de todas as versões do pacote), a solicitação uri deve conter um campo de prefixo com o nome do diretório (pacote) terminando necessariamente com uma barra /. Caso contrário, são possíveis colisões ao solicitar o conteúdo de um diretório, por exemplo. Existem diretórios aiohttp-request e aiohttp-requests e se a solicitação especificar /?prefix=aiohttp-request, a resposta conterá o conteúdo de ambos os diretórios. Se houver uma barra no final, /?prefix=aiohttp-request/, a resposta conterá apenas o diretório necessário. E se solicitarmos um arquivo, o uri resultante não deverá ser diferente do original.

Salve e reinicie o Nginx. No navegador inserimos o endereço do nosso Nginx, o resultado da solicitação será XML, por exemplo:

Lista de diretórios

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

Da lista de diretórios você só precisará dos elementos CommonPrefixes.

Ao adicionar o diretório que precisamos ao nosso endereço no navegador, também receberemos seu conteúdo em formato XML:

Lista de arquivos em um diretório

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

Da lista de arquivos pegaremos apenas elementos Key.

Tudo o que resta é analisar o XML resultante e enviá-lo como HTML, tendo primeiro substituído o cabeçalho Content-Type por 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="/pt/${reValue.groups.v}">${a_text}</a>`);
    }
  }

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

Tentando PyPI

Verificamos se nada quebra em nenhum lugar dos pacotes que funcionam.

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

Repetimos com nossas bibliotecas.

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

No CI, criar e carregar um pacote é assim:

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

Autenticação

No Gitlab é possível utilizar JWT para autenticação/autorização de serviços externos. Usando a diretiva auth_request no Nginx, redirecionaremos os dados de autenticação para uma subsolicitação contendo uma chamada de função no script. O script fará outra subsolicitação para a url do Gitlab e se os dados de autenticação foram especificados corretamente, então o Gitlab retornará o código 200 e o upload/download do pacote será permitido. Por que não usar uma subconsulta e enviar imediatamente os dados para o Gitlab? Porque então teremos que editar o arquivo de configuração do Nginx toda vez que fizermos alguma alteração na autorização, e esta é uma tarefa um tanto tediosa. Além disso, se o Kubernetes usar uma política de sistema de arquivos raiz somente leitura, isso adicionará ainda mais complexidade ao substituir o nginx.conf via configmap. E torna-se absolutamente impossível configurar o Nginx via configmap enquanto usa simultaneamente políticas que proíbem a conexão de volumes (pvc) e sistema de arquivos raiz somente leitura (isso também acontece).

Usando o intermediário NJS, temos a oportunidade de alterar os parâmetros especificados na configuração do nginx usando variáveis ​​de ambiente e fazer algumas verificações no script (por exemplo, uma URL especificada incorretamente).

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}

Muito provavelmente a questão está se formando: -Por que não usar módulos prontos? Tudo já foi feito lá! Por exemplo, var AWS = require('aws-sdk') e não há necessidade de escrever uma “bicicleta” com autenticação S3!

Vamos passar para os contras

Para mim, a impossibilidade de importar módulos JS externos tornou-se um recurso desagradável, mas esperado. Descrito no exemplo acima, require('crypto') é módulos integrados e require apenas funciona para eles. Também não há como reutilizar o código dos scripts e você precisa copiá-lo e colá-lo em arquivos diferentes. Espero que algum dia essa funcionalidade seja implementada.

A compactação também deve ser desabilitada para o projeto atual no Nginx gzip off;

Como não existe módulo gzip no NJS e é impossível conectá-lo, portanto não há como trabalhar com dados compactados. É verdade que isso não é realmente um sinal negativo neste caso. Não há muito texto e os arquivos transferidos já estão compactados e a compactação adicional não os ajudará muito. Além disso, este não é um serviço tão carregado ou crítico que você precise se preocupar em entregar conteúdo alguns milissegundos mais rápido.

A depuração do script é demorada e só é possível através de “prints” no error.log. Dependendo das informações do nível de registro definido, aviso ou erro, é possível usar 3 métodos r.log, r.warn, r.error respectivamente. Tento depurar alguns scripts no Chrome (v8) ou na ferramenta de console njs, mas nem tudo pode ser verificado lá. Ao depurar código, também conhecido como teste funcional, o histórico se parece com isto:

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

e pode haver centenas dessas sequências.

Escrever código usando subconsultas e variáveis ​​para eles se transforma em um emaranhado. Às vezes você começa a percorrer diferentes janelas do IDE tentando descobrir a sequência de ações do seu código. Não é difícil, mas às vezes é muito chato.

Não há suporte completo para ES6.

Pode haver algumas outras deficiências, mas não encontrei mais nada. Compartilhe informações se tiver experiência negativa com o uso de NJS.

Conclusão

NJS é um interpretador leve de código aberto que permite implementar vários scripts JavaScript no Nginx. Durante o seu desenvolvimento, muita atenção foi dada ao desempenho. Claro, ainda falta muita coisa, mas o projeto está sendo desenvolvido por uma pequena equipe e eles estão ativamente adicionando novos recursos e corrigindo bugs. Espero que algum dia o NJS permita conectar módulos externos, o que tornará a funcionalidade do Nginx quase ilimitada. Mas existe o NGINX Plus e provavelmente não haverá recursos!

Repositório com código completo do artigo

njs-pypi com suporte para AWS Sign v4

Descrição das diretivas do módulo ngx_http_js_module

Repositório oficial do NJS и a documentação

Exemplos de uso de NJS de Dmitry Volintsev

njs - script JavaScript nativo no nginx / Discurso de Dmitry Volnyev no Saint HighLoad++ 2019

NJS em produção / Discurso de Vasily Soshnikov no HighLoad++ 2019

Assinatura e autenticação de solicitações REST na AWS

Fonte: habr.com