net/http — ImmutableLog
Middleware para a biblioteca padrão do Go. Compatível com qualquer router que aceite http.Handler — gorilla/mux, chi, ServeMux nativo. Zero dependências além do stdlib.
Código do middleware
Crie um arquivo immutablelog/middleware.go no seu projeto. Usa apenas a biblioteca padrão do Go — nenhuma dependência externa.
package immutablelog
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// contextKey is unexported to avoid collisions with other packages.
type contextKey string
const EventNameKey contextKey = "imtbl.eventName"
const TrailKey contextKey = "imtbl.trail"
type Options struct {
APIKey string
ServiceName string
Env string
APIURL string
SkipPaths []string
// Timezone do cliente — usado pelo dashboard para exibir os timestamps.
// Client timezone — used by the dashboard to render timestamps.
ClientTZ string
ClientOffsetMinutes int
}
// sanitizeTrail normaliza o immutable_trail conforme as regras do servidor:
// trim, nao-vazio, max 256 chars, sem ':' (o servidor responde 400 se violado).
func sanitizeTrail(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
v = strings.ReplaceAll(v, ":", "-")
if len(v) > 256 {
v = v[:256]
}
return v
}
// responseWriter wraps http.ResponseWriter to capture the status code.
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
func (rw *responseWriter) Status() int {
if rw.statusCode == 0 {
return http.StatusOK
}
return rw.statusCode
}
// Middleware wraps an http.Handler and sends an audit event to ImmutableLog
// for every request not in SkipPaths. The event is sent asynchronously —
// it never blocks the HTTP response.
func Middleware(opts Options) func(http.Handler) http.Handler {
if opts.APIURL == "" {
opts.APIURL = "https://api.immutablelog.com"
}
if opts.APIKey == "" {
opts.APIKey = os.Getenv("IMTBL_API_KEY")
}
if opts.ServiceName == "" {
opts.ServiceName = os.Getenv("IMTBL_SERVICE_NAME")
}
if opts.Env == "" {
opts.Env = os.Getenv("IMTBL_ENV")
}
if opts.ClientTZ == "" {
opts.ClientTZ = "America/Sao_Paulo"
}
if opts.ClientOffsetMinutes == 0 {
opts.ClientOffsetMinutes = -180
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip health checks and monitoring paths
for _, p := range opts.SkipPaths {
if strings.HasPrefix(r.URL.Path, p) {
next.ServeHTTP(w, r)
return
}
}
startedAt := time.Now()
// Buffer the request body so downstream handlers can still read it
var bodyBytes []byte
if r.Body != nil {
bodyBytes, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
// Wrap the response writer to capture the status code
wrapped := &responseWriter{ResponseWriter: w}
next.ServeHTTP(wrapped, r)
// Fire-and-forget — never blocks the response
go emit(r, wrapped.Status(), time.Since(startedAt), bodyBytes, opts)
})
}
}
type eventPayload struct {
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
LatencyMs int64 `json:"latency_ms"`
ClientIP string `json:"client_ip"`
UserAgent string `json:"user_agent,omitempty"`
RequestBodyHash string `json:"request_body_hash,omitempty"`
}
// Todos os valores de meta precisam ser strings (omitempty para opcionais).
// Every meta value must be a string (omitempty for optional fields).
type eventMeta struct {
Type string `json:"type"`
EventName string `json:"event_name"`
Service string `json:"service"`
Env string `json:"env"`
RequestID string `json:"request_id"`
ImmutableTrail string `json:"immutable_trail,omitempty"`
}
type eventBody struct {
Payload string `json:"payload"`
Meta eventMeta `json:"meta"`
}
func emit(r *http.Request, status int, elapsed time.Duration, body []byte, opts Options) {
requestID := r.Header.Get("X-Request-Id")
if requestID == "" {
requestID = fmt.Sprintf("%d", time.Now().UnixNano())
}
// Custom event name via context (set by a handler before responding)
eventName, _ := r.Context().Value(EventNameKey).(string)
if eventName == "" {
eventName = strings.ToLower(r.Method) + "." +
strings.ReplaceAll(strings.TrimPrefix(r.URL.Path, "/"), "/", ".")
}
// Trilha imutavel: context TrailKey -> header X-Imtbl-Trail.
trail, _ := r.Context().Value(TrailKey).(string)
trail = sanitizeTrail(trail)
if trail == "" {
trail = sanitizeTrail(r.Header.Get("X-Imtbl-Trail"))
}
kind := "success"
if status >= 400 {
kind = "error"
} else if status >= 300 {
kind = "info"
}
p := eventPayload{
Method: r.Method,
Path: r.URL.Path,
Status: status,
LatencyMs: elapsed.Milliseconds(),
ClientIP: clientIP(r),
UserAgent: r.UserAgent(),
}
if len(body) > 0 {
p.RequestBodyHash = sha256Hex(body)
}
payloadJSON, err := json.Marshal(p)
if err != nil {
return
}
evt := eventBody{
Payload: string(payloadJSON),
Meta: eventMeta{
Type: kind,
EventName: eventName,
Service: opts.ServiceName,
Env: opts.Env,
RequestID: requestID,
ImmutableTrail: trail,
},
}
evtJSON, err := json.Marshal(evt)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
opts.APIURL+"/v1/events", bytes.NewBuffer(evtJSON))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+opts.APIKey)
// Idempotency-Key e OBRIGATORIO (sem ele a API responde 400).
req.Header.Set("Idempotency-Key", eventName+"-"+requestID)
req.Header.Set("Request-Id", requestID)
req.Header.Set("X-Client-TZ", opts.ClientTZ)
req.Header.Set("X-Client-Offset-Minutes", fmt.Sprintf("%d", opts.ClientOffsetMinutes))
if trail != "" {
req.Header.Set("X-Imtbl-Trail", trail)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.SplitN(xff, ",", 2)[0]
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
return r.RemoteAddr
}
func sha256Hex(data []byte) string {
h := sha256.Sum256(data)
return fmt.Sprintf("%x", h)
}Registro no servidor
package main
import (
"net/http"
"github.com/yourorg/yourapp/immutablelog"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/users", usersHandler)
mux.HandleFunc("/payments", paymentsHandler)
mux.HandleFunc("/health", healthHandler)
audit := immutablelog.Middleware(immutablelog.Options{
APIKey: "iml_live_xxxx", // or use env IMTBL_API_KEY
ServiceName: "my-api",
Env: "production",
SkipPaths: []string{"/health", "/metrics"},
})
http.ListenAndServe(":8080", audit(mux))
}Como funciona
1. responseWriter wrapper
http.ResponseWriter não expõe o status code após ser enviado. O wrapper intercepta WriteHeader() e armazena o código para o middleware ler depois de next.ServeHTTP().
2. io.NopCloser
O body HTTP é um stream lido apenas uma vez. O middleware lê com io.ReadAll() e restaura com io.NopCloser() para que handlers downstream possam ler normalmente.
3. go emit()
O evento é enviado em uma goroutine com go emit(). A resposta ao cliente já foi enviada. context.WithTimeout(5s) evita que a goroutine fique presa indefinidamente.
4. contextKey tipada
A chave de contexto é um tipo privado (type contextKey string) para evitar colisões com outras bibliotecas que usam context.WithValue().
Evento customizado
Use context.WithValue() no handler para definir um nome semântico de evento. O middleware lê o valor após next.ServeHTTP().
package handlers
import (
"context"
"net/http"
"github.com/yourorg/yourapp/immutablelog"
)
func PaymentHandler(w http.ResponseWriter, r *http.Request) {
// Set custom event name — middleware reads it via context after ServeHTTP
ctx := context.WithValue(r.Context(), immutablelog.EventNameKey, "payment.created")
r = r.WithContext(ctx)
// ... process payment ...
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"ok":true}`))
}Trilha imutável (immutable_trail)
A trilha agrupa eventos relacionados em uma mesma linha do tempo auditável. Defina via context.WithValue(r.Context(), immutablelog.TrailKey, ...) no handler ou propague entre serviços com o header X-Imtbl-Trail.
func RunFlowHandler(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), immutablelog.TrailKey, "flow-123")
r = r.WithContext(ctx)
// ... business logic ...
w.WriteHeader(http.StatusOK)
}A trilha não pode ser vazia, exceder 256 caracteres ou conter ":" — violações retornam 400 invalid_immutable_trail. O sanitizeTrail corrige o valor antes do envio.
Compatibilidade com routers
O middleware retorna func(http.Handler) http.Handler — o padrão mais comum no ecossistema Go. Funciona com qualquer router que siga essa interface.
// Works with any http.Handler-compatible router:
// Standard library ServeMux
http.ListenAndServe(":8080", audit(mux))
// gorilla/mux
router := mux.NewRouter()
http.ListenAndServe(":8080", audit(router))
// chi
r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
return audit(next)
})
http.ListenAndServe(":8080", r)Recover de panics
Adicione um middleware de recover separado. O audit captura o status code mesmo em caso de panic, porque next.ServeHTTP() já retornou antes do recover.
// Place recover AFTER audit in the chain so audit sees the panic status.
// With net/http, panics bubble up — add a recover middleware explicitly.
func RecoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// Chain: recover → audit → handler
// audit fires after the response is written (even after a panic)
handler := RecoverMiddleware(audit(mux))Campos de meta
Além do payload, o objeto meta carrega os campos que o SIEM usa para normalizar (ECS) e enriquecer o evento. Todos os valores de meta são strings.
| Campo em meta | O que faz no SIEM |
|---|---|
event_name | O rótulo do evento (ex.: http.GET.auth-me-user, Pagamento erro) — o identificador que aparece na listagem. Vira event.action e deriva event.category (ex.: nome com login → authentication). |
client_ip | Vira source.ip com precedência máxima e proveniência client_asserted. Ver a seção abaixo. |
immutable_trail | Vira imtbl.immutable_trail — agrupa eventos relacionados para investigação (ex.: um pedido, um usuário). |
type | Severidade do evento: error / warning / info / success. Colore o badge na listagem. (event_type é aceito como sinônimo.) |
service, env, request_id | Metadados: indexados e consultáveis no painel (busca/filtros). |
client_ip — IP do usuário final
O problema que resolve
Entre o navegador e o core há proxies/ALB. O IP que o core observa na conexão é o do salto anterior (o backend do cliente ou um proxy da AWS), não o do usuário. Sem ação, source.ip seria o IP do proxy — inútil para geo, threat e detecção por IP. (Exemplo real: o XFF chegava 44.192.13.3 (AWS) enquanto o usuário era 179.110.4.205.)
A solução
O backend do cliente é o único que enxerga o IP real do navegador — então ele encaminha esse IP em meta.client_ip ao chamar POST /v1/events.
Como o SIEM trata
- Valida que é um IP (v4/v6). Um valor não-IP é ignorado — não derruba o evento.
- Escreve em
source.ipcom precedência máxima (ver ordem abaixo). - Carimba
imtbl.source_ip_origin = "client_asserted"— a UI mostra o selo “IP do usuário”. - Alimenta GeoIP, threat intel e regras de detecção por IP.
client_ipX-Forwarded-For (1º hop)X-Real-IPIP de conexãoProveniência e confiança
client_ip é asserido pelo tenant — o backend do cliente afirma o IP, e é spoofável por quem controla aquele backend. Porém é auto-contido ao próprio tenant (não cruza a fronteira de outro tenant). Um hit de threat/geo sobre um client_asserted é uma alegação, não uma observação de borda.
Como obter o IP no seu backend
Pegue o primeiro IP do X-Forwarded-For (o mais próximo do usuário); no fallback, use o IP de conexão.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0]) // primeiro hop
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
return host
}
meta := map[string]string{"event_name": "user.login", "client_ip": clientIP(r)}Exemplo de payload
{
"payload": "{\"user\":\"bob\",\"action\":\"login\"}",
"meta": {
"event_name": "user.login",
"client_ip": "179.110.4.205",
"immutable_trail": "user-bob"
}
}Nota de PII: client_ip é PII e vai selado no bloco imutável (permanente). Decida conscientemente entre evidência forense e “direito ao esquecimento” antes de enviá-lo.
Esta documentação reflete o comportamento atual da integração. Para dúvidas ou integrações avançadas, entre em contato com o time de suporte.
