diff --git a/doc/README.md b/doc/README.md index 42a89d0..d2cc1b7 100644 --- a/doc/README.md +++ b/doc/README.md @@ -9,13 +9,13 @@ ## 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 ### 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 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/admin/server.go b/internal/admin/server.go index 84bfc64..9b9e0c5 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -9,6 +9,7 @@ import ( "forge.cadoles.com/cadoles/bouncer/internal/auth" "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/jwk" "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.Use(middleware.Logger) + if s.serverConfig.HTTP.UseRealIP { + router.Use(middleware.RealIP) + } + + router.Use(middleware.RequestLogger(bouncerChi.NewLogFormatter())) if s.serverConfig.Sentry.DSN != "" { logger.Info(ctx, "enabling sentry http middleware") diff --git a/internal/config/http.go b/internal/config/http.go index d99bcca..f344b0d 100644 --- a/internal/config/http.go +++ b/internal/config/http.go @@ -1,13 +1,15 @@ package config type HTTPConfig struct { - Host InterpolatedString `yaml:"host"` - Port InterpolatedInt `yaml:"port"` + Host InterpolatedString `yaml:"host"` + Port InterpolatedInt `yaml:"port"` + UseRealIP InterpolatedBool `yaml:"useRealIP"` } func NewHTTPConfig(host string, port int) HTTPConfig { return HTTPConfig{ - Host: InterpolatedString(host), - Port: InterpolatedInt(port), + Host: InterpolatedString(host), + Port: InterpolatedInt(port), + UseRealIP: true, } } 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/proxy/server.go b/internal/proxy/server.go index eeee775..47f4d8c 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -89,6 +89,10 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e s.directorLayers..., ) + if s.serverConfig.HTTP.UseRealIP { + router.Use(middleware.RealIP) + } + router.Use(middleware.RequestLogger(bouncerChi.NewLogFormatter())) if s.serverConfig.Sentry.DSN != "" { 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" }} + + +
+ + +La page à laquelle vous souhaitez accéder est actuellement indisponible.
+ +