monteirotf.com
Controle de Tráfego na Origem · Rust

Aegis (Proxy Reverso e Rate Limiter)

Proxy reverso em Rust (tokio, axum, hyper) que escrevi para aplicar rate limit por IP com token bucket, responder 429 aos clientes abusivos e encaminhar o resto ao backend.

FROM scratch, non-root
Imagem Docker
SIGHUP, sem derrubar conexões
Recarga de config
Cap de buckets + sweeper
Memória

O problema

Um backend HTTP exposto fica à mercê de clientes abusivos: rajadas de requisições por IP, conexões lentas que seguram sockets (slowloris) e corpos gigantes. Faltava uma camada de proteção rodando na própria origem, sem depender de serviço externo.

A abordagem

Escrevi o Aegis em Rust, sobre tokio, axum e hyper: proxy reverso transparente com rate limit por IP em token bucket (DashMap particionado, lock por shard no caminho quente), limites por rota, agrupamento de IPv6 por prefixo, timeouts em todas as etapas, proxying de WebSocket, TLS opcional e recarga de configuração por SIGHUP sem derrubar conexões. Health check e métricas Prometheus vivem num plano administrativo separado, que nunca é limitado.

01

Arquitetura

Cliente → server.rs: porta :8080server.rs → limiter.rs: requisição com ConnectInfolimiter.rs → proxy.rs: dentro do orçamentoproxy.rs → Backend: HTTP/1.1 com streamingstate.rs → limiter.rs: limites e regras atuaisstate.rs → proxy.rs: URL do backend e timeoutsPrometheus → Plano admin :9000: GET /metricsPlano admin :9000 → state.rs: lê contadoresCliente · HTTP/1.1, ou HTTP/2 via TLS/ALPNClienteHTTP/1.1, ou HTTP/2 via T…Prometheus · scrape de /metricsPrometheusscrape de /metricsserver.rs · loop próprio: timeout de leitura de headers, TLS opcional, upgradesserver.rsloop próprio: timeout de …Plano admin :9000 · /healthz e /metrics, nunca limitadoPlano admin :9000/healthz e /metrics, nunc…limiter.rs · token bucket por IP em DashMap, regras por rota, 429limiter.rstoken bucket por IP em Da…state.rs · config quente em ArcSwap (SIGHUP) e contadoresstate.rsconfig quente em ArcSwap …proxy.rs · remove headers hop-by-hop, X-Forwarded-*, WebSocketproxy.rsremove headers hop-by-hop…Backend · sempre HTTP/1.1; timeouts 504 e 408Backendsempre HTTP/1.1; timeouts…
  1. 01Cliente → server.rs: porta :8080
  2. 02server.rs → limiter.rs: requisição com ConnectInfo
  3. 03limiter.rs → proxy.rs: dentro do orçamento
  4. 04proxy.rs → Backend: HTTP/1.1 com streaming
  5. 05state.rs → limiter.rs: limites e regras atuais
  6. 06state.rs → proxy.rs: URL do backend e timeouts
  7. 07Prometheus → Plano admin :9000: GET /metrics
  8. 08Plano admin :9000 → state.rs: lê contadores
02

Decisões

  1. 01

    Buckets em DashMap particionado, com refill preguiçoso e teto

    Por quê Coloquei os buckets num DashMap particionado e os reabasteço no acesso, então IPs distintos raramente disputam o mesmo lock. Um sweeper de buckets ociosos e o teto AEGIS_MAX_BUCKETS limitam a memória sob floods de IPs forjados.

  2. 02

    Recarga por SIGHUP via ArcSwap

    Por quê Troco o estado recarregável inteiro num ArcSwap. Assim um SIGHUP recarrega backend, limites, regras por rota, teto de corpo e timeouts sem derrubar conexões.

    Custo Endereços de escuta e material TLS ficam fixos durante a vida do processo, porque os sockets já estão abertos.

  3. 03

    TLS opcional, atrás de uma feature do Cargo

    Por quê Deixei o TLS atrás de uma feature para manter o build padrão pequeno e totalmente estático, sem dependências de C ou de criptografia.

    Custo Terminação TLS e backends https:// só existem com --features tls, e o plano admin continua em texto puro.

  4. 04

    Salto para o backend sempre em HTTP/1.1

    Por quê O cliente pode falar HTTP/2 via TLS, mas o cliente de upstream em pool fala HTTP/1.1. Fixei a versão para desacoplar os dois saltos, o que corrigiu um erro 502 UserUnsupportedVersion.

    Custo O backend nunca recebe HTTP/2, mesmo quando o cliente usa HTTP/2.

  5. 05

    panic = "abort" no perfil de release

    Por quê Sem tabelas de unwinding, o binário fica menor. No mesmo perfil uso LTO fat, codegen-units = 1 e strip.

    Custo Um handler que entra em pânico derruba o processo inteiro em vez de ser isolado por requisição.

03

Ao vivo

Token bucket · Aegislimiter.rs · regra /api=10:20
20.0 / 20 tokens no balde0 repassadas0 limitadas
  1. Dispare requisições contra GET /api/orders.
04

Números com fonte

Cada número aponta para o arquivo do repositório de onde saiu.

Taxa sustentada padrão por cliente
100 req/s
fonte: README.md, .env.example
Burst padrão por cliente
200
fonte: README.md, .env.example
Teto de buckets rastreados
1.000.000 / 1,000,000
fonte: README.md, .env.example
TTL de bucket ocioso
300 s
fonte: README.md, .env.example
Intervalo do sweeper
60 s
fonte: README.md, .env.example
Agrupamento IPv6
/64
fonte: README.md, src/limiter.rs
Timeout de conexão ao upstream
5 s
fonte: README.md, .env.example
Timeout de requisição ao upstream (504)
30 s
fonte: README.md, .env.example
Timeout total da requisição (408)
60 s
fonte: README.md, .env.example
Prazo de leitura de headers HTTP/1 (slowloris)
10 s
fonte: README.md, .env.example
Content-Length máximo (413)
10485760 bytes (10 MiB)
fonte: README.md, .env.example
Prazo de drenagem no desligamento
30 s
fonte: README.md, .env.example
Portas proxy / admin
8080 / 9000
fonte: README.md, Dockerfile
Usuário da imagem Docker
65534:65534 (nobody), FROM scratch
fonte: Dockerfile
Funções de teste
15 (13 unitárias, 2 de integração) / 15 (13 unit, 2 integration)
fonte: src/limiter.rs, src/proxy.rs, tests/rate_limit.rs
Jobs de CI
3 (check, cargo-audit, imagem Docker) / 3 (check, cargo-audit, Docker image)
fonte: .github/workflows/ci.yml
05

Stack

  • Rust
  • Tokio
  • Axum
  • Hyper
  • Prometheus
  • Docker