Compare commits

...

13 Commits

Author SHA1 Message Date
eea51c6030 Merge pull request 'feat(rewriter): add redirect(), get_cookie(), add_cookie() methods to rule engine (#36)' (#41) from issue-36 into develop
Some checks failed
Cadoles/bouncer/pipeline/head There was a failure building this commit
Reviewed-on: #41
2024-09-25 15:53:00 +02:00
04b41baea3 feat(rewriter): add redirect(), get_cookie(), add_cookie() methods to rule engine (#36)
Some checks are pending
Cadoles/bouncer/pipeline/pr-develop Build started...
2024-09-25 15:52:49 +02:00
cb9260ac2b Merge pull request 'feat(authn) : add case to test multiples CIDR #34' (#35) from issue-4 into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #35
2024-09-25 15:15:11 +02:00
5eac425fda feat(authn) : add case to test multiples CIDR
Some checks are pending
Cadoles/bouncer/pipeline/pr-develop Build started...
2024-09-25 15:15:04 +02:00
0b032fccc9 Merge pull request 'Moteur de règles V2' (#40) from rule-engine-2 into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #40
2024-09-25 09:11:46 +02:00
fea0610346 feat: reusable rule engine to prevent memory reallocation
All checks were successful
Cadoles/bouncer/pipeline/pr-develop This commit looks good
2024-09-24 18:45:34 +02:00
f37425018b feat: use shared redis client to maximize pooling usage (#39)
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-09-23 15:16:30 +02:00
4801974ca3 fix(queue): prevent metrics update cancellation on aborted http requests (#39)
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-09-23 10:34:24 +02:00
bf15732935 feat: disable sentry integration when no dsn is defined
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-09-23 10:13:04 +02:00
8317ac5b9a feat: add configurable profiling endpoints (#38) 2024-09-23 10:12:42 +02:00
f35384c0f3 feat: create profiling package + rewrite profiling tutorial
Some checks reported warnings
Cadoles/bouncer/pipeline/head This commit was not built
2024-06-28 17:44:51 +02:00
c73fe8cca5 feat(rewriter): pass structured url to ease request rewriting
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-06-28 10:46:38 +02:00
3c1939f418 feat: add revision number to proxy and layers to identify changes
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-06-27 17:03:50 +02:00
67 changed files with 2575 additions and 469 deletions

2
.gitignore vendored
View File

@ -10,4 +10,4 @@
/out
.dockerconfigjson
*.prof
proxy.test
*.test

View File

@ -17,7 +17,8 @@ GOTEST_ARGS ?= -short
OPENWRT_DEVICE ?= 192.168.1.1
SIEGE_URLS_FILE ?= misc/siege/urls.txt
SIEGE_CONCURRENCY ?= 100
SIEGE_CONCURRENCY ?= 50
SIEGE_DURATION ?= 1M
data/bootstrap.d/dummy.yml:
mkdir -p data/bootstrap.d
@ -114,7 +115,7 @@ grafterm: tools/grafterm/bin/grafterm
siege:
$(eval TMP := $(shell mktemp))
cat $(SIEGE_URLS_FILE) | envsubst > $(TMP)
siege -i -b -c $(SIEGE_CONCURRENCY) -f $(TMP)
siege -R ./misc/siege/siege.conf -i -b -c $(SIEGE_CONCURRENCY) -t $(SIEGE_DURATION) -f $(TMP)
rm -rf $(TMP)
tools/gitea-release/bin/gitea-release.sh:
@ -131,7 +132,7 @@ tools/grafterm/bin/grafterm:
GOBIN=$(PWD)/tools/grafterm/bin go install github.com/slok/grafterm/cmd/grafterm@v0.2.0
bench:
go test -bench=. -run '^$$' -count=10 ./...
go test -bench=. -run '^$$' -benchtime=10s ./internal/bench
tools/benchstat/bin/benchstat:
mkdir -p tools/benchstat/bin
@ -150,7 +151,7 @@ run-redis:
-v $(PWD)/data/redis:/data \
-p 6379:6379 \
redis:alpine3.17 \
redis-server --save 60 1 --loglevel warning
redis-server --save 60 1 --loglevel debug
redis-shell:
docker exec -it \

View File

@ -24,6 +24,7 @@
- [(FR) - Ajouter une authentification OpenID Connect](./fr/tutorials/add-oidc-authn-layer.md)
- [(FR) - Amorçage d'un serveur Bouncer via la configuration](./fr/tutorials/bootstrapping.md)
- [(FR) - Intégration avec Kubernetes](./fr/tutorials/kubernetes-integration.md)
- [(FR) - Profilage](./fr/tutorials/profiling.md)
### Développement

View File

@ -27,8 +27,8 @@ Bouncer utilise le projet [`expr`](https://expr-lang.org/) comme DSL. En plus de
Le comportement des règles par défaut est le suivant:
1. L'ensemble des entêtes HTTP correspondant au patron `Remote-*` sont supprimés ;
2. L'identifiant de l'utilisateur identifié (`user.subject`) est exporté sous la forme de l'entête HTTP `Remote-User` ;
3. L'ensemble des attributs de l'utilisateur identifié (`user.attrs`) sont exportés sous la forme `Remote-User-Attr-<name>``<name>` est le nom de l'attribut en minuscule, avec les `_` transformés en `-`.
2. L'identifiant de l'utilisateur identifié (`vars.user.subject`) est exporté sous la forme de l'entête HTTP `Remote-User` ;
3. L'ensemble des attributs de l'utilisateur identifié (`vars.user.attrs`) sont exportés sous la forme `Remote-User-Attr-<name>``<name>` est le nom de l'attribut en minuscule, avec les `_` transformés en `-`.
### Fonctions
@ -36,25 +36,25 @@ Le comportement des règles par défaut est le suivant:
Interdire l'accès à l'utilisateur.
##### `add_header(name string, value string)`
##### `add_header(ctx, 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)`
##### `set_header(ctx, 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)`
##### `del_headers(ctx, 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.
##### `set_host(host string)`
##### `set_host(ctx, host string)`
Modifier la valeur de l'entête `Host` de la requête.
##### `set_url(url string)`
##### `set_url(ctx, url string)`
Modifier l'URL du serveur cible.
@ -62,7 +62,7 @@ Modifier l'URL du serveur cible.
Les règles ont accès aux variables suivantes pendant leur exécution.
#### `user`
#### `vars.user`
L'utilisateur identifié par le layer.

View File

@ -14,12 +14,12 @@ Les options disponibles pour le layer sont décrites via un [schéma JSON](https
En plus de ces options spécifiques le layer peut également être configuré via [les options communes aux layers `authn-*`](../../../../../internal/proxy/director/layer/authn/layer-options.json).
## Objet `user` et attributs
## Objet `vars.user` et attributs
L'objet `user` exposé au moteur de règles sera construit de la manière suivante:
- `user.subject` sera initialisé avec le nom d'utilisateur identifié ;
- `user.attrs` sera composé des attributs associés à l'utilisation (voir les options).
- `vars.user.subject` sera initialisé avec le nom d'utilisateur identifié ;
- `vars.user.attrs` sera composé des attributs associés à l'utilisation (voir les options).
## Métriques

View File

@ -14,12 +14,12 @@ Les options disponibles pour le layer sont décrites via un [schéma JSON](https
En plus de ces options spécifiques le layer peut également être configuré via [les options communes aux layers `authn-*`](../../../../../internal/proxy/director/layer/authn/layer-options.json).
## Objet `user` et attributs
## Objet `vars.user` et attributs
L'objet `user` exposé au moteur de règles sera construit de la manière suivante:
L'objet `vars.user` exposé au moteur de règles sera construit de la manière suivante:
- `user.subject` sera initialisé avec le couple `<remote_address>:<remote_port>` ;
- `user.attrs` sera vide.
- `vars.user.subject` sera initialisé avec le couple `<remote_address>:<remote_port>` ;
- `vars.user.attrs` sera vide.
## Métriques

View File

@ -16,18 +16,18 @@ Les options disponibles pour le layer sont décrites via un [schéma JSON](https
En plus de ces options spécifiques le layer peut également être configuré via [les options communes aux layers `authn-*`](../../../../../internal/proxy/director/layer/authn/layer-options.json).
## Objet `user` et attributs
## Objet `vars.user` et attributs
L'objet `user` exposé au moteur de règles sera construit de la manière suivante:
L'objet `vars.user` exposé au moteur de règles sera construit de la manière suivante:
- `user.subject` sera initialisé avec la valeur du [claim](https://openid.net/specs/openid-connect-core-1_0.html#Claims) `sub` extrait de l'`idToken` récupéré lors de l'authentification ;
- `user.attrs` comportera les propriétés suivantes:
- `vars.user.subject` sera initialisé avec la valeur du [claim](https://openid.net/specs/openid-connect-core-1_0.html#Claims) `sub` extrait de l'`idToken` récupéré lors de l'authentification ;
- `vars.user.attrs` comportera les propriétés suivantes:
- L'ensemble des `claims` provenant de l'`idToken` seront transposés en `claim_<name>` (ex: `idToken.iss` sera transposé en `user.attrs.claim_iss`) ;
- `user.attrs.access_token`: le jeton d'accès associé à l'authentification ;
- `user.attrs.refresh_token`: le jeton de rafraîchissement associé à l'authentification (si disponible, en fonction des `scopes` demandés par le client) ;
- `user.attrs.token_expiry`: Horodatage Unix (en secondes) associé à la date d'expiration du jeton d'accès ;
- `user.attrs.logout_url`: URL de déconnexion pour la suppression de la session Bouncer.
- L'ensemble des `claims` provenant de l'`idToken` seront transposés en `claim_<name>` (ex: `idToken.iss` sera transposé en `vars.user.attrs.claim_iss`) ;
- `vars.user.attrs.access_token`: le jeton d'accès associé à l'authentification ;
- `vars.user.attrs.refresh_token`: le jeton de rafraîchissement associé à l'authentification (si disponible, en fonction des `scopes` demandés par le client) ;
- `vars.user.attrs.token_expiry`: Horodatage Unix (en secondes) associé à la date d'expiration du jeton d'accès ;
- `vars.user.attrs.logout_url`: URL de déconnexion pour la suppression de la session Bouncer.
**Attention** Cette URL ne permet dans la plupart des cas que de supprimer la session côté Bouncer. La suppression de la session côté fournisseur d'identité est conditionné à la présence ou non de l'attribut [`end_session_endpoint`](https://openid.net/specs/openid-connect-session-1_0-17.html#OPMetadata) dans les données du point d'entrée de découverte de service (`.wellknown/openid-configuration`).

View File

@ -24,30 +24,63 @@ Bouncer utilise le projet [`expr`](https://expr-lang.org/) comme DSL. En plus de
#### Communes
##### `add_header(name string, value string)`
##### `add_header(ctx, 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)`
##### `set_header(ctx, 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)`
##### `del_headers(ctx, 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.
##### `get_cookie(ctx, name string) Cookie`
Récupère un cookie depuis la requête/réponse (en fonction du contexte d'utilisation).
Retourne `nil` si le cookie n'existe pas.
**Cookie**
```js
// Plus d'informations sur https://pkg.go.dev/net/http#Cookie
{
name: "string", // Nom du cookie
value: "string", // Valeur associée au cookie
path: "string", // Chemin associé au cookie (présent uniquement dans un contexte de réponse)
domain: "string", // Domaine associé au cookie (présent uniquement dans un contexte de réponse)
expires: "string", // Date d'expiration du cookie (présent uniquement dans un contexte de réponse)
max_age: "string", // Age maximum du cookie (présent uniquement dans un contexte de réponse)
secure: "boolean", // Le cookie doit-il être présent uniquement en HTTPS ? (présent uniquement dans un contexte de réponse)
http_only: "boolean", // Le cookie est il accessible en Javascript ? (présent uniquement dans un contexte de réponse)
same_site: "int" // Voir https://pkg.go.dev/net/http#SameSite (présent uniquement dans un contexte de réponse)
}
```
##### `set_cookie(ctx, cookie Cookie)`
Définit un cookie sur la requête/réponse (en fonction du contexte d'utilisation).
Voir la méthode `get_cookie()` pour voir les attributs potentiels.
#### Requête
##### `set_host(host string)`
##### `set_host(ctx, host string)`
Modifier la valeur de l'entête `Host` de la requête.
##### `set_url(url string)`
##### `set_url(ctx, url string)`
Modifier l'URL du serveur cible.
##### `redirect(ctx, statusCode int, url string)`
Interrompt la requête et retourne une redirection HTTP au client.
Le code HTTP utilisé doit être supérieur ou égale à `300` et inférieur à `400` (non inclus).
#### Réponse
_Pas de fonctions spécifiques._
@ -58,7 +91,28 @@ Les règles ont accès aux variables suivantes pendant leur exécution. **Ces do
#### Requête
##### `request`
##### `vars.original_url`
L'URL originale, avant réécriture du `Host` par Bouncer.
```js
{
scheme: "string", // Schéma HTTP de l'URL
opaque: "string", // Données opaque de l'URL
user: { // Identifiants d'URL (Basic Auth)
username: "",
password: ""
},
host: "string", // Nom d'hôte (<domaine>:<port>) de l'URL
path: "string", // Chemin de l'URL (format assaini)
raw_path: "string", // Chemin de l'URL (format brut)
raw_query: "string", // Variables d'URL (format brut)
fragment : "string", // Fragment d'URL (format assaini)
raw_fragment : "string" // Fragment d'URL (format brut)
}
```
##### `vars.request`
La requête en cours de traitement.
@ -66,48 +120,66 @@ La requête en cours de traitement.
{
method: "string", // Méthode HTTP
host: "string", // Nom d'hôte (`Host`) associé à la requête
url: "string", // URL associée à la requête
url: { // URL associée à la requête sous sa forme structurée
scheme: "string", // Schéma HTTP de l'URL
opaque: "string", // Données opaque de l'URL
user: { // Identifiants d'URL (Basic Auth)
username: "",
password: ""
},
host: "string", // Nom d'hôte (<domaine>:<port>) de l'URL
path: "string", // Chemin de l'URL (format assaini)
raw_path: "string", // Chemin de l'URL (format brut)
raw_query: "string", // Variables d'URL (format brut)
fragment : "string", // Fragment d'URL (format assaini)
raw_fragment : "string" // Fragment d'URL (format brut)
},
raw_url: "string", // URL associée à la requête (format assaini)
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é
proto_major: "int", // Numéro de version majeure du protocole utilisé
proto_minor: "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
content_length: "int", // Taille du corps de la requête
transfer_encoding: ["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)
remote_addr: "string", // Adresse du client HTTP à l'origine de la requête
request_uri: "string" // URL "brute" associée à la requêtes (avant opérations d'assainissement, utiliser "url" plutôt)
}
```
#### Réponse
##### `response`
##### `vars.response`
La réponse en cours de traitement.
```js
{
statusCode: "int", // Code de statut de la réponse
status_code: "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é
proto_major: "int", // Numéro de version majeure du protocole utilisé
proto_minor: "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
content_length: "int", // Taille du corps de la réponse
transfer_encoding: ["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`
##### `vars.request`
_Voir section précédente._
##### `vars.original_url`
_Voir section précédente._

View File

@ -1,31 +1,68 @@
# Étudier les performances de Bouncer
1. Lancer un benchmark du proxy
## In situ
```shell
go test -bench=. -run '^$' -count=5 -cpuprofile bench_proxy.prof ./internal/proxy
Il est possible d'activer via la configuration de Bouncer de endpoints capable de générer des fichiers de profil au format [`pprof`](https://github.com/google/pprof). Par défaut, le point d'entrée est `.bouncer/profiling` (l'activation et la personnalisation de ce point d'entrée sont modifiables via la [configuration](../../../misc/packaging/common/config.yml)).
**Exemple:** Visualiser l'utilisation mémoire de Bouncer
```bash
go tool pprof -web http://<bouncer_proxy>/.bouncer/profiling/heap
```
2. Visualiser les temps d'exécution
L'ensemble des profils disponibles sont visibles à l'adresse `http://<bouncer_proxy>/.bouncer/profiling`.
```shell
go tool pprof -web bench_proxy.prof
## En développement
Le package `./internal` est dédié à l'étude des performances de Bouncer. Il contient une suite de benchmarks simulant de proxies avec différentes configurations de layers afin d'évaluer les points d'engorgement sur le traitement des requêtes.
Voir le répertoire `./internal/bench/testdata/proxies` pour voir les différentes configurations de cas.
### Lancer les benchmarks
Le plus simple est d'utiliser la commande `make bench` qui exécutera séquentiellement tous les benchmarks. Il est également possible de lancer un benchmark spécifique via la commande suivante:
```bash
go test -bench="BenchmarkProxies/$BENCH_CASE" -run='^$' ./internal/bench
```
3. Comparer les performances d'une exécution à l'autre
Par exemple:
```bash
# Pour exécuter ./internal/bench/testdata/proxies/basic-auth.yml
go test -bench='BenchmarkProxies/basic-auth' -run='^$' ./internal/bench
```
### Visualiser les profils d'exécution
Vous pouvez visualiser les profils d'exécution via la commande suivante:
```shell
go tool pprof -web path/to/file.prof
```
Par défaut l'exécution des benchmarks créera automatiquement des fichiers de profil dans le répertoire `./internal/bench/testdata/proxies`.
Par exemple:
```shell
go tool pprof -web ./internal/bench/testdata/proxies/basic-auth.prof
```
### Comparer les évolutions
```bash
# Lancer un premier benchmark
go test -bench=. -run '^$' -count=10 ./internal/proxy > bench_before.txt
go test -bench="BenchmarkProxies/$BENCH_CASE" -run='^$' ./internal/bench
# Faire une sauvegarde du fichier de profil
cp ./internal/bench/testdata/proxies/$BENCH_CASE.prof ./internal/bench/testdata/proxies/$BENCH_CASE-prev.prof
# Faire des modifications sur les sources
# Lancer un second benchmark
go test -bench=. -run '^$' -count=10 ./internal/proxy > bench_after.txt
go test -bench="BenchmarkProxies/$BENCH_CASE" -run='^$' ./internal/bench
# Installer l'outil benchstat
make tools/benchstat/bin/benchstat
# Comparer les rapports
tools/benchstat/bin/benchstat bench_before.txt bench_after.txt
# Visualiser la différence entre les deux profils
go tool pprof -web -base=./internal/bench/testdata/proxies/$BENCH_CASE-prev.prof ./internal/bench/testdata/proxies/$BENCH_CASE.prof
```

4
go.mod
View File

@ -1,8 +1,8 @@
module forge.cadoles.com/cadoles/bouncer
go 1.22
go 1.23
toolchain go1.22.0
toolchain go1.23.0
require (
forge.cadoles.com/Cadoles/go-proxy v0.0.0-20240626132607-e1db6466a926

View File

@ -27,7 +27,7 @@ func (s *Server) initRepositories(ctx context.Context) error {
}
func (s *Server) initRedisClient(ctx context.Context) error {
client := setup.NewRedisClient(ctx, s.redisConfig)
client := setup.NewSharedClient(s.redisConfig)
s.redisClient = client

View File

@ -6,6 +6,7 @@ import (
"log"
"net"
"net/http"
"net/http/pprof"
"forge.cadoles.com/cadoles/bouncer/internal/auth"
"forge.cadoles.com/cadoles/bouncer/internal/auth/jwt"
@ -155,6 +156,34 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
})
}
if s.serverConfig.Profiling.Enabled {
profiling := s.serverConfig.Profiling
logger.Info(ctx, "enabling profiling", logger.F("endpoint", profiling.Endpoint))
router.Group(func(r chi.Router) {
if profiling.BasicAuth != nil {
logger.Info(ctx, "enabling authentication on metrics endpoint")
r.Use(middleware.BasicAuth(
"profiling",
profiling.BasicAuth.CredentialsMap(),
))
}
r.Route(string(profiling.Endpoint), func(r chi.Router) {
r.HandleFunc("/", pprof.Index)
r.HandleFunc("/cmdline", pprof.Cmdline)
r.HandleFunc("/profile", pprof.Profile)
r.HandleFunc("/symbol", pprof.Symbol)
r.HandleFunc("/trace", pprof.Trace)
r.HandleFunc("/{name}", func(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
pprof.Handler(name).ServeHTTP(w, r)
})
})
})
}
router.Route("/api/v1", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(auth.Middleware(

View File

@ -0,0 +1,318 @@
package proxy_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"runtime/pprof"
"strings"
"testing"
"time"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/cadoles/bouncer/internal/cache/memory"
"forge.cadoles.com/cadoles/bouncer/internal/cache/ttl"
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/store"
redisStore "forge.cadoles.com/cadoles/bouncer/internal/store/redis"
"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
"gitlab.com/wpetit/goweb/logger"
"gopkg.in/yaml.v3"
"forge.cadoles.com/cadoles/bouncer/internal/setup"
)
func BenchmarkProxies(b *testing.B) {
proxyFiles, err := filepath.Glob("testdata/proxies/*.yml")
if err != nil {
b.Fatalf("%+v", errors.WithStack(err))
}
for _, f := range proxyFiles {
name := strings.TrimSuffix(filepath.Base(f), filepath.Ext(f))
b.Run(name, func(b *testing.B) {
heap, err := os.Create(filepath.Join("testdata", "proxies", name+"_heap.prof"))
if err != nil {
b.Fatalf("%+v", errors.Wrapf(err, "could not create heap profile"))
}
defer func() {
defer heap.Close()
if err := pprof.WriteHeapProfile(heap); err != nil {
b.Fatalf("%+v", errors.WithStack(err))
}
}()
conf, err := loadProxyBenchConfig(f)
if err != nil {
b.Fatalf("%+v", errors.Wrapf(err, "could notre load bench config"))
}
proxy, backend, err := createProxy(name, conf, b.Logf)
if err != nil {
b.Fatalf("%+v", errors.Wrapf(err, "could not create proxy"))
}
defer proxy.Close()
if backend != nil {
defer backend.Close()
}
client := proxy.Client()
proxyURL, err := url.Parse(proxy.URL)
if err != nil {
b.Fatalf("%+v", errors.Wrapf(err, "could not parse proxy url"))
}
if conf.Fetch.URL.Path != "" {
proxyURL.Path = conf.Fetch.URL.Path
}
if conf.Fetch.URL.RawQuery != "" {
proxyURL.RawQuery = conf.Fetch.URL.RawQuery
}
if conf.Fetch.URL.User.Username != "" || conf.Fetch.URL.User.Password != "" {
proxyURL.User = url.UserPassword(conf.Fetch.URL.User.Username, conf.Fetch.URL.User.Password)
}
rawProxyURL := proxyURL.String()
b.Logf("fetching url '%s'", rawProxyURL)
profile, err := os.Create(filepath.Join("testdata", "proxies", name+"_cpu.prof"))
if err != nil {
b.Fatalf("%+v", errors.Wrapf(err, "could not create cpu profile"))
}
defer profile.Close()
if err := pprof.StartCPUProfile(profile); err != nil {
b.Fatalf("%+v", errors.WithStack(err))
}
defer pprof.StopCPUProfile()
b.ResetTimer()
for i := 0; i < b.N; i++ {
res, err := client.Get(rawProxyURL)
if err != nil {
b.Errorf("could not fetch proxy url: %+v", errors.WithStack(err))
}
body, err := io.ReadAll(res.Body)
if err != nil {
b.Errorf("could not read response body: %+v", errors.WithStack(err))
}
b.Logf("%s \n %v", res.Status, string(body))
if err := res.Body.Close(); err != nil {
b.Errorf("could not close response body: %+v", errors.WithStack(err))
}
}
})
}
}
type proxyBenchConfig struct {
Proxy config.BootstrapProxyConfig `yaml:"proxy"`
Fetch fetchBenchConfig `yaml:"fetch"`
}
type fetchBenchConfig struct {
URL fetchURLBenchConfig `yaml:"url"`
}
type fetchURLBenchConfig struct {
Path string `yaml:"path"`
RawQuery string `yaml:"rawQuery"`
User fetchURLUserBenchConfig `yaml:"user"`
}
type fetchURLUserBenchConfig struct {
Username string `yaml:"username"`
Password string `yaml:"password"`
}
func loadProxyBenchConfig(filename string) (*proxyBenchConfig, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, errors.Wrapf(err, "could not read file '%s'", filename)
}
conf := proxyBenchConfig{}
if err := yaml.Unmarshal(data, &conf); err != nil {
return nil, errors.Wrapf(err, "could not unmarshal config")
}
return &conf, nil
}
func createProxy(name string, conf *proxyBenchConfig, logf func(format string, a ...any)) (*httptest.Server, *httptest.Server, error) {
redisEndpoint := os.Getenv("BOUNCER_BENCH_REDIS_ADDR")
if redisEndpoint == "" {
redisEndpoint = "127.0.0.1:6379"
}
client := redis.NewUniversalClient(&redis.UniversalOptions{
Addrs: []string{redisEndpoint},
})
proxyRepository := redisStore.NewProxyRepository(client, redisStore.DefaultTxMaxAttempts, redisStore.DefaultTxBaseDelay)
layerRepository := redisStore.NewLayerRepository(client, redisStore.DefaultTxMaxAttempts, redisStore.DefaultTxBaseDelay)
var backend *httptest.Server
if conf.Proxy.To == "" {
backend = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte("Hello, world.")); err != nil {
logf("[ERROR] %+v", errors.WithStack(err))
}
}))
if err := waitFor(backend.URL, 5*time.Second); err != nil {
return nil, nil, errors.WithStack(err)
}
logf("started backend '%s'", backend.URL)
}
ctx := context.Background()
proxyName := store.ProxyName("bench-" + name)
proxies, err := proxyRepository.QueryProxy(ctx)
if err != nil {
return nil, nil, errors.WithStack(err)
}
// Cleanup existing proxies
for _, p := range proxies {
if err := proxyRepository.DeleteProxy(ctx, p.Name); err != nil {
return nil, nil, errors.WithStack(err)
}
}
logf("creating proxy '%s'", proxyName)
to := string(conf.Proxy.To)
if to == "" {
to = backend.URL
}
if _, err := proxyRepository.CreateProxy(ctx, proxyName, to, conf.Proxy.From...); err != nil {
return nil, nil, errors.WithStack(err)
}
if _, err := proxyRepository.UpdateProxy(ctx, proxyName, store.WithProxyUpdateEnabled(true)); err != nil {
return nil, nil, errors.WithStack(err)
}
for layerName, layerConf := range conf.Proxy.Layers {
if err := layerRepository.DeleteLayer(ctx, proxyName, store.LayerName(layerName)); err != nil {
return nil, nil, errors.WithStack(err)
}
_, err := layerRepository.CreateLayer(ctx, proxyName, store.LayerName(layerName), store.LayerType(layerConf.Type), layerConf.Options.Data)
if err != nil {
return nil, nil, errors.WithStack(err)
}
_, err = layerRepository.UpdateLayer(ctx, proxyName, store.LayerName(layerName), store.WithLayerUpdateEnabled(bool(layerConf.Enabled)))
if err != nil {
return nil, nil, errors.WithStack(err)
}
}
appConf := config.NewDefault()
appConf.Logger.Level = config.InterpolatedInt(logger.LevelError)
appConf.Layers.Authn.TemplateDir = "../../layers/authn/templates"
appConf.Layers.Queue.TemplateDir = "../../layers/queue/templates"
layers, err := setup.GetLayers(context.Background(), appConf)
if err != nil {
return nil, nil, errors.WithStack(err)
}
director := director.New(
proxyRepository, layerRepository,
director.WithLayerCache(
ttl.NewCache(
memory.NewCache[string, []*store.Layer](),
memory.NewCache[string, time.Time](),
30*time.Second,
),
),
director.WithProxyCache(
ttl.NewCache(
memory.NewCache[string, []*store.Proxy](),
memory.NewCache[string, time.Time](),
30*time.Second,
),
),
director.WithLayers(layers...),
)
directorMiddleware := director.Middleware()
handler := proxy.New(
proxy.WithRequestTransformers(
director.RequestTransformer(),
),
proxy.WithResponseTransformers(
director.ResponseTransformer(),
),
proxy.WithReverseProxyFactory(func(ctx context.Context, target *url.URL) *httputil.ReverseProxy {
reverse := httputil.NewSingleHostReverseProxy(target)
reverse.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
logf("[ERROR] %s", errors.WithStack(err))
}
return reverse
}),
)
server := httptest.NewServer(directorMiddleware(handler))
return server, backend, nil
}
func waitFor(url string, ttl time.Duration) error {
var lastErr error
timeout := time.After(ttl)
for {
select {
case <-timeout:
if lastErr != nil {
return lastErr
}
return errors.New("wait timed out")
default:
res, err := http.Get(url)
if err != nil {
lastErr = errors.WithStack(err)
continue
}
if res.StatusCode >= 200 && res.StatusCode < 400 {
return nil
}
}
}
}

View File

@ -0,0 +1,20 @@
proxy:
from: ["*"]
to: ""
layers:
basic-auth:
type: authn-basic
enabled: true
options:
users:
- username: foo
passwordHash: "$2y$10$ShTc856wMB8PCxyr46qJRO8z06MpV4UejAVRDJ/bixhu0XTGn7Giy"
attributes:
email: foo@bar.com
rules:
- set_header(ctx, "Remote-User-Attr-Email", vars.user.attrs.email)
fetch:
url:
user:
username: foo
password: bar

View File

@ -0,0 +1,3 @@
proxy:
from: ["*"]
to: ""

View File

@ -0,0 +1,10 @@
proxy:
from: ["*"]
to: ""
layers:
queue:
type: queue
enabled: true
options:
capacity: 100
keepAlive: 10s

View File

@ -0,0 +1,12 @@
proxy:
from: ["*"]
to: ""
layers:
host-rewriter:
type: rewriter
enabled: true
options:
rules:
request:
- set_host(ctx, vars.request.url.host)
- set_header(ctx, "X-Proxied-With", "bouncer")

View File

@ -13,6 +13,7 @@ func layerHeaderHints(outputMode format.OutputMode) format.Hints {
format.NewProp("Type", "Type"),
format.NewProp("Enabled", "Enabled"),
format.NewProp("Weight", "Weight"),
format.NewProp("Revision", "Revision"),
},
}
}
@ -25,6 +26,7 @@ func layerHints(outputMode format.OutputMode) format.Hints {
format.NewProp("Type", "Type"),
format.NewProp("Enabled", "Enabled"),
format.NewProp("Weight", "Weight"),
format.NewProp("Revision", "Revision"),
format.NewProp("Options", "Options"),
format.NewProp("CreatedAt", "CreatedAt", table.WithCompactModeMaxColumnWidth(20)),
format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)),

View File

@ -12,6 +12,7 @@ func proxyHeaderHints(outputMode format.OutputMode) format.Hints {
format.NewProp("Name", "Name"),
format.NewProp("Enabled", "Enabled"),
format.NewProp("Weight", "Weight"),
format.NewProp("Revision", "Revision"),
},
}
}
@ -25,6 +26,7 @@ func proxyHints(outputMode format.OutputMode) format.Hints {
format.NewProp("To", "To"),
format.NewProp("Enabled", "Enabled"),
format.NewProp("Weight", "Weight"),
format.NewProp("Revision", "Revision"),
format.NewProp("CreatedAt", "CreatedAt", table.WithCompactModeMaxColumnWidth(20)),
format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)),
},

View File

@ -35,12 +35,15 @@ func RunCommand() *cli.Command {
logger.SetLevel(logger.Level(conf.Logger.Level))
projectVersion := ctx.String("projectVersion")
flushSentry, err := setup.SetupSentry(ctx.Context, conf.Admin.Sentry, projectVersion)
if conf.Proxy.Sentry.DSN != "" {
flushSentry, err := setup.SetupSentry(ctx.Context, conf.Proxy.Sentry, projectVersion)
if err != nil {
return errors.Wrap(err, "could not initialize sentry client")
}
defer flushSentry()
}
integrations, err := setup.SetupIntegrations(ctx.Context, conf)
if err != nil {

View File

@ -4,14 +4,14 @@
<h2>Incoming headers</h2>
<table style="width: 100%">
<thead>
<tr>
<tr style="text-align: left">
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{{ range $key, $val := .Request.Header }}
<tr>
<tr style="text-align: left">
<td>
<b>{{ $key }}</b>
</td>
@ -27,7 +27,7 @@
<h2>Incoming cookies</h2>
<table style="width: 100%">
<thead>
<tr>
<tr style="text-align: left">
<th>Name</th>
<th>Domain</th>
<th>Path</th>
@ -41,7 +41,7 @@
</thead>
<tbody>
{{ range $cookie := .Request.Cookies }}
<tr>
<tr style="text-align: left">
<td>
<b>{{ $cookie.Name }}</b>
</td>

View File

@ -30,12 +30,15 @@ func RunCommand() *cli.Command {
logger.SetLevel(logger.Level(conf.Logger.Level))
projectVersion := ctx.String("projectVersion")
if conf.Proxy.Sentry.DSN != "" {
flushSentry, err := setup.SetupSentry(ctx.Context, conf.Proxy.Sentry, projectVersion)
if err != nil {
return errors.Wrap(err, "could not initialize sentry client")
}
defer flushSentry()
}
layers, err := setup.GetLayers(ctx.Context, conf)
if err != nil {

View File

@ -5,6 +5,7 @@ type AdminServerConfig struct {
CORS CORSConfig `yaml:"cors"`
Auth AuthConfig `yaml:"auth"`
Metrics MetricsConfig `yaml:"metrics"`
Profiling ProfilingConfig `yaml:"profiling"`
Sentry SentryConfig `yaml:"sentry"`
}
@ -15,6 +16,7 @@ func NewDefaultAdminServerConfig() AdminServerConfig {
Auth: NewDefaultAuthConfig(),
Metrics: NewDefaultMetricsConfig(),
Sentry: NewDefaultSentryConfig(),
Profiling: NewDefaultProfilingConfig(),
}
}

View File

@ -80,9 +80,22 @@ func loadBootstrapDir(dir string) (map[store.ProxyName]BootstrapProxyConfig, err
proxies := make(map[store.ProxyName]BootstrapProxyConfig)
for _, f := range files {
data, err := os.ReadFile(f)
proxy, err := loadBootstrappedProxyConfig(f)
if err != nil {
return nil, errors.Wrapf(err, "could not read file '%s'", f)
return nil, errors.Wrapf(err, "could not load proxy bootstrap file '%s'", f)
}
name := store.ProxyName(strings.TrimSuffix(filepath.Base(f), filepath.Ext(f)))
proxies[name] = *proxy
}
return proxies, nil
}
func loadBootstrappedProxyConfig(filename string) (*BootstrapProxyConfig, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, errors.Wrapf(err, "could not read file '%s'", filename)
}
proxy := BootstrapProxyConfig{}
@ -91,11 +104,7 @@ func loadBootstrapDir(dir string) (map[store.ProxyName]BootstrapProxyConfig, err
return nil, errors.Wrapf(err, "could not unmarshal proxy")
}
name := store.ProxyName(strings.TrimSuffix(filepath.Base(f), filepath.Ext(f)))
proxies[name] = proxy
}
return proxies, nil
return &proxy, nil
}
func overrideProxies(base map[store.ProxyName]BootstrapProxyConfig, proxies map[store.ProxyName]BootstrapProxyConfig) map[store.ProxyName]BootstrapProxyConfig {

View File

@ -127,7 +127,7 @@ func (im *InterpolatedMap) UnmarshalYAML(value *yaml.Node) error {
return nil
}
func (im *InterpolatedMap) interpolateRecursive(data any) (any, error) {
func (im InterpolatedMap) interpolateRecursive(data any) (any, error) {
switch typ := data.(type) {
case map[string]any:
for key, value := range typ {

View File

@ -0,0 +1,15 @@
package config
type ProfilingConfig struct {
Enabled InterpolatedBool `yaml:"enabled"`
Endpoint InterpolatedString `yaml:"endpoint"`
BasicAuth *BasicAuthConfig `yaml:"basicAuth"`
}
func NewDefaultProfilingConfig() ProfilingConfig {
return ProfilingConfig{
Enabled: true,
Endpoint: "/.bouncer/profiling",
BasicAuth: nil,
}
}

View File

@ -10,6 +10,7 @@ type ProxyServerConfig struct {
Debug InterpolatedBool `yaml:"debug"`
HTTP HTTPConfig `yaml:"http"`
Metrics MetricsConfig `yaml:"metrics"`
Profiling ProfilingConfig `yaml:"profiling"`
Transport TransportConfig `yaml:"transport"`
Dial DialConfig `yaml:"dial"`
Sentry SentryConfig `yaml:"sentry"`
@ -27,6 +28,7 @@ func NewDefaultProxyServerConfig() ProxyServerConfig {
Sentry: NewDefaultSentryConfig(),
Cache: NewDefaultCacheConfig(),
Templates: NewDefaultTemplatesConfig(),
Profiling: NewDefaultProfilingConfig(),
}
}

View File

@ -15,6 +15,8 @@ type RedisConfig struct {
WriteTimeout InterpolatedDuration `yaml:"writeTimeout"`
DialTimeout InterpolatedDuration `yaml:"dialTimeout"`
LockMaxRetries InterpolatedInt `yaml:"lockMaxRetries"`
MaxRetries InterpolatedInt `yaml:"maxRetries"`
PingInterval InterpolatedDuration `yaml:"pingInterval"`
}
func NewDefaultRedisConfig() RedisConfig {
@ -25,5 +27,7 @@ func NewDefaultRedisConfig() RedisConfig {
WriteTimeout: InterpolatedDuration(30 * time.Second),
DialTimeout: InterpolatedDuration(30 * time.Second),
LockMaxRetries: 10,
MaxRetries: 3,
PingInterval: InterpolatedDuration(30 * time.Second),
}
}

View File

@ -28,10 +28,10 @@ func NewDefaultSentryConfig() SentryConfig {
Debug: false,
FlushTimeout: NewInterpolatedDuration(2 * time.Second),
AttachStacktrace: true,
SampleRate: 1,
SampleRate: 0.2,
EnableTracing: true,
TracesSampleRate: 0.2,
ProfilesSampleRate: 1,
ProfilesSampleRate: 0.2,
IgnoreErrors: []string{},
SendDefaultPII: false,
ServerName: "",

View File

@ -74,7 +74,7 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
return
}
if err := l.applyRules(r, options, user); err != nil {
if err := l.applyRules(ctx, r, options, user); err != nil {
if errors.Is(err, ErrForbidden) {
l.renderForbiddenPage(w, r, layer, options, user)
return

View File

@ -39,6 +39,23 @@ func TestMatchAuthorizedCIDRs(t *testing.T) {
},
ExpectedResult: false,
},
{
RemoteHostPort: "192.168.1.15:43349",
AuthorizedCIDRs: []string{
"192.168.1.5/32",
"192.168.1.0/24",
},
ExpectedResult: true,
},
{
RemoteHostPort: "192.168.1.15:43349",
AuthorizedCIDRs: []string{
"192.168.1.5/32",
"192.168.1.6/32",
"192.168.1.7/32",
},
ExpectedResult: false,
},
}
auth := Authenticator{}

View File

@ -1,6 +1,7 @@
package authn
import (
"context"
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/rule"
@ -9,30 +10,32 @@ import (
"github.com/pkg/errors"
)
type Env struct {
type Vars struct {
User *User `expr:"user"`
}
func (l *Layer) applyRules(r *http.Request, options *LayerOptions, user *User) error {
func (l *Layer) applyRules(ctx context.Context, r *http.Request, options *LayerOptions, user *User) error {
rules := options.Rules
if len(rules) == 0 {
return nil
}
engine, err := rule.NewEngine[*Env](
engine, err := rule.NewEngine[*Vars](
rule.WithRules(options.Rules...),
rule.WithExpr(getAuthnAPI()...),
ruleHTTP.WithRequestFuncs(r),
ruleHTTP.WithRequestFuncs(),
)
if err != nil {
return errors.WithStack(err)
}
env := &Env{
vars := &Vars{
User: user,
}
if _, err := engine.Apply(env); err != nil {
ctx = ruleHTTP.WithRequest(ctx, r)
if _, err := engine.Apply(ctx, vars); err != nil {
return errors.WithStack(err)
}

View File

@ -65,7 +65,7 @@ func (q *Queue) Middleware(layer *store.Layer) proxy.Middleware {
return
}
defer q.updateMetrics(ctx, layer.Proxy, layer.Name, options)
defer q.updateMetrics(layer.Proxy, layer.Name, options)
cookieName := q.getCookieName(layer.Name)
@ -217,7 +217,9 @@ func (q *Queue) refreshQueue(ctx context.Context, layerName store.LayerName, kee
}
}
func (q *Queue) updateMetrics(ctx context.Context, proxyName store.ProxyName, layerName store.LayerName, options *LayerOptions) {
func (q *Queue) updateMetrics(proxyName store.ProxyName, layerName store.LayerName, options *LayerOptions) {
ctx := context.Background()
// Update queue capacity metric
metricQueueCapacity.With(
prometheus.Labels{

View File

@ -0,0 +1,79 @@
package rewriter
import (
"context"
"fmt"
"forge.cadoles.com/cadoles/bouncer/internal/rule"
"github.com/expr-lang/expr"
"github.com/pkg/errors"
)
type errRedirect struct {
statusCode int
url string
}
func (e *errRedirect) StatusCode() int {
return e.statusCode
}
func (e *errRedirect) URL() string {
return e.url
}
func (e *errRedirect) Error() string {
return fmt.Sprintf("redirect %d %s", e.statusCode, e.url)
}
func newErrRedirect(statusCode int, url string) *errRedirect {
return &errRedirect{
url: url,
statusCode: statusCode,
}
}
var _ error = &errRedirect{}
func redirectFunc() expr.Option {
return expr.Function(
"redirect",
func(params ...any) (any, error) {
_, err := rule.Assert[context.Context](params[0])
if err != nil {
return nil, errors.WithStack(err)
}
statusCode, err := rule.Assert[int](params[1])
if err != nil {
return nil, errors.WithStack(err)
}
if statusCode < 300 || statusCode >= 400 {
return nil, errors.Errorf("unexpected redirect status code '%d'", statusCode)
}
url, err := rule.Assert[string](params[2])
if err != nil {
return nil, errors.WithStack(err)
}
return nil, newErrRedirect(statusCode, url)
},
new(func(context.Context, int, string) bool),
)
}
func WithRewriterFuncs() rule.OptionFunc {
return func(opts *rule.Options) {
funcs := []expr.Option{
redirectFunc(),
}
if len(opts.Expr) == 0 {
opts.Expr = make([]expr.Option, 0)
}
opts.Expr = append(opts.Expr, funcs...)
}
}

View File

@ -6,6 +6,9 @@ import (
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/proxy/director/layer/util"
"forge.cadoles.com/cadoles/bouncer/internal/rule"
ruleHTTP "forge.cadoles.com/cadoles/bouncer/internal/rule/http"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
@ -13,7 +16,10 @@ import (
const LayerType store.LayerType = "rewriter"
type Layer struct{}
type Layer struct {
requestRuleEngine *util.RevisionedRuleEngine[*RequestVars, *LayerOptions]
responseRuleEngine *util.RevisionedRuleEngine[*ResponseVars, *LayerOptions]
}
func (l *Layer) LayerType() store.LayerType {
return LayerType
@ -39,7 +45,13 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
return
}
if err := l.applyRequestRules(r, options); err != nil {
if err := l.applyRequestRules(ctx, r, layer.Revision, options); err != nil {
var redirect *errRedirect
if errors.As(err, &redirect) {
http.Redirect(w, r, redirect.URL(), redirect.StatusCode())
return
}
logger.Error(ctx, "could not apply request rules", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@ -66,7 +78,9 @@ func (l *Layer) ResponseTransformer(layer *store.Layer) proxy.ResponseTransforme
return nil
}
if err := l.applyResponseRules(r, options); err != nil {
ctx := r.Request.Context()
if err := l.applyResponseRules(ctx, r, layer.Revision, options); err != nil {
return errors.WithStack(err)
}
@ -74,8 +88,32 @@ func (l *Layer) ResponseTransformer(layer *store.Layer) proxy.ResponseTransforme
}
}
func New() *Layer {
return &Layer{}
func New(funcs ...OptionFunc) *Layer {
return &Layer{
requestRuleEngine: util.NewRevisionedRuleEngine(func(options *LayerOptions) (*rule.Engine[*RequestVars], error) {
engine, err := rule.NewEngine[*RequestVars](
rule.WithRules(options.Rules.Request...),
ruleHTTP.WithRequestFuncs(),
WithRewriterFuncs(),
)
if err != nil {
return nil, errors.WithStack(err)
}
return engine, nil
}),
responseRuleEngine: util.NewRevisionedRuleEngine(func(options *LayerOptions) (*rule.Engine[*ResponseVars], error) {
engine, err := rule.NewEngine[*ResponseVars](
rule.WithRules(options.Rules.Response...),
ruleHTTP.WithResponseFuncs(),
)
if err != nil {
return nil, errors.WithStack(err)
}
return engine, nil
}),
}
}
var (

View File

@ -0,0 +1,16 @@
package rewriter
type Options struct {
}
type OptionFunc func(opts *Options)
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{}
for _, fn := range funcs {
fn(opts)
}
return opts
}

View File

@ -1,50 +1,79 @@
package rewriter
import (
"context"
"net/http"
"net/url"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"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 RequestVars struct {
Request RequestVar `expr:"request"`
OriginalURL URLVar `expr:"original_url"`
}
type RequestInfo struct {
type URLVar struct {
Scheme string `expr:"scheme"`
Opaque string `expr:"opaque"`
User UserVar `expr:"user"`
Host string `expr:"host"`
Path string `expr:"path"`
RawPath string `expr:"raw_path"`
RawQuery string `expr:"raw_query"`
Fragment string `expr:"fragment"`
RawFragment string `expr:"raw_fragment"`
}
func fromURL(url *url.URL) URLVar {
return URLVar{
Scheme: url.Scheme,
Opaque: url.Opaque,
User: UserVar{
Username: url.User.Username(),
Password: func() string {
passwd, _ := url.User.Password()
return passwd
}(),
},
Host: url.Host,
Path: url.Path,
RawPath: url.RawPath,
RawQuery: url.RawQuery,
Fragment: url.Fragment,
RawFragment: url.RawFragment,
}
}
type UserVar struct {
Username string `expr:"username"`
Password string `expr:"password"`
}
type RequestVar struct {
Method string `expr:"method"`
URL string `expr:"url"`
URL URLVar `expr:"url"`
RawURL string `expr:"raw_url"`
Proto string `expr:"proto"`
ProtoMajor int `expr:"protoMajor"`
ProtoMinor int `expr:"protoMinor"`
ProtoMajor int `expr:"proto_major"`
ProtoMinor int `expr:"proto_minor"`
Header map[string][]string `expr:"header"`
ContentLength int64 `expr:"contentLength"`
TransferEncoding []string `expr:"transferEncoding"`
ContentLength int64 `expr:"content_length"`
TransferEncoding []string `expr:"transfer_encoding"`
Host string `expr:"host"`
Trailer map[string][]string `expr:"trailer"`
RemoteAddr string `expr:"remoteAddr"`
RequestURI string `expr:"requestUri"`
RemoteAddr string `expr:"remote_addr"`
RequestURI string `expr:"request_uri"`
}
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{
func fromRequest(r *http.Request) RequestVar {
return RequestVar{
Method: r.Method,
URL: r.URL.String(),
URL: fromURL(r.URL),
RawURL: r.URL.String(),
Proto: r.Proto,
ProtoMajor: r.ProtoMajor,
ProtoMinor: r.ProtoMinor,
@ -55,64 +84,87 @@ func (l *Layer) applyRequestRules(r *http.Request, options *LayerOptions) error
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 {
func (l *Layer) applyRequestRules(ctx context.Context, r *http.Request, layerRevision int, 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),
)
engine, err := l.getRequestRuleEngine(ctx, layerRevision, options)
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{
originalURL, err := director.OriginalURL(ctx)
if err != nil {
return errors.WithStack(err)
}
vars := &RequestVars{
OriginalURL: fromURL(originalURL),
Request: fromRequest(r),
}
ctx = ruleHTTP.WithRequest(ctx, r)
if _, err := engine.Apply(ctx, vars); err != nil {
return errors.WithStack(err)
}
return nil
}
func (l *Layer) getRequestRuleEngine(ctx context.Context, layerRevision int, options *LayerOptions) (*rule.Engine[*RequestVars], error) {
engine, err := l.requestRuleEngine.Get(ctx, layerRevision, options)
if err != nil {
return nil, errors.WithStack(err)
}
return engine, nil
}
type ResponseVars struct {
OriginalURL URLVar `expr:"original_url"`
Request RequestVar `expr:"request"`
Response ResponseVar `expr:"response"`
}
type ResponseVar struct {
Status string `expr:"status"`
StatusCode int `expr:"status_code"`
Proto string `expr:"proto"`
ProtoMajor int `expr:"proto_major"`
ProtoMinor int `expr:"proto_minor"`
Header map[string][]string `expr:"header"`
ContentLength int64 `expr:"content_length"`
TransferEncoding []string `expr:"transfer_encoding"`
Uncompressed bool `expr:"uncompressed"`
Trailer map[string][]string `expr:"trailer"`
}
func (l *Layer) applyResponseRules(ctx context.Context, r *http.Response, layerRevision int, options *LayerOptions) error {
rules := options.Rules.Response
if len(rules) == 0 {
return nil
}
engine, err := l.getResponseRuleEngine(ctx, layerRevision, options)
if err != nil {
return errors.WithStack(err)
}
originalURL, err := director.OriginalURL(ctx)
if err != nil {
return errors.WithStack(err)
}
vars := &ResponseVars{
OriginalURL: fromURL(originalURL),
Request: fromRequest(r.Request),
Response: ResponseVar{
Proto: r.Proto,
ProtoMajor: r.ProtoMajor,
ProtoMinor: r.ProtoMinor,
@ -125,9 +177,21 @@ func (l *Layer) applyResponseRules(r *http.Response, options *LayerOptions) erro
},
}
if _, err := engine.Apply(env); err != nil {
ctx = ruleHTTP.WithResponse(ctx, r)
ctx = ruleHTTP.WithRequest(ctx, r.Request)
if _, err := engine.Apply(ctx, vars); err != nil {
return errors.WithStack(err)
}
return nil
}
func (l *Layer) getResponseRuleEngine(ctx context.Context, layerRevision int, options *LayerOptions) (*rule.Engine[*ResponseVars], error) {
engine, err := l.responseRuleEngine.Get(ctx, layerRevision, options)
if err != nil {
return nil, errors.WithStack(err)
}
return engine, nil
}

View File

@ -0,0 +1,51 @@
package util
import (
"context"
"sync"
"forge.cadoles.com/cadoles/bouncer/internal/rule"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type RuleEngineFactoryFunc[V any, O any] func(ops O) (*rule.Engine[V], error)
type RevisionedRuleEngine[V any, O any] struct {
mutex sync.RWMutex
revision int
engine *rule.Engine[V]
factory RuleEngineFactoryFunc[V, O]
}
func (e *RevisionedRuleEngine[V, O]) Get(ctx context.Context, revision int, opts O) (*rule.Engine[V], error) {
e.mutex.RLock()
if revision == e.revision {
logger.Debug(ctx, "using cached rule engine", logger.F("layerRevision", revision))
defer e.mutex.RUnlock()
return e.engine, nil
}
e.mutex.RUnlock()
e.mutex.Lock()
defer e.mutex.Unlock()
logger.Debug(ctx, "creating rule engine", logger.F("layerRevision", revision))
engine, err := e.factory(opts)
if err != nil {
return nil, errors.WithStack(err)
}
e.engine = engine
e.revision = revision
return engine, nil
}
func NewRevisionedRuleEngine[V any, O any](factory RuleEngineFactoryFunc[V, O]) *RevisionedRuleEngine[V, O] {
return &RevisionedRuleEngine[V, O]{
factory: factory,
}
}

View File

@ -9,7 +9,7 @@ import (
)
func (s *Server) initRepositories(ctx context.Context) error {
client := setup.NewRedisClient(ctx, s.redisConfig)
client := setup.NewSharedClient(s.redisConfig)
if err := s.initProxyRepository(ctx, client); err != nil {
return errors.WithStack(err)

View File

@ -1,156 +0,0 @@
package proxy_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os"
"testing"
"time"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/cadoles/bouncer/internal/cache/memory"
"forge.cadoles.com/cadoles/bouncer/internal/cache/ttl"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/store"
redisStore "forge.cadoles.com/cadoles/bouncer/internal/store/redis"
"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
)
func BenchmarkProxy(b *testing.B) {
redisEndpoint := os.Getenv("BOUNCER_BENCH_REDIS_ADDR")
if redisEndpoint == "" {
redisEndpoint = "127.0.0.1:6379"
}
client := redis.NewUniversalClient(&redis.UniversalOptions{
Addrs: []string{redisEndpoint},
})
proxyRepository := redisStore.NewProxyRepository(client)
layerRepository := redisStore.NewLayerRepository(client)
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte("Hello, world.")); err != nil {
b.Logf("[ERROR] %+v", errors.WithStack(err))
}
}))
defer backend.Close()
if err := waitFor(backend.URL, 5*time.Second); err != nil {
b.Fatalf("[FATAL] %+v", errors.WithStack(err))
}
b.Logf("started backend '%s'", backend.URL)
ctx := context.Background()
proxyName := store.ProxyName(b.Name())
b.Logf("creating proxy '%s'", proxyName)
if err := proxyRepository.DeleteProxy(ctx, proxyName); err != nil {
b.Fatalf("[FATAL] %+v", errors.WithStack(err))
}
if _, err := proxyRepository.CreateProxy(ctx, proxyName, backend.URL, "*"); err != nil {
b.Fatalf("[FATAL] %+v", errors.WithStack(err))
}
if _, err := proxyRepository.UpdateProxy(ctx, proxyName, store.WithProxyUpdateEnabled(true)); err != nil {
b.Fatalf("[FATAL] %+v", errors.WithStack(err))
}
director := director.New(
proxyRepository, layerRepository,
director.WithLayerCache(
ttl.NewCache(
memory.NewCache[string, []*store.Layer](),
memory.NewCache[string, time.Time](),
30*time.Second,
),
),
director.WithProxyCache(
ttl.NewCache(
memory.NewCache[string, []*store.Proxy](),
memory.NewCache[string, time.Time](),
30*time.Second,
),
),
)
directorMiddleware := director.Middleware()
handler := proxy.New(
proxy.WithRequestTransformers(
director.RequestTransformer(),
),
proxy.WithResponseTransformers(
director.ResponseTransformer(),
),
proxy.WithReverseProxyFactory(func(ctx context.Context, target *url.URL) *httputil.ReverseProxy {
reverse := httputil.NewSingleHostReverseProxy(target)
reverse.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
b.Logf("[ERROR] %s", errors.WithStack(err))
}
return reverse
}),
)
server := httptest.NewServer(directorMiddleware(handler))
defer server.Close()
b.Logf("started proxy '%s'", server.URL)
httpClient := server.Client()
b.ResetTimer()
for i := 0; i < b.N; i++ {
res, err := httpClient.Get(server.URL)
if err != nil {
b.Errorf("could not fetch server url: %+v", errors.WithStack(err))
}
body, err := io.ReadAll(res.Body)
if err != nil {
b.Errorf("could not read response body: %+v", errors.WithStack(err))
}
b.Logf("%s - %v", res.Status, string(body))
if err := res.Body.Close(); err != nil {
b.Errorf("could not close response body: %+v", errors.WithStack(err))
}
}
}
func waitFor(url string, ttl time.Duration) error {
var lastErr error
timeout := time.After(ttl)
for {
select {
case <-timeout:
if lastErr != nil {
return lastErr
}
return errors.New("wait timed out")
default:
res, err := http.Get(url)
if err != nil {
lastErr = errors.WithStack(err)
continue
}
if res.StatusCode >= 200 && res.StatusCode < 400 {
return nil
}
}
}
}

View File

@ -8,6 +8,7 @@ import (
"net"
"net/http"
"net/http/httputil"
"net/http/pprof"
"net/url"
"path/filepath"
"strconv"
@ -146,6 +147,34 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
})
}
if s.serverConfig.Profiling.Enabled {
profiling := s.serverConfig.Profiling
logger.Info(ctx, "enabling profiling", logger.F("endpoint", profiling.Endpoint))
router.Group(func(r chi.Router) {
if profiling.BasicAuth != nil {
logger.Info(ctx, "enabling authentication on metrics endpoint")
r.Use(middleware.BasicAuth(
"profiling",
profiling.BasicAuth.CredentialsMap(),
))
}
r.Route(string(profiling.Endpoint), func(r chi.Router) {
r.HandleFunc("/", pprof.Index)
r.HandleFunc("/cmdline", pprof.Cmdline)
r.HandleFunc("/profile", pprof.Profile)
r.HandleFunc("/symbol", pprof.Symbol)
r.HandleFunc("/trace", pprof.Trace)
r.HandleFunc("/{name}", func(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
pprof.Handler(name).ServeHTTP(w, r)
})
})
})
}
router.Group(func(r chi.Router) {
r.Use(director.Middleware())

View File

@ -1,16 +1,28 @@
package rule
import (
"context"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/vm"
"github.com/pkg/errors"
)
type Engine[E any] struct {
type Engine[V any] struct {
rules []*vm.Program
}
func (e *Engine[E]) Apply(env E) ([]any, error) {
func (e *Engine[V]) Apply(ctx context.Context, vars V) ([]any, error) {
type Env[V any] struct {
Context context.Context `expr:"ctx"`
Vars V `expr:"vars"`
}
env := Env[V]{
Context: ctx,
Vars: vars,
}
results := make([]any, 0, len(e.rules))
for i, r := range e.rules {
result, err := expr.Run(r, env)
@ -42,3 +54,26 @@ func NewEngine[E any](funcs ...OptionFunc) (*Engine[E], error) {
return engine, nil
}
func Context[T any](ctx context.Context, key any) (T, bool) {
raw := ctx.Value(key)
if raw == nil {
return *new(T), false
}
value, err := Assert[T](raw)
if err != nil {
return *new(T), false
}
return value, true
}
func Assert[T any](raw any) (T, error) {
value, ok := raw.(T)
if !ok {
return *new(T), errors.Errorf("unexpected value '%T'", value)
}
return value, nil
}

View File

@ -0,0 +1,31 @@
package http
import (
"context"
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/rule"
)
type contextKey string
const (
contextKeyRequest contextKey = "request"
contextKeyResponse contextKey = "response"
)
func WithRequest(ctx context.Context, r *http.Request) context.Context {
return context.WithValue(ctx, contextKeyRequest, r)
}
func WithResponse(ctx context.Context, r *http.Response) context.Context {
return context.WithValue(ctx, contextKeyResponse, r)
}
func CtxRequest(ctx context.Context) (*http.Request, bool) {
return rule.Context[*http.Request](ctx, contextKeyRequest)
}
func CtxResponse(ctx context.Context) (*http.Response, bool) {
return rule.Context[*http.Response](ctx, contextKeyResponse)
}

View File

@ -1,20 +1,20 @@
package http
import (
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/rule"
"github.com/expr-lang/expr"
)
func WithRequestFuncs(r *http.Request) rule.OptionFunc {
func WithRequestFuncs() rule.OptionFunc {
return func(opts *rule.Options) {
funcs := []expr.Option{
setRequestURL(r),
setRequestHeaderFunc(r),
addRequestHeaderFunc(r),
delRequestHeadersFunc(r),
setRequestHostFunc(r),
setRequestURLFunc(),
setRequestHeaderFunc(),
addRequestHeaderFunc(),
delRequestHeadersFunc(),
setRequestHostFunc(),
getRequestCookieFunc(),
addRequestCookieFunc(),
}
if len(opts.Expr) == 0 {
@ -25,12 +25,14 @@ func WithRequestFuncs(r *http.Request) rule.OptionFunc {
}
}
func WithResponseFuncs(r *http.Response) rule.OptionFunc {
func WithResponseFuncs() rule.OptionFunc {
return func(opts *rule.Options) {
funcs := []expr.Option{
setResponseHeaderFunc(r),
addResponseHeaderFunc(r),
delResponseHeadersFunc(r),
setResponseHeaderFunc(),
addResponseHeaderFunc(),
delResponseHeadersFunc(),
addResponseCookieFunc(),
getResponseCookieFunc(),
}
if len(opts.Expr) == 0 {

View File

@ -1,6 +1,7 @@
package http
import (
"context"
"fmt"
"net/http"
"net/url"
@ -9,101 +10,147 @@ import (
"time"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/rule"
"github.com/expr-lang/expr"
"github.com/pkg/errors"
)
func setRequestHostFunc(r *http.Request) expr.Option {
func setRequestHostFunc() expr.Option {
return expr.Function(
"set_host",
func(params ...any) (any, error) {
host := params[0].(string)
ctx, err := rule.Assert[context.Context](params[0])
if err != nil {
return nil, errors.WithStack(err)
}
host, err := rule.Assert[string](params[1])
if err != nil {
return nil, errors.WithStack(err)
}
r, ok := CtxRequest(ctx)
if !ok {
return nil, errors.New("could not find http request in context")
}
r.Host = host
return true, nil
},
new(func(string) bool),
new(func(context.Context, string) bool),
)
}
func setRequestURL(r *http.Request) expr.Option {
func setRequestURLFunc() expr.Option {
return expr.Function(
"set_url",
func(params ...any) (any, error) {
rawURL := params[0].(string)
ctx, err := rule.Assert[context.Context](params[0])
if err != nil {
return nil, errors.WithStack(err)
}
rawURL, err := rule.Assert[string](params[1])
if err != nil {
return nil, errors.WithStack(err)
}
url, err := url.Parse(rawURL)
if err != nil {
return false, errors.WithStack(err)
}
r, ok := CtxRequest(ctx)
if !ok {
return nil, errors.New("could not find http request in context")
}
r.URL = url
return true, nil
},
new(func(string) bool),
new(func(context.Context, string) bool),
)
}
func addRequestHeaderFunc(r *http.Request) expr.Option {
func addRequestHeaderFunc() expr.Option {
return expr.Function(
"add_header",
func(params ...any) (any, error) {
name := params[0].(string)
rawValue := params[1]
ctx, err := rule.Assert[context.Context](params[0])
if err != nil {
return nil, errors.WithStack(err)
}
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)
name, err := rule.Assert[string](params[1])
if err != nil {
return nil, errors.WithStack(err)
}
value := formatValue(params[2])
r, ok := CtxRequest(ctx)
if !ok {
return nil, errors.New("could not find http request in context")
}
r.Header.Add(name, value)
return true, nil
},
new(func(string, string) bool),
new(func(context.Context, string, string) bool),
)
}
func setRequestHeaderFunc(r *http.Request) expr.Option {
func setRequestHeaderFunc() expr.Option {
return expr.Function(
"set_header",
func(params ...any) (any, error) {
name := params[0].(string)
rawValue := params[1]
ctx, err := rule.Assert[context.Context](params[0])
if err != nil {
return nil, errors.WithStack(err)
}
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)
name, err := rule.Assert[string](params[1])
if err != nil {
return nil, errors.WithStack(err)
}
value := formatValue(params[2])
r, ok := CtxRequest(ctx)
if !ok {
return nil, errors.New("could not find http request in context")
}
r.Header.Set(name, value)
return true, nil
},
new(func(string, string) bool),
new(func(context.Context, string, string) bool),
)
}
func delRequestHeadersFunc(r *http.Request) expr.Option {
func delRequestHeadersFunc() expr.Option {
return expr.Function(
"del_headers",
func(params ...any) (any, error) {
pattern := params[0].(string)
ctx, err := rule.Assert[context.Context](params[0])
if err != nil {
return nil, errors.WithStack(err)
}
pattern, err := rule.Assert[string](params[1])
if err != nil {
return nil, errors.WithStack(err)
}
r, ok := CtxRequest(ctx)
if !ok {
return nil, errors.New("could not find http request in context")
}
deleted := false
for key := range r.Header {
@ -117,6 +164,160 @@ func delRequestHeadersFunc(r *http.Request) expr.Option {
return deleted, nil
},
new(func(string) bool),
new(func(context.Context, string) bool),
)
}
type CookieVar struct {
Name string `expr:"name"`
Value string `expr:"value"`
Path string `expr:"path"`
Domain string `expr:"domain"`
Expires time.Time `expr:"expires"`
MaxAge int `expr:"max_age"`
Secure bool `expr:"secure"`
HttpOnly bool `expr:"http_only"`
SameSite http.SameSite `expr:"same_site"`
}
func getRequestCookieFunc() expr.Option {
return expr.Function(
"get_cookie",
func(params ...any) (any, error) {
ctx, err := rule.Assert[context.Context](params[0])
if err != nil {
return nil, errors.WithStack(err)
}
name, err := rule.Assert[string](params[1])
if err != nil {
return nil, errors.WithStack(err)
}
r, ok := CtxRequest(ctx)
if !ok {
return nil, errors.New("could not find http request in context")
}
cookie, err := r.Cookie(name)
if err != nil && !errors.Is(err, http.ErrNoCookie) {
return nil, errors.WithStack(err)
}
if cookie == nil {
return nil, nil
}
return CookieVar{
Name: cookie.Name,
Value: cookie.Value,
Path: cookie.Path,
Domain: cookie.Domain,
Expires: cookie.Expires,
MaxAge: cookie.MaxAge,
Secure: cookie.Secure,
HttpOnly: cookie.HttpOnly,
SameSite: cookie.SameSite,
}, nil
},
new(func(context.Context, string) CookieVar),
)
}
func addRequestCookieFunc() expr.Option {
return expr.Function(
"add_cookie",
func(params ...any) (any, error) {
ctx, err := rule.Assert[context.Context](params[0])
if err != nil {
return nil, errors.WithStack(err)
}
values, err := rule.Assert[map[string]any](params[1])
if err != nil {
return nil, errors.WithStack(err)
}
cookie, err := cookieFrom(values)
if err != nil {
return nil, errors.WithStack(err)
}
r, ok := CtxRequest(ctx)
if !ok {
return nil, errors.New("could not find http request in context")
}
r.AddCookie(cookie)
return true, nil
},
new(func(context.Context, map[string]any) bool),
)
}
func cookieFrom(values map[string]any) (*http.Cookie, error) {
cookie := &http.Cookie{}
if name, ok := values["name"].(string); ok {
cookie.Name = name
}
if value, ok := values["value"].(string); ok {
cookie.Value = value
}
if domain, ok := values["domain"].(string); ok {
cookie.Domain = domain
}
if path, ok := values["path"].(string); ok {
cookie.Path = path
}
if httpOnly, ok := values["http_only"].(bool); ok {
cookie.HttpOnly = httpOnly
}
if maxAge, ok := values["max_age"].(int); ok {
cookie.MaxAge = maxAge
}
if secure, ok := values["secure"].(bool); ok {
cookie.Secure = secure
}
if sameSite, ok := values["same_site"].(http.SameSite); ok {
cookie.SameSite = sameSite
} else if sameSite, ok := values["same_site"].(int); ok {
cookie.SameSite = http.SameSite(sameSite)
}
if expires, ok := values["expires"].(time.Time); ok {
cookie.Expires = expires
} else if rawExpires, ok := values["expires"].(string); ok {
expires, err := time.Parse(http.TimeFormat, rawExpires)
if err != nil {
return nil, errors.WithStack(err)
}
cookie.Expires = expires
}
return cookie, nil
}
func formatValue(v any) string {
var value string
switch v := v.(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", v)
}
return value
}

View File

@ -0,0 +1,324 @@
package http
import (
"context"
"fmt"
"net/http"
"testing"
"forge.cadoles.com/cadoles/bouncer/internal/rule"
"github.com/pkg/errors"
)
func TestSetRequestHost(t *testing.T) {
type Vars struct {
NewHost string `expr:"newHost"`
}
engine := createRuleEngine[Vars](t,
rule.WithExpr(setRequestHostFunc()),
rule.WithRules(
"set_host(ctx, vars.newHost)",
),
)
req, err := http.NewRequest("GET", "http://example.net", nil)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
ctx := context.Background()
ctx = WithRequest(ctx, req)
vars := Vars{
NewHost: "foobar",
}
if _, err := engine.Apply(ctx, vars); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if e, g := vars.NewHost, req.Host; e != g {
t.Errorf("req.Host: expected '%v', got '%v'", e, g)
}
}
func TestSetRequestURL(t *testing.T) {
type Vars struct {
NewURL string `expr:"newURL"`
}
engine := createRuleEngine[Vars](t,
rule.WithExpr(setRequestURLFunc()),
rule.WithRules(
"set_url(ctx, vars.newURL)",
),
)
req, err := http.NewRequest("GET", "http://example.net", nil)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
ctx := context.Background()
ctx = WithRequest(ctx, req)
vars := Vars{
NewURL: "http://localhost",
}
if _, err := engine.Apply(ctx, vars); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if e, g := vars.NewURL, req.URL.String(); e != g {
t.Errorf("req.URL.String(): expected '%v', got '%v'", e, g)
}
}
func TestAddRequestHeader(t *testing.T) {
type Vars struct {
NewHeaderKey string `expr:"newHeaderKey"`
NewHeaderValue string `expr:"newHeaderValue"`
}
engine := createRuleEngine[Vars](t,
rule.WithExpr(addRequestHeaderFunc()),
rule.WithRules(
"add_header(ctx, vars.newHeaderKey, vars.newHeaderValue)",
),
)
req, err := http.NewRequest("GET", "http://example.net", nil)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
ctx := context.Background()
ctx = WithRequest(ctx, req)
vars := Vars{
NewHeaderKey: "X-My-Header",
NewHeaderValue: "foobar",
}
if _, err := engine.Apply(ctx, vars); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if e, g := vars.NewHeaderValue, req.Header.Get(vars.NewHeaderKey); e != g {
t.Errorf("req.Header.Get(vars.NewHeaderKey): expected '%v', got '%v'", e, g)
}
}
func TestSetRequestHeader(t *testing.T) {
type Vars struct {
HeaderKey string `expr:"headerKey"`
HeaderValue string `expr:"headerValue"`
}
engine := createRuleEngine[Vars](t,
rule.WithExpr(setRequestHeaderFunc()),
rule.WithRules(
"set_header(ctx, vars.headerKey, vars.headerValue)",
),
)
req, err := http.NewRequest("GET", "http://example.net", nil)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
vars := Vars{
HeaderKey: "X-My-Header",
HeaderValue: "foobar",
}
req.Header.Set(vars.HeaderKey, "test")
ctx := context.Background()
ctx = WithRequest(ctx, req)
if _, err := engine.Apply(ctx, vars); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if e, g := vars.HeaderValue, req.Header.Get(vars.HeaderKey); e != g {
t.Errorf("req.Header.Get(vars.HeaderKey): expected '%v', got '%v'", e, g)
}
}
func TestDelRequestHeaders(t *testing.T) {
type Vars struct {
HeaderPattern string `expr:"headerPattern"`
}
engine := createRuleEngine[Vars](t,
rule.WithExpr(delRequestHeadersFunc()),
rule.WithRules(
"del_headers(ctx, vars.headerPattern)",
),
)
req, err := http.NewRequest("GET", "http://example.net", nil)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
vars := Vars{
HeaderPattern: "X-My-*",
}
req.Header.Set("X-My-Header", "test")
ctx := context.Background()
ctx = WithRequest(ctx, req)
if _, err := engine.Apply(ctx, vars); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if val := req.Header.Get("X-My-Header"); val != "" {
t.Errorf("req.Header.Get(\"X-My-Header\") should be empty, got '%v'", val)
}
}
func TestAddRequestCookie(t *testing.T) {
type TestCase struct {
Cookie map[string]any
Check func(t *testing.T, tc TestCase, req *http.Request)
ShouldFail bool
}
testCases := []TestCase{
{
Cookie: map[string]any{
"name": "test",
},
Check: func(t *testing.T, tc TestCase, req *http.Request) {
cookie, err := req.Cookie(tc.Cookie["name"].(string))
if err != nil {
t.Errorf("%+v", errors.WithStack(err))
return
}
if e, g := tc.Cookie["name"], cookie.Name; e != g {
t.Errorf("cookie.Name: expected '%v', got '%v'", e, g)
}
},
},
{
Cookie: map[string]any{
"name": "foo",
"value": "test",
},
Check: func(t *testing.T, tc TestCase, req *http.Request) {
cookie, err := req.Cookie(tc.Cookie["name"].(string))
if err != nil {
t.Errorf("%+v", errors.WithStack(err))
return
}
if e, g := tc.Cookie["name"], cookie.Name; e != g {
t.Errorf("cookie.Name: expected '%v', got '%v'", e, g)
}
if e, g := tc.Cookie["value"], cookie.Value; e != g {
t.Errorf("cookie.Value: expected '%v', got '%v'", e, g)
}
},
},
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("Case_%d", idx), func(t *testing.T) {
type Vars struct {
NewCookie map[string]any `expr:"new_cookie"`
}
engine := createRuleEngine[Vars](t,
rule.WithExpr(addRequestCookieFunc()),
rule.WithRules(
`add_cookie(ctx, vars.new_cookie)`,
),
)
req, err := http.NewRequest("GET", "http://example.net", nil)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
vars := Vars{
NewCookie: tc.Cookie,
}
ctx := context.Background()
ctx = WithRequest(ctx, req)
if _, err := engine.Apply(ctx, vars); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if tc.ShouldFail {
t.Error("engine.Apply() should have failed")
}
if tc.Check != nil {
tc.Check(t, tc, req)
}
})
}
}
func TestGetRequestCookie(t *testing.T) {
type Vars struct {
CookieName string `expr:"cookieName"`
}
engine := createRuleEngine[Vars](t,
rule.WithExpr(getRequestCookieFunc()),
rule.WithRules(
"let cookie = get_cookie(ctx, vars.cookieName); cookie.value",
),
)
req, err := http.NewRequest("GET", "http://example.net", nil)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
vars := Vars{
CookieName: "foo",
}
cookie := &http.Cookie{
Name: vars.CookieName,
Value: "bar",
}
req.AddCookie(cookie)
ctx := context.Background()
ctx = WithRequest(ctx, req)
results, err := engine.Apply(ctx, vars)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if e, g := cookie.Value, results[0]; e != g {
t.Errorf("result[0]: expected '%v', got '%v'", e, g)
}
}
func createRuleEngine[V any](t *testing.T, funcs ...rule.OptionFunc) *rule.Engine[V] {
engine, err := rule.NewEngine[V](funcs...)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
return engine
}

View File

@ -1,6 +1,7 @@
package http
import (
"context"
"fmt"
"net/http"
"strconv"
@ -8,15 +9,26 @@ import (
"time"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/rule"
"github.com/expr-lang/expr"
"github.com/pkg/errors"
)
func addResponseHeaderFunc(r *http.Response) expr.Option {
func addResponseHeaderFunc() expr.Option {
return expr.Function(
"add_header",
func(params ...any) (any, error) {
name := params[0].(string)
rawValue := params[1]
ctx, err := rule.Assert[context.Context](params[0])
if err != nil {
return nil, errors.WithStack(err)
}
name, err := rule.Assert[string](params[1])
if err != nil {
return nil, errors.WithStack(err)
}
rawValue := params[2]
var value string
switch v := rawValue.(type) {
@ -30,20 +42,34 @@ func addResponseHeaderFunc(r *http.Response) expr.Option {
value = fmt.Sprintf("%v", rawValue)
}
r, ok := CtxResponse(ctx)
if !ok {
return nil, errors.New("could not find http response in context")
}
r.Header.Add(name, value)
return true, nil
},
new(func(string, string) bool),
new(func(context.Context, string, string) bool),
)
}
func setResponseHeaderFunc(r *http.Response) expr.Option {
func setResponseHeaderFunc() expr.Option {
return expr.Function(
"set_header",
func(params ...any) (any, error) {
name := params[0].(string)
rawValue := params[1]
ctx, err := rule.Assert[context.Context](params[0])
if err != nil {
return nil, errors.WithStack(err)
}
name, err := rule.Assert[string](params[1])
if err != nil {
return nil, errors.WithStack(err)
}
rawValue := params[2]
var value string
switch v := rawValue.(type) {
@ -57,19 +83,38 @@ func setResponseHeaderFunc(r *http.Response) expr.Option {
value = fmt.Sprintf("%v", rawValue)
}
r, ok := CtxResponse(ctx)
if !ok {
return nil, errors.New("could not find http response in context")
}
r.Header.Set(name, value)
return true, nil
},
new(func(string, string) bool),
new(func(context.Context, string, string) bool),
)
}
func delResponseHeadersFunc(r *http.Response) expr.Option {
func delResponseHeadersFunc() expr.Option {
return expr.Function(
"del_headers",
func(params ...any) (any, error) {
pattern := params[0].(string)
ctx, err := rule.Assert[context.Context](params[0])
if err != nil {
return nil, errors.WithStack(err)
}
pattern, err := rule.Assert[string](params[1])
if err != nil {
return nil, errors.WithStack(err)
}
r, ok := CtxResponse(ctx)
if !ok {
return nil, errors.New("could not find http response in context")
}
deleted := false
for key := range r.Header {
@ -83,6 +128,87 @@ func delResponseHeadersFunc(r *http.Response) expr.Option {
return deleted, nil
},
new(func(string) bool),
new(func(context.Context, string) bool),
)
}
func addResponseCookieFunc() expr.Option {
return expr.Function(
"add_cookie",
func(params ...any) (any, error) {
ctx, err := rule.Assert[context.Context](params[0])
if err != nil {
return nil, errors.WithStack(err)
}
values, err := rule.Assert[map[string]any](params[1])
if err != nil {
return nil, errors.WithStack(err)
}
cookie, err := cookieFrom(values)
if err != nil {
return nil, errors.WithStack(err)
}
r, ok := CtxResponse(ctx)
if !ok {
return nil, errors.New("could not find http request in context")
}
r.Header.Add("Set-Cookie", cookie.String())
return true, nil
},
new(func(context.Context, map[string]any) bool),
)
}
func getResponseCookieFunc() expr.Option {
return expr.Function(
"get_cookie",
func(params ...any) (any, error) {
ctx, err := rule.Assert[context.Context](params[0])
if err != nil {
return nil, errors.WithStack(err)
}
name, err := rule.Assert[string](params[1])
if err != nil {
return nil, errors.WithStack(err)
}
res, ok := CtxResponse(ctx)
if !ok {
return nil, errors.New("could not find http response in context")
}
var cookie *http.Cookie
for _, c := range res.Cookies() {
if c.Name != name {
continue
}
cookie = c
break
}
if cookie == nil {
return nil, nil
}
return CookieVar{
Name: cookie.Name,
Value: cookie.Value,
Path: cookie.Path,
Domain: cookie.Domain,
Expires: cookie.Expires,
MaxAge: cookie.MaxAge,
Secure: cookie.Secure,
HttpOnly: cookie.HttpOnly,
SameSite: cookie.SameSite,
}, nil
},
new(func(context.Context, string) CookieVar),
)
}

View File

@ -0,0 +1,317 @@
package http
import (
"context"
"fmt"
"io"
"net/http"
"testing"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/rule"
"github.com/pkg/errors"
)
func TestAddResponseHeader(t *testing.T) {
type Vars struct {
NewHeaderKey string `expr:"newHeaderKey"`
NewHeaderValue string `expr:"newHeaderValue"`
}
engine := createRuleEngine[Vars](t,
rule.WithExpr(addResponseHeaderFunc()),
rule.WithRules(
"add_header(ctx, vars.newHeaderKey, vars.newHeaderValue)",
),
)
req, err := http.NewRequest("GET", "http://example.net", nil)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
resp := createResponse(req, http.StatusOK, nil)
ctx := context.Background()
ctx = WithResponse(ctx, resp)
vars := Vars{
NewHeaderKey: "X-My-Header",
NewHeaderValue: "foobar",
}
if _, err := engine.Apply(ctx, vars); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if e, g := vars.NewHeaderValue, resp.Header.Get(vars.NewHeaderKey); e != g {
t.Errorf("resp.Header.Get(vars.NewHeaderKey): expected '%v', got '%v'", e, g)
}
}
func TestResponseSetHeader(t *testing.T) {
type Vars struct {
HeaderKey string `expr:"headerKey"`
HeaderValue string `expr:"headerValue"`
}
engine := createRuleEngine[Vars](t,
rule.WithExpr(setResponseHeaderFunc()),
rule.WithRules(
"set_header(ctx, vars.headerKey, vars.headerValue)",
),
)
req, err := http.NewRequest("GET", "http://example.net", nil)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
resp := createResponse(req, http.StatusOK, nil)
vars := Vars{
HeaderKey: "X-My-Header",
HeaderValue: "foobar",
}
resp.Header.Set(vars.HeaderKey, "test")
ctx := context.Background()
ctx = WithResponse(ctx, resp)
if _, err := engine.Apply(ctx, vars); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if e, g := vars.HeaderValue, resp.Header.Get(vars.HeaderKey); e != g {
t.Errorf("resp.Header.Get(vars.HeaderKey): expected '%v', got '%v'", e, g)
}
}
func TestResponseDelHeaders(t *testing.T) {
type Vars struct {
HeaderPattern string `expr:"headerPattern"`
}
engine := createRuleEngine[Vars](t,
rule.WithExpr(delResponseHeadersFunc()),
rule.WithRules(
"del_headers(ctx, vars.headerPattern)",
),
)
req, err := http.NewRequest("GET", "http://example.net", nil)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
resp := createResponse(req, http.StatusOK, nil)
vars := Vars{
HeaderPattern: "X-My-*",
}
resp.Header.Set("X-My-Header", "test")
ctx := context.Background()
ctx = WithResponse(ctx, resp)
if _, err := engine.Apply(ctx, vars); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if val := resp.Header.Get("X-My-Header"); val != "" {
t.Errorf("resp.Header.Get(\"X-My-Header\") should be empty, got '%v'", val)
}
}
func TestAddResponseCookie(t *testing.T) {
type TestCase struct {
Cookie map[string]any
Check func(t *testing.T, tc TestCase, res *http.Response)
ShouldFail bool
}
testCases := []TestCase{
{
Cookie: map[string]any{
"name": "foo",
"value": "test",
"domain": "example.net",
"path": "/custom",
"same_site": http.SameSiteStrictMode,
"http_only": true,
"secure": false,
"expires": time.Now().UTC().Truncate(time.Second),
},
Check: func(t *testing.T, tc TestCase, res *http.Response) {
var cookie *http.Cookie
for _, c := range res.Cookies() {
if c.Name == tc.Cookie["name"] {
cookie = c
break
}
}
if cookie == nil {
t.Errorf("could not find cookie '%s'", tc.Cookie["name"])
return
}
if e, g := tc.Cookie["name"], cookie.Name; e != g {
t.Errorf("cookie.Name: expected '%v', got '%v'", e, g)
}
if e, g := tc.Cookie["value"], cookie.Value; e != g {
t.Errorf("cookie.Value: expected '%v', got '%v'", e, g)
}
if e, g := tc.Cookie["domain"], cookie.Domain; e != g {
t.Errorf("cookie.Domain: expected '%v', got '%v'", e, g)
}
if e, g := tc.Cookie["path"], cookie.Path; e != g {
t.Errorf("cookie.Path: expected '%v', got '%v'", e, g)
}
if e, g := tc.Cookie["secure"], cookie.Secure; e != g {
t.Errorf("cookie.Secure: expected '%v', got '%v'", e, g)
}
if e, g := tc.Cookie["http_only"], cookie.HttpOnly; e != g {
t.Errorf("cookie.HttpOnly: expected '%v', got '%v'", e, g)
}
if e, g := tc.Cookie["same_site"], cookie.SameSite; e != g {
t.Errorf("cookie.SameSite: expected '%v', got '%v'", e, g)
}
if e, g := tc.Cookie["expires"], cookie.Expires; e != g {
t.Errorf("cookie.Expires: expected '%v', got '%v'", e, g)
}
},
},
{
Cookie: map[string]any{
"name": "foo",
"expires": time.Now().UTC().Format(http.TimeFormat),
},
Check: func(t *testing.T, tc TestCase, res *http.Response) {
var cookie *http.Cookie
for _, c := range res.Cookies() {
if c.Name == tc.Cookie["name"] {
cookie = c
break
}
}
if cookie == nil {
t.Errorf("could not find cookie '%s'", tc.Cookie["name"])
return
}
if e, g := tc.Cookie["expires"], cookie.Expires.Format(http.TimeFormat); e != g {
t.Errorf("cookie.Expires: expected '%v', got '%v'", e, g)
}
},
},
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("Case_%d", idx), func(t *testing.T) {
type Vars struct {
NewCookie map[string]any `expr:"new_cookie"`
}
engine := createRuleEngine[Vars](t,
rule.WithExpr(addResponseCookieFunc()),
rule.WithRules(
`add_cookie(ctx, vars.new_cookie)`,
),
)
req, err := http.NewRequest("GET", "http://example.net", nil)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
resp := createResponse(req, http.StatusOK, nil)
vars := Vars{
NewCookie: tc.Cookie,
}
ctx := context.Background()
ctx = WithRequest(ctx, req)
ctx = WithResponse(ctx, resp)
if _, err := engine.Apply(ctx, vars); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if tc.ShouldFail {
t.Error("engine.Apply() should have failed")
}
if tc.Check != nil {
tc.Check(t, tc, resp)
}
})
}
}
func TestGetResponseCookie(t *testing.T) {
type Vars struct {
CookieName string `expr:"cookieName"`
}
engine := createRuleEngine[Vars](t,
rule.WithExpr(getResponseCookieFunc()),
rule.WithRules(
"let cookie = get_cookie(ctx, vars.cookieName); cookie.value",
),
)
req, err := http.NewRequest("GET", "http://example.net", nil)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
resp := createResponse(req, http.StatusOK, nil)
vars := Vars{
CookieName: "foo",
}
cookie := &http.Cookie{
Name: vars.CookieName,
Value: "bar",
}
resp.Header.Add("Set-Cookie", cookie.String())
ctx := context.Background()
ctx = WithResponse(ctx, resp)
results, err := engine.Apply(ctx, vars)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if e, g := cookie.Value, results[0]; e != g {
t.Errorf("result[0]: expected '%v', got '%v'", e, g)
}
}
func createResponse(req *http.Request, statusCode int, body io.Reader) *http.Response {
return &http.Response{
Status: http.StatusText(statusCode),
StatusCode: statusCode,
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Body: io.NopCloser(body),
ContentLength: -1,
Request: req,
Header: make(http.Header, 0),
}
}

View File

@ -23,7 +23,7 @@ func init() {
}
func setupAuthnOIDCLayer(conf *config.Config) (director.Layer, error) {
rdb := newRedisClient(conf.Redis)
rdb := NewSharedClient(conf.Redis)
adapter := redis.NewStoreAdapter(rdb)
store := session.NewStore(adapter)

View File

@ -27,7 +27,7 @@ func SetupIntegrations(ctx context.Context, conf *config.Config) ([]integration.
}
func setupKubernetesIntegration(ctx context.Context, conf *config.Config) (*kubernetes.Integration, error) {
client := newRedisClient(conf.Redis)
client := NewSharedClient(conf.Redis)
locker := redis.NewLocker(client, 10)
integration := kubernetes.NewIntegration(

View File

@ -9,7 +9,7 @@ import (
)
func SetupLocker(ctx context.Context, conf *config.Config) (lock.Locker, error) {
client := newRedisClient(conf.Redis)
client := NewSharedClient(conf.Redis)
locker := redis.NewLocker(client, int(conf.Redis.LockMaxRetries))
return locker, nil
}

View File

@ -3,23 +3,15 @@ package setup
import (
"context"
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/store"
redisStore "forge.cadoles.com/cadoles/bouncer/internal/store/redis"
"github.com/redis/go-redis/v9"
)
func NewRedisClient(ctx context.Context, conf config.RedisConfig) redis.UniversalClient {
return redis.NewUniversalClient(&redis.UniversalOptions{
Addrs: conf.Adresses,
MasterName: string(conf.Master),
})
}
func NewProxyRepository(ctx context.Context, client redis.UniversalClient) (store.ProxyRepository, error) {
return redisStore.NewProxyRepository(client), nil
return redisStore.NewProxyRepository(client, redisStore.DefaultTxMaxAttempts, redisStore.DefaultTxBaseDelay), nil
}
func NewLayerRepository(ctx context.Context, client redis.UniversalClient) (store.LayerRepository, error) {
return redisStore.NewLayerRepository(client), nil
return redisStore.NewLayerRepository(client, redisStore.DefaultTxMaxAttempts, redisStore.DefaultTxBaseDelay), nil
}

View File

@ -35,6 +35,6 @@ func setupQueueLayer(conf *config.Config) (director.Layer, error) {
}
func newQueueAdapter(redisConf config.RedisConfig) (queue.Adapter, error) {
rdb := newRedisClient(redisConf)
rdb := NewSharedClient(redisConf)
return queueRedis.NewAdapter(rdb, 2), nil
}

View File

@ -1,14 +1,38 @@
package setup
import (
"context"
"strings"
"sync"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/config"
"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
"gitlab.com/wpetit/goweb/logger"
)
var clients sync.Map
func NewSharedClient(conf config.RedisConfig) redis.UniversalClient {
key := strings.Join(conf.Adresses, "|") + "|" + string(conf.Master)
value, exists := clients.Load(key)
if exists {
if client, ok := (value).(redis.UniversalClient); ok {
return client
}
}
client := newRedisClient(conf)
clients.Store(key, client)
return client
}
func newRedisClient(conf config.RedisConfig) redis.UniversalClient {
return redis.NewUniversalClient(&redis.UniversalOptions{
client := redis.NewUniversalClient(&redis.UniversalOptions{
Addrs: conf.Adresses,
MasterName: string(conf.Master),
ReadTimeout: time.Duration(conf.ReadTimeout),
@ -16,5 +40,33 @@ func newRedisClient(conf config.RedisConfig) redis.UniversalClient {
DialTimeout: time.Duration(conf.DialTimeout),
RouteByLatency: true,
ContextTimeoutEnabled: true,
MaxRetries: int(conf.MaxRetries),
})
go func() {
ctx := logger.With(context.Background(),
logger.F("adresses", conf.Adresses),
logger.F("master", conf.Master),
)
timer := time.NewTicker(time.Duration(conf.PingInterval))
defer timer.Stop()
connected := true
for range timer.C {
if _, err := client.Ping(ctx).Result(); err != nil {
logger.Error(ctx, "redis disconnected", logger.E(errors.WithStack(err)))
connected = false
continue
}
if !connected {
logger.Info(ctx, "redis reconnected")
connected = true
}
}
}()
return client
}

View File

@ -13,6 +13,7 @@ type (
type LayerHeader struct {
Proxy ProxyName `json:"proxy"`
Name LayerName `json:"name"`
Revision int `json:"revision"`
Type LayerType `json:"type"`
Weight int `json:"weight"`

View File

@ -8,7 +8,7 @@ type ProxyName Name
type ProxyHeader struct {
Name ProxyName `json:"name"`
Revision int `json:"revision"`
Weight int `json:"weight"`
Enabled bool `json:"enabled"`
}

View File

@ -3,10 +3,18 @@ package redis
import (
"context"
"encoding/json"
"math/rand"
"strings"
"time"
"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
"gitlab.com/wpetit/goweb/logger"
)
var (
DefaultTxMaxAttempts = 20
DefaultTxBaseDelay = 100 * time.Millisecond
)
type jsonWrapper[T any] struct {
@ -65,6 +73,35 @@ func key(parts ...string) string {
return strings.Join(parts, ":")
}
func WithRetry(ctx context.Context, client redis.UniversalClient, key string, fn func(ctx context.Context, tx *redis.Tx) error, maxAttempts int, baseDelay time.Duration) error {
var err error
delay := baseDelay
for attempt := 0; attempt < maxAttempts; attempt++ {
if err = WithTx(ctx, client, key, fn); err != nil {
err = errors.WithStack(err)
logger.Debug(ctx, "redis transaction failed", logger.E(err))
if errors.Is(err, redis.TxFailedErr) {
logger.Debug(ctx, "retrying redis transaction", logger.F("attempts", attempt), logger.F("delay", delay))
time.Sleep(delay)
delay = delay*2 + time.Duration(rand.Int63n(int64(baseDelay)))
continue
}
return errors.WithStack(err)
}
return nil
}
logger.Error(ctx, "redis error", logger.E(errors.WithStack(err)))
return errors.WithStack(redis.TxFailedErr)
}
func WithTx(ctx context.Context, client redis.UniversalClient, key string, fn func(ctx context.Context, tx *redis.Tx) error) error {
txf := func(tx *redis.Tx) error {
if err := fn(ctx, tx); err != nil {

View File

@ -10,6 +10,7 @@ import (
type layerHeaderItem struct {
Proxy string `redis:"proxy"`
Name string `redis:"name"`
Revision int `redis:"revision"`
Type string `redis:"type"`
Weight int `redis:"weight"`
@ -20,6 +21,7 @@ func (i *layerHeaderItem) ToLayerHeader() (*store.LayerHeader, error) {
layerHeader := &store.LayerHeader{
Proxy: store.ProxyName(i.Proxy),
Name: store.LayerName(i.Name),
Revision: i.Revision,
Type: store.LayerType(i.Type),
Weight: i.Weight,
Enabled: i.Enabled,

View File

@ -15,6 +15,8 @@ const (
type LayerRepository struct {
client redis.UniversalClient
txMaxAttempts int
txRetryBaseDelay time.Duration
}
// CreateLayer implements store.LayerRepository
@ -28,12 +30,13 @@ func (r *LayerRepository) CreateLayer(ctx context.Context, proxyName store.Proxy
Name: string(layerName),
Type: string(layerType),
Weight: 0,
Revision: 0,
Enabled: false,
},
CreatedAt: wrap(now),
UpdatedAt: wrap(now),
Options: wrap(store.LayerOptions{}),
Options: wrap(options),
}
txf := func(tx *redis.Tx) error {
@ -57,6 +60,11 @@ func (r *LayerRepository) CreateLayer(ctx context.Context, proxyName store.Proxy
return errors.WithStack(err)
}
layerItem, err = r.txGetLayerItem(ctx, tx, proxyName, layerName)
if err != nil {
return errors.WithStack(err)
}
return nil
}
@ -67,16 +75,16 @@ func (r *LayerRepository) CreateLayer(ctx context.Context, proxyName store.Proxy
return &store.Layer{
LayerHeader: store.LayerHeader{
Name: layerName,
Proxy: proxyName,
Type: layerType,
Weight: 0,
Enabled: false,
Name: store.LayerName(layerItem.Name),
Proxy: store.ProxyName(layerItem.Proxy),
Type: store.LayerType(layerItem.Type),
Weight: layerItem.Weight,
Enabled: layerItem.Enabled,
},
CreatedAt: now,
UpdatedAt: now,
Options: store.LayerOptions{},
CreatedAt: layerItem.CreatedAt.Value(),
UpdatedAt: layerItem.UpdatedAt.Value(),
Options: layerItem.Options.Value(),
}, nil
}
@ -96,7 +104,7 @@ func (r *LayerRepository) GetLayer(ctx context.Context, proxyName store.ProxyNam
key := layerKey(proxyName, layerName)
var layerItem *layerItem
err := WithTx(ctx, r.client, key, func(ctx context.Context, tx *redis.Tx) error {
err := WithRetry(ctx, r.client, key, func(ctx context.Context, tx *redis.Tx) error {
pItem, err := r.txGetLayerItem(ctx, tx, proxyName, layerName)
if err != nil {
return errors.WithStack(err)
@ -105,7 +113,7 @@ func (r *LayerRepository) GetLayer(ctx context.Context, proxyName store.ProxyNam
layerItem = pItem
return nil
})
}, r.txMaxAttempts, r.txRetryBaseDelay)
if err != nil {
return nil, errors.WithStack(err)
}
@ -197,7 +205,7 @@ func (r *LayerRepository) UpdateLayer(ctx context.Context, proxyName store.Proxy
key := layerKey(proxyName, layerName)
var layerItem layerItem
err := WithTx(ctx, r.client, key, func(ctx context.Context, tx *redis.Tx) error {
err := WithRetry(ctx, r.client, key, func(ctx context.Context, tx *redis.Tx) error {
item, err := r.txGetLayerItem(ctx, tx, proxyName, layerName)
if err != nil {
return errors.WithStack(err)
@ -216,6 +224,7 @@ func (r *LayerRepository) UpdateLayer(ctx context.Context, proxyName store.Proxy
}
item.UpdatedAt = wrap(time.Now().UTC())
item.Revision = item.Revision + 1
_, err = tx.TxPipelined(ctx, func(p redis.Pipeliner) error {
p.HMSet(ctx, key, item.layerHeaderItem)
@ -230,7 +239,7 @@ func (r *LayerRepository) UpdateLayer(ctx context.Context, proxyName store.Proxy
layerItem = *item
return nil
})
}, r.txMaxAttempts, r.txRetryBaseDelay)
if err != nil {
return nil, errors.WithStack(err)
}
@ -243,9 +252,11 @@ func (r *LayerRepository) UpdateLayer(ctx context.Context, proxyName store.Proxy
return layer, nil
}
func NewLayerRepository(client redis.UniversalClient) *LayerRepository {
func NewLayerRepository(client redis.UniversalClient, txMaxAttempts int, txRetryBaseDelay time.Duration) *LayerRepository {
return &LayerRepository{
client: client,
txMaxAttempts: txMaxAttempts,
txRetryBaseDelay: txRetryBaseDelay,
}
}

View File

@ -7,6 +7,6 @@ import (
)
func TestLayerRepository(t *testing.T) {
repository := NewLayerRepository(client)
repository := NewLayerRepository(client, DefaultTxMaxAttempts, DefaultTxBaseDelay)
testsuite.TestLayerRepository(t, repository)
}

View File

@ -9,6 +9,7 @@ import (
type proxyHeaderItem struct {
Name string `redis:"name"`
Revision int `redis:"revision"`
Weight int `redis:"weight"`
Enabled bool `redis:"enabled"`
@ -20,6 +21,7 @@ type proxyHeaderItem struct {
func (i *proxyHeaderItem) ToProxyHeader() (*store.ProxyHeader, error) {
proxyHeader := &store.ProxyHeader{
Name: store.ProxyName(i.Name),
Revision: i.Revision,
Weight: i.Weight,
Enabled: i.Enabled,
}

View File

@ -15,6 +15,8 @@ const (
type ProxyRepository struct {
client redis.UniversalClient
txMaxAttempts int
txRetryBaseDelay time.Duration
}
// GetProxy implements store.ProxyRepository
@ -22,7 +24,7 @@ func (r *ProxyRepository) GetProxy(ctx context.Context, name store.ProxyName) (*
key := proxyKey(name)
var proxyItem *proxyItem
err := WithTx(ctx, r.client, key, func(ctx context.Context, tx *redis.Tx) error {
err := WithRetry(ctx, r.client, key, func(ctx context.Context, tx *redis.Tx) error {
pItem, err := r.txGetProxyItem(ctx, tx, name)
if err != nil {
return errors.WithStack(err)
@ -31,7 +33,7 @@ func (r *ProxyRepository) GetProxy(ctx context.Context, name store.ProxyName) (*
proxyItem = pItem
return nil
})
}, r.txMaxAttempts, r.txRetryBaseDelay)
if err != nil {
return nil, errors.WithStack(err)
}
@ -89,6 +91,7 @@ func (r *ProxyRepository) CreateProxy(ctx context.Context, name store.ProxyName,
CreatedAt: wrap(now),
UpdatedAt: wrap(now),
Weight: 0,
Revision: 0,
Enabled: false,
},
To: to,
@ -191,7 +194,7 @@ func (r *ProxyRepository) UpdateProxy(ctx context.Context, name store.ProxyName,
key := proxyKey(name)
var proxyItem proxyItem
err := WithTx(ctx, r.client, key, func(ctx context.Context, tx *redis.Tx) error {
err := WithRetry(ctx, r.client, key, func(ctx context.Context, tx *redis.Tx) error {
item, err := r.txGetProxyItem(ctx, tx, name)
if err != nil {
return errors.WithStack(err)
@ -214,6 +217,7 @@ func (r *ProxyRepository) UpdateProxy(ctx context.Context, name store.ProxyName,
}
item.UpdatedAt = wrap(time.Now().UTC())
item.Revision = item.Revision + 1
_, err = tx.TxPipelined(ctx, func(p redis.Pipeliner) error {
p.HMSet(ctx, key, item.proxyHeaderItem)
@ -228,7 +232,7 @@ func (r *ProxyRepository) UpdateProxy(ctx context.Context, name store.ProxyName,
proxyItem = *item
return nil
})
}, r.txMaxAttempts, r.txRetryBaseDelay)
if err != nil {
return nil, errors.WithStack(err)
}
@ -241,9 +245,11 @@ func (r *ProxyRepository) UpdateProxy(ctx context.Context, name store.ProxyName,
return proxy, nil
}
func NewProxyRepository(client redis.UniversalClient) *ProxyRepository {
func NewProxyRepository(client redis.UniversalClient, txMaxAttempts int, txRetryBaseDelay time.Duration) *ProxyRepository {
return &ProxyRepository{
client: client,
txMaxAttempts: 20,
txRetryBaseDelay: txRetryBaseDelay,
}
}

View File

@ -7,6 +7,6 @@ import (
)
func TestProxyRepository(t *testing.T) {
repository := NewProxyRepository(client)
repository := NewProxyRepository(client, DefaultTxMaxAttempts, DefaultTxBaseDelay)
testsuite.TestProxyRepository(t, repository)
}

View File

@ -3,6 +3,7 @@ package testsuite
import (
"context"
"reflect"
"sync"
"testing"
"forge.cadoles.com/cadoles/bouncer/internal/store"
@ -49,6 +50,10 @@ var layerRepositoryTestCases = []layerRepositoryTestCase{
return errors.Errorf("layer.UpdatedAt should not be zero value")
}
if layer.Revision != 0 {
return errors.Errorf("layer.Revision should be zero")
}
return nil
},
},
@ -230,6 +235,86 @@ var layerRepositoryTestCases = []layerRepositoryTestCase{
return errors.New("could not find created layer in query results")
}
return nil
},
},
{
Name: "Create then update layer",
Do: func(repo store.LayerRepository) error {
ctx := context.Background()
var layerName store.LayerName = "create_then_update_layer"
var proxyName store.ProxyName = store.ProxyName(string(layerName) + "_proxy")
var layerType store.LayerType = "dummy"
var layerOptions store.LayerOptions = store.LayerOptions{}
createdLayer, err := repo.CreateLayer(ctx, proxyName, layerName, layerType, layerOptions)
if err != nil {
return errors.WithStack(err)
}
if e, g := 0, createdLayer.Revision; e != g {
return errors.Errorf("createdLayer.Revision: expected '%v', got '%v'", e, g)
}
updatedLayer, err := repo.UpdateLayer(ctx, proxyName, layerName)
if err != nil {
return errors.Wrap(err, "err should be nil")
}
if e, g := 1, updatedLayer.Revision; e != g {
return errors.Errorf("updatedLayer.Revision: expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Update layer concurrently",
Do: func(repo store.LayerRepository) error {
ctx := context.Background()
var layerName store.LayerName = "update_layer_concurrently"
var proxyName store.ProxyName = store.ProxyName(string(layerName) + "_proxy")
var layerType store.LayerType = "dummy"
var layerOptions store.LayerOptions = store.LayerOptions{}
createdLayer, err := repo.CreateLayer(ctx, proxyName, layerName, layerType, layerOptions)
if err != nil {
return errors.WithStack(err)
}
if createdLayer.Revision != 0 {
return errors.Errorf("createdLayer.Revision should be zero")
}
var wg sync.WaitGroup
total := 100
wg.Add(total)
for i := 0; i < total; i++ {
go func(i int) {
defer wg.Done()
if _, err := repo.UpdateLayer(ctx, createdLayer.Proxy, createdLayer.Name); err != nil {
panic(errors.Wrap(err, "err should be nil"))
}
}(i)
}
wg.Wait()
layer, err := repo.GetLayer(ctx, createdLayer.Proxy, createdLayer.Name)
if err != nil {
return errors.Wrap(err, "err should be nil")
}
if e, g := total, layer.Revision; e != g {
return errors.Errorf("layer.Revision: expected '%v', got '%v'", e, g)
}
return nil
},
},

View File

@ -3,6 +3,7 @@ package testsuite
import (
"context"
"reflect"
"sync"
"testing"
"forge.cadoles.com/cadoles/bouncer/internal/store"
@ -51,6 +52,10 @@ var proxyRepositoryTestCases = []proxyRepositoryTestCase{
return errors.Errorf("proxy.UpdatedAt should not be zero value")
}
if proxy.Revision != 0 {
return errors.Errorf("proxy.Revision should be zero")
}
return nil
},
},
@ -99,6 +104,10 @@ var proxyRepositoryTestCases = []proxyRepositoryTestCase{
return errors.Errorf("foundProxy.UpdatedAt: expected '%v', got '%v'", createdProxy.UpdatedAt, foundProxy.UpdatedAt)
}
if foundProxy.Revision != 0 {
return errors.Errorf("foundProxy.Revision should be zero")
}
return nil
},
},
@ -194,6 +203,84 @@ var proxyRepositoryTestCases = []proxyRepositoryTestCase{
return errors.Errorf("err: expected store.ErrAlreadyExists, got '%+v'", err)
}
return nil
},
},
{
Name: "Create then update proxy",
Do: func(repo store.ProxyRepository) error {
ctx := context.Background()
to := "http://example.com"
var name store.ProxyName = "create_then_update_proxy"
createdProxy, err := repo.CreateProxy(ctx, name, to, "127.0.0.1:*", "localhost:*")
if err != nil {
return errors.Wrap(err, "err should be nil")
}
if createdProxy.Revision != 0 {
return errors.Errorf("createdProxy.Revision should be zero")
}
updatedProxy, err := repo.UpdateProxy(ctx, name)
if err != nil {
return errors.Wrap(err, "err should be nil")
}
if e, g := 1, updatedProxy.Revision; e != g {
return errors.Errorf("updatedProxy.Revision: expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Update proxy concurrently",
Do: func(repo store.ProxyRepository) error {
ctx := context.Background()
to := "http://example.com"
var name store.ProxyName = "update_proxy_concurrently"
createdProxy, err := repo.CreateProxy(ctx, name, to, "127.0.0.1:*", "localhost:*")
if err != nil {
return errors.Wrap(err, "err should be nil")
}
if createdProxy.Revision != 0 {
return errors.Errorf("createdProxy.Revision should be zero")
}
var wg sync.WaitGroup
total := 100
wg.Add(total)
for i := 0; i < total; i++ {
go func(i int) {
defer wg.Done()
if _, err := repo.UpdateProxy(ctx, name); err != nil {
panic(errors.Wrap(err, "err should be nil"))
}
}(i)
}
wg.Wait()
proxy, err := repo.GetProxy(ctx, name)
if err != nil {
return errors.Wrap(err, "err should be nil")
}
if e, g := total, proxy.Revision; e != g {
return errors.Errorf("proxy.Revision: expected '%v', got '%v'", e, g)
}
return nil
},
},

View File

@ -49,6 +49,19 @@ admin:
# Mettre à null pour désactiver l'authentification
basicAuth: null
# Profiling
profiling:
# Activer ou désactiver les endpoints de profiling
enabled: true
# Route de publication des endpoints de profiling
endpoint: /.bouncer/profiling
# Authentification "basic auth" sur les endpoints
# de profiling
# Mettre à null pour désactiver l'authentification
basicAuth:
credentials:
prof: iling
# Configuration de l'intégration Sentry
# Voir https://pkg.go.dev/github.com/getsentry/sentry-go?utm_source=godoc#ClientOptions
sentry:
@ -59,7 +72,7 @@ admin:
sampleRate: 1
enableTracing: true
tracesSampleRate: 0.2
profilesSampleRate: 1
profilesSampleRate: 0.2
ignoreErrors: []
sendDefaultPII: false
serverName: ""
@ -99,6 +112,19 @@ proxy:
credentials:
prom: etheus
# Profiling
profiling:
# Activer ou désactiver les endpoints de profiling
enabled: true
# Route de publication des endpoints de profiling
endpoint: /.bouncer/profiling
# Authentification "basic auth" sur les endpoints
# de profiling
# Mettre à null pour désactiver l'authentification
basicAuth:
credentials:
prof: iling
# Configuration de la mise en cache
# locale des données proxy/layers
cache:
@ -164,6 +190,8 @@ redis:
writeTimeout: 30s
readTimeout: 30s
dialTimeout: 30s
maxRetries: 3
pingInterval: 30s
# Configuration des logs
logger:

79
misc/siege/siege.conf Normal file
View File

@ -0,0 +1,79 @@
# Updated by Siege %_VERSION%, %_DATE%
# Copyright 2000-2016 by %_AUTHOR%
#
# Siege configuration file -- edit as necessary
# For more information about configuring and running this program,
# visit: http://www.joedog.org/
#
#
# Verbose mode: With this feature enabled, siege will print the
# result of each transaction to stdout. (Enabled by default)
#
# ex: verbose = true|false
#
verbose = true
#
# Color mode: This option works in conjunction with verbose mode.
# It tells siege whether or not it should display its output in
# color-coded output. (Enabled by default)
#
# ex: color = on | off
#
color = on
#
# Cache revalidation. Siege supports cache revalidation for both ETag
# and Last-modified headers. If a copy is still fresh, the server
# responds with 304. While this feature is required for HTTP/1.1, it
# may not be welcomed for load testing. We allow you to breach the
# protocol and turn off caching
#
# HTTP/1.1 200 0.00 secs: 2326 bytes ==> /apache_pb.gif
# HTTP/1.1 304 0.00 secs: 0 bytes ==> /apache_pb.gif
# HTTP/1.1 304 0.00 secs: 0 bytes ==> /apache_pb.gif
#
# Siege also supports Cache-control headers. Consider this server
# response: Cache-Control: max-age=3
# That tells siege to cache the file for three seconds. While it
# doesn't actually store the file, it will logically grab it from
# its cache. In verbose output, it designates a cached resource
# with (c):
#
# HTTP/1.1 200 0.25 secs: 159 bytes ==> GET /expires/
# HTTP/1.1 200 1.48 secs: 498419 bytes ==> GET /expires/Otter_in_Southwold.jpg
# HTTP/1.1 200 0.24 secs: 159 bytes ==> GET /expires/
# HTTP/1.1 200(C) 0.00 secs: 0 bytes ==> GET /expires/Otter_in_Southwold.jpg
#
# NOTE: with color enabled, cached URLs appear in green
#
# ex: cache = true
#
cache = true
#
# Cookie support: by default siege accepts cookies. This directive is
# available to disable that support. Set cookies to 'false' to refuse
# cookies. Set it to 'true' to accept them. The default value is true.
# If you want to maintain state with the server, then this MUST be set
# to true.
#
# ex: cookies = false
#
cookies = true
#
# Failures: This is the number of total connection failures allowed
# before siege aborts. Connection failures (timeouts, socket failures,
# etc.) are combined with 400 and 500 level errors in the final stats,
# but those errors do not count against the abort total. If you set
# this total to 10, then siege will abort after ten socket timeouts,
# but it will NOT abort after ten 404s. This is designed to prevent a
# run-away mess on an unattended siege.
#
# The default value is 1024
#
# ex: failures = 50
#
failures = -1