Implémentation du layer "circuitbreaker" #6

Merged
wpetit merged 3 commits from circuitbreaker into develop 2023-07-06 16:46:19 +02:00
15 changed files with 410 additions and 10 deletions

View File

@ -9,13 +9,13 @@
## Référence ## Référence
- [(FR) - Layers](./fr/references/layers/README.md) - [(FR) - Layers](./fr/references/layers/README.md)
- [Fichier de configuration](../misc/packaging/common/config.yml) - [(FR) - Fichier de configuration](../misc/packaging/common/config.yml)
## Tutoriels ## Tutoriels
### Utilisation ### Utilisation
- [(FR) - Ajouter un calque de type "file d'attente"](./fr/tutorials/add-queue-layer.md) - [(FR) - Ajouter un layer de type "file d'attente"](./fr/tutorials/add-queue-layer.md)
### Développement ### Développement

View File

@ -2,4 +2,5 @@
Vous trouverez ci-dessous la liste des entités "Layer" activables sur vos entité "Proxy": Vous trouverez ci-dessous la liste des entités "Layer" activables sur vos entité "Proxy":
- [Queue](./queue.md) - File d'attente dynamique - [Queue](./queue.md) - File d'attente dynamique
- [Circuit Breaker](./circuitbreaker.md) - Coupure d'accès à un site ou une sous section de celui ci

View File

@ -0,0 +1,37 @@
# Layer "Circuit Breaker"
## Description
Ce layer permet de bloquer l'accès à un site (ou une section de celui ci) ciblé par un proxy.
## Type
`circuitbreaker`
## Options
### `authorizedCIDRs`
- **Type:** `[]string`
- **Valeur par défaut:** `[]`
- **Description:** Autoriser les adresses distantes contenues dans un des masques réseau (en notation ["CIDR"](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_notation) définis à contourner la restriction d'accès.
### `matchURLs`
- **Type:** `[]string`
- **Valeur par défaut:** `["*"]`
- **Description:** Limiter l'action du layer à cette liste de patrons d'URLs.
Par exemple, si vous souhaitez limiter votre restriction d'accès à l'ensemble d'une section "`/blog`" d'un site, vous pouvez déclarer la valeur `["*/blog*"]`. Les autres URLs du site ne seront pas affectées par la restriction.
### `templateBlock`
- **Type:** `string`
- **Valeur par défaut:** `"default"`
- **Description:** Bloc du template HTML pour effectuer le rendu de la page indiquant la restriction d'accès.
Voir le [fichier de configuration de référence](../../../../misc/packaging/common/config.yml), section `layers.circuitbreaker` pour voir les options permettant de personnaliser le chemin du répertoire contenant les templates.
### Schéma
Voir le [schéma JSON](../../../../internal/proxy/director/layer/circuitbreaker/layer-options.json).

View File

@ -9,6 +9,7 @@ import (
"forge.cadoles.com/cadoles/bouncer/internal/auth" "forge.cadoles.com/cadoles/bouncer/internal/auth"
"forge.cadoles.com/cadoles/bouncer/internal/auth/jwt" "forge.cadoles.com/cadoles/bouncer/internal/auth/jwt"
bouncerChi "forge.cadoles.com/cadoles/bouncer/internal/chi"
"forge.cadoles.com/cadoles/bouncer/internal/config" "forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/jwk" "forge.cadoles.com/cadoles/bouncer/internal/jwk"
"forge.cadoles.com/cadoles/bouncer/internal/store" "forge.cadoles.com/cadoles/bouncer/internal/store"
@ -91,7 +92,11 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
router := chi.NewRouter() router := chi.NewRouter()
router.Use(middleware.Logger) if s.serverConfig.HTTP.UseRealIP {
router.Use(middleware.RealIP)
}
router.Use(middleware.RequestLogger(bouncerChi.NewLogFormatter()))
if s.serverConfig.Sentry.DSN != "" { if s.serverConfig.Sentry.DSN != "" {
logger.Info(ctx, "enabling sentry http middleware") logger.Info(ctx, "enabling sentry http middleware")

View File

@ -1,13 +1,15 @@
package config package config
type HTTPConfig struct { type HTTPConfig struct {
Host InterpolatedString `yaml:"host"` Host InterpolatedString `yaml:"host"`
Port InterpolatedInt `yaml:"port"` Port InterpolatedInt `yaml:"port"`
UseRealIP InterpolatedBool `yaml:"useRealIP"`
} }
func NewHTTPConfig(host string, port int) HTTPConfig { func NewHTTPConfig(host string, port int) HTTPConfig {
return HTTPConfig{ return HTTPConfig{
Host: InterpolatedString(host), Host: InterpolatedString(host),
Port: InterpolatedInt(port), Port: InterpolatedInt(port),
UseRealIP: true,
} }
} }

View File

@ -3,7 +3,8 @@ package config
import "time" import "time"
type LayersConfig struct { type LayersConfig struct {
Queue QueueLayerConfig `yaml:"queue"` Queue QueueLayerConfig `yaml:"queue"`
CircuitBreaker CircuitBreakerLayerConfig `yaml:"circuitbreaker"`
} }
func NewDefaultLayersConfig() LayersConfig { func NewDefaultLayersConfig() LayersConfig {
@ -12,6 +13,9 @@ func NewDefaultLayersConfig() LayersConfig {
TemplateDir: "./layers/queue/templates", TemplateDir: "./layers/queue/templates",
DefaultKeepAlive: NewInterpolatedDuration(time.Minute), DefaultKeepAlive: NewInterpolatedDuration(time.Minute),
}, },
CircuitBreaker: CircuitBreakerLayerConfig{
TemplateDir: "./layers/circuitbreaker/templates",
},
} }
} }
@ -19,3 +23,7 @@ type QueueLayerConfig struct {
TemplateDir InterpolatedString `yaml:"templateDir"` TemplateDir InterpolatedString `yaml:"templateDir"`
DefaultKeepAlive *InterpolatedDuration `yaml:"defaultKeepAlive"` DefaultKeepAlive *InterpolatedDuration `yaml:"defaultKeepAlive"`
} }
type CircuitBreakerLayerConfig struct {
TemplateDir InterpolatedString `yaml:"templateDir"`
}

View File

@ -0,0 +1,23 @@
{
"$id": "https://forge.cadoles.com/cadoles/bouncer/schemas/circuitbreaker-layer-options",
"title": "Circuit breaker layer options",
"type": "object",
"properties": {
"matchURLs": {
"type": "array",
"items": {
"type": "string"
}
},
"authorizedCIDRs": {
"type": "array",
"items": {
"type": "string"
}
},
"templateBlock": {
"type": "string"
}
},
"additionalProperties": false
}

View File

@ -0,0 +1,151 @@
package circuitbreaker
import (
"context"
"html/template"
"net"
"net/http"
"path/filepath"
"sync"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/Masterminds/sprig/v3"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
const LayerType store.LayerType = "circuitbreaker"
type Layer struct {
templateDir string
loadOnce sync.Once
tmpl *template.Template
}
// LayerType implements director.MiddlewareLayer
func (l *Layer) LayerType() store.LayerType {
return LayerType
}
// Middleware implements director.MiddlewareLayer
func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
options, err := fromStoreOptions(layer.Options)
if err != nil {
logger.Error(ctx, "could not parse layer options", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
matches, err := l.matchAnyAuthorizedCIDRs(ctx, r.RemoteAddr, options.AuthorizedCIDRs)
if err != nil {
logger.Error(ctx, "could not match authorized cidrs", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if matches {
h.ServeHTTP(w, r)
return
}
matches = wildcard.MatchAny(r.URL.String(), options.MatchURLs...)
if !matches {
h.ServeHTTP(w, r)
return
}
l.renderCircuitBreakerPage(w, r, layer, options)
}
return http.HandlerFunc(fn)
}
}
func (l *Layer) matchAnyAuthorizedCIDRs(ctx context.Context, remoteHostPort string, CIDRs []string) (bool, error) {
remoteHost, _, err := net.SplitHostPort(remoteHostPort)
if err != nil {
return false, errors.WithStack(err)
}
remoteAddr := net.ParseIP(remoteHost)
if remoteAddr == nil {
return false, errors.Errorf("remote host '%s' is not a valid ip address", remoteHost)
}
for _, rawCIDR := range CIDRs {
_, net, err := net.ParseCIDR(rawCIDR)
if err != nil {
return false, errors.WithStack(err)
}
match := net.Contains(remoteAddr)
if !match {
continue
}
return true, nil
}
logger.Debug(ctx, "comparing remote host with authorized cidrs", logger.F("remoteAddr", remoteAddr))
return false, nil
}
func (l *Layer) renderCircuitBreakerPage(w http.ResponseWriter, r *http.Request, layer *store.Layer, options *LayerOptions) {
ctx := r.Context()
pattern := filepath.Join(l.templateDir, "*.gohtml")
logger.Info(ctx, "loading circuit breaker page templates", logger.F("pattern", pattern))
tmpl, err := template.New("").Funcs(sprig.FuncMap()).ParseGlob(pattern)
if err != nil {
logger.Error(ctx, "could not load circuit breaker templates", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
templateData := struct {
Layer *store.Layer
LayerOptions *LayerOptions
}{
Layer: layer,
LayerOptions: options,
}
w.Header().Add("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
if err := tmpl.ExecuteTemplate(w, options.TemplateBlock, templateData); err != nil {
logger.Error(ctx, "could not render circuit breaker page", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
func New(funcs ...OptionFunc) *Layer {
opts := defaultOptions()
for _, fn := range funcs {
fn(opts)
}
return &Layer{
templateDir: opts.TemplateDir,
}
}
var _ director.MiddlewareLayer = &Layer{}

View File

@ -0,0 +1,36 @@
package circuitbreaker
import (
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/mitchellh/mapstructure"
"github.com/pkg/errors"
)
type LayerOptions struct {
MatchURLs []string `mapstructure:"matchURLs"`
AuthorizedCIDRs []string `mapstructure:"authorizedCIDRs"`
TemplateBlock string `mapstructure:"templateBlock"`
}
func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
layerOptions := LayerOptions{
MatchURLs: []string{"*"},
AuthorizedCIDRs: []string{},
TemplateBlock: "default",
}
config := mapstructure.DecoderConfig{
Result: &layerOptions,
}
decoder, err := mapstructure.NewDecoder(&config)
if err != nil {
return nil, err
}
if err := decoder.Decode(storeOptions); err != nil {
return nil, errors.WithStack(err)
}
return &layerOptions, nil
}

View File

@ -0,0 +1,19 @@
package circuitbreaker
type Options struct {
TemplateDir string
}
type OptionFunc func(*Options)
func defaultOptions() *Options {
return &Options{
TemplateDir: "./templates",
}
}
func WithTemplateDir(templateDir string) OptionFunc {
return func(o *Options) {
o.TemplateDir = templateDir
}
}

View File

@ -0,0 +1,8 @@
package circuitbreaker
import (
_ "embed"
)
//go:embed layer-options.json
var RawLayerOptionsSchema []byte

View File

@ -89,6 +89,10 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
s.directorLayers..., s.directorLayers...,
) )
if s.serverConfig.HTTP.UseRealIP {
router.Use(middleware.RealIP)
}
router.Use(middleware.RequestLogger(bouncerChi.NewLogFormatter())) router.Use(middleware.RequestLogger(bouncerChi.NewLogFormatter()))
if s.serverConfig.Sentry.DSN != "" { if s.serverConfig.Sentry.DSN != "" {

View File

@ -0,0 +1,21 @@
package setup
import (
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/circuitbreaker"
)
func init() {
RegisterLayer(circuitbreaker.LayerType, setupCircuitBreakerLayer, circuitbreaker.RawLayerOptionsSchema)
}
func setupCircuitBreakerLayer(conf *config.Config) (director.Layer, error) {
options := []circuitbreaker.OptionFunc{
circuitbreaker.WithTemplateDir(string(conf.Layers.CircuitBreaker.TemplateDir)),
}
return circuitbreaker.New(
options...,
), nil
}

View File

@ -0,0 +1,73 @@
{{ define "default" }}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Accès bloqué - {{ .Layer.Name }}</title>
<style>
html {
box-sizing: border-box;
font-size: 16px;
}
*, *:before, *:after {
box-sizing: inherit;
}
body, h1, h2, h3, h4, h5, h6, p, ol, ul {
margin: 0;
padding: 0;
font-weight: normal;
}
html, body {
width: 100%;
height: 100%;
font-family: Arial, Helvetica, sans-serif;
background-color: #f7f7f7;
}
#container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
flex-direction: column;
}
#card {
padding: 1.5em 1em;
border: 1px solid #e0e0e0;
background-color: white;
border-radius: 5px;
box-shadow: 2px 2px #cccccc1c;
color: #333333 !important;
}
.title {
margin-bottom: 1.2em;
}
p {
margin-bottom: 0.5em;
}
.footer {
font-size: 0.7em;
margin-top: 2em;
text-align: right;
}
</style>
</head>
<body>
<div id="container">
<div id="card">
<h2 class="title">Page indisponible</h2>
<p>La page à laquelle vous souhaitez accéder est actuellement indisponible.</p>
<p class="footer">Propulsé par <a href="https://forge.cadoles.com/Cadoles/bouncer">Bouncer</a>.</p>
</div>
</div>
</body>
</html>
{{ end }}

View File

@ -6,6 +6,9 @@ admin:
host: 127.0.0.1 host: 127.0.0.1
# Port d'écoute du service # Port d'écoute du service
port: 8081 port: 8081
# Utiliser les entêtes HTTP True-Client-IP, X-Real-IP ou X-Forwarded-For
# pour le calcul de l'adresse distante à l'origine des requêtes
useRealIP: true
# Configuration CORS du service # Configuration CORS du service
# Uniquement nécessaire si un frontend web # Uniquement nécessaire si un frontend web
@ -73,6 +76,9 @@ proxy:
host: 0.0.0.0 host: 0.0.0.0
# Port d'écoute du service # Port d'écoute du service
port: 8080 port: 8080
# Utiliser les entêtes HTTP True-Client-IP, X-Real-IP ou X-Forwarded-For
# pour le calcul de l'adresse distante à l'origine des requêtes
useRealIP: true
# Métriques Prometheus # Métriques Prometheus
metrics: metrics:
@ -161,4 +167,10 @@ layers:
# Répertoire contenant les templates # Répertoire contenant les templates
templateDir: "/etc/bouncer/layers/queue/templates" templateDir: "/etc/bouncer/layers/queue/templates"
# Temps de vie par défaut d'une session # Temps de vie par défaut d'une session
defaultKeepAlive: 1m defaultKeepAlive: 1m
# Configuration du layer "circuitbreaker"
circuitbreaker:
# Répertoire contenant les templates
templateDir: "/etc/bouncer/layers/circuitbreaker/templates"