From a207291c045cd3170799ab1872a20a9f2308a959 Mon Sep 17 00:00:00 2001 From: William Petit Date: Wed, 5 Jul 2023 19:19:45 -0600 Subject: [PATCH] feat: implements circuitbreaker layer --- doc/README.md | 2 +- doc/fr/references/layers/README.md | 3 +- doc/fr/references/layers/circuitbreaker.md | 37 +++++ internal/config/layers.go | 10 +- .../layer/circuitbreaker/layer-options.json | 23 +++ .../director/layer/circuitbreaker/layer.go | 151 ++++++++++++++++++ .../layer/circuitbreaker/layer_options.go | 36 +++++ .../director/layer/circuitbreaker/options.go | 19 +++ .../director/layer/circuitbreaker/schema.go | 8 + internal/setup/circuitbreaker_layer.go | 21 +++ .../circuitbreaker/templates/default.gohtml | 73 +++++++++ misc/packaging/common/config.yml | 8 +- 12 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 doc/fr/references/layers/circuitbreaker.md create mode 100644 internal/proxy/director/layer/circuitbreaker/layer-options.json create mode 100644 internal/proxy/director/layer/circuitbreaker/layer.go create mode 100644 internal/proxy/director/layer/circuitbreaker/layer_options.go create mode 100644 internal/proxy/director/layer/circuitbreaker/options.go create mode 100644 internal/proxy/director/layer/circuitbreaker/schema.go create mode 100644 internal/setup/circuitbreaker_layer.go create mode 100644 layers/circuitbreaker/templates/default.gohtml diff --git a/doc/README.md b/doc/README.md index 42a89d0..662e994 100644 --- a/doc/README.md +++ b/doc/README.md @@ -9,7 +9,7 @@ ## Référence - [(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 diff --git a/doc/fr/references/layers/README.md b/doc/fr/references/layers/README.md index 206d48f..24c3f86 100644 --- a/doc/fr/references/layers/README.md +++ b/doc/fr/references/layers/README.md @@ -2,4 +2,5 @@ Vous trouverez ci-dessous la liste des entités "Layer" activables sur vos entité "Proxy": -- [Queue](./queue.md) - File d'attente dynamique \ No newline at end of file +- [Queue](./queue.md) - File d'attente dynamique +- [Circuit Breaker](./circuitbreaker.md) - Coupure d'accès à un site ou une sous section de celui ci \ No newline at end of file diff --git a/doc/fr/references/layers/circuitbreaker.md b/doc/fr/references/layers/circuitbreaker.md new file mode 100644 index 0000000..bb48090 --- /dev/null +++ b/doc/fr/references/layers/circuitbreaker.md @@ -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). \ No newline at end of file diff --git a/internal/config/layers.go b/internal/config/layers.go index b9e6630..797f097 100644 --- a/internal/config/layers.go +++ b/internal/config/layers.go @@ -3,7 +3,8 @@ package config import "time" type LayersConfig struct { - Queue QueueLayerConfig `yaml:"queue"` + Queue QueueLayerConfig `yaml:"queue"` + CircuitBreaker CircuitBreakerLayerConfig `yaml:"circuitbreaker"` } func NewDefaultLayersConfig() LayersConfig { @@ -12,6 +13,9 @@ func NewDefaultLayersConfig() LayersConfig { TemplateDir: "./layers/queue/templates", DefaultKeepAlive: NewInterpolatedDuration(time.Minute), }, + CircuitBreaker: CircuitBreakerLayerConfig{ + TemplateDir: "./layers/circuitbreaker/templates", + }, } } @@ -19,3 +23,7 @@ type QueueLayerConfig struct { TemplateDir InterpolatedString `yaml:"templateDir"` DefaultKeepAlive *InterpolatedDuration `yaml:"defaultKeepAlive"` } + +type CircuitBreakerLayerConfig struct { + TemplateDir InterpolatedString `yaml:"templateDir"` +} diff --git a/internal/proxy/director/layer/circuitbreaker/layer-options.json b/internal/proxy/director/layer/circuitbreaker/layer-options.json new file mode 100644 index 0000000..4a0772b --- /dev/null +++ b/internal/proxy/director/layer/circuitbreaker/layer-options.json @@ -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 +} \ No newline at end of file diff --git a/internal/proxy/director/layer/circuitbreaker/layer.go b/internal/proxy/director/layer/circuitbreaker/layer.go new file mode 100644 index 0000000..c4e94e1 --- /dev/null +++ b/internal/proxy/director/layer/circuitbreaker/layer.go @@ -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{} diff --git a/internal/proxy/director/layer/circuitbreaker/layer_options.go b/internal/proxy/director/layer/circuitbreaker/layer_options.go new file mode 100644 index 0000000..c78c899 --- /dev/null +++ b/internal/proxy/director/layer/circuitbreaker/layer_options.go @@ -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 +} diff --git a/internal/proxy/director/layer/circuitbreaker/options.go b/internal/proxy/director/layer/circuitbreaker/options.go new file mode 100644 index 0000000..d720710 --- /dev/null +++ b/internal/proxy/director/layer/circuitbreaker/options.go @@ -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 + } +} diff --git a/internal/proxy/director/layer/circuitbreaker/schema.go b/internal/proxy/director/layer/circuitbreaker/schema.go new file mode 100644 index 0000000..3891689 --- /dev/null +++ b/internal/proxy/director/layer/circuitbreaker/schema.go @@ -0,0 +1,8 @@ +package circuitbreaker + +import ( + _ "embed" +) + +//go:embed layer-options.json +var RawLayerOptionsSchema []byte diff --git a/internal/setup/circuitbreaker_layer.go b/internal/setup/circuitbreaker_layer.go new file mode 100644 index 0000000..af21510 --- /dev/null +++ b/internal/setup/circuitbreaker_layer.go @@ -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 +} diff --git a/layers/circuitbreaker/templates/default.gohtml b/layers/circuitbreaker/templates/default.gohtml new file mode 100644 index 0000000..098325c --- /dev/null +++ b/layers/circuitbreaker/templates/default.gohtml @@ -0,0 +1,73 @@ +{{ define "default" }} + + + + + + Accès bloqué - {{ .Layer.Name }} + + + +
+
+

Page indisponible

+

La page à laquelle vous souhaitez accéder est actuellement indisponible.

+ +
+
+ + +{{ end }} \ No newline at end of file diff --git a/misc/packaging/common/config.yml b/misc/packaging/common/config.yml index 426e1f0..ff1f1b7 100644 --- a/misc/packaging/common/config.yml +++ b/misc/packaging/common/config.yml @@ -161,4 +161,10 @@ layers: # Répertoire contenant les templates templateDir: "/etc/bouncer/layers/queue/templates" # Temps de vie par défaut d'une session - defaultKeepAlive: 1m \ No newline at end of file + defaultKeepAlive: 1m + + # Configuration du layer "circuitbreaker" + circuitbreaker: + # Répertoire contenant les templates + templateDir: "/etc/bouncer/layers/circuitbreaker/templates" + \ No newline at end of file