ALB com mTLS e CA própria: expondo um serviço interno só para parceiros com certificado
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 A
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, ex.: CN=parceiro-a,O=Parceiros,C=BR
|
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.keyfora 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,/-/readinesse/-/livenesssó respondem 200 para IPs damonitoring_whitelist. O ALB chama de IP interno da VPC, então o target ficaunhealthycom 404 até você liberar o CIDR nogitlab.rbe rodargitlab-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,*"]}},
{"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,*"]}},
{"Field":"path-pattern","PathPatternConfig":{"Values":["/parceiro-b/*"]}}
]' \
--actions "Type=forward,TargetGroupArn=${TG_ARN}"
Confira o formato exato do subject no seu caso antes de fechar a regra (a ordem dos campos depende de como o certificado foi emitido). O jeito mais rápido é o log de conexão do passo 8 ou um backend de teste que devolve os headers.
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 --mirrorapaga 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
- Mutual TLS no ALB: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/mutual-authentication.html
- Connection logs do ALB: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-connection-logs.html
- Health check do GitLab: https://docs.gitlab.com/ee/administration/monitoring/health_check.html
-
openssl ca: https://docs.openssl.org/master/man1/openssl-ca/
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.