1. Objetivo

Expor um serviço interno (no exemplo, um GitLab que fica atrás de VPN) para um conjunto pequeno de parceiros externos, sem abrir para a internet e sem exigir VPN de pipeline de CI. A porta de entrada é um Application Load Balancer público com mutual TLS em modo verify: só completa o handshake quem apresenta um certificado de cliente emitido pela nossa CA.

Resultado esperado:

  • Sem certificado: a conexão nem chega no HTTP, o ALB derruba no handshake.
  • Com certificado válido: a requisição chega no GitLab, com os dados do certificado nos headers.
  • Cada parceiro tem o próprio certificado e só enxerga o caminho dele.
  • Revogar um parceiro é subir uma CRL, sem mexer nos outros.

O caso real que motivou isso: pipelines de clientes no GitHub Actions fazendo git push --mirror para o GitLab interno. Quando o GitLab foi para trás da VPN, o mirror parou. Liberar os IPs do GitHub Actions não é opção (são milhares e mudam), e colocar VPN no runner é complicado. mTLS no ALB resolve com pouca peça.

2. Conceitos

2.1 Passthrough vs verify

Modo Quem valida o certificado Quando usar
off Ninguém Padrão
passthrough O backend. O ALB só repassa o certificado no header X-Amzn-Mtls-Clientcert Quando a aplicação já sabe validar
verify O próprio ALB, contra um trust store Quando o backend não sabe (caso do GitLab atrás do ALB)

Aqui usamos verify. A validação acontece no ALB e o backend recebe só tráfego já autenticado.

2.2 O que o ALB manda para o backend em modo verify

Header Conteúdo
X-Amzn-Mtls-Clientcert-Subject Subject do certificado em RFC2253, ex.: C=BR,O=Parceiros,CN=parceiro-a
X-Amzn-Mtls-Clientcert-Issuer Quem emitiu
X-Amzn-Mtls-Clientcert-Serial-Number Serial em hex
X-Amzn-Mtls-Clientcert-Validity NotBefore e NotAfter
X-Amzn-Mtls-Clientcert-Leaf O certificado em PEM (URL encoded)

Esses headers também podem ser usados em regras do listener, e é isso que permite isolar um parceiro do outro sem tocar no backend.

2.3 Desenho

 GitHub Actions (parceiro A) ---+ cert CN=parceiro-a | HTTPS 443 + certificado de cliente v GitHub Actions (parceiro B) --> ALB publico (mTLS verify) --> Target group --> GitLab (subnet privada) cert CN=parceiro-b ^ | | +-- trust store (CA + CRL no S3) Qualquer outro -----------------X (handshake recusado) 

3. Pré-requisitos

  • [ ] VPC com duas subnets públicas (ALB) e o serviço em subnet privada
  • [ ] Certificado de servidor no ACM para git-mirror.<SEU_DOMINIO>
  • [ ] Bucket S3 privado para o trust store
  • [ ] OpenSSL 1.1.1 ou superior na máquina onde a CA vai ficar
  • [ ] AWS CLI v2
export AWS_REGION="us-east-1" export DOMINIO="git-mirror.<SEU_DOMINIO>" export TS_BUCKET="<BUCKET_TRUSTSTORE>" export VPC_ID="vpc-<ID>" export SUBNETS_PUB="subnet-pub-a subnet-pub-b" export ACM_ARN="arn:aws:acm:us-east-1:<ACCOUNT_ID>:certificate/<ID>" export GITLAB_INSTANCE="i-<ID>" 

4. Passo 1: Criar a CA

Uma CA simples com openssl ca. Usar openssl ca (e não só openssl x509 -req) dá mais trabalho no começo, mas é o que mantém o index.txt e permite gerar CRL depois. Sem isso, revogar vira dor de cabeça.

mkdir -p ~/ca-mtls/{certs,crl,newcerts,private,clientes} cd ~/ca-mtls chmod 700 private touch index.txt echo 1000 > serial echo 1000 > crlnumber 

openssl.cnf:

[ ca ] default_ca = CA_mtls [ CA_mtls ] dir = . certs = $dir/certs crl_dir = $dir/crl new_certs_dir = $dir/newcerts database = $dir/index.txt serial = $dir/serial crlnumber = $dir/crlnumber private_key = $dir/private/ca.key certificate = $dir/certs/ca.pem crl = $dir/crl/ca.crl default_md = sha256 default_days = 365 default_crl_days = 30 policy = policy_parceiro unique_subject = no copy_extensions = none [ policy_parceiro ] commonName = supplied organizationName = optional countryName = optional [ req ] distinguished_name = req_dn prompt = no [ req_dn ] CN = CA mTLS Parceiros O = <SUA_ORG> C = BR [ ext_ca ] basicConstraints = critical, CA:true keyUsage = critical, keyCertSign, cRLSign subjectKeyIdentifier = hash [ ext_cliente ] basicConstraints = critical, CA:false keyUsage = critical, digitalSignature, keyEncipherment extendedKeyUsage = clientAuth subjectKeyIdentifier = hash authorityKeyIdentifier = keyid 

Gerar a chave e o certificado da CA (10 anos):

openssl genrsa -out private/ca.key 4096 chmod 400 private/ca.key openssl req -config openssl.cnf -new -x509 -days 3650 \ -key private/ca.key -extensions ext_ca -out certs/ca.pem openssl x509 -in certs/ca.pem -noout -subject -dates -ext basicConstraints,keyUsage 

Guarde a ca.key fora da máquina de trabalho. Quem tem essa chave emite certificado aceito pelo ALB. No mínimo, cofre de senha com o arquivo cifrado ou um secret no Secrets Manager com acesso restrito a duas pessoas.

5. Passo 2: Emitir um certificado por parceiro

Script emitir.sh:

#!/usr/bin/env bash set -euo pipefail PARCEIRO="$1" # ex.: parceiro-a cd ~/ca-mtls openssl genrsa -out "clientes/${PARCEIRO}.key" 2048 openssl req -new -key "clientes/${PARCEIRO}.key" \ -subj "/CN=${PARCEIRO}/O=Parceiros/C=BR" \ -out "clientes/${PARCEIRO}.csr" openssl ca -config openssl.cnf -batch -extensions ext_cliente \ -in "clientes/${PARCEIRO}.csr" -out "clientes/${PARCEIRO}.pem" rm -f "clientes/${PARCEIRO}.csr" openssl x509 -in "clientes/${PARCEIRO}.pem" -noout -subject -serial -enddate 
chmod +x emitir.sh ./emitir.sh parceiro-a ./emitir.sh parceiro-b 

Anote o serial de cada um. É ele que vai aparecer no header e no log de conexão do ALB, e é por ele que você vai saber quem fez o quê.

Entregue ao parceiro o .pem e o .key por canal seguro (link de uso único de cofre, nunca anexo de e-mail).

6. Passo 3: Trust store

aws s3 cp ~/ca-mtls/certs/ca.pem "s3://${TS_BUCKET}/truststore/ca-bundle.pem" TS_ARN=$(aws elbv2 create-trust-store \ --name ts-parceiros \ --ca-certificates-bundle-s3-bucket "$TS_BUCKET" \ --ca-certificates-bundle-s3-key truststore/ca-bundle.pem \ --query 'TrustStores[0].TrustStoreArn' --output text) echo "$TS_ARN" aws elbv2 describe-trust-stores --trust-store-arns "$TS_ARN" \ --query 'TrustStores[0].{status:Status,cas:NumberOfCaCertificates}' 

O ALB lê o arquivo do S3 só no momento da criação ou da modificação. Trocar o arquivo no bucket depois não muda nada até você rodar modify-trust-store.

7. Passo 4: Target group

TG_ARN=$(aws elbv2 create-target-group \ --name tg-gitlab-mirror \ --protocol HTTP --port 80 \ --vpc-id "$VPC_ID" \ --target-type instance \ --health-check-path "/-/readiness" \ --matcher HttpCode=200 \ --query 'TargetGroups[0].TargetGroupArn' --output text) aws elbv2 register-targets --target-group-arn "$TG_ARN" --targets "Id=${GITLAB_INSTANCE}" 

Health check do GitLab. Os endpoints /-/health, /-/readiness e /-/liveness só respondem 200 para IPs da monitoring_whitelist. O ALB chama de IP interno da VPC, então o target fica unhealthy com 404 até você liberar o CIDR no gitlab.rb e rodar gitlab-ctl reconfigure:

gitlab_rails['monitoring_whitelist'] = ['127.0.0.0/8', '<CIDR_DA_VPC>'] 

Ainda no gitlab.rb, como o TLS termina no ALB, informe o GitLab que ele está atrás de proxy HTTPS:

external_url 'https://git-mirror.<SEU_DOMINIO>' nginx['listen_port'] = 80 nginx['listen_https'] = false nginx['real_ip_trusted_addresses'] = ['<CIDR_DA_VPC>'] nginx['real_ip_header'] = 'X-Forwarded-For' 

8. Passo 5: ALB e listener com mTLS

Security group do ALB: 443 aberto para 0.0.0.0/0. Parece estranho, mas a autenticação aqui é o certificado, não o IP. O SG da instância do GitLab aceita 80 só do SG do ALB.

SG_ALB=$(aws ec2 create-security-group --group-name sg-alb-mtls \ --description "ALB mTLS parceiros" --vpc-id "$VPC_ID" --query GroupId --output text) aws ec2 authorize-security-group-ingress --group-id "$SG_ALB" \ --protocol tcp --port 443 --cidr 0.0.0.0/0 aws ec2 authorize-security-group-ingress --group-id <SG_GITLAB> \ --protocol tcp --port 80 --source-group "$SG_ALB" ALB_ARN=$(aws elbv2 create-load-balancer \ --name alb-git-mirror \ --type application --scheme internet-facing \ --subnets $SUBNETS_PUB \ --security-groups "$SG_ALB" \ --query 'LoadBalancers[0].LoadBalancerArn' --output text) 

Listener. A ação padrão é 404 fixo, e não forward. O tráfego só chega no GitLab pelas regras do passo 6:

LISTENER_ARN=$(aws elbv2 create-listener \ --load-balancer-arn "$ALB_ARN" \ --protocol HTTPS --port 443 \ --ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \ --certificates "CertificateArn=${ACM_ARN}" \ --mutual-authentication "Mode=verify,TrustStoreArn=${TS_ARN},IgnoreClientCertificateExpiry=false" \ --default-actions 'Type=fixed-response,FixedResponseConfig={StatusCode=404,ContentType=text/plain,MessageBody=nao encontrado}' \ --query 'Listeners[0].ListenerArn' --output text) 

Crie o registro DNS (CNAME ou alias Route 53) de git-mirror.<SEU_DOMINIO> para o DNS do ALB.

9. Passo 6: Isolar um parceiro do outro

O mTLS sozinho garante que só parceiros entram. Mas, sem regra, o parceiro A com certificado válido consegue tentar falar com o repositório do parceiro B. O GitLab ainda vai pedir token, mas é melhor que a requisição nem chegue.

Supondo grupos no GitLab parceiro-a/ e parceiro-b/:

aws elbv2 create-rule --listener-arn "$LISTENER_ARN" --priority 10 \ --conditions '[ {"Field":"http-header","HttpHeaderConfig":{"HttpHeaderName":"X-Amzn-Mtls-Clientcert-Subject","Values":["*CN=parceiro-a","CN=parceiro-a,*"]}}, {"Field":"path-pattern","PathPatternConfig":{"Values":["/parceiro-a/*"]}} ]' \ --actions "Type=forward,TargetGroupArn=${TG_ARN}" aws elbv2 create-rule --listener-arn "$LISTENER_ARN" --priority 20 \ --conditions '[ {"Field":"http-header","HttpHeaderConfig":{"HttpHeaderName":"X-Amzn-Mtls-Clientcert-Subject","Values":["*CN=parceiro-b","CN=parceiro-b,*"]}}, {"Field":"path-pattern","PathPatternConfig":{"Values":["/parceiro-b/*"]}} ]' \ --actions "Type=forward,TargetGroupArn=${TG_ARN}" 

Atenção à ordem: o ALB imprime o subject no formato RFC2253, que inverte a ordem em que os campos foram gravados. Um certificado emitido com -subj "/CN=parceiro-a/O=Parceiros/C=BR" chega como C=BR,O=Parceiros,CN=parceiro-a, com o CN no final. Por isso a regra tem dois valores (eles funcionam como OU): *CN=parceiro-a casa quando o CN é o último campo e CN=parceiro-a,* quando é o primeiro. Para conferir como o seu certificado vai aparecer:

openssl x509 -in clientes/parceiro-a.pem -noout -subject -nameopt RFC2253 

Não use *CN=parceiro-a* solto: ele também casaria com um parceiro-ab que você crie no futuro.

Por que não usar só o token do GitLab? Porque o token vaza. Fica em variável de CI, em log de pipeline mal configurado, em print de tela. Com mTLS na frente, um token vazado sozinho não serve para nada fora do runner que tem o certificado.

10. Passo 7: Lado do parceiro (GitHub Actions)

O parceiro guarda três secrets no repositório: MIRROR_CERT (conteúdo do .pem), MIRROR_KEY (conteúdo do .key) e MIRROR_TOKEN (project access token do GitLab com write_repository).

.github/workflows/mirror.yml:

name: mirror-gitlab on: push: branches: ["**"] tags: ["**"] jobs: mirror: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Certificado de cliente run: | install -m 700 -d "$HOME/.mtls" printf '%s\n' "${{ secrets.MIRROR_CERT }}" > "$HOME/.mtls/cliente.pem" printf '%s\n' "${{ secrets.MIRROR_KEY }}" > "$HOME/.mtls/cliente.key" chmod 600 "$HOME/.mtls/"* - name: Push mirror env: TOKEN: ${{ secrets.MIRROR_TOKEN }} run: | git config --global http.https://git-mirror.<SEU_DOMINIO>/.sslCert "$HOME/.mtls/cliente.pem" git config --global http.https://git-mirror.<SEU_DOMINIO>/.sslKey "$HOME/.mtls/cliente.key" git push --mirror "https://oauth2:${TOKEN}@git-mirror.<SEU_DOMINIO>/parceiro-a/<REPO>.git" - name: Limpeza if: always() run: rm -rf "$HOME/.mtls" 

O http.<url>.sslCert limita o certificado a esse host. Se usar só http.sslCert, o git manda o certificado para qualquer servidor HTTPS, incluindo o github.com no checkout.

push --mirror apaga refs. Ele remove do destino o que não existe na origem. É o comportamento certo para mirror, mas confirme que ninguém faz commit direto no repositório de destino. Se fizer, vai perder.

11. Passo 8: Logs de conexão

Os access logs normais do ALB não mostram falha de handshake, porque a requisição HTTP nunca existiu. Para mTLS, o que importa são os connection logs:

aws elbv2 modify-load-balancer-attributes --load-balancer-arn "$ALB_ARN" \ --attributes \ Key=connection_logs.s3.enabled,Value=true \ Key=connection_logs.s3.bucket,Value=<BUCKET_LOGS> \ Key=connection_logs.s3.prefix,Value=alb-git-mirror 

O bucket precisa da policy padrão de logs do ELB para a região. Cada linha traz IP de origem, porta, protocolo TLS, subject e serial do certificado do cliente, e o resultado da verificação (Success ou o motivo da falha, como certificado expirado ou CA desconhecida).

12. Passo 9: Testar

# Sem certificado: deve falhar no handshake curl -v "https://${DOMINIO}/parceiro-a/" 2>&1 | grep -iE "alert|handshake|reset|error" # Com certificado do parceiro A no caminho dele: deve chegar no GitLab (302 ou 200) curl -s -o /dev/null -w "%{http_code}\n" \ --cert ~/ca-mtls/clientes/parceiro-a.pem --key ~/ca-mtls/clientes/parceiro-a.key \ "https://${DOMINIO}/parceiro-a/" # Parceiro A no caminho do B: deve dar 404 do listener curl -s -o /dev/null -w "%{http_code}\n" \ --cert ~/ca-mtls/clientes/parceiro-a.pem --key ~/ca-mtls/clientes/parceiro-a.key \ "https://${DOMINIO}/parceiro-b/" # git ls-remote com certificado GIT_SSL_CERT=~/ca-mtls/clientes/parceiro-a.pem GIT_SSL_KEY=~/ca-mtls/clientes/parceiro-a.key \ git ls-remote "https://oauth2:<TOKEN>@${DOMINIO}/parceiro-a/<REPO>.git" 

Os três resultados de curl têm que ser, nessa ordem: falha de handshake, código do GitLab, 404.

13. Passo 10: Revogar um parceiro

cd ~/ca-mtls openssl ca -config openssl.cnf -revoke clientes/parceiro-b.pem openssl ca -config openssl.cnf -gencrl -out crl/ca.crl openssl crl -in crl/ca.crl -noout -text | grep -A1 "Serial Number" aws s3 cp crl/ca.crl "s3://${TS_BUCKET}/truststore/crl.pem" aws elbv2 add-trust-store-revocations --trust-store-arn "$TS_ARN" \ --revocation-contents "S3Bucket=${TS_BUCKET},S3Key=truststore/crl.pem,RevocationType=CRL" aws elbv2 describe-trust-store-revocations --trust-store-arn "$TS_ARN" 

Repita o curl do parceiro B: deve falhar no handshake. Os outros continuam funcionando.

A CRL gerada tem validade de default_crl_days (30 dias no exemplo). Coloque na agenda a regeração mensal, ou aumente esse valor se a CA for usada só para isso.

14. Troubleshooting

Sintoma Causa provável Ação
Trust store em CREATING e depois erro PEM com chave privada junto ou CA sem CA:true Bundle só com certificados de CA
Handshake falha mesmo com certificado Certificado sem clientAuth ou emitido por outra CA openssl x509 -text e conferir issuer
Target unhealthy com 404 monitoring_whitelist do GitLab Liberar CIDR da VPC
Funciona com curl e falha no git Git mandando certificado para outro host ou não mandando GIT_CURL_VERBOSE=1 GIT_TRACE=1
Regra nunca casa e tudo dá 404 Formato do subject diferente do esperado Conferir subject no connection log
Push dá 401 mTLS ok, token do GitLab errado ou sem escopo Project access token com write_repository

15. Referências