Eu criei meu repositório PyPI com autenticação e S3. No Nginx

Neste artigo, quero compartilhar minha experiência com o NJS, o interpretador JavaScript para Nginx desenvolvido pela empresa Nginx Inc., descrevendo suas principais funcionalidades por meio de um exemplo prático. NJS é um subconjunto da linguagem de programação JavaScript que permite expandir a funcionalidade do Nginx. A pergunta é por que ter seu próprio interpretador??? respondeu detalhadamente Dmitry Volyntsev. Resumindo: NJS é o jeito nginx, enquanto JavaScript é mais progressivo, 'nativo' e sem GC, diferentemente do Lua.

Há muito tempo...

No meu emprego anterior, herdei um gitlab com uma série de pipelines CI/CD variados usando docker-compose, dind e outras maravilhas, que foram transformados para kaniko. As imagens que eram usadas anteriormente no CI foram mantidas em seu estado original. Elas funcionaram bem até o dia em que nosso gitlab mudou de IP, e o CI se tornou um caos. O problema era que, em uma das imagens docker que participava do CI, havia o git, que por ssh puxava módulos Python. Para o ssh, é necessário uma chave privada e... ela estava na imagem junto com known_hosts. E qualquer CI terminava com um erro de verificação da chave devido à discrepância entre o IP real e o especificado em known_hosts. A partir dos Dockfiles disponíveis, rapidamente foi criada uma nova imagem e adicionada a opção StrictHostKeyChecking no. Mas o gosto amargo ficou e surgiu a vontade de mover as bibliotecas para um repositório PyPI privado. Um bônus adicional, após a migração para um PyPI privado, é que o pipeline se tornou mais simples e a descrição do requirements.txt ficou normal.

A escolha foi feita, senhores!

Estamos todos rodando na nuvem e Kubernetes e, no final das contas, queríamos criar um pequeno serviço que consistisse em um contêiner stateless com armazenamento externo. E como usamos o S3, ele foi priorizado. E, se possível, com autenticação no gitlab (podemos adicionar isso se necessário).

Uma busca rápida produziu alguns resultados: s3pypi, pypicloud e uma opção de criação 'manual' de arquivos html para o repositório. A última opção foi descartada.

s3pypi: É um cli para uso de hospedagem no S3. Carregamos arquivos, geramos html e subimos para o mesmo bucket. Para uso doméstico, é adequado.

pypicloud: Parecia um projeto interessante, mas após ler a documentação, veio a decepção. Apesar da boa documentação e possibilidades de personalização, na prática, se mostrou excessivo e complicado de configurar. Ajustar o código para as minhas necessidades levaria, segundo estimativas da época, de 3 a 5 dias. Além disso, o serviço requer um banco de dados. Optamos por mantê-lo caso não encontrássemos uma alternativa melhor.

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

Ao me voltar para a fonte original e ler PEP-503 , percebi que o XML pode ser convertido em HTML automaticamente e enviado pelo pip. Depois de pesquisar um pouco mais sobre Nginx e S3, encontrei um exemplo de autenticação em S3 escrito em JS para Nginx. Foi assim que conheci o NJS.

Tomando este exemplo como base, em uma hora vi no meu navegador o mesmo XML que aparecia ao usar o módulo ngx_aws_auth, mas agora tudo estava escrito em JS.

A solução em Nginx me agradou bastante. Primeiro, a documentação é boa e há muitos exemplos; segundo, obtemos todos os benefícios do Nginx ao lidar com arquivos (pronto para uso); terceiro, qualquer pessoa que saiba escrever configurações para Nginx conseguirá entender como funciona. Além disso, para mim, o minimalismo é um ponto positivo em comparação com Python ou Go (se escrito do zero), sem mencionar o nexus.

TL;DR: Em 2 dias, a versão de teste do PyPi já estava sendo usada no CI.

Como funciona?

No Nginx, o módulo é carregado ngx_http_js_module, incluído na imagem oficial do docker. Importamos nosso script usando a diretiva js_importna configuração do Nginx. A chamada da função é feita pela diretiva js_content. Para definir variáveis, usamos a diretiva js_set, que aceita como argumento apenas funções definidas no script. Porém, para executar subconsultas no NJS, precisamos usar o Nginx, e nada de XMLHTTPRequest. Para isso, é necessário adicionar uma localização correspondente na configuração do Nginx. E no script, deve ser descrita uma subconsulta (subrequest) para essa localização. Para poder chamar a função a partir da configuração do Nginx, no próprio script é necessário exportar o nome da função 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) {
    // código do manipulador
    r.return(resp.status, resp.responseBody);
  }

  r.subrequest('/sub-query', { method: r.method }, call_back);
}

export default {request}

Ao fazer uma solicitação no navegador http://localhost:8080/ chegamos em location /onde a diretiva js_content chama a função request descrita em nosso script script.js. Por sua vez, na função request é feita uma subsolicitação para location = /sub-query, com o método (neste exemplo GET) obtido do argumento (r), implicitamente passado ao chamar essa função. O tratamento da resposta da subsolicitação será feito na função call_back.

Testamos S3

Para fazer uma solicitação para um armazenamento S3 privado, precisamos de:

ACCESS_KEY

SECRET_KEY

S3_BUCKET

A partir do método http utilizado, a data/hora atual, S3_NAME e o URI, é gerada uma string de determinado formato, que é assinada (HMAC_SHA1) usando o SECRET_KEY. Em seguida, a string do tipo AWS $ACCESS_KEY:$HASH, pode ser utilizada 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, isso parece 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/(?.*) {
    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(exemplo de autorização AWS Sign v2, traduzido para o 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}

Um pouco de explicação sobre _subrequest_uri: esta é uma variável que, dependendo do uri original, forma a solicitação para o S3. Se precisar obter o conteúdo da "raiz", será necessário formar a solicitação uri com a indicação do delimitador delimiter, que retornará a lista de todos os elementos XML CommonPrefixes, correspondendo aos diretórios (no caso do PyPI, a lista de todos os pacotes). Se precisar obter a lista do conteúdo em um diretório específico (lista de todas as versões dos pacotes), então a solicitação uri deve conter o campo prefix com o nome do diretório (pacote), obrigatoriamente terminando com uma barra \. Caso contrário, podem ocorrer colisões ao solicitar o conteúdo do diretório, por exemplo. Existem diretórios aiohttp-request e aiohttp-requests e, se a solicitação for feita /?prefix=aiohttp-request, então 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, a URI resultante não deve diferir da original.

Salvamos, reiniciamos o Nginx. No navegador, digitamos o endereço do nosso Nginx, e o resultado da solicitação será XML, por exemplo:

Lista de diretórios

myback-space
  
  
  10000
  /
  false
  
    new/
  
  
    old/

Da lista de diretórios, precisaremos apenas dos elementos CommonPrefixes.

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

Lista de arquivos no diretório

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

Da lista de arquivos, pegaremos apenas os elementos Chave.

Resta analisar o XML obtido e enviá-lo como HTML, substituindo previamente o cabeçalho Content-Type para 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>'
}

Testamos o PyPI

Verificamos se nada está quebrado em pacotes que sabemos 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, a criação e o upload do pacote acontecem 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 usar JWT para autenticação/autorização de serviços externos. Ao aproveitar a diretiva auth_request no Nginx, redirecionaremos os dados de autenticação em uma subrequisição que contém a chamada de uma função no script. No script, será feita uma nova subrequisição para a URL do GitLab e, se os dados de autenticação foram fornecidos corretamente, o GitLab retornará o código 200 e permitirá o upload/download do pacote. Por que não usar uma única subrequisição e enviar os dados diretamente para o GitLab? Isso porque teríamos que alterar o arquivo de configuração do Nginx toda vez que tivéssemos mudanças na autorização, e isso é uma tarefa bastante trabalhosa. Além disso, se o Kubernetes utiliza a política de sistema de arquivos root apenas para leitura, isso adiciona ainda mais complexidade ao substituir o nginx.conf através do configmap. E se torna absolutamente impossível configurar o Nginx através do configmap ao mesmo tempo em que se utilizam políticas que proíbem a conexão de volumes (pvc) e o sistema de arquivos root apenas para leitura (isso também pode acontecer).

Usando o NJS como um intermediário, conseguimos alterar os parâmetros especificados na configuração do Nginx por meio de variáveis de ambiente e fazer algumas verificações no script (por exemplo, uma URL indicada 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 ~ "^/(?[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}

Provavelmente surge a questão: - Por que não usar módulos prontos? Afinal, tudo já está feito! Por exemplo, var AWS = require('aws-sdk') e não é necessário reinventar a roda com autenticação S3!

Vamos passar para os contras

Para mim, a impossibilidade de importar módulos JS externos foi uma característica desagradável, mas esperada. O require('crypto') descrito no exemplo acima, é módulos embutidos e o require funciona apenas para eles. Também não há como reutilizar código de scripts e é necessário copiar e colar em diferentes arquivos. Espero que um dia essa funcionalidade seja implementada.

Para o projeto atual no Nginx, a compressão deve ser desativada gzip off;

Porque não há módulo gzip no NJS e não é possível conectá-lo, portanto, não há como trabalhar com dados comprimidos. Na verdade, isso não é um grande problema para esse caso. O texto não é muito e os arquivos transferidos já estão comprimidos, então uma compressão adicional não ajudaria muito. Além disso, não é um serviço tão carregado ou crítico para se preocupar em entregar conteúdo alguns milissegundos mais rápido.

A depuração do script é longa e só pode ser feita através de "prints" no error.log. Dependendo do nível de log configurado (info, warn ou error), é 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 ali. Ao depurar o código, que é uma forma de teste funcional, o histórico parece mais ou menos assim:

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

e tais sequências podem ser centenas.

Escrever código usando subconsultas e variáveis para elas se torna um emaranhado complicado. Às vezes, você começa a pular entre diferentes janelas do IDE tentando entender a sequência de ações do seu código. Isso não é difícil, mas às vezes é bastante frustrante.

Não há suporte completo para ES6.

Pode haver outras desvantagens, mas não encontrei mais nada. Compartilhe informações se você tiver uma experiência negativa com o NJS.

Conclusão

O NJS é um interpretador open-source leve que permite implementar diferentes cenários em Nginx usando a linguagem JavaScript. Durante seu desenvolvimento, foi dada grande atenção ao desempenho. Certamente há muitas coisas que ainda faltam, mas o projeto está evoluindo com uma pequena equipe e eles estão ativamente adicionando novos recursos e corrigindo bugs. Eu espero que algum dia o NJS permita a conexão de módulos externos, o que tornará a funcionalidade do Nginx praticamente ilimitada. Mas existe o NGINX Plus, e provavelmente não haverá alguns recursos!

Repositório com o 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 e documentação

Exemplos de uso do NJS por Dmitry Volyntsev

njs — scripting nativo em JavaScript no Nginx / Выступление Дмитрия Волныева на Saint HighLoad++ 2019

NJS em produção / Выступление Василия Сошникова на HighLoad++ 2019

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

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster