Compare commits

...

47 Commits

Author SHA1 Message Date
9d10a69b0d chore: update go and alpine docker image version
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2025-04-03 11:12:32 +02:00
8b6e75ae77 feat: use sentry tags instead of context for better observability
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2025-03-19 15:04:55 +01:00
692523e54f feat: prevent call bursts on oidc provider refresh
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2025-03-18 15:51:25 +01:00
59ecfa7b4e feat: prevent burst of proxy/layers update
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2025-03-18 12:52:59 +01:00
cc5cdcea96 fix: cache ttl interpolation
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2025-03-18 12:20:00 +01:00
1af7248a6f feat: reset proxy cache with sigusr2
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2025-03-18 11:35:15 +01:00
8b132dddd4 feat: add proxy information in sentry context
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2025-03-17 12:07:30 +01:00
6a4a144c97 feat: silence context expired errors
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
closes #49
2025-03-10 18:32:36 +01:00
ac7b7e8189 Merge pull request 'Mise en cache des fournisseurs oidc pour améliorer les performances' (#48) from issue-47 into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #48
Reviewed-by: Laurent Gourvenec <lgourvenec@cadoles.com>
2025-03-07 13:42:39 +01:00
2df74bad4f feat: cache oidc.Provider to reduce pressure on OIDC identity provider (#47)
All checks were successful
Cadoles/bouncer/pipeline/pr-develop This commit looks good
2025-03-07 11:15:28 +01:00
076a3d784e Merge pull request 'Fix append error in admin/run.go' (#46) from fix-append into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #46
2025-03-07 10:21:27 +01:00
826edef358 fix : suppression de append inutile, remonté par go vet
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Cadoles/bouncer/pipeline/pr-develop This commit looks good
2024-11-18 11:05:17 +01:00
ce7415af20 fix: prevent nil pointer when err session retrieval fails
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
see https://sentry.in.nuonet.fr/share/issue/48b82c13ee3f4721bb6306b533799709/
2024-11-14 10:10:16 +01:00
7cc9de180c feat(authn): add configurable global ttl for session storage 2024-11-14 10:09:33 +01:00
74c2a2c055 fix(authn): correctly handle session-limited cookies
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
See CNOUS/mse#4347
2024-11-08 12:21:23 +01:00
239d4573c3 feat(sentry): ignore 'net/http: abort' errors
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
See:
- https://sentry.in.nuonet.fr/share/issue/972e100ea22d44759c44b6cfad8be7b2/
- https://pkg.go.dev/net/http#:~:text=ErrAbortHandler%20is%20a%20sentinel%20panic%20value%20to%20abort%20a%20handler.%20While%20any%20panic%20from%20ServeHTTP%20aborts%20the%20response%20to%20the%20client%2C%20panicking%20with%20ErrAbortHandler%20also%20suppresses%20logging%20of%20a%20stack%20trace%20to%20the%20server%27s%20error%20log.
2024-11-08 11:19:38 +01:00
cffe3eca1b fix: prevent loss of information when returning errors
Some checks reported warnings
Cadoles/bouncer/pipeline/head This commit was not built
Linked to:
- https://sentry.in.nuonet.fr/share/issue/5fa72de1b01b46bc81601958a2ff5fd2/
- https://sentry.in.nuonet.fr/share/issue/5a225f6400a647c0bbf1f7ea01566e63/
2024-11-08 11:13:39 +01:00
a686c52aed Merge pull request 'fix(rewriter): prevent mixing of cached rule engines (#44)' (#45) from issue-44 into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #45
2024-10-21 14:07:51 +02:00
c0470ca623 feat: better display of large error messsages
Some checks are pending
Cadoles/bouncer/pipeline/head Build queued...
Cadoles/bouncer/pipeline/pr-develop Build queued...
2024-10-21 13:49:32 +02:00
c611705d45 fix(rewriter): prevent mixing of cached rule engines (#44) 2024-10-21 13:48:59 +02:00
16fa751dc7 doc: fix rewriter rule method name 2024-10-21 13:48:07 +02:00
8983a44d9e feat: do not capture warning errors
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-10-11 14:18:52 +02:00
11375e546f feat: allow more control on redis client configuration
Some checks reported warnings
Cadoles/bouncer/pipeline/head This commit was not built
2024-10-11 14:17:45 +02:00
69501f6302 feat: log context cancelled error as warn instead of errors
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-10-02 12:13:50 +02:00
382d17cc85 feat: do not use logger.Debug in critical path 2024-10-02 12:13:26 +02:00
9bd1d0fbd7 feat: remove superfluous sentry span handlers 2024-10-02 12:12:51 +02:00
ecacbb1cbd feat: disable sentry tracing by default 2024-10-02 12:12:10 +02:00
910f1f8ba2 feat: do not use fmt.Sprintf in http logger 2024-10-02 12:11:30 +02:00
be59be1795 feat: add recoverer + request-id http middlewares 2024-10-02 12:10:38 +02:00
4d6958e2f5 chore: increase default siege requests volume 2024-10-02 12:09:41 +02:00
f3b553cb10 feat: expose expvars as profiling endpoint 2024-10-02 12:09:02 +02:00
0ff9391a1b Merge pull request 'feat: global error handler with template rendering' (#42) from error-handler into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #42
2024-09-27 15:03:05 +02:00
d4c28b80d7 feat: global error handler with template rendering
Some checks are pending
Cadoles/bouncer/pipeline/pr-develop Build started...
2024-09-27 15:02:49 +02:00
590505e17a feat: add sentry spans to evaluate proxy performances
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-09-27 10:46:18 +02:00
867e7c549f feat: capture logged errors and forward them to sentry when enabled
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-09-27 10:15:08 +02:00
169578c25d chore: fix log typo
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-09-26 15:48:22 +02:00
b0a71fc599 feat: use go 1.23 for docker build
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-09-26 14:46:28 +02:00
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
80 changed files with 2555 additions and 548 deletions

View File

@ -1,4 +1,4 @@
FROM reg.cadoles.com/proxy_cache/library/golang:1.22 AS BUILD
FROM reg.cadoles.com/proxy_cache/library/golang:1.24.2 AS build
RUN apt-get update \
&& apt-get install -y make
@ -33,7 +33,7 @@ RUN /src/dist/bouncer_linux_amd64_v1/bouncer -c '' config dump > /src/dist/bounc
&& yq -i '.bootstrap.lockTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.integrations.kubernetes.lockTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml
FROM reg.cadoles.com/proxy_cache/library/alpine:3.20 AS RUNTIME
FROM reg.cadoles.com/proxy_cache/library/alpine:3.21 AS runtime
RUN apk add --no-cache ca-certificates dumb-init
@ -41,10 +41,10 @@ ENTRYPOINT ["/usr/bin/dumb-init", "--"]
RUN mkdir -p /usr/local/bin /usr/share/bouncer/bin /etc/bouncer
COPY --from=BUILD /src/dist/bouncer_linux_amd64_v1/bouncer /usr/share/bouncer/bin/bouncer
COPY --from=BUILD /src/layers /usr/share/bouncer/layers
COPY --from=BUILD /src/templates /usr/share/bouncer/templates
COPY --from=BUILD /src/dist/bouncer_linux_amd64_v1/config.yml /etc/bouncer/config.yml
COPY --from=build /src/dist/bouncer_linux_amd64_v1/bouncer /usr/share/bouncer/bin/bouncer
COPY --from=build /src/layers /usr/share/bouncer/layers
COPY --from=build /src/templates /usr/share/bouncer/templates
COPY --from=build /src/dist/bouncer_linux_amd64_v1/config.yml /etc/bouncer/config.yml
RUN ln -s /usr/share/bouncer/bin/bouncer /usr/local/bin/bouncer

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 ?= 200
SIEGE_DURATION ?= 5M
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 '^$$' ./internal/bench
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)
}
```
##### `add_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.
@ -67,61 +121,65 @@ La requête en cours de traitement.
method: "string", // Méthode HTTP
host: "string", // Nom d'hôte (`Host`) associé à 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": ""
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)
"rawPath": "string", // Chemin de l'URL (format brut)
"rawQuery": "string", // Variables d'URL (format brut)
"fragment" : "string", // Fragment d'URL (format assaini)
"rawFragment" : "string" // Fragment d'URL (format brut)
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)
},
rawUrl: "string", // URL associée à la requête (format assaini)
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,10 +1,24 @@
# Étudier les performances de Bouncer
## In situ
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
```
L'ensemble des profils disponibles sont visibles à l'adresse `http://<bouncer_proxy>/.bouncer/profiling`.
## 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
### 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:
@ -19,7 +33,7 @@ Par exemple:
go test -bench='BenchmarkProxies/basic-auth' -run='^$' ./internal/bench
```
## Visualiser les profils d'exécution
### Visualiser les profils d'exécution
Vous pouvez visualiser les profils d'exécution via la commande suivante:
@ -35,7 +49,7 @@ Par exemple:
go tool pprof -web ./internal/bench/testdata/proxies/basic-auth.prof
```
## Comparer les évolutions
### Comparer les évolutions
```bash
# Lancer un premier benchmark

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

@ -70,7 +70,7 @@ func assertRequestUser(w http.ResponseWriter, r *http.Request) (auth.User, bool)
ctx := r.Context()
user, err := auth.CtxUser(ctx)
if err != nil {
logger.Error(ctx, "could not retrieve user", logger.E(errors.WithStack(err)))
logger.Error(ctx, "could not retrieve user", logger.CapturedE(errors.WithStack(err)))
forbidden(w, r)

View File

@ -6,7 +6,6 @@ import (
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/schema"
"github.com/getsentry/sentry-go"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
@ -29,11 +28,8 @@ func invalidDataErrorResponse(w http.ResponseWriter, r *http.Request, err *schem
}{
Message: message,
})
return
}
func logAndCaptureError(ctx context.Context, message string, err error) {
sentry.CaptureException(err)
logger.Error(ctx, message, logger.E(err))
logger.Error(ctx, message, logger.CapturedE(err))
}

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

@ -2,10 +2,12 @@ package admin
import (
"context"
"expvar"
"fmt"
"log"
"net"
"net/http"
"net/http/pprof"
"forge.cadoles.com/cadoles/bouncer/internal/auth"
"forge.cadoles.com/cadoles/bouncer/internal/auth/jwt"
@ -114,7 +116,9 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
router.Use(middleware.RealIP)
}
router.Use(middleware.RequestID)
router.Use(middleware.RequestLogger(bouncerChi.NewLogFormatter()))
router.Use(middleware.Recoverer)
if s.serverConfig.Sentry.DSN != "" {
logger.Info(ctx, "enabling sentry http middleware")
@ -155,6 +159,35 @@ 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 profiling 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.Handle("/vars", expvar.Handler())
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

@ -52,7 +52,7 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler
for _, auth := range authenticators {
user, err = auth.Authenticate(ctx, r)
if err != nil {
logger.Debug(ctx, "could not authenticate request", logger.E(errors.WithStack(err)))
logger.Debug(ctx, "could not authenticate request", logger.CapturedE(errors.WithStack(err)))
continue
}

View File

@ -3,7 +3,6 @@ package proxy_test
import (
"context"
"io"
"log"
"net/http"
"net/http/httptest"
"net/http/httputil"
@ -24,6 +23,7 @@ import (
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"
@ -39,6 +39,19 @@ func BenchmarkProxies(b *testing.B) {
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"))
@ -78,7 +91,7 @@ func BenchmarkProxies(b *testing.B) {
b.Logf("fetching url '%s'", rawProxyURL)
profile, err := os.Create(filepath.Join("testdata", "proxies", name+".prof"))
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"))
}
@ -86,7 +99,7 @@ func BenchmarkProxies(b *testing.B) {
defer profile.Close()
if err := pprof.StartCPUProfile(profile); err != nil {
log.Fatal(err)
b.Fatalf("%+v", errors.WithStack(err))
}
defer pprof.StopCPUProfile()
@ -227,7 +240,12 @@ func createProxy(name string, conf *proxyBenchConfig, logf func(format string, a
}
layers, err := setup.GetLayers(context.Background(), config.NewDefault())
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)
}

View File

@ -12,7 +12,7 @@ proxy:
attributes:
email: foo@bar.com
rules:
- set_header("Remote-User-Attr-Email", user.attrs.email)
- set_header(ctx, "Remote-User-Attr-Email", vars.user.attrs.email)
fetch:
url:
user:

View File

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

View File

@ -8,5 +8,5 @@ proxy:
options:
rules:
request:
- set_host(request.url.host)
- set_header("X-Proxied-With", "bouncer")
- set_host(ctx, vars.request.url.host)
- set_header(ctx, "X-Proxied-With", "bouncer")

View File

@ -3,4 +3,5 @@ package cache
type Cache[K comparable, V any] interface {
Get(key K) (V, bool)
Set(key K, value V)
Clear()
}

View File

@ -25,6 +25,10 @@ func (c *Cache[K, V]) Set(key K, value V) {
c.store.Store(key, value)
}
func (c *Cache[K, V]) Clear() {
c.store.Clear()
}
func NewCache[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{
store: new(sync.Map),

View File

@ -28,6 +28,11 @@ func (c *Cache[K, V]) Set(key K, value V) {
c.values.Set(key, value)
}
func (c *Cache[K, V]) Clear() {
c.timestamps.Clear()
c.values.Clear()
}
func NewCache[K comparable, V any](values cache.Cache[K, V], timestamps cache.Cache[K, time.Time], ttl time.Duration) *Cache[K, V] {
return &Cache[K, V]{
values: values,

View File

@ -2,7 +2,6 @@ package chi
import (
"context"
"fmt"
"net/http"
"time"
@ -37,12 +36,19 @@ type LogEntry struct {
// Panic implements middleware.LogEntry
func (e *LogEntry) Panic(v interface{}, stack []byte) {
logger.Error(e.ctx, fmt.Sprintf("%s %s", e.method, e.path), logger.F("stack", string(stack)))
logger.Error(
e.ctx, "http panic",
logger.F("stack", string(stack)),
logger.F("host", e.host),
logger.F("method", e.method),
logger.F("path", e.path),
)
}
// Write implements middleware.LogEntry
func (e *LogEntry) Write(status int, bytes int, header http.Header, elapsed time.Duration, extra interface{}) {
logger.Info(e.ctx, fmt.Sprintf("%s %s - %d", e.method, e.path, status),
logger.Info(
e.ctx, "http request",
logger.F("host", e.host),
logger.F("status", status),
logger.F("bytes", bytes),

View File

@ -12,14 +12,8 @@ import (
"gitlab.com/wpetit/goweb/logger"
)
const (
flagPrintDefaultToken = "print-default-token"
)
func RunCommand() *cli.Command {
flags := append(
common.Flags(),
)
flags := common.Flags()
return &cli.Command{
Name: "run",
@ -34,13 +28,18 @@ func RunCommand() *cli.Command {
logger.SetFormat(logger.Format(conf.Logger.Format))
logger.SetLevel(logger.Level(conf.Logger.Level))
projectVersion := ctx.String("projectVersion")
flushSentry, err := setup.SetupSentry(ctx.Context, conf.Admin.Sentry, projectVersion)
if err != nil {
return errors.Wrap(err, "could not initialize sentry client")
}
logger.Debug(ctx.Context, "using config", logger.F("config", conf))
defer flushSentry()
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()
}
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

@ -53,7 +53,7 @@ func RunCommand() *cli.Command {
}
if err := tmpl.Execute(w, data); err != nil {
logger.Error(ctx.Context, "could not execute template", logger.E(errors.WithStack(err)))
logger.Error(ctx.Context, "could not execute template", logger.CapturedE(errors.WithStack(err)))
}
})

View File

@ -29,13 +29,18 @@ func RunCommand() *cli.Command {
logger.SetFormat(logger.Format(conf.Logger.Format))
logger.SetLevel(logger.Level(conf.Logger.Level))
projectVersion := ctx.String("projectVersion")
flushSentry, err := setup.SetupSentry(ctx.Context, conf.Proxy.Sentry, projectVersion)
if err != nil {
return errors.Wrap(err, "could not initialize sentry client")
}
logger.Debug(ctx.Context, "using config", logger.F("config", conf))
defer flushSentry()
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 {
@ -46,7 +51,7 @@ func RunCommand() *cli.Command {
proxy.WithServerConfig(conf.Proxy),
proxy.WithRedisConfig(conf.Redis),
proxy.WithDirectorLayers(layers...),
proxy.WithDirectorCacheTTL(time.Duration(conf.Proxy.Cache.TTL)),
proxy.WithDirectorCacheTTL(time.Duration(*conf.Proxy.Cache.TTL)),
)
addrs, srvErrs := srv.Start(ctx.Context)

View File

@ -1,20 +1,22 @@
package config
type AdminServerConfig struct {
HTTP HTTPConfig `yaml:"http"`
CORS CORSConfig `yaml:"cors"`
Auth AuthConfig `yaml:"auth"`
Metrics MetricsConfig `yaml:"metrics"`
Sentry SentryConfig `yaml:"sentry"`
HTTP HTTPConfig `yaml:"http"`
CORS CORSConfig `yaml:"cors"`
Auth AuthConfig `yaml:"auth"`
Metrics MetricsConfig `yaml:"metrics"`
Profiling ProfilingConfig `yaml:"profiling"`
Sentry SentryConfig `yaml:"sentry"`
}
func NewDefaultAdminServerConfig() AdminServerConfig {
return AdminServerConfig{
HTTP: NewHTTPConfig("127.0.0.1", 8081),
CORS: NewDefaultCORSConfig(),
Auth: NewDefaultAuthConfig(),
Metrics: NewDefaultMetricsConfig(),
Sentry: NewDefaultSentryConfig(),
HTTP: NewHTTPConfig("127.0.0.1", 8081),
CORS: NewDefaultCORSConfig(),
Auth: NewDefaultAuthConfig(),
Metrics: NewDefaultMetricsConfig(),
Sentry: NewDefaultSentryConfig(),
Profiling: NewDefaultProfilingConfig(),
}
}

View File

@ -19,7 +19,7 @@ func (is *InterpolatedString) UnmarshalYAML(value *yaml.Node) error {
return errors.WithStack(err)
}
str, err := envsubst.EvalEnv(str)
str, err := envsubst.Eval(str, getEnv)
if err != nil {
return errors.WithStack(err)
}
@ -38,7 +38,7 @@ func (ii *InterpolatedInt) UnmarshalYAML(value *yaml.Node) error {
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into string", value.Value, value.Line)
}
str, err := envsubst.EvalEnv(str)
str, err := envsubst.Eval(str, getEnv)
if err != nil {
return errors.WithStack(err)
}
@ -62,7 +62,7 @@ func (ifl *InterpolatedFloat) UnmarshalYAML(value *yaml.Node) error {
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into string", value.Value, value.Line)
}
str, err := envsubst.EvalEnv(str)
str, err := envsubst.Eval(str, getEnv)
if err != nil {
return errors.WithStack(err)
}
@ -86,7 +86,7 @@ func (ib *InterpolatedBool) UnmarshalYAML(value *yaml.Node) error {
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into string", value.Value, value.Line)
}
str, err := envsubst.EvalEnv(str)
str, err := envsubst.Eval(str, getEnv)
if err != nil {
return errors.WithStack(err)
}
@ -101,9 +101,10 @@ func (ib *InterpolatedBool) UnmarshalYAML(value *yaml.Node) error {
return nil
}
var getEnv = os.Getenv
type InterpolatedMap struct {
Data map[string]any
getEnv func(string) string
Data map[string]any
}
func (im *InterpolatedMap) UnmarshalYAML(value *yaml.Node) error {
@ -113,10 +114,6 @@ func (im *InterpolatedMap) UnmarshalYAML(value *yaml.Node) error {
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into map", value.Value, value.Line)
}
if im.getEnv == nil {
im.getEnv = os.Getenv
}
interpolated, err := im.interpolateRecursive(data)
if err != nil {
return errors.WithStack(err)
@ -140,7 +137,7 @@ func (im InterpolatedMap) interpolateRecursive(data any) (any, error) {
}
case string:
value, err := envsubst.Eval(typ, im.getEnv)
value, err := envsubst.Eval(typ, getEnv)
if err != nil {
return nil, errors.WithStack(err)
}
@ -171,7 +168,7 @@ func (iss *InterpolatedStringSlice) UnmarshalYAML(value *yaml.Node) error {
}
for index, value := range data {
value, err := envsubst.EvalEnv(value)
value, err := envsubst.Eval(value, getEnv)
if err != nil {
return errors.WithStack(err)
}
@ -193,7 +190,7 @@ func (id *InterpolatedDuration) UnmarshalYAML(value *yaml.Node) error {
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into string", value.Value, value.Line)
}
str, err := envsubst.EvalEnv(str)
str, err := envsubst.Eval(str, getEnv)
if err != nil {
return errors.WithStack(err)
}

View File

@ -4,6 +4,7 @@ import (
"fmt"
"os"
"testing"
"time"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
@ -65,7 +66,7 @@ func TestInterpolatedMap(t *testing.T) {
var interpolatedMap InterpolatedMap
if tc.Env != nil {
interpolatedMap.getEnv = func(key string) string {
getEnv = func(key string) string {
return tc.Env[key]
}
}
@ -80,3 +81,54 @@ func TestInterpolatedMap(t *testing.T) {
})
}
}
func TestInterpolatedDuration(t *testing.T) {
type testCase struct {
Path string
Env map[string]string
Assert func(t *testing.T, parsed *InterpolatedDuration)
}
testCases := []testCase{
{
Path: "testdata/environment/interpolated-duration.yml",
Env: map[string]string{
"MY_DURATION": "30s",
},
Assert: func(t *testing.T, parsed *InterpolatedDuration) {
if e, g := 30*time.Second, parsed; e != time.Duration(*g) {
t.Errorf("parsed: expected '%v', got '%v'", e, g)
}
},
},
}
for idx, tc := range testCases {
t.Run(fmt.Sprintf("Case #%d", idx), func(t *testing.T) {
data, err := os.ReadFile(tc.Path)
if err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if tc.Env != nil {
getEnv = func(key string) string {
return tc.Env[key]
}
}
config := struct {
Duration *InterpolatedDuration `yaml:"duration"`
}{
Duration: NewInterpolatedDuration(-1),
}
if err := yaml.Unmarshal(data, &config); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if tc.Assert != nil {
tc.Assert(t, config.Duration)
}
})
}
}

View File

@ -20,6 +20,10 @@ func NewDefaultLayersConfig() LayersConfig {
TransportConfig: NewDefaultTransportConfig(),
Timeout: NewInterpolatedDuration(10 * time.Second),
},
ProviderCacheTimeout: NewInterpolatedDuration(time.Hour),
},
Sessions: AuthnLayerSessionConfig{
TTL: NewInterpolatedDuration(time.Hour),
},
},
}
@ -31,13 +35,19 @@ type QueueLayerConfig struct {
}
type AuthnLayerConfig struct {
Debug InterpolatedBool `yaml:"debug"`
TemplateDir InterpolatedString `yaml:"templateDir"`
OIDC AuthnOIDCLayerConfig `yaml:"oidc"`
Debug InterpolatedBool `yaml:"debug"`
TemplateDir InterpolatedString `yaml:"templateDir"`
OIDC AuthnOIDCLayerConfig `yaml:"oidc"`
Sessions AuthnLayerSessionConfig `yaml:"sessions"`
}
type AuthnLayerSessionConfig struct {
TTL *InterpolatedDuration `yaml:"ttl"`
}
type AuthnOIDCLayerConfig struct {
HTTPClient AuthnOIDCHTTPClientConfig `yaml:"httpClient"`
HTTPClient AuthnOIDCHTTPClientConfig `yaml:"httpClient"`
ProviderCacheTimeout *InterpolatedDuration `yaml:"providerCacheTimeout"`
}
type AuthnOIDCHTTPClientConfig struct {

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(),
}
}
@ -111,12 +113,12 @@ func NewDefaultDialConfig() DialConfig {
}
type CacheConfig struct {
TTL InterpolatedDuration `yaml:"ttl"`
TTL *InterpolatedDuration `yaml:"ttl"`
}
func NewDefaultCacheConfig() CacheConfig {
return CacheConfig{
TTL: *NewInterpolatedDuration(time.Second * 30),
TTL: NewInterpolatedDuration(time.Second * 30),
}
}

View File

@ -9,21 +9,35 @@ const (
)
type RedisConfig struct {
Adresses InterpolatedStringSlice `yaml:"addresses"`
Master InterpolatedString `yaml:"master"`
ReadTimeout InterpolatedDuration `yaml:"readTimeout"`
WriteTimeout InterpolatedDuration `yaml:"writeTimeout"`
DialTimeout InterpolatedDuration `yaml:"dialTimeout"`
LockMaxRetries InterpolatedInt `yaml:"lockMaxRetries"`
Adresses InterpolatedStringSlice `yaml:"addresses"`
Master InterpolatedString `yaml:"master"`
ReadTimeout InterpolatedDuration `yaml:"readTimeout"`
WriteTimeout InterpolatedDuration `yaml:"writeTimeout"`
DialTimeout InterpolatedDuration `yaml:"dialTimeout"`
LockMaxRetries InterpolatedInt `yaml:"lockMaxRetries"`
RouteByLatency InterpolatedBool `yaml:"routeByLatency"`
ContextTimeoutEnabled InterpolatedBool `yaml:"contextTimeoutEnabled"`
MaxRetries InterpolatedInt `yaml:"maxRetries"`
PingInterval InterpolatedDuration `yaml:"pingInterval"`
PoolSize InterpolatedInt `yaml:"poolSize"`
PoolTimeout InterpolatedDuration `yaml:"poolTimeout"`
MinIdleConns InterpolatedInt `yaml:"minIdleConns"`
MaxIdleConns InterpolatedInt `yaml:"maxIdleConns"`
ConnMaxIdleTime InterpolatedDuration `yaml:"connMaxIdleTime"`
ConnMaxLifetime InterpolatedDuration `yaml:"connMaxLifeTime"`
}
func NewDefaultRedisConfig() RedisConfig {
return RedisConfig{
Adresses: InterpolatedStringSlice{"localhost:6379"},
Master: "",
ReadTimeout: InterpolatedDuration(30 * time.Second),
WriteTimeout: InterpolatedDuration(30 * time.Second),
DialTimeout: InterpolatedDuration(30 * time.Second),
LockMaxRetries: 10,
Adresses: InterpolatedStringSlice{"localhost:6379"},
Master: "",
ReadTimeout: InterpolatedDuration(30 * time.Second),
WriteTimeout: InterpolatedDuration(30 * time.Second),
DialTimeout: InterpolatedDuration(30 * time.Second),
LockMaxRetries: 10,
MaxRetries: 3,
PingInterval: InterpolatedDuration(30 * time.Second),
ContextTimeoutEnabled: true,
RouteByLatency: true,
}
}

View File

@ -29,10 +29,10 @@ func NewDefaultSentryConfig() SentryConfig {
FlushTimeout: NewInterpolatedDuration(2 * time.Second),
AttachStacktrace: true,
SampleRate: 1,
EnableTracing: true,
TracesSampleRate: 0.2,
ProfilesSampleRate: 1,
IgnoreErrors: []string{},
EnableTracing: false,
TracesSampleRate: 0.1,
ProfilesSampleRate: 0.1,
IgnoreErrors: []string{"context canceled", "net/http: abort"},
SendDefaultPII: false,
ServerName: "",
Environment: "",

View File

@ -0,0 +1 @@
duration: ${MY_DURATION}

View File

@ -38,7 +38,7 @@ func (l *Locker) WithLock(ctx context.Context, key string, timeout time.Duration
defer func() {
if err := lock.Release(ctx); err != nil {
logger.Error(ctx, "could not release lock", logger.E(errors.WithStack(err)))
logger.Error(ctx, "could not release lock", logger.CapturedE(errors.WithStack(err)))
}
logger.Debug(ctx, "lock released")

View File

@ -30,7 +30,7 @@ func retryWithBackoff(ctx context.Context, attempts int, fn func(ctx context.Con
return errors.Wrapf(err, "execution failed after %d attempts", attempts)
}
logger.Error(ctx, "error while executing func, retrying with backoff", logger.E(err), logger.F("backoffDelay", backoffDelay), logger.F("remainingAttempts", attempts-count))
logger.Error(ctx, "error while executing func, retrying with backoff", logger.CapturedE(err), logger.F("backoffDelay", backoffDelay), logger.F("remainingAttempts", attempts-count))
time.Sleep(backoffDelay)

View File

@ -2,10 +2,13 @@ package director
import (
"context"
"net/http"
"net/url"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/getsentry/sentry-go"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type contextKey string
@ -14,6 +17,8 @@ const (
contextKeyProxy contextKey = "proxy"
contextKeyLayers contextKey = "layers"
contextKeyOriginalURL contextKey = "originalURL"
contextKeyHandleError contextKey = "handleError"
contextKeySentryScope contextKey = "sentryScope"
)
var (
@ -60,3 +65,35 @@ func ctxValue[T any](ctx context.Context, key contextKey) (T, error) {
return value, nil
}
type HandleErrorFunc func(w http.ResponseWriter, r *http.Request, status int, err error)
func withHandleError(ctx context.Context, fn HandleErrorFunc) context.Context {
return context.WithValue(ctx, contextKeyHandleError, fn)
}
func HandleError(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, err error) {
err = errors.WithStack(err)
fn, ok := ctx.Value(contextKeyHandleError).(HandleErrorFunc)
if !ok {
logger.Error(ctx, err.Error(), logger.CapturedE(err))
http.Error(w, http.StatusText(status), status)
return
}
fn(w, r, status, err)
}
func withSentryScope(ctx context.Context, scope *sentry.Scope) context.Context {
return context.WithValue(ctx, contextKeySentryScope, scope)
}
func SentryScope(ctx context.Context) (*sentry.Scope, error) {
scope, err := ctxValue[*sentry.Scope](ctx, contextKeySentryScope)
if err != nil {
return nil, errors.WithStack(err)
}
return scope, nil
}

View File

@ -7,8 +7,9 @@ import (
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/cache"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"forge.cadoles.com/cadoles/bouncer/internal/syncx"
"github.com/getsentry/sentry-go"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
"gitlab.com/wpetit/goweb/logger"
@ -19,20 +20,23 @@ type Director struct {
layerRepository store.LayerRepository
layerRegistry *LayerRegistry
proxyCache cache.Cache[string, []*store.Proxy]
layerCache cache.Cache[string, []*store.Layer]
cachedProxies *syncx.CachedResource[string, []*store.Proxy]
cachedLayers *syncx.CachedResource[string, []*store.Layer]
handleError HandleErrorFunc
}
const proxiesCacheKey = "proxies"
func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
ctx := r.Context()
proxies, err := d.getProxies(ctx)
proxies, _, err := d.cachedProxies.Get(ctx, proxiesCacheKey)
if err != nil {
return r, errors.WithStack(err)
}
url := getRequestURL(r)
ctx = withOriginalURL(ctx, url)
ctx = logger.With(ctx, logger.F("url", url.String()))
@ -40,21 +44,11 @@ func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
for _, p := range proxies {
for _, from := range p.From {
logger.Debug(
ctx, "matching request with proxy's from",
logger.F("from", from),
)
if matches := wildcard.Match(url.String(), from); !matches {
continue
}
logger.Debug(
ctx, "proxy's from matched",
logger.F("from", from),
)
ctx = logger.With(ctx,
proxyCtx := logger.With(ctx,
logger.F("proxy", p.Name),
logger.F("host", r.Host),
logger.F("remoteAddr", r.RemoteAddr),
@ -62,7 +56,7 @@ func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
metricProxyRequestsTotal.With(prometheus.Labels{metricLabelProxy: string(p.Name)}).Add(1)
proxyLayers, err := d.getLayers(ctx, p.Name)
proxyLayers, _, err := d.cachedLayers.Get(proxyCtx, string(p.Name))
if err != nil {
return r, errors.WithStack(err)
}
@ -82,8 +76,16 @@ func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
r.URL.Scheme = toURL.Scheme
r.URL.Path = toURL.JoinPath(r.URL.Path).Path
ctx = withLayers(ctx, layers)
r = r.WithContext(ctx)
proxyCtx = withLayers(proxyCtx, layers)
r = r.WithContext(proxyCtx)
if sentryScope, _ := SentryScope(ctx); sentryScope != nil {
sentryScope.SetTags(map[string]string{
"bouncer.proxy.name": string(p.Name),
"bouncer.proxy.target.url": r.URL.String(),
"bouncer.proxy.target.host": r.URL.Host,
})
}
return r, nil
}
@ -95,13 +97,8 @@ func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
return r, nil
}
const proxiesCacheKey = "proxies"
func (d *Director) getProxies(ctx context.Context) ([]*store.Proxy, error) {
proxies, exists := d.proxyCache.Get(proxiesCacheKey)
if exists {
return proxies, nil
}
func (d *Director) getProxies(ctx context.Context, key string) ([]*store.Proxy, error) {
logger.Debug(ctx, "querying fresh proxies")
headers, err := d.proxyRepository.QueryProxy(ctx, store.WithProxyQueryEnabled(true))
if err != nil {
@ -110,7 +107,7 @@ func (d *Director) getProxies(ctx context.Context) ([]*store.Proxy, error) {
sort.Sort(store.ByProxyWeight(headers))
proxies = make([]*store.Proxy, 0, len(headers))
proxies := make([]*store.Proxy, 0, len(headers))
for _, h := range headers {
if !h.Enabled {
@ -125,18 +122,13 @@ func (d *Director) getProxies(ctx context.Context) ([]*store.Proxy, error) {
proxies = append(proxies, proxy)
}
d.proxyCache.Set(proxiesCacheKey, proxies)
return proxies, nil
}
func (d *Director) getLayers(ctx context.Context, proxyName store.ProxyName) ([]*store.Layer, error) {
cacheKey := "layers-" + string(proxyName)
func (d *Director) getLayers(ctx context.Context, rawProxyName string) ([]*store.Layer, error) {
proxyName := store.ProxyName(rawProxyName)
layers, exists := d.layerCache.Get(cacheKey)
if exists {
return layers, nil
}
logger.Debug(ctx, "querying fresh layers")
headers, err := d.layerRepository.QueryLayers(ctx, proxyName, store.WithLayerQueryEnabled(true))
if err != nil {
@ -145,7 +137,7 @@ func (d *Director) getLayers(ctx context.Context, proxyName store.ProxyName) ([]
sort.Sort(store.ByLayerWeight(headers))
layers = make([]*store.Layer, 0, len(headers))
layers := make([]*store.Layer, 0, len(headers))
for _, h := range headers {
if !h.Enabled {
@ -160,21 +152,20 @@ func (d *Director) getLayers(ctx context.Context, proxyName store.ProxyName) ([]
layers = append(layers, layer)
}
d.layerCache.Set(cacheKey, layers)
return layers, nil
}
func (d *Director) RequestTransformer() proxy.RequestTransformer {
return func(r *http.Request) {
ctx := r.Context()
layers, err := ctxLayers(ctx)
if err != nil {
if errors.Is(err, errContextKeyNotFound) {
return
}
logger.Error(ctx, "could not retrieve layers from context", logger.E(errors.WithStack(err)))
logger.Error(ctx, "could not retrieve layers from context", logger.CapturedE(errors.WithStack(err)))
return
}
@ -224,41 +215,43 @@ func (d *Director) ResponseTransformer() proxy.ResponseTransformer {
func (d *Director) Middleware() proxy.Middleware {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
r, err := d.rewriteRequest(r)
if err != nil {
logger.Error(r.Context(), "could not rewrite request", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
sentry.ConfigureScope(func(scope *sentry.Scope) {
ctx := withHandleError(r.Context(), d.handleError)
ctx = withSentryScope(ctx, scope)
r = r.WithContext(ctx)
return
}
ctx := r.Context()
layers, err := ctxLayers(ctx)
if err != nil {
if errors.Is(err, errContextKeyNotFound) {
r, err := d.rewriteRequest(r)
if err != nil {
HandleError(ctx, w, r, http.StatusInternalServerError, errors.Wrap(err, "could not rewrite request"))
return
}
logger.Error(ctx, "could not retrieve proxy and layers from context", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
ctx = r.Context()
return
}
layers, err := ctxLayers(ctx)
if err != nil {
if errors.Is(err, errContextKeyNotFound) {
return
}
httpMiddlewares := make([]proxy.Middleware, 0)
for _, layer := range layers {
middleware, ok := d.layerRegistry.GetMiddleware(layer.Type)
if !ok {
continue
HandleError(ctx, w, r, http.StatusInternalServerError, errors.Wrap(err, "could not retrieve proxy and layers from context"))
return
}
httpMiddlewares = append(httpMiddlewares, middleware.Middleware(layer))
}
httpMiddlewares := make([]proxy.Middleware, 0)
for _, layer := range layers {
middleware, ok := d.layerRegistry.GetMiddleware(layer.Type)
if !ok {
continue
}
handler := createMiddlewareChain(next, httpMiddlewares)
httpMiddlewares = append(httpMiddlewares, middleware.Middleware(layer))
}
handler.ServeHTTP(w, r)
handler := createMiddlewareChain(next, httpMiddlewares)
handler.ServeHTTP(w, r)
})
}
return http.HandlerFunc(fn)
@ -270,11 +263,15 @@ func New(proxyRepository store.ProxyRepository, layerRepository store.LayerRepos
registry := NewLayerRegistry(opts.Layers...)
return &Director{
director := &Director{
proxyRepository: proxyRepository,
layerRepository: layerRepository,
layerRegistry: registry,
proxyCache: opts.ProxyCache,
layerCache: opts.LayerCache,
handleError: opts.HandleError,
}
director.cachedProxies = syncx.NewCachedResource(opts.ProxyCache, director.getProxies)
director.cachedLayers = syncx.NewCachedResource(opts.LayerCache, director.getLayers)
return director
}

View File

@ -1,13 +1,18 @@
package authn
import (
"bytes"
"html/template"
"io"
"net/http"
"path/filepath"
"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/Masterminds/sprig/v3"
"github.com/pkg/errors"
@ -19,6 +24,8 @@ type Layer struct {
auth Authenticator
debug bool
ruleEngineCache *util.RuleEngineCache[*Vars, *LayerOptions]
templateDir string
}
@ -29,9 +36,7 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
options, err := fromStoreOptions(layer.Options)
if err != nil {
logger.Error(ctx, "could not parse layer options", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
director.HandleError(ctx, w, r, http.StatusInternalServerError, errors.Wrap(err, "could not parse layer options"))
return
}
@ -42,7 +47,7 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
}
err = errors.WithStack(err)
logger.Error(ctx, "could not execute pre-auth hook", logger.E(err))
logger.Error(ctx, "could not execute pre-auth hook", logger.CapturedE(err))
l.renderErrorPage(w, r, layer, options, err)
return
@ -68,20 +73,20 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
}
err = errors.WithStack(err)
logger.Error(ctx, "could not authenticate user", logger.E(err))
logger.Error(ctx, "could not authenticate user", logger.CapturedE(err))
l.renderErrorPage(w, r, layer, options, err)
return
}
if err := l.applyRules(r, options, user); err != nil {
if err := l.applyRules(ctx, r, layer, options, user); err != nil {
if errors.Is(err, ErrForbidden) {
l.renderForbiddenPage(w, r, layer, options, user)
return
}
err = errors.WithStack(err)
logger.Error(ctx, "could not apply rules", logger.E(err))
logger.Error(ctx, "could not apply rules", logger.CapturedE(err))
l.renderErrorPage(w, r, layer, options, err)
return
@ -99,7 +104,7 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
}
err = errors.WithStack(err)
logger.Error(ctx, "could not execute post-auth hook", logger.E(err))
logger.Error(ctx, "could not execute post-auth hook", logger.CapturedE(err))
l.renderErrorPage(w, r, layer, options, err)
return
@ -162,20 +167,22 @@ func (l *Layer) renderPage(w http.ResponseWriter, r *http.Request, page string,
tmpl, err := template.New("").Funcs(sprig.FuncMap()).ParseGlob(pattern)
if err != nil {
logger.Error(ctx, "could not load authn templates", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
director.HandleError(ctx, w, r, http.StatusInternalServerError, errors.Wrap(err, "could not load authn templates"))
return
}
w.Header().Add("Cache-Control", "no-cache")
if err := tmpl.ExecuteTemplate(w, block, templateData); err != nil {
logger.Error(ctx, "could not render authn page", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(w, block, templateData); err != nil {
director.HandleError(ctx, w, r, http.StatusInternalServerError, errors.Wrap(err, "could not render authn page"))
return
}
if _, err := io.Copy(w, &buf); err != nil {
logger.Error(ctx, "could not write authn page", logger.CapturedE(errors.WithStack(err)))
}
}
// LayerType implements director.MiddlewareLayer
@ -187,6 +194,18 @@ func NewLayer(layerType store.LayerType, auth Authenticator, funcs ...OptionFunc
opts := NewOptions(funcs...)
return &Layer{
ruleEngineCache: util.NewInMemoryRuleEngineCache[*Vars, *LayerOptions](func(options *LayerOptions) (*rule.Engine[*Vars], error) {
engine, err := rule.NewEngine[*Vars](
rule.WithRules(options.Rules...),
rule.WithExpr(getAuthnAPI()...),
ruleHTTP.WithRequestFuncs(),
)
if err != nil {
return nil, errors.WithStack(err)
}
return engine, nil
}),
layerType: layerType,
auth: auth,
templateDir: opts.TemplateDir,

View File

@ -28,12 +28,13 @@ func DefaultLayerOptions() LayerOptions {
return LayerOptions{
MatchURLs: []string{"*"},
Rules: []string{
"del_headers('Remote-*')",
"set_header('Remote-User', user.subject)",
"del_headers(ctx, 'Remote-*')",
"set_header(ctx,'Remote-User', vars.user.subject)",
`map(
toPairs(user.attrs), {
toPairs(vars.user.attrs), {
let name = replace(lower(string(get(#, 0))), '_', '-');
set_header(
ctx,
'Remote-User-Attr-' + name,
get(#, 1)
)

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

@ -13,9 +13,12 @@ import (
"time"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"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/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"forge.cadoles.com/cadoles/bouncer/internal/syncx"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gorilla/sessions"
"github.com/pkg/errors"
@ -24,9 +27,10 @@ import (
)
type Authenticator struct {
store sessions.Store
httpTransport *http.Transport
httpClientTimeout time.Duration
store sessions.Store
httpTransport *http.Transport
httpClientTimeout time.Duration
cachedOIDCProvider *syncx.CachedResource[string, *oidc.Provider]
}
func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request, layer *store.Layer) error {
@ -44,7 +48,7 @@ func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request
sess, err := a.store.Get(r, a.getCookieName(options.Cookie.Name, layer.Proxy, layer.Name))
if err != nil {
logger.Error(ctx, "could not retrieve session", logger.E(errors.WithStack(err)))
logger.Error(ctx, "could not retrieve session", logger.CapturedE(errors.WithStack(err)))
}
loginCallbackURL, err := a.getLoginCallbackURL(originalURL, layer.Proxy, layer.Name, options)
@ -52,7 +56,7 @@ func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request
return errors.WithStack(err)
}
client, err := a.getClient(options, loginCallbackURL.String())
client, err := a.getClient(ctx, options, loginCallbackURL.String())
if err != nil {
return errors.WithStack(err)
}
@ -86,7 +90,7 @@ func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request
if postLogoutRedirectURL != "" {
isAuthorized := slices.Contains(options.OIDC.PostLogoutRedirectURLs, postLogoutRedirectURL)
if !isAuthorized {
http.Error(w, "unauthorized post-logout redirect", http.StatusBadRequest)
director.HandleError(ctx, w, r, http.StatusBadRequest, errors.New("unauthorized post-logout redirect"))
return errors.WithStack(authn.ErrSkipRequest)
}
}
@ -128,7 +132,7 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay
defer func() {
if err := sess.Save(r, w); err != nil {
logger.Error(ctx, "could not save session", logger.E(errors.WithStack(err)))
logger.Error(ctx, "could not save session", logger.CapturedE(errors.WithStack(err)))
}
}()
@ -158,7 +162,7 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay
return nil, errors.WithStack(err)
}
client, err := a.getClient(options, loginCallbackURL.String())
client, err := a.getClient(ctx, options, loginCallbackURL.String())
if err != nil {
return nil, errors.WithStack(err)
}
@ -360,9 +364,7 @@ func (a *Authenticator) templatize(rawTemplate string, proxyName store.ProxyName
return raw.String(), nil
}
func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*Client, error) {
ctx := context.Background()
func (a *Authenticator) getClient(ctx context.Context, options *LayerOptions, redirectURL string) (*Client, error) {
transport := a.httpTransport.Clone()
if options.OIDC.TLSInsecureSkipVerify {
@ -373,6 +375,10 @@ func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*C
transport.TLSClientConfig.InsecureSkipVerify = true
}
if options.OIDC.SkipIssuerVerification {
ctx = oidc.InsecureIssuerURLContext(ctx, options.OIDC.IssuerURL)
}
httpClient := &http.Client{
Timeout: a.httpClientTimeout,
Transport: transport,
@ -384,9 +390,9 @@ func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*C
ctx = oidc.InsecureIssuerURLContext(ctx, options.OIDC.IssuerURL)
}
provider, err := oidc.NewProvider(ctx, options.OIDC.IssuerURL)
provider, _, err := a.cachedOIDCProvider.Get(ctx, options.OIDC.IssuerURL)
if err != nil {
return nil, errors.Wrap(err, "could not create oidc provider")
return nil, errors.Wrap(err, "could not retrieve oidc provider")
}
client := NewClient(
@ -401,6 +407,17 @@ func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*C
return client, nil
}
func (a *Authenticator) getOIDCProvider(ctx context.Context, issuerURL string) (*oidc.Provider, error) {
logger.Debug(ctx, "refreshing oidc provider", logger.F("issuerURL", issuerURL))
provider, err := oidc.NewProvider(ctx, issuerURL)
if err != nil {
return nil, errors.Wrap(err, "could not create oidc provider")
}
return provider, nil
}
const defaultCookieNamePrefix = "_bouncer_authn_oidc"
func (a *Authenticator) getCookieName(cookieName string, proxyName store.ProxyName, layerName store.LayerName) string {
@ -411,6 +428,25 @@ func (a *Authenticator) getCookieName(cookieName string, proxyName store.ProxyNa
return strings.ToLower(fmt.Sprintf("%s_%s_%s", defaultCookieNamePrefix, proxyName, layerName))
}
func NewAuthenticator(httpTransport *http.Transport, clientTimeout time.Duration, store sessions.Store, oidcProviderCacheTimeout time.Duration) *Authenticator {
authenticator := &Authenticator{
httpTransport: httpTransport,
httpClientTimeout: clientTimeout,
store: store,
}
authenticator.cachedOIDCProvider = syncx.NewCachedResource(
ttl.NewCache(
memory.NewCache[string, *oidc.Provider](),
memory.NewCache[string, time.Time](),
oidcProviderCacheTimeout,
),
authenticator.getOIDCProvider,
)
return authenticator
}
var (
_ authn.PreAuthentication = &Authenticator{}
_ authn.Authenticator = &Authenticator{}

View File

@ -6,6 +6,7 @@ import (
"net/url"
"strings"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/dchest/uniuri"
"github.com/gorilla/sessions"
@ -68,8 +69,7 @@ func (c *Client) login(w http.ResponseWriter, r *http.Request, sess *sessions.Se
sess.Values[sessionKeyPostLoginRedirectURL] = postLoginRedirectURL
if err := sess.Save(r, w); err != nil {
logger.Error(ctx, "could not save session", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
director.HandleError(ctx, w, r, http.StatusInternalServerError, errors.Wrap(err, "could not save session"))
return
}
@ -127,7 +127,7 @@ func (c *Client) HandleLogout(w http.ResponseWriter, r *http.Request, sess *sess
rawIDToken, err := c.getRawIDToken(sess)
if err != nil {
logger.Error(ctx, "could not retrieve raw id token", logger.E(errors.WithStack(err)))
logger.Error(ctx, "could not retrieve raw id token", logger.CapturedE(errors.WithStack(err)))
}
sess.Values[sessionKeyIDToken] = nil

View File

@ -10,9 +10,11 @@ const LayerType store.LayerType = "authn-oidc"
func NewLayer(store sessions.Store, funcs ...OptionFunc) *authn.Layer {
opts := NewOptions(funcs...)
return authn.NewLayer(LayerType, &Authenticator{
httpTransport: opts.HTTPTransport,
httpClientTimeout: opts.HTTPClientTimeout,
store: store,
}, opts.AuthnOptions...)
authenticator := NewAuthenticator(
opts.HTTPTransport,
opts.HTTPClientTimeout,
store,
opts.OIDCProviderCacheTimeout,
)
return authn.NewLayer(LayerType, authenticator, opts.AuthnOptions...)
}

View File

@ -8,9 +8,10 @@ import (
)
type Options struct {
HTTPTransport *http.Transport
HTTPClientTimeout time.Duration
AuthnOptions []authn.OptionFunc
HTTPTransport *http.Transport
HTTPClientTimeout time.Duration
AuthnOptions []authn.OptionFunc
OIDCProviderCacheTimeout time.Duration
}
type OptionFunc func(opts *Options)
@ -33,11 +34,18 @@ func WithAuthnOptions(funcs ...authn.OptionFunc) OptionFunc {
}
}
func WithOIDCProviderCacheTimeout(timeout time.Duration) OptionFunc {
return func(opts *Options) {
opts.OIDCProviderCacheTimeout = timeout
}
}
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
HTTPTransport: http.DefaultTransport.(*http.Transport),
HTTPClientTimeout: 30 * time.Second,
AuthnOptions: make([]authn.OptionFunc, 0),
HTTPTransport: http.DefaultTransport.(*http.Transport),
HTTPClientTimeout: 30 * time.Second,
AuthnOptions: make([]authn.OptionFunc, 0),
OIDCProviderCacheTimeout: time.Hour,
}
for _, fn := range funcs {

View File

@ -1,38 +1,35 @@
package authn
import (
"context"
"net/http"
"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/expr-lang/expr"
"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 {
rules := options.Rules
if len(rules) == 0 {
return nil
}
func (l *Layer) applyRules(ctx context.Context, r *http.Request, layer *store.Layer, options *LayerOptions, user *User) error {
key := string(layer.Proxy) + "-" + string(layer.Name)
revisionedEngine := l.ruleEngineCache.Get(key)
engine, err := rule.NewEngine[*Env](
rule.WithRules(options.Rules...),
rule.WithExpr(getAuthnAPI()...),
ruleHTTP.WithRequestFuncs(r),
)
engine, err := revisionedEngine.Get(ctx, layer.Revision, options)
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

@ -1,9 +1,11 @@
package queue
import (
"bytes"
"context"
"fmt"
"html/template"
"io"
"math/rand"
"net/http"
"path/filepath"
@ -52,9 +54,7 @@ func (q *Queue) Middleware(layer *store.Layer) proxy.Middleware {
options, err := fromStoreOptions(layer.Options, q.defaultKeepAlive)
if err != nil {
logger.Error(ctx, "could not parse layer options", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
director.HandleError(ctx, w, r, http.StatusInternalServerError, errors.Wrap(err, "could not parse layer options"))
return
}
@ -65,13 +65,13 @@ 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)
cookie, err := r.Cookie(cookieName)
if err != nil && !errors.Is(err, http.ErrNoCookie) {
logger.Error(ctx, "could not retrieve cookie", logger.E(errors.WithStack(err)))
logger.Error(ctx, "could not retrieve cookie", logger.CapturedE(errors.WithStack(err)))
}
if cookie == nil {
@ -89,9 +89,7 @@ func (q *Queue) Middleware(layer *store.Layer) proxy.Middleware {
rank, err := q.adapter.Touch(ctx, queueName, sessionID)
if err != nil {
logger.Error(ctx, "could not retrieve session rank", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
director.HandleError(ctx, w, r, http.StatusInternalServerError, errors.Wrap(err, "could not update queue session rank"))
return
}
@ -126,7 +124,7 @@ func (q *Queue) updateSessionsMetric(ctx context.Context, proxyName store.ProxyN
status, err := q.adapter.Status(ctx, queueName)
if err != nil {
logger.Error(ctx, "could not retrieve queue status", logger.E(errors.WithStack(err)))
logger.Error(ctx, "could not retrieve queue status", logger.CapturedE(errors.WithStack(err)))
return
}
@ -144,9 +142,7 @@ func (q *Queue) renderQueuePage(w http.ResponseWriter, r *http.Request, queueNam
status, err := q.adapter.Status(ctx, queueName)
if err != nil {
logger.Error(ctx, "could not retrieve queue status", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
director.HandleError(ctx, w, r, http.StatusInternalServerError, errors.Wrap(err, "could not retrieve queue status"))
return
}
@ -157,7 +153,7 @@ func (q *Queue) renderQueuePage(w http.ResponseWriter, r *http.Request, queueNam
tmpl, err := template.New("").Funcs(sprig.FuncMap()).ParseGlob(pattern)
if err != nil {
logger.Error(ctx, "could not load queue templates", logger.E(errors.WithStack(err)))
logger.Error(ctx, "could not load queue templates", logger.CapturedE(errors.WithStack(err)))
return
}
@ -166,9 +162,7 @@ func (q *Queue) renderQueuePage(w http.ResponseWriter, r *http.Request, queueNam
})
if q.tmpl == nil {
logger.Error(ctx, "queue page templates not loaded", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
director.HandleError(ctx, w, r, http.StatusInternalServerError, errors.New("queue page templates not loaded"))
return
}
@ -194,12 +188,16 @@ func (q *Queue) renderQueuePage(w http.ResponseWriter, r *http.Request, queueNam
w.Header().Add("Retry-After", strconv.FormatInt(int64(refreshRate.Seconds()), 10))
w.WriteHeader(http.StatusServiceUnavailable)
if err := q.tmpl.ExecuteTemplate(w, "queue", templateData); err != nil {
logger.Error(ctx, "could not render queue page", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
var buf bytes.Buffer
if err := q.tmpl.ExecuteTemplate(&buf, "queue", templateData); err != nil {
director.HandleError(ctx, w, r, http.StatusInternalServerError, errors.Wrap(err, "could not render queue page"))
return
}
if _, err := io.Copy(w, &buf); err != nil {
logger.Error(ctx, "could not write queue page", logger.CapturedE(errors.WithStack(err)))
}
}
func (q *Queue) refreshQueue(ctx context.Context, layerName store.LayerName, keepAlive time.Duration) {
@ -211,13 +209,15 @@ func (q *Queue) refreshQueue(ctx context.Context, layerName store.LayerName, kee
if err := q.adapter.Refresh(ctx, string(layerName), keepAlive); err != nil {
logger.Error(ctx, "could not refresh queue",
logger.E(errors.WithStack(err)),
logger.CapturedE(errors.WithStack(err)),
logger.F("queue", layerName),
)
}
}
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,14 +6,19 @@ 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"
)
const LayerType store.LayerType = "rewriter"
type Layer struct{}
type Layer struct {
requestRuleEngineCache *util.RuleEngineCache[*RequestVars, *LayerOptions]
responseRuleEngineCache *util.RuleEngineCache[*ResponseVars, *LayerOptions]
}
func (l *Layer) LayerType() store.LayerType {
return LayerType
@ -26,9 +31,7 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
options, err := fromStoreOptions(layer.Options)
if err != nil {
logger.Error(ctx, "could not parse layer options", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
director.HandleError(ctx, w, r, http.StatusInternalServerError, errors.Wrap(err, "could not parse layer options"))
return
}
@ -39,9 +42,14 @@ func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
return
}
if err := l.applyRequestRules(r, options); err != nil {
logger.Error(ctx, "could not apply request rules", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
if err := l.applyRequestRules(ctx, r, layer, options); err != nil {
var redirect *errRedirect
if errors.As(err, &redirect) {
http.Redirect(w, r, redirect.URL(), redirect.StatusCode())
return
}
director.HandleError(ctx, w, r, http.StatusInternalServerError, errors.Wrap(err, "could not apply request rules"))
return
}
@ -66,7 +74,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, options); err != nil {
return errors.WithStack(err)
}
@ -75,7 +85,31 @@ func (l *Layer) ResponseTransformer(layer *store.Layer) proxy.ResponseTransforme
}
func New(funcs ...OptionFunc) *Layer {
return &Layer{}
return &Layer{
requestRuleEngineCache: util.NewInMemoryRuleEngineCache(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
}),
responseRuleEngineCache: util.NewInMemoryRuleEngineCache(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

@ -1,107 +1,128 @@
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"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
type RequestEnv struct {
Request RequestInfo `expr:"request"`
type RequestVars struct {
Request RequestVar `expr:"request"`
OriginalURL URLVar `expr:"original_url"`
}
type URLEnv struct {
Scheme string `expr:"scheme"`
Opaque string `expr:"opaque"`
User UserInfoEnv `expr:"user"`
Host string `expr:"host"`
Path string `expr:"path"`
RawPath string `expr:"rawPath"`
RawQuery string `expr:"rawQuery"`
Fragment string `expr:"fragment"`
RawFragment string `expr:"rawFragment"`
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"`
}
type UserInfoEnv struct {
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 RequestInfo struct {
type RequestVar struct {
Method string `expr:"method"`
URL URLEnv `expr:"url"`
RawURL string `expr:"rawUrl"`
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 {
func fromRequest(r *http.Request) RequestVar {
return RequestVar{
Method: r.Method,
URL: fromURL(r.URL),
RawURL: r.URL.String(),
Proto: r.Proto,
ProtoMajor: r.ProtoMajor,
ProtoMinor: r.ProtoMinor,
Header: r.Header,
ContentLength: r.ContentLength,
TransferEncoding: r.TransferEncoding,
Host: r.Host,
Trailer: r.Trailer,
RemoteAddr: r.RemoteAddr,
RequestURI: r.RequestURI,
}
}
func (l *Layer) applyRequestRules(ctx context.Context, r *http.Request, layer *store.Layer, options *LayerOptions) error {
rules := options.Rules.Request
if len(rules) == 0 {
return nil
}
engine, err := l.getRequestRuleEngine(r, options)
engine, err := l.getRequestRuleEngine(ctx, layer, options)
if err != nil {
return errors.WithStack(err)
}
env := &RequestEnv{
Request: RequestInfo{
Method: r.Method,
URL: URLEnv{
Scheme: r.URL.Scheme,
Opaque: r.URL.Opaque,
User: UserInfoEnv{
Username: r.URL.User.Username(),
Password: func() string {
passwd, _ := r.URL.User.Password()
return passwd
}(),
},
Host: r.URL.Host,
Path: r.URL.Path,
RawPath: r.URL.RawPath,
RawQuery: r.URL.RawQuery,
Fragment: r.URL.Fragment,
RawFragment: r.URL.RawFragment,
},
RawURL: r.URL.String(),
Proto: r.Proto,
ProtoMajor: r.ProtoMajor,
ProtoMinor: r.ProtoMinor,
Header: r.Header,
ContentLength: r.ContentLength,
TransferEncoding: r.TransferEncoding,
Host: r.Host,
Trailer: r.Trailer,
RemoteAddr: r.RemoteAddr,
RequestURI: r.RequestURI,
},
originalURL, err := director.OriginalURL(ctx)
if err != nil {
return errors.WithStack(err)
}
if _, err := engine.Apply(env); err != nil {
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(r *http.Request, options *LayerOptions) (*rule.Engine[*RequestEnv], error) {
engine, err := rule.NewEngine[*RequestEnv](
rule.WithRules(options.Rules.Request...),
ruleHTTP.WithRequestFuncs(r),
)
func (l *Layer) getRequestRuleEngine(ctx context.Context, layer *store.Layer, options *LayerOptions) (*rule.Engine[*RequestVars], error) {
key := string(layer.Proxy) + "-" + string(layer.Name)
revisionedEngine := l.requestRuleEngineCache.Get(key)
engine, err := revisionedEngine.Get(ctx, layer.Revision, options)
if err != nil {
return nil, errors.WithStack(err)
}
@ -109,68 +130,45 @@ func (l *Layer) getRequestRuleEngine(r *http.Request, options *LayerOptions) (*r
return engine, nil
}
type ResponseEnv struct {
Request RequestInfo `expr:"request"`
Response ResponseInfo `expr:"response"`
type ResponseVars struct {
OriginalURL URLVar `expr:"original_url"`
Request RequestVar `expr:"request"`
Response ResponseVar `expr:"response"`
}
type ResponseInfo struct {
type ResponseVar struct {
Status string `expr:"status"`
StatusCode int `expr:"statusCode"`
StatusCode int `expr:"status_code"`
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"`
Uncompressed bool `expr:"uncompressed"`
Trailer map[string][]string `expr:"trailer"`
}
func (l *Layer) applyResponseRules(r *http.Response, options *LayerOptions) error {
func (l *Layer) applyResponseRules(ctx context.Context, r *http.Response, layer *store.Layer, options *LayerOptions) error {
rules := options.Rules.Response
if len(rules) == 0 {
return nil
}
engine, err := l.getResponseRuleEngine(r, options)
engine, err := l.getResponseRuleEngine(ctx, layer, options)
if err != nil {
return errors.WithStack(err)
}
env := &ResponseEnv{
Request: RequestInfo{
Method: r.Request.Method,
URL: URLEnv{
Scheme: r.Request.URL.Scheme,
Opaque: r.Request.URL.Opaque,
User: UserInfoEnv{
Username: r.Request.URL.User.Username(),
Password: func() string {
passwd, _ := r.Request.URL.User.Password()
return passwd
}(),
},
Host: r.Request.URL.Host,
Path: r.Request.URL.Path,
RawPath: r.Request.URL.RawPath,
RawQuery: r.Request.URL.RawQuery,
Fragment: r.Request.URL.Fragment,
RawFragment: r.Request.URL.RawFragment,
},
RawURL: 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 := &ResponseVars{
OriginalURL: fromURL(originalURL),
Request: fromRequest(r.Request),
Response: ResponseVar{
Proto: r.Proto,
ProtoMajor: r.ProtoMajor,
ProtoMinor: r.ProtoMinor,
@ -183,18 +181,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(r *http.Response, options *LayerOptions) (*rule.Engine[*ResponseEnv], error) {
engine, err := rule.NewEngine[*ResponseEnv](
rule.WithRules(options.Rules.Response...),
ruleHTTP.WithResponseFuncs(r),
)
func (l *Layer) getResponseRuleEngine(ctx context.Context, layer *store.Layer, options *LayerOptions) (*rule.Engine[*ResponseVars], error) {
key := string(layer.Proxy) + "-" + string(layer.Name)
revisionedEngine := l.responseRuleEngineCache.Get(key)
engine, err := revisionedEngine.Get(ctx, layer.Revision, options)
if err != nil {
return nil, errors.WithStack(err)
}

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

@ -0,0 +1,28 @@
package util
import (
"forge.cadoles.com/cadoles/bouncer/internal/cache"
"forge.cadoles.com/cadoles/bouncer/internal/cache/memory"
)
type RuleEngineCache[V any, O any] struct {
cache cache.Cache[string, *RevisionedRuleEngine[V, O]]
factory RuleEngineFactoryFunc[V, O]
}
func (c *RuleEngineCache[V, O]) Get(key string) *RevisionedRuleEngine[V, O] {
revisionedRuleEngine, exists := c.cache.Get(key)
if !exists {
revisionedRuleEngine = NewRevisionedRuleEngine(c.factory)
c.cache.Set(key, revisionedRuleEngine)
}
return revisionedRuleEngine
}
func NewInMemoryRuleEngineCache[V any, O any](factory RuleEngineFactoryFunc[V, O]) *RuleEngineCache[V, O] {
return &RuleEngineCache[V, O]{
factory: factory,
cache: memory.NewCache[string, *RevisionedRuleEngine[V, O]](),
}
}

View File

@ -1,18 +1,21 @@
package director
import (
"net/http"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/cache"
"forge.cadoles.com/cadoles/bouncer/internal/cache/memory"
"forge.cadoles.com/cadoles/bouncer/internal/cache/ttl"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"gitlab.com/wpetit/goweb/logger"
)
type Options struct {
Layers []Layer
ProxyCache cache.Cache[string, []*store.Proxy]
LayerCache cache.Cache[string, []*store.Layer]
Layers []Layer
ProxyCache cache.Cache[string, []*store.Proxy]
LayerCache cache.Cache[string, []*store.Layer]
HandleError HandleErrorFunc
}
type OptionFunc func(opts *Options)
@ -30,6 +33,10 @@ func NewOptions(funcs ...OptionFunc) *Options {
memory.NewCache[string, time.Time](),
30*time.Second,
),
HandleError: func(w http.ResponseWriter, r *http.Request, status int, err error) {
logger.Error(r.Context(), err.Error(), logger.CapturedE(err))
http.Error(w, http.StatusText(status), status)
},
}
for _, fn := range funcs {
@ -56,3 +63,9 @@ func WithLayerCache(cache cache.Cache[string, []*store.Layer]) OptionFunc {
opts.LayerCache = cache
}
}
func WithHandleErrorFunc(fn HandleErrorFunc) OptionFunc {
return func(opts *Options) {
opts.HandleError = fn
}
}

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,16 +1,23 @@
package proxy
import (
"bytes"
"context"
"expvar"
"fmt"
"html/template"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/http/pprof"
"net/url"
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"time"
"forge.cadoles.com/Cadoles/go-proxy"
@ -22,7 +29,6 @@ import (
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/Masterminds/sprig/v3"
"github.com/getsentry/sentry-go"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@ -91,31 +97,25 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
logger.Info(ctx, "http server listening")
layerCache, proxyCache, cancel := s.createDirectorCaches(ctx)
defer cancel()
director := director.New(
s.proxyRepository,
s.layerRepository,
director.WithLayers(s.directorLayers...),
director.WithLayerCache(
ttl.NewCache(
memory.NewCache[string, []*store.Layer](),
memory.NewCache[string, time.Time](),
s.directorCacheTTL,
),
),
director.WithProxyCache(
ttl.NewCache(
memory.NewCache[string, []*store.Proxy](),
memory.NewCache[string, time.Time](),
s.directorCacheTTL,
),
),
director.WithLayerCache(layerCache),
director.WithProxyCache(proxyCache),
director.WithHandleErrorFunc(s.handleError),
)
if s.serverConfig.HTTP.UseRealIP {
router.Use(middleware.RealIP)
}
router.Use(middleware.RequestID)
router.Use(middleware.RequestLogger(bouncerChi.NewLogFormatter()))
router.Use(middleware.Recoverer)
if s.serverConfig.Sentry.DSN != "" {
logger.Info(ctx, "enabling sentry http middleware")
@ -146,6 +146,35 @@ 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 profiling 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.Handle("/vars", expvar.Handler())
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())
@ -170,6 +199,44 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
logger.Info(ctx, "http server exiting")
}
func (s *Server) createDirectorCaches(ctx context.Context) (*ttl.Cache[string, []*store.Layer], *ttl.Cache[string, []*store.Proxy], func()) {
layerCache := ttl.NewCache(
memory.NewCache[string, []*store.Layer](),
memory.NewCache[string, time.Time](),
s.directorCacheTTL,
)
proxyCache := ttl.NewCache(
memory.NewCache[string, []*store.Proxy](),
memory.NewCache[string, time.Time](),
s.directorCacheTTL,
)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGUSR2)
go func() {
for {
_, ok := <-sig
if !ok {
return
}
logger.Info(ctx, "received sigusr2 signal, clearing proxies and layers cache")
layerCache.Clear()
proxyCache.Clear()
}
}()
cancel := func() {
close(sig)
}
return layerCache, proxyCache, cancel
}
func (s *Server) createReverseProxy(ctx context.Context, target *url.URL) *httputil.ReverseProxy {
reverseProxy := httputil.NewSingleHostReverseProxy(target)
@ -186,27 +253,27 @@ func (s *Server) createReverseProxy(ctx context.Context, target *url.URL) *httpu
httpTransport.DialContext = dialer.DialContext
reverseProxy.Transport = httpTransport
reverseProxy.ErrorHandler = s.handleError
reverseProxy.ErrorHandler = s.handleProxyError
return reverseProxy
}
func (s *Server) handleDefault(w http.ResponseWriter, r *http.Request) {
err := errors.Errorf("no proxy target found")
logger.Error(r.Context(), "proxy error", logger.E(err))
sentry.CaptureException(err)
s.renderErrorPage(w, r, err, http.StatusBadGateway, http.StatusText(http.StatusBadGateway))
s.handleError(w, r, http.StatusBadGateway, errors.Errorf("no proxy target found"))
}
func (s *Server) handleError(w http.ResponseWriter, r *http.Request, err error) {
func (s *Server) handleError(w http.ResponseWriter, r *http.Request, status int, err error) {
err = errors.WithStack(err)
logger.Error(r.Context(), "proxy error", logger.E(err))
sentry.CaptureException(err)
if !errors.Is(err, context.Canceled) {
logger.Error(r.Context(), err.Error(), logger.CapturedE(err))
}
s.renderErrorPage(w, r, err, http.StatusBadGateway, http.StatusText(http.StatusBadGateway))
s.renderErrorPage(w, r, err, status, http.StatusText(status))
}
func (s *Server) handleProxyError(w http.ResponseWriter, r *http.Request, err error) {
s.handleError(w, r, http.StatusBadGateway, err)
}
func (s *Server) renderErrorPage(w http.ResponseWriter, r *http.Request, err error, statusCode int, status string) {
@ -237,7 +304,7 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page string,
tmpl, err := template.New("").Funcs(sprig.FuncMap()).ParseGlob(pattern)
if err != nil {
logger.Error(ctx, "could not load proxy templates", logger.E(errors.WithStack(err)))
logger.Error(ctx, "could not load proxy templates", logger.CapturedE(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
@ -257,12 +324,18 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, page string,
return
}
if err := blockTmpl.Execute(w, templateData); err != nil {
logger.Error(ctx, "could not render proxy page", logger.E(errors.WithStack(err)))
var buf bytes.Buffer
if err := blockTmpl.Execute(&buf, templateData); err != nil {
logger.Error(ctx, "could not render proxy page", logger.CapturedE(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if _, err := io.Copy(w, &buf); err != nil {
logger.Error(ctx, "could not write page", logger.CapturedE(errors.WithStack(err)))
}
}
func NewServer(funcs ...OptionFunc) *Server {

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

@ -10,6 +10,7 @@ import (
type Options struct {
Session sessions.Options
KeyPrefix string
TTL time.Duration
}
type OptionFunc func(opts *Options)
@ -25,6 +26,7 @@ func NewOptions(funcs ...OptionFunc) *Options {
SameSite: http.SameSiteDefaultMode,
},
KeyPrefix: "session:",
TTL: time.Hour,
}
for _, fn := range funcs {
@ -45,3 +47,9 @@ func WithKeyPrefix(prefix string) OptionFunc {
opts.KeyPrefix = prefix
}
}
func WithTTL(ttl time.Duration) OptionFunc {
return func(opts *Options) {
opts.TTL = ttl
}
}

View File

@ -31,6 +31,7 @@ type Store struct {
keyPrefix string
keyGen KeyGenFunc
serializer SessionSerializer
ttl time.Duration
}
type KeyGenFunc func() (string, error)
@ -43,6 +44,7 @@ func NewStore(adapter StoreAdapter, funcs ...OptionFunc) *Store {
keyPrefix: opts.KeyPrefix,
keyGen: generateRandomKey,
serializer: GobSerializer{},
ttl: opts.TTL,
}
return rs
@ -62,20 +64,21 @@ func (s *Store) New(r *http.Request, name string) (*sessions.Session, error) {
if err != nil {
return session, nil
}
session.ID = c.Value
err = s.load(r.Context(), session)
if err == nil {
session.IsNew = false
} else if !errors.Is(err, ErrNotFound) {
return nil, errors.WithStack(err)
return session, errors.WithStack(err)
}
return session, nil
}
func (s *Store) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
if session.Options.MaxAge <= 0 {
if session.Options.MaxAge < 0 {
if err := s.delete(r.Context(), session); err != nil {
return errors.WithStack(err)
}
@ -120,7 +123,12 @@ func (s *Store) save(ctx context.Context, session *sessions.Session) error {
return errors.WithStack(err)
}
if err := s.adapter.Set(ctx, s.keyPrefix+session.ID, b, time.Duration(session.Options.MaxAge)*time.Second); err != nil {
ttl := time.Duration(session.Options.MaxAge) * time.Second
if s.ttl < ttl || ttl == 0 {
ttl = s.ttl
}
if err := s.adapter.Set(ctx, s.keyPrefix+session.ID, b, ttl); err != nil {
return errors.WithStack(err)
}

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)
@ -37,5 +37,6 @@ func setupAuthnOIDCLayer(conf *config.Config) (director.Layer, error) {
authn.WithTemplateDir(string(conf.Layers.Authn.TemplateDir)),
authn.WithDebug(bool(conf.Layers.Authn.Debug)),
),
oidc.WithOIDCProviderCacheTimeout(time.Duration(*conf.Layers.Authn.OIDC.ProviderCacheTimeout)),
), nil
}

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,19 +3,11 @@ 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, 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,20 +1,78 @@
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),
WriteTimeout: time.Duration(conf.WriteTimeout),
DialTimeout: time.Duration(conf.DialTimeout),
RouteByLatency: true,
ContextTimeoutEnabled: true,
RouteByLatency: bool(conf.RouteByLatency),
ContextTimeoutEnabled: bool(conf.ContextTimeoutEnabled),
MaxRetries: int(conf.MaxRetries),
PoolSize: int(conf.PoolSize),
PoolTimeout: time.Duration(conf.PoolTimeout),
MinIdleConns: int(conf.MinIdleConns),
MaxIdleConns: int(conf.MaxIdleConns),
ConnMaxIdleTime: time.Duration(conf.ConnMaxIdleTime),
ConnMaxLifetime: time.Duration(conf.ConnMaxLifetime),
})
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.CapturedE(errors.WithStack(err)))
connected = false
continue
}
if !connected {
logger.Info(ctx, "redis reconnected")
connected = true
}
}
}()
return client
}

View File

@ -34,6 +34,10 @@ func SetupSentry(ctx context.Context, conf config.SentryConfig, release string)
return nil, errors.WithStack(err)
}
logger.SetCaptureFunc(func(err error) {
sentry.CaptureException(err)
})
flush := func() {
sentry.Flush(time.Duration(*conf.FlushTimeout))
}

View File

@ -81,7 +81,7 @@ func WithRetry(ctx context.Context, client redis.UniversalClient, key string, fn
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))
logger.Debug(ctx, "redis transaction failed", logger.CapturedE(err))
if errors.Is(err, redis.TxFailedErr) {
logger.Debug(ctx, "retrying redis transaction", logger.F("attempts", attempt), logger.F("delay", delay))
@ -91,12 +91,14 @@ func WithRetry(ctx context.Context, client redis.UniversalClient, key string, fn
continue
}
return err
return errors.WithStack(err)
}
return nil
}
logger.Error(ctx, "redis error", logger.CapturedE(errors.WithStack(err)))
return errors.WithStack(redis.TxFailedErr)
}

View File

@ -0,0 +1,63 @@
package syncx
import (
"context"
"sync"
"forge.cadoles.com/cadoles/bouncer/internal/cache"
"github.com/pkg/errors"
)
type RefreshFunc[K comparable, V any] func(ctx context.Context, key K) (V, error)
type CachedResource[K comparable, V any] struct {
cache cache.Cache[K, V]
lock sync.RWMutex
refresh RefreshFunc[K, V]
}
func (r *CachedResource[K, V]) Clear() {
r.cache.Clear()
}
func (r *CachedResource[K, V]) Get(ctx context.Context, key K) (V, bool, error) {
value, exists := r.cache.Get(key)
if exists {
return value, false, nil
}
locked := r.lock.TryLock()
if !locked {
r.lock.RLock()
value, exists := r.cache.Get(key)
if exists {
r.lock.RUnlock()
return value, false, nil
}
r.lock.RUnlock()
}
if !locked {
r.lock.Lock()
}
defer r.lock.Unlock()
value, err := r.refresh(ctx, key)
if err != nil {
return *new(V), false, errors.WithStack(err)
}
r.cache.Set(key, value)
return value, true, nil
}
func NewCachedResource[K comparable, V any](cache cache.Cache[K, V], refresh RefreshFunc[K, V]) *CachedResource[K, V] {
return &CachedResource[K, V]{
cache: cache,
refresh: refresh,
}
}

View File

@ -0,0 +1,66 @@
package syncx
import (
"context"
"math"
"sync"
"testing"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/cache/memory"
"forge.cadoles.com/cadoles/bouncer/internal/cache/ttl"
"github.com/pkg/errors"
)
func TestCachedResource(t *testing.T) {
refreshCalls := 0
cacheTTL := 1*time.Second + 500*time.Millisecond
duration := 2 * time.Second
expectedCalls := math.Ceil(float64(duration) / float64(cacheTTL))
resource := NewCachedResource(
ttl.NewCache(
memory.NewCache[string, string](),
memory.NewCache[string, time.Time](),
cacheTTL,
),
func(ctx context.Context, key string) (string, error) {
refreshCalls++
return "bar", nil
},
)
concurrents := 50
key := "foo"
var wg sync.WaitGroup
wg.Add(concurrents)
for i := range concurrents {
go func(i int) {
done := time.After(duration)
defer wg.Done()
for {
select {
case <-done:
return
default:
value, fresh, err := resource.Get(context.Background(), key)
if err != nil {
t.Errorf("%+v", errors.WithStack(err))
}
t.Logf("resource retrieved for goroutine #%d: (%s, %s, %v)", i, key, value, fresh)
}
}
}(i)
}
wg.Wait()
if e, g := int(expectedCalls), refreshCalls; e != g {
t.Errorf("refreshCalls: expected '%d', got '%d'", e, g)
}
}

View File

@ -55,6 +55,8 @@
border-radius: 5px;
box-shadow: 2px 2px #cccccc1c;
color: #810000 !important;
max-width: 80%;
overflow-x: hidden;
}
.title {
@ -81,7 +83,7 @@
<div id="card">
<h2 class="title">Une erreur est survenue !</h2>
{{ if .Debug }}
<pre>{{ .Err }}</pre>
<pre style="overflow-x: auto">{{ .Err }}</pre>
{{ end }}
{{/* if a public base url is provided, show navigation link */}}
{{ $oidc := ( index .Layer.Options "oidc" ) }}

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,12 +112,31 @@ 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:
# Les proxys/layers sont mis en cache local pour une durée de 30s
# par défaut. Si les modifications sont rares, vous pouvez augmenter
# cette valeur pour réduire la "pression" sur le serveur Redis.
# Il est possible de forcer la réinitialisation du cache en envoyant
# le signal SIGUSR2 au processus Bouncer.
#
# Exemple
#
# kill -s USR2 $(pgrep bouncer)
ttl: 30s
# Configuration du transport HTTP(S)
@ -164,6 +196,8 @@ redis:
writeTimeout: 30s
readTimeout: 30s
dialTimeout: 30s
maxRetries: 3
pingInterval: 30s
# Configuration des logs
logger:
@ -190,6 +224,11 @@ layers:
authn:
# Répertoire contenant les templates
templateDir: "/etc/bouncer/layers/authn/templates"
# Configuration des sessions
sessions:
# Temps de persistence sans actualisation des sessions dans le store
# (prévalent sur le MaxAge de la session)
ttl: "1h"
# Configuration d'une série de proxy/layers
# à créer par défaut par le serveur d'administration

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

View File

@ -76,14 +76,26 @@
margin-top: 2em;
text-align: right;
}
.stacktrace {
max-height: 250px;
overflow-y: auto;
background-color: #dca0a0;
padding: 0 10px;
border-radius: 5px;
margin-top: 10px;
}
</style>
</head>
<body>
<div id="container">
<div id="card">
<h2 class="title">{{ $title }}</h2>
<h2 class="title">{{ $title }}</h2>
{{ if .Debug }}
<pre>{{ .Err }}</pre>
<h3>Stack Trace</h3>
<div class="stacktrace">
<pre>{{ printf "%+v" .Err }}</pre>
</div>
{{ end }}
<p class="footer">
Propulsé par