Compare commits

..

10 Commits

Author SHA1 Message Date
d667bb03f5 Merge pull request 'Mise en place de cache local au niveau du serveur pour améliorer les temps de traitement des requêtes' (#26) from benchmark into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #26
2024-05-28 16:52:34 +02:00
3a9fde9bc9 feat: improve perf by caching proxy and layers locally
Some checks are pending
Cadoles/bouncer/pipeline/head This commit looks good
Cadoles/bouncer/pipeline/pr-develop Build started...
2024-05-28 16:45:15 +02:00
42dab5797a chore(doc): fix typo
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-05-28 11:16:15 +02:00
132bf1e642 feat(authn-oidc): allow for dynamic post-logout redirection
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-05-24 17:01:06 +02:00
26a9ad0e2e feat(authn-oidc): match login callback/logout urls with query string by default
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-05-24 15:28:21 +02:00
3e5dd446cb feat(authn-oidc): use relative redirection to prevent internal/public host mixing 2024-05-24 15:27:43 +02:00
d5c846a9ce fix(docker): format default config durations
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-05-24 14:52:31 +02:00
82c93d3f1e feat(config): interpolate recursively in interpolated map
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-05-24 12:49:03 +02:00
544326a4b7 feat(authn-oidc): use full urls for login callback/logout options 2024-05-23 17:41:36 +02:00
499bb3696d fix(authn-network): handles r.RemoteAddr without port
Some checks are pending
Cadoles/bouncer/pipeline/head This commit looks good
Cadoles/bouncer/pipeline/pr-authn-oidc-redirect-url Build started...
2024-05-22 15:24:40 +02:00
36 changed files with 1052 additions and 151 deletions

2
.gitignore vendored
View File

@ -9,3 +9,5 @@
/data
/out
.dockerconfigjson
*.prof
proxy.test

View File

@ -28,7 +28,9 @@ RUN /src/dist/bouncer_linux_amd64_v1/bouncer -c '' config dump > /src/dist/bounc
&& yq -i '.redis.adresses = ["redis:6379"]' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.redis.writeTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.redis.readTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.redis.dialTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml
&& yq -i '.redis.dialTimeout = "30s"' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& 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.19.1 AS RUNTIME
@ -46,6 +48,7 @@ RUN ln -s /usr/share/bouncer/bin/bouncer /usr/local/bin/bouncer
EXPOSE 8080
EXPOSE 8081
EXPOSE 8082
RUN adduser -D -H bouncer

View File

@ -130,6 +130,13 @@ tools/grafterm/bin/grafterm:
mkdir -p tools/grafterm/bin
GOBIN=$(PWD)/tools/grafterm/bin go install github.com/slok/grafterm/cmd/grafterm@v0.2.0
bench:
go test -bench=. -run '^$$' -count=10 ./...
tools/benchstat/bin/benchstat:
mkdir -p tools/benchstat/bin
GOBIN=$(PWD)/tools/benchstat/bin go install golang.org/x/perf/cmd/benchstat@latest
full-version:
@echo $(FULL_VERSION)

View File

@ -55,7 +55,7 @@ Par défaut ce serveur écoute sur le port 8082. Il est possible de modifier l'a
bouncer admin proxy update --proxy-name my-proxy --proxy-enabled
```
3. À ce stade, vous devriez pouvoir afficher la page du serveur `dummy` en ouvrant l'URL de votre instance Bouncer, par exemple `http://localhost:8080` si vous avez travaillez avec une instance Bouncer locale avec la configuration par défaut
3. À ce stade, vous devriez pouvoir afficher la page du serveur `dummy` en ouvrant l'URL de votre instance Bouncer, par exemple `http://localhost:8080` si vous travaillez avec une instance Bouncer locale avec la configuration par défaut
4. Créer un layer de type `authn-oidc` pour notre nouveau proxy

View File

@ -0,0 +1,31 @@
# Analyser les performances de Bouncer
1. Lancer un benchmark du proxy
```shell
go test -bench=. -run '^$' -count=5 -cpuprofile bench_proxy.prof ./internal/proxy
```
2. Visualiser les temps d'exécution
```shell
go tool pprof -web bench_proxy.prof
```
3. Comparer les performances d'une exécution à l'autre
```shell
# Lancer un premier benchmark
go test -bench=. -run '^$' -count=10 ./internal/proxy > bench_before.txt
# Faire des modifications sur les sources
# Lancer un second benchmark
go test -bench=. -run '^$' -count=10 ./internal/proxy > bench_after.txt
# Installer l'outil benchstat
make tools/benchstat/bin/benchstat
# Comparer les rapports
tools/benchstat/bin/benchstat bench_before.txt bench_after.txt
```

1
go.mod
View File

@ -93,6 +93,7 @@ require (
go.opentelemetry.io/otel v1.21.0 // indirect
go.opentelemetry.io/otel/trace v1.21.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.3.0 // indirect
google.golang.org/appengine v1.6.8 // indirect

4
go.sum
View File

@ -405,8 +405,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@ -65,7 +65,7 @@ func (s *Server) bootstrapProxies(ctx context.Context) error {
for layerName, layerConfig := range proxyConfig.Layers {
layerType := store.LayerType(layerConfig.Type)
layerOptions := store.LayerOptions(layerConfig.Options)
layerOptions := store.LayerOptions(layerConfig.Options.Data)
if _, err := layerRepo.CreateLayer(ctx, proxyName, layerName, layerType, layerOptions); err != nil {
return errors.WithStack(err)
@ -109,7 +109,7 @@ func (s *Server) validateBootstrap(ctx context.Context) error {
}
rawOptions := func(opts config.InterpolatedMap) map[string]any {
return opts
return opts.Data
}(layerConf.Options)
if err := schema.Validate(ctx, layerOptionsSchema, rawOptions); err != nil {

6
internal/cache/cache.go vendored Normal file
View File

@ -0,0 +1,6 @@
package cache
type Cache[K comparable, V any] interface {
Get(key K) (V, bool)
Set(key K, value V)
}

34
internal/cache/memory/cache.go vendored Normal file
View File

@ -0,0 +1,34 @@
package memory
import (
"sync"
cache "forge.cadoles.com/cadoles/bouncer/internal/cache"
)
type Cache[K comparable, V any] struct {
store *sync.Map
}
// Get implements cache.Cache.
func (c *Cache[K, V]) Get(key K) (V, bool) {
raw, exists := c.store.Load(key)
if !exists {
return *new(V), false
}
return raw.(V), exists
}
// Set implements cache.Cache.
func (c *Cache[K, V]) Set(key K, value V) {
c.store.Store(key, value)
}
func NewCache[K comparable, V any]() *Cache[K, V] {
return &Cache[K, V]{
store: new(sync.Map),
}
}
var _ cache.Cache[string, bool] = &Cache[string, bool]{}

39
internal/cache/ttl/cache.go vendored Normal file
View File

@ -0,0 +1,39 @@
package ttl
import (
"time"
cache "forge.cadoles.com/cadoles/bouncer/internal/cache"
)
type Cache[K comparable, V any] struct {
timestamps cache.Cache[K, time.Time]
values cache.Cache[K, V]
ttl time.Duration
}
// Get implements cache.Cache.
func (c *Cache[K, V]) Get(key K) (V, bool) {
timestamp, exists := c.timestamps.Get(key)
if !exists || timestamp.Add(c.ttl).Before(time.Now()) {
return *new(V), false
}
return c.values.Get(key)
}
// Set implements cache.Cache.
func (c *Cache[K, V]) Set(key K, value V) {
c.timestamps.Set(key, time.Now())
c.values.Set(key, value)
}
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,
timestamps: timestamps,
ttl: ttl,
}
}
var _ cache.Cache[string, bool] = &Cache[string, bool]{}

39
internal/cache/ttl/cache_test.go vendored Normal file
View File

@ -0,0 +1,39 @@
package ttl
import (
"testing"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/cache/memory"
)
func TestCache(t *testing.T) {
cache := NewCache(
memory.NewCache[string, int](),
memory.NewCache[string, time.Time](),
time.Second,
)
key := "foo"
if _, exists := cache.Get(key); exists {
t.Errorf("cache.Get(\"%s\"): should not exists", key)
}
cache.Set(key, 1)
value, exists := cache.Get(key)
if !exists {
t.Errorf("cache.Get(\"%s\"): should exists", key)
}
if e, g := 1, value; e != g {
t.Errorf("cache.Get(\"%s\"): expected '%v', got '%v'", key, e, g)
}
time.Sleep(time.Second)
if _, exists := cache.Get("foo"); exists {
t.Errorf("cache.Get(\"%s\"): should not exists", key)
}
}

View File

@ -3,6 +3,7 @@ package proxy
import (
"fmt"
"strings"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/command/common"
"forge.cadoles.com/cadoles/bouncer/internal/proxy"
@ -45,6 +46,7 @@ func RunCommand() *cli.Command {
proxy.WithServerConfig(conf.Proxy),
proxy.WithRedisConfig(conf.Redis),
proxy.WithDirectorLayers(layers...),
proxy.WithDirectorCacheTTL(time.Duration(conf.Proxy.Cache.TTL)),
)
addrs, srvErrs := srv.Start(ctx.Context)

View File

@ -101,33 +101,66 @@ func (ib *InterpolatedBool) UnmarshalYAML(value *yaml.Node) error {
return nil
}
type InterpolatedMap map[string]interface{}
type InterpolatedMap struct {
Data map[string]any
getEnv func(string) string
}
func (im *InterpolatedMap) UnmarshalYAML(value *yaml.Node) error {
var data map[string]interface{}
var data map[string]any
if err := value.Decode(&data); err != nil {
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into map", value.Value, value.Line)
}
for key, value := range data {
strVal, ok := value.(string)
if !ok {
continue
}
if match := reVar.FindStringSubmatch(strVal); len(match) > 0 {
strVal = os.Getenv(match[1])
}
data[key] = strVal
if im.getEnv == nil {
im.getEnv = os.Getenv
}
*im = data
interpolated, err := im.interpolateRecursive(data)
if err != nil {
return errors.WithStack(err)
}
im.Data = interpolated.(map[string]any)
return nil
}
func (im *InterpolatedMap) interpolateRecursive(data any) (any, error) {
switch typ := data.(type) {
case map[string]any:
for key, value := range typ {
value, err := im.interpolateRecursive(value)
if err != nil {
return nil, errors.WithStack(err)
}
typ[key] = value
}
case string:
value, err := envsubst.Eval(typ, im.getEnv)
if err != nil {
return nil, errors.WithStack(err)
}
data = value
case []any:
for idx := range typ {
value, err := im.interpolateRecursive(typ[idx])
if err != nil {
return nil, errors.WithStack(err)
}
typ[idx] = value
}
}
return data, nil
}
type InterpolatedStringSlice []string
func (iss *InterpolatedStringSlice) UnmarshalYAML(value *yaml.Node) error {

View File

@ -0,0 +1,82 @@
package config
import (
"fmt"
"os"
"testing"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)
func TestInterpolatedMap(t *testing.T) {
type testCase struct {
Path string
Env map[string]string
Assert func(t *testing.T, parsed InterpolatedMap)
}
testCases := []testCase{
{
Path: "testdata/environment/interpolated-map-1.yml",
Env: map[string]string{
"TEST_PROP1": "foo",
"TEST_SUB_PROP1": "bar",
"TEST_SUB2_PROP1": "baz",
},
Assert: func(t *testing.T, parsed InterpolatedMap) {
if e, g := "foo", parsed.Data["prop1"]; e != g {
t.Errorf("parsed.Data[\"prop1\"]: expected '%v', got '%v'", e, g)
}
if e, g := "bar", parsed.Data["sub"].(map[string]any)["subProp1"]; e != g {
t.Errorf("parsed.Data[\"sub\"][\"subProp1\"]: expected '%v', got '%v'", e, g)
}
if e, g := "baz", parsed.Data["sub2"].(map[string]any)["sub2Prop1"].([]any)[0]; e != g {
t.Errorf("parsed.Data[\"sub2\"][\"sub2Prop1\"][0]: expected '%v', got '%v'", e, g)
}
if e, g := "test", parsed.Data["sub2"].(map[string]any)["sub2Prop1"].([]any)[1]; e != g {
t.Errorf("parsed.Data[\"sub2\"][\"sub2Prop1\"][1]: expected '%v', got '%v'", e, g)
}
},
},
{
Path: "testdata/environment/interpolated-map-2.yml",
Env: map[string]string{
"BAR": "bar",
},
Assert: func(t *testing.T, parsed InterpolatedMap) {
if e, g := "http://bar", parsed.Data["foo"]; e != g {
t.Errorf("parsed.Data[\"foo\"]: 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))
}
var interpolatedMap InterpolatedMap
if tc.Env != nil {
interpolatedMap.getEnv = func(key string) string {
return tc.Env[key]
}
}
if err := yaml.Unmarshal(data, &interpolatedMap); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
if tc.Assert != nil {
tc.Assert(t, interpolatedMap)
}
})
}
}

View File

@ -15,6 +15,12 @@ func NewDefaultLayersConfig() LayersConfig {
},
Authn: AuthnLayerConfig{
TemplateDir: "./layers/authn/templates",
OIDC: AuthnOIDCLayerConfig{
HTTPClient: AuthnOIDCHTTPClientConfig{
TransportConfig: NewDefaultTransportConfig(),
Timeout: NewInterpolatedDuration(10 * time.Second),
},
},
},
}
}
@ -25,5 +31,15 @@ type QueueLayerConfig struct {
}
type AuthnLayerConfig struct {
TemplateDir InterpolatedString `yaml:"templateDir"`
TemplateDir InterpolatedString `yaml:"templateDir"`
OIDC AuthnOIDCLayerConfig `yaml:"oidc"`
}
type AuthnOIDCLayerConfig struct {
HTTPClient AuthnOIDCHTTPClientConfig `yaml:"httpClient"`
}
type AuthnOIDCHTTPClientConfig struct {
TransportConfig
Timeout *InterpolatedDuration `yaml:"timeout"`
}

View File

@ -17,9 +17,9 @@ func (c *BasicAuthConfig) CredentialsMap() map[string]string {
return map[string]string{}
}
credentials := make(map[string]string, len(*c.Credentials))
credentials := make(map[string]string, len(c.Credentials.Data))
for k, v := range *c.Credentials {
for k, v := range c.Credentials.Data {
credentials[k] = fmt.Sprintf("%v", v)
}

View File

@ -1,6 +1,10 @@
package config
import "time"
import (
"crypto/tls"
"net/http"
"time"
)
type ProxyServerConfig struct {
HTTP HTTPConfig `yaml:"http"`
@ -8,6 +12,18 @@ type ProxyServerConfig struct {
Transport TransportConfig `yaml:"transport"`
Dial DialConfig `yaml:"dial"`
Sentry SentryConfig `yaml:"sentry"`
Cache CacheConfig `yaml:"cache"`
}
func NewDefaultProxyServerConfig() ProxyServerConfig {
return ProxyServerConfig{
HTTP: NewHTTPConfig("0.0.0.0", 8080),
Metrics: NewDefaultMetricsConfig(),
Transport: NewDefaultTransportConfig(),
Dial: NewDefaultDialConfig(),
Sentry: NewDefaultSentryConfig(),
Cache: NewDefaultCacheConfig(),
}
}
// See https://pkg.go.dev/net/http#Transport
@ -25,15 +41,51 @@ type TransportConfig struct {
WriteBufferSize InterpolatedInt `yaml:"writeBufferSize"`
ReadBufferSize InterpolatedInt `yaml:"readBufferSize"`
MaxResponseHeaderBytes InterpolatedInt `yaml:"maxResponseHeaderBytes"`
InsecureSkipVerify InterpolatedBool `yaml:"insecureSkipVerify"`
}
func NewDefaultProxyServerConfig() ProxyServerConfig {
return ProxyServerConfig{
HTTP: NewHTTPConfig("0.0.0.0", 8080),
Metrics: NewDefaultMetricsConfig(),
Transport: NewDefaultTransportConfig(),
Dial: NewDefaultDialConfig(),
Sentry: NewDefaultSentryConfig(),
func (c TransportConfig) AsTransport() *http.Transport {
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
httpTransport.Proxy = http.ProxyFromEnvironment
httpTransport.ForceAttemptHTTP2 = bool(c.ForceAttemptHTTP2)
httpTransport.MaxIdleConns = int(c.MaxIdleConns)
httpTransport.MaxIdleConnsPerHost = int(c.MaxIdleConnsPerHost)
httpTransport.MaxConnsPerHost = int(c.MaxConnsPerHost)
httpTransport.IdleConnTimeout = time.Duration(*c.IdleConnTimeout)
httpTransport.TLSHandshakeTimeout = time.Duration(*c.TLSHandshakeTimeout)
httpTransport.ExpectContinueTimeout = time.Duration(*c.ExpectContinueTimeout)
httpTransport.DisableKeepAlives = bool(c.DisableKeepAlives)
httpTransport.DisableCompression = bool(c.DisableCompression)
httpTransport.ResponseHeaderTimeout = time.Duration(*c.ResponseHeaderTimeout)
httpTransport.WriteBufferSize = int(c.WriteBufferSize)
httpTransport.ReadBufferSize = int(c.ReadBufferSize)
httpTransport.MaxResponseHeaderBytes = int64(c.MaxResponseHeaderBytes)
if httpTransport.TLSClientConfig == nil {
httpTransport.TLSClientConfig = &tls.Config{}
}
httpTransport.TLSClientConfig.InsecureSkipVerify = bool(c.InsecureSkipVerify)
return httpTransport
}
func NewDefaultTransportConfig() TransportConfig {
return TransportConfig{
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
MaxConnsPerHost: 100,
IdleConnTimeout: NewInterpolatedDuration(90 * time.Second),
TLSHandshakeTimeout: NewInterpolatedDuration(10 * time.Second),
ExpectContinueTimeout: NewInterpolatedDuration(1 * time.Second),
ResponseHeaderTimeout: NewInterpolatedDuration(10 * time.Second),
DisableCompression: false,
DisableKeepAlives: false,
ReadBufferSize: 4096,
WriteBufferSize: 4096,
MaxResponseHeaderBytes: 0,
InsecureSkipVerify: false,
}
}
@ -54,20 +106,12 @@ func NewDefaultDialConfig() DialConfig {
}
}
func NewDefaultTransportConfig() TransportConfig {
return TransportConfig{
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 100,
MaxConnsPerHost: 100,
IdleConnTimeout: NewInterpolatedDuration(90 * time.Second),
TLSHandshakeTimeout: NewInterpolatedDuration(10 * time.Second),
ExpectContinueTimeout: NewInterpolatedDuration(1 * time.Second),
ResponseHeaderTimeout: NewInterpolatedDuration(10 * time.Second),
DisableCompression: false,
DisableKeepAlives: false,
ReadBufferSize: 4096,
WriteBufferSize: 4096,
MaxResponseHeaderBytes: 0,
type CacheConfig struct {
TTL InterpolatedDuration `yaml:"ttl"`
}
func NewDefaultCacheConfig() CacheConfig {
return CacheConfig{
TTL: *NewInterpolatedDuration(time.Second * 30),
}
}

View File

@ -0,0 +1,6 @@
prop1: "${TEST_PROP1}"
prop2: 1
sub:
subProp1: "${TEST_SUB_PROP1}"
sub2:
sub2Prop1: ["${TEST_SUB2_PROP1}", "test"]

View File

@ -0,0 +1 @@
foo: http://${BAR}

View File

@ -7,6 +7,7 @@ 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"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
@ -17,6 +18,9 @@ type Director struct {
proxyRepository store.ProxyRepository
layerRepository store.LayerRepository
layerRegistry *LayerRegistry
proxyCache cache.Cache[string, []*store.Proxy]
layerCache cache.Cache[string, []*store.Layer]
}
func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
@ -88,7 +92,14 @@ MAIN:
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
}
headers, err := d.proxyRepository.QueryProxy(ctx, store.WithProxyQueryEnabled(true))
if err != nil {
return nil, errors.WithStack(err)
@ -96,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 {
@ -111,10 +122,19 @@ 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)
layers, exists := d.layerCache.Get(cacheKey)
if exists {
return layers, nil
}
headers, err := d.layerRepository.QueryLayers(ctx, proxyName, store.WithLayerQueryEnabled(true))
if err != nil {
return nil, errors.WithStack(err)
@ -122,7 +142,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 {
@ -137,6 +157,8 @@ func (d *Director) getLayers(ctx context.Context, proxyName store.ProxyName) ([]
layers = append(layers, layer)
}
d.layerCache.Set(cacheKey, layers)
return layers, nil
}
@ -240,8 +262,16 @@ func (d *Director) Middleware() proxy.Middleware {
}
}
func New(proxyRepository store.ProxyRepository, layerRepository store.LayerRepository, layers ...Layer) *Director {
registry := NewLayerRegistry(layers...)
func New(proxyRepository store.ProxyRepository, layerRepository store.LayerRepository, funcs ...OptionFunc) *Director {
opts := NewOptions(funcs...)
return &Director{proxyRepository, layerRepository, registry}
registry := NewLayerRegistry(opts.Layers...)
return &Director{
proxyRepository: proxyRepository,
layerRepository: layerRepository,
layerRegistry: registry,
proxyCache: opts.ProxyCache,
layerCache: opts.LayerCache,
}
}

View File

@ -4,6 +4,7 @@ import (
"context"
"net"
"net/http"
"strings"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
@ -49,9 +50,15 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay
}
func (a *Authenticator) matchAnyAuthorizedCIDRs(ctx context.Context, remoteHostPort string, CIDRs []string) (bool, error) {
remoteHost, _, err := net.SplitHostPort(remoteHostPort)
if err != nil {
return false, errors.WithStack(err)
var remoteHost string
if strings.Contains(remoteHostPort, ":") {
var err error
remoteHost, _, err = net.SplitHostPort(remoteHostPort)
if err != nil {
return false, errors.WithStack(err)
}
} else {
remoteHost = remoteHostPort
}
remoteAddr := net.ParseIP(remoteHost)

View File

@ -0,0 +1,60 @@
package network
import (
"context"
"fmt"
"testing"
"github.com/pkg/errors"
)
func TestMatchAuthorizedCIDRs(t *testing.T) {
type testCase struct {
RemoteHostPort string
AuthorizedCIDRs []string
ExpectedResult bool
ExpectedError error
}
testCases := []testCase{
{
RemoteHostPort: "192.168.1.15",
AuthorizedCIDRs: []string{
"192.168.1.0/24",
},
ExpectedResult: true,
},
{
RemoteHostPort: "192.168.1.15:43349",
AuthorizedCIDRs: []string{
"192.168.1.0/24",
},
ExpectedResult: true,
},
{
RemoteHostPort: "192.168.1.15:43349",
AuthorizedCIDRs: []string{
"192.168.1.5/32",
},
ExpectedResult: false,
},
}
auth := Authenticator{}
ctx := context.Background()
for idx, tc := range testCases {
t.Run(fmt.Sprintf("Case #%d", idx), func(t *testing.T) {
result, err := auth.matchAnyAuthorizedCIDRs(ctx, tc.RemoteHostPort, tc.AuthorizedCIDRs)
if g, e := result, tc.ExpectedResult; e != g {
t.Errorf("result: expected '%v', got '%v'", e, g)
}
if e, g := tc.ExpectedError, err; !errors.Is(err, tc.ExpectedError) {
t.Errorf("err: expected '%v', got '%v'", e, g)
}
})
}
}

View File

@ -1,11 +1,18 @@
package oidc
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"text/template"
"time"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"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"
@ -17,7 +24,9 @@ import (
)
type Authenticator struct {
store sessions.Store
store sessions.Store
httpTransport *http.Transport
httpClientTimeout time.Duration
}
func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request, layer *store.Layer) error {
@ -38,16 +47,30 @@ func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request
logger.Error(ctx, "could not retrieve session", logger.E(errors.WithStack(err)))
}
redirectURL := a.getRedirectURL(layer.Proxy, layer.Name, originalURL, options)
logoutURL := a.getLogoutURL(layer.Proxy, layer.Name, originalURL, options)
client, err := a.getClient(options, redirectURL.String())
loginCallbackURL, err := a.getLoginCallbackURL(originalURL, layer.Proxy, layer.Name, options)
if err != nil {
return errors.WithStack(err)
}
switch r.URL.Path {
case redirectURL.Path:
client, err := a.getClient(options, loginCallbackURL.String())
if err != nil {
return errors.WithStack(err)
}
loginCallbackPathPattern, err := a.templatize(options.OIDC.MatchLoginCallbackPath, layer.Proxy, layer.Name)
if err != nil {
return errors.WithStack(err)
}
logoutPathPattern, err := a.templatize(options.OIDC.MatchLogoutPath, layer.Proxy, layer.Name)
if err != nil {
return errors.WithStack(err)
}
logger.Debug(ctx, "checking url", logger.F("loginCallbackPathPattern", loginCallbackPathPattern), logger.F("logoutPathPattern", logoutPathPattern))
switch {
case wildcard.Match(originalURL.Path, loginCallbackPathPattern):
if err := client.HandleCallback(w, r, sess); err != nil {
return errors.WithStack(err)
}
@ -57,10 +80,23 @@ func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request
metricLabelProxy: string(layer.Proxy),
}).Add(1)
case logoutURL.Path:
postLogoutRedirectURL := options.OIDC.PostLogoutRedirectURL
if options.OIDC.PostLogoutRedirectURL == "" {
postLogoutRedirectURL = originalURL.Scheme + "://" + originalURL.Host
case wildcard.Match(originalURL.Path, logoutPathPattern):
postLogoutRedirectURL := r.URL.Query().Get("redirect")
if postLogoutRedirectURL != "" {
isAuthorized := slices.Contains(options.OIDC.PostLogoutRedirectURLs, postLogoutRedirectURL)
if !isAuthorized {
http.Error(w, "unauthorized post-logout redirect", http.StatusBadRequest)
return errors.WithStack(authn.ErrSkipRequest)
}
}
if postLogoutRedirectURL == "" {
if options.OIDC.PublicBaseURL != "" {
postLogoutRedirectURL = options.OIDC.PublicBaseURL
} else {
postLogoutRedirectURL = originalURL.Scheme + "://" + originalURL.Host
}
}
if err := client.HandleLogout(w, r, sess, postLogoutRedirectURL); err != nil {
@ -80,11 +116,6 @@ func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request
func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, layer *store.Layer) (*authn.User, error) {
ctx := r.Context()
originalURL, err := director.OriginalURL(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
options, err := fromStoreOptions(layer.Options)
if err != nil {
return nil, errors.WithStack(err)
@ -117,14 +148,27 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay
sess.Options.SameSite = http.SameSiteDefaultMode
}
redirectURL := a.getRedirectURL(layer.Proxy, layer.Name, originalURL, options)
client, err := a.getClient(options, redirectURL.String())
originalURL, err := director.OriginalURL(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
idToken, err := client.Authenticate(w, r, sess)
loginCallbackURL, err := a.getLoginCallbackURL(originalURL, layer.Proxy, layer.Name, options)
if err != nil {
return nil, errors.WithStack(err)
}
client, err := a.getClient(options, loginCallbackURL.String())
if err != nil {
return nil, errors.WithStack(err)
}
postLoginRedirectURL, err := a.mergeURL(originalURL, originalURL.Path, options.OIDC.PublicBaseURL, true)
if err != nil {
return nil, errors.WithStack(err)
}
idToken, err := client.Authenticate(w, r, sess, postLoginRedirectURL.String())
if err != nil {
if errors.Is(err, ErrLoginRequired) {
metricLoginRequestsTotal.With(prometheus.Labels{
@ -138,7 +182,7 @@ func (a *Authenticator) Authenticate(w http.ResponseWriter, r *http.Request, lay
return nil, errors.WithStack(err)
}
user, err := a.toUser(idToken, layer.Proxy, layer.Name, originalURL, options, sess)
user, err := a.toUser(originalURL, idToken, layer.Proxy, layer.Name, options, sess)
if err != nil {
return nil, errors.WithStack(err)
}
@ -196,7 +240,7 @@ func (c claims) AsAttrs() map[string]any {
return attrs
}
func (a *Authenticator) toUser(idToken *oidc.IDToken, proxyName store.ProxyName, layerName store.LayerName, originalURL *url.URL, options *LayerOptions, sess *sessions.Session) (*authn.User, error) {
func (a *Authenticator) toUser(originalURL *url.URL, idToken *oidc.IDToken, proxyName store.ProxyName, layerName store.LayerName, options *LayerOptions, sess *sessions.Session) (*authn.User, error) {
var claims claims
if err := idToken.Claims(&claims); err != nil {
@ -209,7 +253,11 @@ func (a *Authenticator) toUser(idToken *oidc.IDToken, proxyName store.ProxyName,
attrs := claims.AsAttrs()
logoutURL := a.getLogoutURL(proxyName, layerName, originalURL, options)
logoutURL, err := a.getLogoutURL(originalURL, proxyName, layerName, options)
if err != nil {
return nil, errors.WithStack(err)
}
attrs["logout_url"] = logoutURL.String()
if accessToken, exists := sess.Values[sessionKeyAccessToken]; exists && accessToken != nil {
@ -229,25 +277,109 @@ func (a *Authenticator) toUser(idToken *oidc.IDToken, proxyName store.ProxyName,
return user, nil
}
func (a *Authenticator) getRedirectURL(proxyName store.ProxyName, layerName store.LayerName, u *url.URL, options *LayerOptions) *url.URL {
return &url.URL{
Scheme: u.Scheme,
Host: u.Host,
Path: fmt.Sprintf(options.OIDC.LoginCallbackPath, fmt.Sprintf("%s/%s", proxyName, layerName)),
func (a *Authenticator) getLoginCallbackURL(originalURL *url.URL, proxyName store.ProxyName, layerName store.LayerName, options *LayerOptions) (*url.URL, error) {
path, err := a.templatize(options.OIDC.LoginCallbackPath, proxyName, layerName)
if err != nil {
return nil, errors.WithStack(err)
}
merged, err := a.mergeURL(originalURL, path, options.OIDC.PublicBaseURL, false)
if err != nil {
return nil, errors.WithStack(err)
}
return merged, nil
}
func (a *Authenticator) getLogoutURL(proxyName store.ProxyName, layerName store.LayerName, u *url.URL, options *LayerOptions) *url.URL {
return &url.URL{
Scheme: u.Scheme,
Host: u.Host,
Path: fmt.Sprintf(options.OIDC.LogoutPath, fmt.Sprintf("%s/%s", proxyName, layerName)),
func (a *Authenticator) getLogoutURL(originalURL *url.URL, proxyName store.ProxyName, layerName store.LayerName, options *LayerOptions) (*url.URL, error) {
path, err := a.templatize(options.OIDC.LogoutPath, proxyName, layerName)
if err != nil {
return nil, errors.WithStack(err)
}
merged, err := a.mergeURL(originalURL, path, options.OIDC.PublicBaseURL, true)
if err != nil {
return nil, errors.WithStack(err)
}
return merged, nil
}
func (a *Authenticator) mergeURL(base *url.URL, path string, overlay string, withQuery bool) (*url.URL, error) {
merged := &url.URL{
Scheme: base.Scheme,
Host: base.Host,
Path: path,
}
if withQuery {
merged.RawQuery = base.RawQuery
}
if overlay != "" {
overlayURL, err := url.Parse(overlay)
if err != nil {
return nil, errors.WithStack(err)
}
merged.Scheme = overlayURL.Scheme
merged.Host = overlayURL.Host
merged.Path = overlayURL.Path + strings.TrimPrefix(path, "/")
for key, values := range overlayURL.Query() {
query := merged.Query()
for _, v := range values {
query.Add(key, v)
}
merged.RawQuery = query.Encode()
}
}
return merged, nil
}
func (a *Authenticator) templatize(rawTemplate string, proxyName store.ProxyName, layerName store.LayerName) (string, error) {
tmpl, err := template.New("").Parse(rawTemplate)
if err != nil {
return "", errors.WithStack(err)
}
var raw bytes.Buffer
err = tmpl.Execute(&raw, struct {
ProxyName store.ProxyName
LayerName store.LayerName
}{
ProxyName: proxyName,
LayerName: layerName,
})
if err != nil {
return "", errors.WithStack(err)
}
return raw.String(), nil
}
func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*Client, error) {
ctx := context.Background()
transport := a.httpTransport.Clone()
if options.OIDC.TLSInsecureSkipVerify {
if transport.TLSClientConfig == nil {
transport.TLSClientConfig = &tls.Config{}
}
transport.TLSClientConfig.InsecureSkipVerify = true
}
httpClient := &http.Client{
Timeout: a.httpClientTimeout,
Transport: transport,
}
ctx = oidc.ClientContext(ctx, httpClient)
if options.OIDC.SkipIssuerVerification {
ctx = oidc.InsecureIssuerURLContext(ctx, options.OIDC.IssuerURL)
}
@ -263,6 +395,7 @@ func (a *Authenticator) getClient(options *LayerOptions, redirectURL string) (*C
WithRedirectURL(redirectURL),
WithScopes(options.OIDC.Scopes...),
WithAuthParams(options.OIDC.AuthParams),
WithHTTPClient(httpClient),
)
return client, nil

View File

@ -6,7 +6,6 @@ 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"
@ -30,6 +29,7 @@ var (
)
type Client struct {
httpClient *http.Client
oauth2 *oauth2.Config
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
@ -44,12 +44,12 @@ func (c *Client) Provider() *oidc.Provider {
return c.provider
}
func (c *Client) Authenticate(w http.ResponseWriter, r *http.Request, sess *sessions.Session) (*oidc.IDToken, error) {
func (c *Client) Authenticate(w http.ResponseWriter, r *http.Request, sess *sessions.Session, postLoginRedirectURL string) (*oidc.IDToken, error) {
idToken, err := c.getIDToken(r, sess)
if err != nil {
logger.Error(r.Context(), "could not retrieve idtoken", logger.E(errors.WithStack(err)))
logger.Warn(r.Context(), "could not retrieve idtoken", logger.E(errors.WithStack(err)))
c.login(w, r, sess)
c.login(w, r, sess, postLoginRedirectURL)
return nil, errors.WithStack(ErrLoginRequired)
}
@ -57,23 +57,15 @@ func (c *Client) Authenticate(w http.ResponseWriter, r *http.Request, sess *sess
return idToken, nil
}
func (c *Client) login(w http.ResponseWriter, r *http.Request, sess *sessions.Session) {
func (c *Client) login(w http.ResponseWriter, r *http.Request, sess *sessions.Session, postLoginRedirectURL string) {
ctx := r.Context()
state := uniuri.New()
nonce := uniuri.New()
originalURL, err := director.OriginalURL(ctx)
if err != nil {
logger.Error(ctx, "could not retrieve original url", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
sess.Values[sessionKeyLoginState] = state
sess.Values[sessionKeyLoginNonce] = nonce
sess.Values[sessionKeyPostLoginRedirectURL] = originalURL.String()
sess.Values[sessionKeyPostLoginRedirectURL] = postLoginRedirectURL
if err := sess.Save(r, w); err != nil {
logger.Error(ctx, "could not save session", logger.E(errors.WithStack(err)))
@ -210,6 +202,7 @@ func (c *Client) sessionEndURL(idTokenHint, state, postLogoutRedirectURL string)
func (c *Client) validate(r *http.Request, sess *sessions.Session) (*oauth2.Token, *oidc.IDToken, string, error) {
ctx := r.Context()
ctx = oidc.ClientContext(ctx, c.httpClient)
rawStoredState := sess.Values[sessionKeyLoginState]
receivedState := r.URL.Query().Get("state")
@ -246,7 +239,7 @@ func (c *Client) validate(r *http.Request, sess *sessions.Session) (*oauth2.Toke
func (c *Client) getRawIDToken(sess *sessions.Session) (string, error) {
rawIDToken, ok := sess.Values[sessionKeyIDToken].(string)
if !ok || rawIDToken == "" {
return "", errors.New("invalid id token")
return "", errors.New("id token not found")
}
return rawIDToken, nil
@ -287,5 +280,6 @@ func NewClient(funcs ...ClientOptionFunc) *Client {
provider: opts.Provider,
verifier: verifier,
authParams: opts.AuthParams,
httpClient: opts.HTTPClient,
}
}

View File

@ -2,6 +2,7 @@ package oidc
import (
"context"
"net/http"
"github.com/coreos/go-oidc/v3/oidc"
)
@ -14,6 +15,7 @@ type ClientOptions struct {
Scopes []string
AuthParams map[string]string
SkipIssuerCheck bool
HTTPClient *http.Client
}
type ClientOptionFunc func(*ClientOptions)
@ -63,9 +65,16 @@ func WithProvider(provider *oidc.Provider) ClientOptionFunc {
}
}
func WithHTTPClient(client *http.Client) ClientOptionFunc {
return func(opt *ClientOptions) {
opt.HTTPClient = client
}
}
func NewClientOptions(funcs ...ClientOptionFunc) *ClientOptions {
opt := &ClientOptions{
Scopes: []string{oidc.ScopeOpenID, "profile"},
Scopes: []string{oidc.ScopeOpenID, "profile"},
HTTPClient: http.DefaultClient,
}
for _, f := range funcs {

View File

@ -17,9 +17,13 @@
"title": "URL de base du fournisseur OpenID Connect (racine du .well-known/openid-configuration)",
"type": "string"
},
"postLogoutRedirectURL": {
"title": "URL de redirection après déconnexion",
"type": "string"
"postLogoutRedirectURLs": {
"title": "URLs de redirection après déconnexion autorisées",
"description": "La variable d'URL 'redirect=<url>' peut être utilisée pour spécifier une redirection après déconnexion.",
"type": "array",
"item": {
"type": "string"
}
},
"scopes": {
"title": "Scopes associés au client OpenID Connect",
@ -44,20 +48,43 @@
},
"loginCallbackPath": {
"title": "Chemin associé à l'URL de callback OpenID Connect",
"default": "/.bouncer/authn/oidc/%s/callback",
"description": "Le marqueur '%s' peut être utilisé pour injecter l'espace de nom '<proxy>/<layer>'.",
"default": "/.bouncer/authn/oidc/{{ .ProxyName }}/{{ .LayerName }}/callback",
"description": "Les marqueurs '{{ .ProxyName }}' et '{{ .LayerName }}' peuvent être utilisés pour injecter le nom du proxy ainsi que celui du layer.",
"type": "string"
},
"matchLoginCallbackPath": {
"title": "Patron de correspondance du chemin interne de callback OpenID Connect",
"default": "*.bouncer/authn/oidc/{{ .ProxyName }}/{{ .LayerName }}/callback",
"description": "Les marqueurs '{{ .ProxyName }}' et '{{ .LayerName }}' peuvent être utilisés pour injecter le nom du proxy ainsi que celui du layer.",
"type": "string"
},
"logoutPath": {
"title": "Chemin associé à l'URL de déconnexion",
"default": "/.bouncer/authn/oidc/%s/logout",
"description": "Le marqueur '%s' peut être utilisé pour injecter l'espace de nom '<proxy>/<layer>'.",
"default": "/.bouncer/authn/oidc/{{ .ProxyName }}/{{ .LayerName }}/logout",
"description": "Les marqueurs '{{ .ProxyName }}' et '{{ .LayerName }}' peuvent être utilisés pour injecter le nom du proxy ainsi que celui du layer.",
"type": "string"
},
"publicBaseURL": {
"title": "URL publique de base associée au service distant",
"default": "",
"description": "Peut être utilisé par exemple si il y a discordance de nom d'hôte ou de chemin sur les URLs publiques/internes.",
"type": "string"
},
"matchLogoutPath": {
"title": "Patron de correspondance du chemin interne de déconnexion",
"default": "*.bouncer/authn/oidc/{{ .ProxyName }}/{{ .LayerName }}/logout",
"description": "Les marqueurs '{{ .ProxyName }}' et '{{ .LayerName }}' peuvent être utilisés pour injecter le nom du proxy ainsi que celui du layer.",
"type": "string"
},
"skipIssuerVerification": {
"title": "Activer/désactiver la vérification de concordance de l'identifiant du fournisseur d'identité",
"default": false,
"type": "boolean"
},
"tlsInsecureSkipVerify": {
"title": "Activer/désactiver la vérification du certificat TLS distant",
"default": false,
"type": "boolean"
}
},
"additionalProperties": false,

View File

@ -8,6 +8,11 @@ import (
const LayerType store.LayerType = "authn-oidc"
func NewLayer(store sessions.Store) *authn.Layer {
return authn.NewLayer(LayerType, &Authenticator{store: store})
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,
})
}

View File

@ -19,11 +19,15 @@ type LayerOptions struct {
type OIDCOptions struct {
ClientID string `mapstructure:"clientId"`
ClientSecret string `mapstructure:"clientSecret"`
PublicBaseURL string `mapstructure:"publicBaseURL"`
LoginCallbackPath string `mapstructure:"loginCallbackPath"`
MatchLoginCallbackPath string `mapstructure:"matchLoginCallbackPath"`
LogoutPath string `mapstructure:"logoutPath"`
MatchLogoutPath string `mapstructure:"matchLogoutPath"`
IssuerURL string `mapstructure:"issuerURL"`
SkipIssuerVerification bool `mapstructure:"skipIssuerVerification"`
PostLogoutRedirectURL string `mapstructure:"postLogoutRedirectURL"`
PostLogoutRedirectURLs []string `mapstructure:"postLogoutRedirectURLs"`
TLSInsecureSkipVerify bool `mapstructure:"tlsInsecureSkipVerify"`
Scopes []string `mapstructure:"scopes"`
AuthParams map[string]string `mapstructure:"authParams"`
}
@ -39,12 +43,18 @@ type CookieOptions struct {
}
func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
loginCallbackPath := ".bouncer/authn/oidc/{{ .ProxyName }}/{{ .LayerName }}/callback"
logoutPath := ".bouncer/authn/oidc/{{ .ProxyName }}/{{ .LayerName }}/logout"
layerOptions := LayerOptions{
LayerOptions: authn.DefaultLayerOptions(),
OIDC: OIDCOptions{
LoginCallbackPath: "/.bouncer/authn/oidc/%s/callback",
LogoutPath: "/.bouncer/authn/oidc/%s/logout",
Scopes: []string{"openid"},
PublicBaseURL: "",
LoginCallbackPath: loginCallbackPath,
MatchLoginCallbackPath: "*" + loginCallbackPath,
LogoutPath: logoutPath,
MatchLogoutPath: "*" + logoutPath,
Scopes: []string{"openid"},
},
Cookie: CookieOptions{
Name: defaultCookieName,

View File

@ -0,0 +1,38 @@
package oidc
import (
"net/http"
"time"
)
type Options struct {
HTTPTransport *http.Transport
HTTPClientTimeout time.Duration
}
type OptionFunc func(opts *Options)
func WithHTTPTransport(transport *http.Transport) OptionFunc {
return func(opts *Options) {
opts.HTTPTransport = transport
}
}
func WithHTTPClientTimeout(timeout time.Duration) OptionFunc {
return func(opts *Options) {
opts.HTTPClientTimeout = timeout
}
}
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
HTTPTransport: http.DefaultTransport.(*http.Transport),
HTTPClientTimeout: 30 * time.Second,
}
for _, fn := range funcs {
fn(opts)
}
return opts
}

View File

@ -0,0 +1,58 @@
package director
import (
"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"
)
type Options struct {
Layers []Layer
ProxyCache cache.Cache[string, []*store.Proxy]
LayerCache cache.Cache[string, []*store.Layer]
}
type OptionFunc func(opts *Options)
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
Layers: make([]Layer, 0),
ProxyCache: ttl.NewCache(
memory.NewCache[string, []*store.Proxy](),
memory.NewCache[string, time.Time](),
30*time.Second,
),
LayerCache: ttl.NewCache(
memory.NewCache[string, []*store.Layer](),
memory.NewCache[string, time.Time](),
30*time.Second,
),
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithLayers(layers ...Layer) OptionFunc {
return func(opts *Options) {
opts.Layers = layers
}
}
func WithProxyCache(cache cache.Cache[string, []*store.Proxy]) OptionFunc {
return func(opts *Options) {
opts.ProxyCache = cache
}
}
func WithLayerCache(cache cache.Cache[string, []*store.Layer]) OptionFunc {
return func(opts *Options) {
opts.LayerCache = cache
}
}

View File

@ -1,23 +1,27 @@
package proxy
import (
"time"
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
)
type Option struct {
ServerConfig config.ProxyServerConfig
RedisConfig config.RedisConfig
DirectorLayers []director.Layer
ServerConfig config.ProxyServerConfig
RedisConfig config.RedisConfig
DirectorLayers []director.Layer
DirectorCacheTTL time.Duration
}
type OptionFunc func(*Option)
func defaultOption() *Option {
return &Option{
ServerConfig: config.NewDefaultProxyServerConfig(),
RedisConfig: config.NewDefaultRedisConfig(),
DirectorLayers: make([]director.Layer, 0),
ServerConfig: config.NewDefaultProxyServerConfig(),
RedisConfig: config.NewDefaultRedisConfig(),
DirectorLayers: make([]director.Layer, 0),
DirectorCacheTTL: 30 * time.Second,
}
}
@ -38,3 +42,9 @@ func WithDirectorLayers(layers ...director.Layer) OptionFunc {
opt.DirectorLayers = layers
}
}
func WithDirectorCacheTTL(ttl time.Duration) OptionFunc {
return func(opt *Option) {
opt.DirectorCacheTTL = ttl
}
}

View File

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

View File

@ -11,6 +11,8 @@ import (
"time"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/cadoles/bouncer/internal/cache/memory"
"forge.cadoles.com/cadoles/bouncer/internal/cache/ttl"
bouncerChi "forge.cadoles.com/cadoles/bouncer/internal/chi"
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
@ -25,11 +27,12 @@ import (
)
type Server struct {
serverConfig config.ProxyServerConfig
redisConfig config.RedisConfig
directorLayers []director.Layer
proxyRepository store.ProxyRepository
layerRepository store.LayerRepository
serverConfig config.ProxyServerConfig
redisConfig config.RedisConfig
directorLayers []director.Layer
directorCacheTTL time.Duration
proxyRepository store.ProxyRepository
layerRepository store.LayerRepository
}
func (s *Server) Start(ctx context.Context) (<-chan net.Addr, <-chan error) {
@ -86,7 +89,21 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
director := director.New(
s.proxyRepository,
s.layerRepository,
s.directorLayers...,
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,
),
),
)
if s.serverConfig.HTTP.UseRealIP {
@ -159,26 +176,10 @@ func (s *Server) createReverseProxy(ctx context.Context, target *url.URL) *httpu
DualStack: bool(dialConfig.DualStack),
}
transportConfig := s.serverConfig.Transport
reverseProxy.Transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: dialer.DialContext,
ForceAttemptHTTP2: bool(transportConfig.ForceAttemptHTTP2),
MaxIdleConns: int(transportConfig.MaxIdleConns),
MaxIdleConnsPerHost: int(transportConfig.MaxIdleConnsPerHost),
MaxConnsPerHost: int(transportConfig.MaxConnsPerHost),
IdleConnTimeout: time.Duration(*transportConfig.IdleConnTimeout),
TLSHandshakeTimeout: time.Duration(*transportConfig.TLSHandshakeTimeout),
ExpectContinueTimeout: time.Duration(*transportConfig.ExpectContinueTimeout),
DisableKeepAlives: bool(transportConfig.DisableKeepAlives),
DisableCompression: bool(transportConfig.DisableCompression),
ResponseHeaderTimeout: time.Duration(*transportConfig.ResponseHeaderTimeout),
WriteBufferSize: int(transportConfig.WriteBufferSize),
ReadBufferSize: int(transportConfig.ReadBufferSize),
MaxResponseHeaderBytes: int64(transportConfig.MaxResponseHeaderBytes),
}
httpTransport := s.serverConfig.Transport.AsTransport()
httpTransport.DialContext = dialer.DialContext
reverseProxy.Transport = httpTransport
reverseProxy.ErrorHandler = s.errorHandler
return reverseProxy
@ -200,8 +201,9 @@ func NewServer(funcs ...OptionFunc) *Server {
}
return &Server{
serverConfig: opt.ServerConfig,
redisConfig: opt.RedisConfig,
directorLayers: opt.DirectorLayers,
serverConfig: opt.ServerConfig,
redisConfig: opt.RedisConfig,
directorLayers: opt.DirectorLayers,
directorCacheTTL: opt.DirectorCacheTTL,
}
}

View File

@ -1,6 +1,8 @@
package setup
import (
"time"
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
@ -25,5 +27,11 @@ func setupAuthnOIDCLayer(conf *config.Config) (director.Layer, error) {
adapter := redis.NewStoreAdapter(rdb)
store := session.NewStore(adapter)
return oidc.NewLayer(store), nil
transport := conf.Layers.Authn.OIDC.HTTPClient.AsTransport()
return oidc.NewLayer(
store,
oidc.WithHTTPTransport(transport),
oidc.WithHTTPClientTimeout(time.Duration(*conf.Layers.Authn.OIDC.HTTPClient.Timeout)),
), nil
}

View File

@ -93,6 +93,14 @@ proxy:
credentials:
prom: etheus
# 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.
ttl: 30s
# Configuration du transport HTTP(S)
# Voir https://pkg.go.dev/net/http#Transport
transport: