From 05b547da4899ee54e46db3cedf7e6637b25cebb1 Mon Sep 17 00:00:00 2001 From: William Petit Date: Tue, 25 Jun 2024 14:03:49 +0200 Subject: [PATCH] feat: rewriter layer --- doc/fr/references/layers/README.md | 1 + doc/fr/references/layers/rewriter.md | 116 +++++++++++++++ .../layer/rewriter/layer-options.json | 38 +++++ .../proxy/director/layer/rewriter/layer.go | 84 +++++++++++ .../director/layer/rewriter/layer_options.go | 56 ++++++++ .../proxy/director/layer/rewriter/rules.go | 133 ++++++++++++++++++ .../proxy/director/layer/rewriter/schema.go | 8 ++ internal/rule/engine.go | 45 ++++++ internal/rule/http/option.go | 42 ++++++ internal/rule/http/request.go | 122 ++++++++++++++++ internal/rule/http/response.go | 88 ++++++++++++ internal/rule/options.go | 35 +++++ internal/setup/rewriter_layer.go | 15 ++ 13 files changed, 783 insertions(+) create mode 100644 doc/fr/references/layers/rewriter.md create mode 100644 internal/proxy/director/layer/rewriter/layer-options.json create mode 100644 internal/proxy/director/layer/rewriter/layer.go create mode 100644 internal/proxy/director/layer/rewriter/layer_options.go create mode 100644 internal/proxy/director/layer/rewriter/rules.go create mode 100644 internal/proxy/director/layer/rewriter/schema.go create mode 100644 internal/rule/engine.go create mode 100644 internal/rule/http/option.go create mode 100644 internal/rule/http/request.go create mode 100644 internal/rule/http/response.go create mode 100644 internal/rule/options.go create mode 100644 internal/setup/rewriter_layer.go diff --git a/doc/fr/references/layers/README.md b/doc/fr/references/layers/README.md index 24958d6..6015b6e 100644 --- a/doc/fr/references/layers/README.md +++ b/doc/fr/references/layers/README.md @@ -4,3 +4,4 @@ Vous trouverez ci-dessous la liste des entités "Layer" activables sur vos entit - [Authn (`authn-*`)](./authn/README.md) - Authentification des accès (SSO) - [Queue](./queue.md) - File d'attente dynamique +- [Rewriter](./rewriter.md) - Réécriture dynamiques des attributs des requêtes/réponses diff --git a/doc/fr/references/layers/rewriter.md b/doc/fr/references/layers/rewriter.md new file mode 100644 index 0000000..7a9b212 --- /dev/null +++ b/doc/fr/references/layers/rewriter.md @@ -0,0 +1,116 @@ +# Layer "Rewriter" + +## Description + +Ce layer permet de modifier dynamiquement certains attributs de requêtes/réponses transitant par le proxy. + +## Type + +`rewriter` + +## Schéma des options + +Les options disponibles pour le layer sont décrites via un [schéma JSON](https://json-schema.org/specification). Elles sont documentées dans le [schéma visible ici](../../../../internal/proxy/director/layer/rewriter/layer-options.json). + +## Moteur de règles + +Les options `rules.request` et `rules.response` permettent de définir des listes de règles utilisant un DSL modifiant de manière dynamique les attributs des requêtes/réponses transitant par le proxy. + +Les listes d'instructions sont exécutées séquentiellement. + +Bouncer utilise le projet [`expr`](https://expr-lang.org/) comme DSL. En plus des fonctionnalités natives du langage, Bouncer ajoute un certain nombre de fonctions spécifiques au contexte d'utilisation. + +### Fonctions + +#### Communes + +##### `add_header(name string, value string)` + +Ajouter une valeur à un entête HTTP via son nom `name` et sa valeur `value`. + +##### `set_header(name string, value string)` + +Définir la valeur d'un entête HTTP via son nom `name` et sa valeur `value`. La valeur précédente est écrasée. + +##### `del_headers(pattern string)` + +Supprimer un ou plusieurs entêtes HTTP dont le nom correspond au patron `pattern`. + +Le patron est défini par une chaîne comprenant un ou plusieurs caractères `*`, signifiant un ou plusieurs caractères arbitraires. + +#### Requête + +##### `set_host(host string)` + +Modifier la valeur de l'entête `Host` de la requête. + +##### `set_url(url string)` + +Modifier l'URL du serveur cible. + +#### Réponse + +_Pas de fonctions spécifiques._ + +### Environnement + +Les règles ont accès aux variables suivantes pendant leur exécution. **Ces données sont en lecture seule.** + +#### Requête + +##### `request` + +La requête en cours de traitement. + +```js +{ + method: "string", // Méthode HTTP + host: "string", // Nom d'hôte (`Host`) associé à la requête + url: "string", // URL associée à la requête + proto: "string", // Numéro de version du protocole utilisé + protoMajor: "int", // Numéro de version majeure du protocole utilisé + protoMinor: "int", // Numéro de version mineur du protocole utilisé + header: { // Table associative des entêtes HTTP associés à la requête + "string": ["string"] + }, + contentLength: "int", // Taille du corps de la requête + transferEncoding: ["string"], // MIME-Type(s) d'encodage du corps de la requête + trailer: { // Table associative des entêtes HTTP associés à la requête, transmises après le corps de la requête + "string": ["string"] + }, + remoteAddr: "string", // Adresse du client HTTP à l'origine de la requête + requestUri: "string" // URL "brute" associée à la requêtes (avant opérations d'assainissement, utiliser "url" plutôt) +} +``` + +#### Réponse + +##### `response` + +La réponse en cours de traitement. + +```js +{ + statusCode: "int", // Code de statut de la réponse + status: "string", // Message associé au code de statut + proto: "string", // Numéro de version du protocole utilisé + protoMajor: "int", // Numéro de version majeure du protocole utilisé + protoMinor: "int", // Numéro de version mineur du protocole utilisé + header: { // Table associative des entêtes HTTP associés à la requête + "string": ["string"] + }, + contentLength: "int", // Taille du corps de la réponse + transferEncoding: ["string"], // MIME-Type(s) d'encodage du corps de la requête + trailer: { // Table associative des entêtes HTTP associés à la requête, transmises après le corps de la requête + "string": ["string"] + }, +} +``` + +##### `request` + +_Voir section précédente._ + +## Métriques + +_Pas de métriques spécifiques._ diff --git a/internal/proxy/director/layer/rewriter/layer-options.json b/internal/proxy/director/layer/rewriter/layer-options.json new file mode 100644 index 0000000..dec25f5 --- /dev/null +++ b/internal/proxy/director/layer/rewriter/layer-options.json @@ -0,0 +1,38 @@ +{ + "type": "object", + "properties": { + "rules": { + "title": "Règles appliquées aux requêtes/réponses transitant par le proxy", + "type": "object", + "properties": { + "request": { + "title": "Règles appliquées aux requêtes transitant par le proxy", + "type": "array", + "items": { + "type": "string" + } + }, + "response": { + "title": "Règles appliquées aux réponses transitant par le proxy", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "matchURLs": { + "title": "Liste de filtrage des URLs sur lesquelles le layer est actif", + "description": "Par exemple, si vous souhaitez limiter votre layer à 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 ce layer.", + "default": [ + "*" + ], + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/internal/proxy/director/layer/rewriter/layer.go b/internal/proxy/director/layer/rewriter/layer.go new file mode 100644 index 0000000..a20a21e --- /dev/null +++ b/internal/proxy/director/layer/rewriter/layer.go @@ -0,0 +1,84 @@ +package rewriter + +import ( + "net/http" + + proxy "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/pkg/errors" + "gitlab.com/wpetit/goweb/logger" +) + +const LayerType store.LayerType = "rewriter" + +type Layer struct{} + +func (l *Layer) LayerType() store.LayerType { + return LayerType +} + +func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware { + return func(next 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 := wildcard.MatchAny(r.URL.String(), options.MatchURLs...) + if !matches { + next.ServeHTTP(w, r) + + return + } + + if err := l.applyRequestRules(r, options); err != nil { + logger.Error(ctx, "could not apply request rules", logger.E(errors.WithStack(err))) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + + return + } + + next.ServeHTTP(w, r) + } + + return http.HandlerFunc(fn) + } +} + +// ResponseTransformer implements director.ResponseTransformerLayer. +func (l *Layer) ResponseTransformer(layer *store.Layer) proxy.ResponseTransformer { + return func(r *http.Response) error { + options, err := fromStoreOptions(layer.Options) + if err != nil { + return errors.WithStack(err) + } + + matches := wildcard.MatchAny(r.Request.URL.String(), options.MatchURLs...) + if !matches { + return nil + } + + if err := l.applyResponseRules(r, options); err != nil { + return errors.WithStack(err) + } + + return nil + } +} + +func New() *Layer { + return &Layer{} +} + +var ( + _ director.MiddlewareLayer = &Layer{} + _ director.ResponseTransformerLayer = &Layer{} +) diff --git a/internal/proxy/director/layer/rewriter/layer_options.go b/internal/proxy/director/layer/rewriter/layer_options.go new file mode 100644 index 0000000..4ac0631 --- /dev/null +++ b/internal/proxy/director/layer/rewriter/layer_options.go @@ -0,0 +1,56 @@ +package rewriter + +import ( + "forge.cadoles.com/cadoles/bouncer/internal/store" + "github.com/mitchellh/mapstructure" + "github.com/pkg/errors" +) + +type LayerOptions struct { + MatchURLs []string `mapstructure:"matchURLs"` + Rules Rules `mapstructure:"rules"` +} + +type Rules struct { + Request []string `mapstructure:"request"` + Response []string `mapstructure:"response"` +} + +func DefaultLayerOptions() LayerOptions { + return LayerOptions{ + MatchURLs: []string{"*"}, + Rules: Rules{ + Request: []string{}, + Response: []string{}, + }, + } + +} + +func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) { + layerOptions := DefaultLayerOptions() + + if err := FromStoreOptions(storeOptions, &layerOptions); err != nil { + return nil, errors.WithStack(err) + } + + return &layerOptions, nil +} + +func FromStoreOptions(storeOptions store.LayerOptions, dest any) error { + config := mapstructure.DecoderConfig{ + Result: dest, + ZeroFields: true, + } + + decoder, err := mapstructure.NewDecoder(&config) + if err != nil { + return errors.WithStack(err) + } + + if err := decoder.Decode(storeOptions); err != nil { + return errors.WithStack(err) + } + + return nil +} diff --git a/internal/proxy/director/layer/rewriter/rules.go b/internal/proxy/director/layer/rewriter/rules.go new file mode 100644 index 0000000..7bcc5a3 --- /dev/null +++ b/internal/proxy/director/layer/rewriter/rules.go @@ -0,0 +1,133 @@ +package rewriter + +import ( + "net/http" + + "forge.cadoles.com/cadoles/bouncer/internal/rule" + ruleHTTP "forge.cadoles.com/cadoles/bouncer/internal/rule/http" + "github.com/pkg/errors" +) + +type RequestEnv struct { + Request RequestInfo `expr:"request"` +} + +type RequestInfo struct { + Method string `expr:"method"` + URL string `expr:"url"` + Proto string `expr:"proto"` + ProtoMajor int `expr:"protoMajor"` + ProtoMinor int `expr:"protoMinor"` + Header map[string][]string `expr:"header"` + ContentLength int64 `expr:"contentLength"` + TransferEncoding []string `expr:"transferEncoding"` + Host string `expr:"host"` + Trailer map[string][]string `expr:"trailer"` + RemoteAddr string `expr:"remoteAddr"` + RequestURI string `expr:"requestUri"` +} + +func (l *Layer) applyRequestRules(r *http.Request, options *LayerOptions) error { + rules := options.Rules.Request + if len(rules) == 0 { + return nil + } + + engine, err := rule.NewEngine[*RequestEnv]( + ruleHTTP.WithRequestFuncs(r), + rule.WithRules(options.Rules.Request...), + ) + if err != nil { + return errors.WithStack(err) + } + + env := &RequestEnv{ + Request: RequestInfo{ + Method: r.Method, + URL: r.URL.String(), + Proto: r.Proto, + ProtoMajor: r.ProtoMajor, + ProtoMinor: r.ProtoMinor, + Header: r.Header, + ContentLength: r.ContentLength, + TransferEncoding: r.TransferEncoding, + Host: r.Host, + Trailer: r.Trailer, + RemoteAddr: r.RemoteAddr, + RequestURI: r.RequestURI, + }, + } + + if _, err := engine.Apply(env); err != nil { + return errors.WithStack(err) + } + + return nil +} + +type ResponseEnv struct { + Request RequestInfo `expr:"request"` + Response ResponseInfo `expr:"response"` +} + +type ResponseInfo struct { + Status string `expr:"status"` + StatusCode int `expr:"statusCode"` + Proto string `expr:"proto"` + ProtoMajor int `expr:"protoMajor"` + ProtoMinor int `expr:"protoMinor"` + Header map[string][]string `expr:"header"` + ContentLength int64 `expr:"contentLength"` + TransferEncoding []string `expr:"transferEncoding"` + Uncompressed bool `expr:"uncompressed"` + Trailer map[string][]string `expr:"trailer"` +} + +func (l *Layer) applyResponseRules(r *http.Response, options *LayerOptions) error { + rules := options.Rules.Request + if len(rules) == 0 { + return nil + } + + engine, err := rule.NewEngine[*ResponseEnv]( + rule.WithRules(options.Rules.Response...), + ruleHTTP.WithResponseFuncs(r), + ) + if err != nil { + return errors.WithStack(err) + } + + env := &ResponseEnv{ + Request: RequestInfo{ + Method: r.Request.Method, + URL: r.Request.URL.String(), + Proto: r.Request.Proto, + ProtoMajor: r.Request.ProtoMajor, + ProtoMinor: r.Request.ProtoMinor, + Header: r.Request.Header, + ContentLength: r.Request.ContentLength, + TransferEncoding: r.Request.TransferEncoding, + Host: r.Request.Host, + Trailer: r.Request.Trailer, + RemoteAddr: r.Request.RemoteAddr, + RequestURI: r.Request.RequestURI, + }, + Response: ResponseInfo{ + Proto: r.Proto, + ProtoMajor: r.ProtoMajor, + ProtoMinor: r.ProtoMinor, + Header: r.Header, + ContentLength: r.ContentLength, + TransferEncoding: r.TransferEncoding, + Trailer: r.Trailer, + Status: r.Status, + StatusCode: r.StatusCode, + }, + } + + if _, err := engine.Apply(env); err != nil { + return errors.WithStack(err) + } + + return nil +} diff --git a/internal/proxy/director/layer/rewriter/schema.go b/internal/proxy/director/layer/rewriter/schema.go new file mode 100644 index 0000000..40241f4 --- /dev/null +++ b/internal/proxy/director/layer/rewriter/schema.go @@ -0,0 +1,8 @@ +package rewriter + +import ( + _ "embed" +) + +//go:embed layer-options.json +var RawLayerOptionsSchema []byte diff --git a/internal/rule/engine.go b/internal/rule/engine.go new file mode 100644 index 0000000..f23f4d4 --- /dev/null +++ b/internal/rule/engine.go @@ -0,0 +1,45 @@ +package rule + +import ( + "github.com/expr-lang/expr" + "github.com/expr-lang/expr/vm" + "github.com/pkg/errors" +) + +type Engine[E any] struct { + rules []*vm.Program +} + +func (e *Engine[E]) Apply(env E) ([]any, error) { + results := make([]any, 0, len(e.rules)) + for i, r := range e.rules { + result, err := expr.Run(r, env) + if err != nil { + return nil, errors.Wrapf(err, "could not run rule #%d", i) + } + + results = append(results, result) + } + + return results, nil +} + +func NewEngine[E any](funcs ...OptionFunc) (*Engine[E], error) { + opts := NewOptions(funcs...) + + engine := &Engine[E]{ + rules: make([]*vm.Program, 0, len(opts.Rules)), + } + + for i, r := range opts.Rules { + + program, err := expr.Compile(r, opts.Expr...) + if err != nil { + return nil, errors.Wrapf(err, "could not compile rule #%d", i) + } + + engine.rules = append(engine.rules, program) + } + + return engine, nil +} diff --git a/internal/rule/http/option.go b/internal/rule/http/option.go new file mode 100644 index 0000000..d7a9c26 --- /dev/null +++ b/internal/rule/http/option.go @@ -0,0 +1,42 @@ +package http + +import ( + "net/http" + + "forge.cadoles.com/cadoles/bouncer/internal/rule" + "github.com/expr-lang/expr" +) + +func WithRequestFuncs(r *http.Request) rule.OptionFunc { + return func(opts *rule.Options) { + funcs := []expr.Option{ + setRequestURL(r), + setRequestHeaderFunc(r), + addRequestHeaderFunc(r), + delRequestHeadersFunc(r), + setRequestHostFunc(r), + } + + if len(opts.Expr) == 0 { + opts.Expr = make([]expr.Option, 0) + } + + opts.Expr = append(opts.Expr, funcs...) + } +} + +func WithResponseFuncs(r *http.Response) rule.OptionFunc { + return func(opts *rule.Options) { + funcs := []expr.Option{ + setResponseHeaderFunc(r), + addResponseHeaderFunc(r), + delResponseHeadersFunc(r), + } + + if len(opts.Expr) == 0 { + opts.Expr = make([]expr.Option, 0) + } + + opts.Expr = append(opts.Expr, funcs...) + } +} diff --git a/internal/rule/http/request.go b/internal/rule/http/request.go new file mode 100644 index 0000000..7452b82 --- /dev/null +++ b/internal/rule/http/request.go @@ -0,0 +1,122 @@ +package http + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "forge.cadoles.com/Cadoles/go-proxy/wildcard" + "github.com/expr-lang/expr" + "github.com/pkg/errors" +) + +func setRequestHostFunc(r *http.Request) expr.Option { + return expr.Function( + "set_host", + func(params ...any) (any, error) { + host := params[0].(string) + r.Host = host + + return true, nil + }, + new(func(string) bool), + ) +} + +func setRequestURL(r *http.Request) expr.Option { + return expr.Function( + "set_url", + func(params ...any) (any, error) { + rawURL := params[0].(string) + + url, err := url.Parse(rawURL) + if err != nil { + return false, errors.WithStack(err) + } + + r.URL = url + + return true, nil + }, + new(func(string) bool), + ) +} + +func addRequestHeaderFunc(r *http.Request) expr.Option { + return expr.Function( + "add_header", + func(params ...any) (any, error) { + name := params[0].(string) + rawValue := params[1] + + var value string + switch v := rawValue.(type) { + case []string: + value = strings.Join(v, ",") + case time.Time: + value = strconv.FormatInt(v.UTC().Unix(), 10) + case time.Duration: + value = strconv.FormatInt(int64(v.Seconds()), 10) + default: + value = fmt.Sprintf("%v", rawValue) + } + + r.Header.Add(name, value) + + return true, nil + }, + new(func(string, string) bool), + ) +} + +func setRequestHeaderFunc(r *http.Request) expr.Option { + return expr.Function( + "set_header", + func(params ...any) (any, error) { + name := params[0].(string) + rawValue := params[1] + + var value string + switch v := rawValue.(type) { + case []string: + value = strings.Join(v, ",") + case time.Time: + value = strconv.FormatInt(v.UTC().Unix(), 10) + case time.Duration: + value = strconv.FormatInt(int64(v.Seconds()), 10) + default: + value = fmt.Sprintf("%v", rawValue) + } + + r.Header.Set(name, value) + + return true, nil + }, + new(func(string, string) bool), + ) +} + +func delRequestHeadersFunc(r *http.Request) expr.Option { + return expr.Function( + "del_headers", + func(params ...any) (any, error) { + pattern := params[0].(string) + deleted := false + + for key := range r.Header { + if !wildcard.Match(key, pattern) { + continue + } + + r.Header.Del(key) + deleted = true + } + + return deleted, nil + }, + new(func(string) bool), + ) +} diff --git a/internal/rule/http/response.go b/internal/rule/http/response.go new file mode 100644 index 0000000..be7c9ee --- /dev/null +++ b/internal/rule/http/response.go @@ -0,0 +1,88 @@ +package http + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "forge.cadoles.com/Cadoles/go-proxy/wildcard" + "github.com/expr-lang/expr" +) + +func addResponseHeaderFunc(r *http.Response) expr.Option { + return expr.Function( + "add_header", + func(params ...any) (any, error) { + name := params[0].(string) + rawValue := params[1] + + var value string + switch v := rawValue.(type) { + case []string: + value = strings.Join(v, ",") + case time.Time: + value = strconv.FormatInt(v.UTC().Unix(), 10) + case time.Duration: + value = strconv.FormatInt(int64(v.Seconds()), 10) + default: + value = fmt.Sprintf("%v", rawValue) + } + + r.Header.Add(name, value) + + return true, nil + }, + new(func(string, string) bool), + ) +} + +func setResponseHeaderFunc(r *http.Response) expr.Option { + return expr.Function( + "set_header", + func(params ...any) (any, error) { + name := params[0].(string) + rawValue := params[1] + + var value string + switch v := rawValue.(type) { + case []string: + value = strings.Join(v, ",") + case time.Time: + value = strconv.FormatInt(v.UTC().Unix(), 10) + case time.Duration: + value = strconv.FormatInt(int64(v.Seconds()), 10) + default: + value = fmt.Sprintf("%v", rawValue) + } + + r.Header.Set(name, value) + + return true, nil + }, + new(func(string, string) bool), + ) +} + +func delResponseHeadersFunc(r *http.Response) expr.Option { + return expr.Function( + "del_headers", + func(params ...any) (any, error) { + pattern := params[0].(string) + deleted := false + + for key := range r.Header { + if !wildcard.Match(key, pattern) { + continue + } + + r.Header.Del(key) + deleted = true + } + + return deleted, nil + }, + new(func(string) bool), + ) +} diff --git a/internal/rule/options.go b/internal/rule/options.go new file mode 100644 index 0000000..25c543f --- /dev/null +++ b/internal/rule/options.go @@ -0,0 +1,35 @@ +package rule + +import "github.com/expr-lang/expr" + +type Options struct { + Rules []string + Expr []expr.Option +} + +type OptionFunc func(opts *Options) + +func NewOptions(funcs ...OptionFunc) *Options { + opts := &Options{ + Expr: make([]expr.Option, 0), + Rules: make([]string, 0), + } + + for _, fn := range funcs { + fn(opts) + } + + return opts +} + +func WithRules(rules ...string) OptionFunc { + return func(opts *Options) { + opts.Rules = rules + } +} + +func WithExpr(options ...expr.Option) OptionFunc { + return func(opts *Options) { + opts.Expr = options + } +} diff --git a/internal/setup/rewriter_layer.go b/internal/setup/rewriter_layer.go new file mode 100644 index 0000000..239e667 --- /dev/null +++ b/internal/setup/rewriter_layer.go @@ -0,0 +1,15 @@ +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/rewriter" +) + +func init() { + RegisterLayer(rewriter.LayerType, setupRewriterLayer, rewriter.RawLayerOptionsSchema) +} + +func setupRewriterLayer(conf *config.Config) (director.Layer, error) { + return rewriter.New(), nil +}