fbonalair/traefik-crowdsec-bouncer

View on GitHub
controler/controler.go

Summary

Maintainability
A
0 mins
Test Coverage
package controler

import (
    "bytes"
    "encoding/json"
    "fmt"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
    "github.com/prometheus/client_golang/prometheus/promhttp"
    "io"
    "io/ioutil"
    "net/http"
    "net/url"
    "strconv"
    "time"

    . "github.com/fbonalair/traefik-crowdsec-bouncer/config"
    "github.com/fbonalair/traefik-crowdsec-bouncer/model"
    "github.com/gin-gonic/gin"
    "github.com/rs/zerolog/log"
)

const (
    realIpHeader         = "X-Real-Ip"
    forwardHeader        = "X-Forwarded-For"
    crowdsecAuthHeader   = "X-Api-Key"
    crowdsecBouncerRoute = "v1/decisions"
    healthCheckIp        = "127.0.0.1"
)

var crowdsecBouncerApiKey = RequiredEnv("CROWDSEC_BOUNCER_API_KEY")
var crowdsecBouncerHost = RequiredEnv("CROWDSEC_AGENT_HOST")
var crowdsecBouncerScheme = OptionalEnv("CROWDSEC_BOUNCER_SCHEME", "http")
var crowdsecBanResponseCode, _ = strconv.Atoi(OptionalEnv("CROWDSEC_BOUNCER_BAN_RESPONSE_CODE", "403")) // Validated via ValidateEnv()
var crowdsecBanResponseMsg = OptionalEnv("CROWDSEC_BOUNCER_BAN_RESPONSE_MSG", "Forbidden")
var (
    ipProcessed = promauto.NewCounter(prometheus.CounterOpts{
        Name: "crowdsec_traefik_bouncer_processed_ip_total",
        Help: "The total number of processed IP",
    })
)

var client = &http.Client{
    Transport: &http.Transport{
        MaxIdleConns:    10,
        IdleConnTimeout: 30 * time.Second,
    },
    Timeout: 5 * time.Second,
}

/**
Call Crowdsec local IP and with realIP and return true if IP does NOT have a ban decisions.
*/
func isIpAuthorized(clientIP string) (bool, error) {
    // Generating crowdsec API request
    decisionUrl := url.URL{
        Scheme:   crowdsecBouncerScheme,
        Host:     crowdsecBouncerHost,
        Path:     crowdsecBouncerRoute,
        RawQuery: fmt.Sprintf("type=ban&ip=%s", clientIP),
    }
    req, err := http.NewRequest(http.MethodGet, decisionUrl.String(), nil)
    if err != nil {
        return false, err
    }
    req.Header.Add(crowdsecAuthHeader, crowdsecBouncerApiKey)
    log.Debug().
        Str("method", http.MethodGet).
        Str("url", decisionUrl.String()).
        Msg("Request Crowdsec's decision Local API")

    // Calling crowdsec API
    resp, err := client.Do(req)
    if err != nil {
        return false, err
    }
    if resp.StatusCode == http.StatusForbidden {
        return false, err
    }

    // Parsing response
    defer func(Body io.ReadCloser) {
        err := Body.Close()
        if err != nil {
            log.Err(err).Msg("An error occurred while closing body reader")
        }
    }(resp.Body)
    reqBody, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return false, err
    }
    if bytes.Equal(reqBody, []byte("null")) {
        log.Debug().Msgf("No decision for IP %q. Accepting", clientIP)
        return true, nil
    }

    log.Debug().RawJSON("decisions", reqBody).Msg("Found Crowdsec's decision(s), evaluating ...")
    var decisions []model.Decision
    err = json.Unmarshal(reqBody, &decisions)
    if err != nil {
        return false, err
    }

    // Authorization logic
    return len(decisions) < 0, nil
}

/*
    Main route used by Traefik to verify authorization for a request
*/
func ForwardAuth(c *gin.Context) {
    ipProcessed.Inc()
    clientIP := c.ClientIP()

    log.Debug().
        Str("ClientIP", clientIP).
        Str("RemoteAddr", c.Request.RemoteAddr).
        Str(forwardHeader, c.Request.Header.Get(forwardHeader)).
        Str(realIpHeader, c.Request.Header.Get(realIpHeader)).
        Msg("Handling forwardAuth request")

    // Getting and verifying ip using ClientIP function
    isAuthorized, err := isIpAuthorized(clientIP)
    if err != nil {
        log.Warn().Err(err).Msgf("An error occurred while checking IP %q", c.Request.Header.Get(clientIP))
        c.String(crowdsecBanResponseCode, crowdsecBanResponseMsg)
    } else if !isAuthorized {
        c.String(crowdsecBanResponseCode, crowdsecBanResponseMsg)
    } else {
        c.Status(http.StatusOK)
    }
}

/*
    Route to check bouncer connectivity with Crowdsec agent. Mainly use for Kubernetes readiness probe
*/
func Healthz(c *gin.Context) {
    isHealthy, err := isIpAuthorized(healthCheckIp)
    if err != nil || !isHealthy {
        log.Warn().Err(err).Msgf("The health check did not pass. Check error if present and if the IP %q is authorized", healthCheckIp)
        c.Status(http.StatusForbidden)
    } else {
        c.Status(http.StatusOK)
    }
}

/*
    Simple route responding pong to every request. Mainly use for Kubernetes liveliness probe
*/
func Ping(c *gin.Context) {
    c.String(http.StatusOK, "pong")
}

func Metrics(c *gin.Context) {
    handler := promhttp.Handler()
    handler.ServeHTTP(c.Writer, c.Request)
}