Compare commits

...

74 Commits

Author SHA1 Message Date
7456dba96f doc: fix typo
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-05-17 12:35:34 +02:00
af34ee2473 Merge pull request 'Création d'un layer d'authentification OpenID Connect' (#20) from authn-oidc into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #20
2024-05-17 11:53:50 +02:00
de70fa89f7 feat: new openid connect authentication layer
Some checks are pending
Cadoles/bouncer/pipeline/pr-develop Build started...
2024-05-17 11:53:19 +02:00
bb5796ab8c doc: add layer endpoints
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-04-19 09:28:46 +02:00
83fcb9a39d feat: add limited retry mechanism to prevent startup error if redis is not ready
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-04-05 10:30:34 +02:00
ad907576dc fix: move log message
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-03-29 11:13:05 +01:00
3a894972f1 doc: enable json highlighting in reference examples
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-03-29 09:36:36 +01:00
274bef13d8 feat: match proxy's from on whole targeted url
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-03-29 09:21:01 +01:00
f548c8c8e7 feat: add host to access log fields
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-03-28 19:47:32 +01:00
a82fe46fa3 Merge pull request 'Utilisation d'une clé privée partagée via un Secret sur Kubernetes' (#19) from kubernetes-private-key into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #19
2024-03-28 15:57:17 +01:00
cc20bdd289 feat: remove printing of default token
Some checks are pending
Cadoles/bouncer/pipeline/pr-develop Build started...
Cadoles/bouncer/pipeline/head This commit looks good
2024-03-28 15:54:59 +01:00
7de166765b feat(k8s): use secret as shared source for admin private key
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-03-28 15:53:40 +01:00
35717429a2 doc(k8s): add in/out cluster api querying procedure
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-03-28 09:10:16 +01:00
16305469c5 Merge pull request 'Intégration basique avec Kubernetes' (#18) from kubernetes-integration into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #18
2024-03-27 17:57:07 +01:00
7515be9583 chore: update go version in ci
All checks were successful
Cadoles/bouncer/pipeline/pr-develop This commit looks good
2024-03-27 17:51:01 +01:00
e76a82668d feat: kubernetes basic integration
Some checks failed
Cadoles/bouncer/pipeline/head There was a failure building this commit
Cadoles/bouncer/pipeline/pr-develop There was a failure building this commit
2024-03-27 17:47:39 +01:00
d8b78ad277 feat(docker): run as non-root user
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-03-27 09:19:08 +01:00
61012b07cd Merge pull request 'feat: bootstrap default proxy and layers from configuration' (#17) from proxy-bootstrap into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #17
2024-03-26 17:30:20 +01:00
d12ebfc642 feat: proxy bootstrapping from configuration
All checks were successful
Cadoles/bouncer/pipeline/pr-develop This commit looks good
2024-03-26 17:28:38 +01:00
441d3a623e Merge pull request 'feat(k8s): adding kubernetes support' (#12) from feat/issue-10/add-k8s-kustomize into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #12
2024-03-26 14:49:24 +01:00
e1d9acb980 fix[README]: Added --role writer in token authentication creation
Some checks are pending
Cadoles/bouncer/pipeline/pr-develop Build started...
2024-03-26 14:04:07 +01:00
f8be2c08d6 fix[README]: add identifier generation step 2024-03-26 14:04:07 +01:00
bc7422a50c feat: add configurable redis timeouts 2024-03-26 14:04:07 +01:00
9d32551ec5 feat: generalize siege task 2024-03-26 14:04:07 +01:00
ded6d179c1 fix(k8s): redis configuration 2024-03-26 14:04:07 +01:00
6f4ee0ebd1 fix(skaffold): adding port-forward for testing 2024-03-26 14:04:07 +01:00
1375c9b317 fup 2024-03-26 14:04:05 +01:00
53a0d26a47 feat(pkg): adding archlinux package to gorelease 2024-03-26 13:49:58 +01:00
87354ef0d4 fix(doc): test command was incorrect 2024-03-26 13:49:58 +01:00
8560041598 fix(kustomization): adding correct labels to deployments 2024-03-26 13:49:58 +01:00
0611cc9f70 feat(k8s): adding kubernetes support
Now we can use skaffold and deploy bouncer in a kubernetes cluster

ref #10
2024-03-26 13:49:58 +01:00
734ed64e8e feat: add basic k6 load testing script
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-03-25 15:40:25 +01:00
c8fc143efa doc: add prometheus metrics documentation
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-02-21 12:29:03 +01:00
f91c14e5d4 feat(admin): print default writer token to logs by default
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2024-02-21 11:09:34 +01:00
1602626e8c Merge pull request 'fix(depends): update go.mod library versions' (#15) from fix/go-lib-versions into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #15
2024-02-05 11:24:02 +01:00
e2e38841f4 fix(depends): update go.mod library versions
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Cadoles/bouncer/pipeline/pr-develop This commit looks good
2024-02-05 11:21:27 +01:00
c23d8e3adb Merge pull request 'fix(config): supporting multiple env variables in a value.' (#11) from fix/issue-9/multiple-env-variables into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #11
2024-02-05 11:13:47 +01:00
a3f44cf123 fix(config): supporting multiple env variables in a value.
ref #9
2024-02-05 11:13:47 +01:00
5453988419 Merge pull request 'fix(dockerfile): updating base images versions.' (#14) from fix/dockerfile into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #14
2024-02-05 11:08:50 +01:00
1e392f94a7 fix(dockerfile): updating base images versions.
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Cadoles/bouncer/pipeline/pr-develop This commit looks good
Keep things up to date and security alerts away from trivi.

Using apk package for dumb-init
2024-02-05 11:04:28 +01:00
b44ff2a68e doc: add proxy http api reference
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-07-08 12:19:43 -06:00
c719fdca37 feat: add prometheus + grafterm dashboard in local dev environment 2023-07-08 12:18:38 -06:00
2b91c1e167 feat(store,repository): add more integration tests
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-07-07 12:22:31 -06:00
cebf1daf72 chore: update github.com/lestrrat-go/jwx/v2
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-07-07 10:20:27 -06:00
6734cf6526 Merge pull request 'Génération de l'image Docker via le pipeline Jenkins' (#7) from ci-docker-release into develop
Some checks reported errors
Cadoles/bouncer/pipeline/head Something is wrong with the build of this commit
Reviewed-on: #7
2023-07-07 18:11:06 +02:00
368273f1ee chore(ci): release docker image
Some checks are pending
Cadoles/bouncer/pipeline/pr-develop Build started...
2023-07-07 10:10:22 -06:00
553513d647 Merge pull request 'Implémentation du layer "circuitbreaker"' (#6) from circuitbreaker into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #6
2023-07-06 16:46:17 +02:00
60487c11d6 feat: optional real-ip middleware
All checks were successful
Cadoles/bouncer/pipeline/pr-develop This commit looks good
2023-07-06 08:16:17 -06:00
e6f18e7cd8 fix(doc): typo
All checks were successful
Cadoles/bouncer/pipeline/pr-develop This commit looks good
2023-07-06 07:59:20 -06:00
a207291c04 feat: implements circuitbreaker layer 2023-07-06 07:59:20 -06:00
64b5182f8b fix(doc): bad link
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-07-06 07:41:53 -06:00
ce2c19f9b3 feat(layer,queue): implement matchURLs option
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-07-05 13:54:01 -06:00
1ffec1f173 feat(layer,queue): prevent browser caching for queue page
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-07-05 13:35:21 -06:00
aab5452fa2 feat: sentry integration
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
ref #3
2023-07-05 12:05:30 -06:00
a176b754cd feat: add queue adapter tests
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-07-05 08:55:15 -06:00
7b04eb2418 fix(doc): bad link
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-07-05 15:14:27 +02:00
f8d9ff15b5 doc: add link to misc/docker-compose
Some checks reported errors
Cadoles/bouncer/pipeline/head Something is wrong with the build of this commit
2023-07-05 15:13:31 +02:00
5bd7cbc132 fix(docker): move templates to expected path
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-07-03 19:42:44 -06:00
1b06f07ce8 feat: update packaged configuration
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-07-01 13:55:26 -06:00
82228fd115 feat: allow customization of proxy transport configuration
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-07-01 13:43:18 -06:00
15daddbe13 feat: add multi-nodes docker-compose deployment example
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-07-01 11:38:16 -06:00
5a7062d53e fix: remove log message
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-07-01 11:33:59 -06:00
74409f18e8 feat: update packaged configuration
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-06-30 17:51:01 -06:00
ab7f64a684 Merge pull request 'Métriques de base Prometheus' (#4) from metrics into develop
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Reviewed-on: #4
2023-07-01 01:32:10 +02:00
d5cc15de3b chore: run service in debug mode by default
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
Cadoles/bouncer/pipeline/pr-develop This commit looks good
2023-06-30 10:26:52 -06:00
56609ec316 feat: add basic prometheus metrics integration
Some checks reported errors
Cadoles/bouncer/pipeline/head Something is wrong with the build of this commit
2023-06-30 10:26:27 -06:00
5bf391b6bf fix(docker): add layers templates in image
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-06-29 20:36:11 -06:00
74928fe413 chore: add log message for workdir and configuration loading
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-06-29 20:16:25 -06:00
ff1d01828d fix: docker image build
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-06-29 20:14:20 -06:00
851f5d64cc doc: add getting started with sources tutorial 2023-06-29 20:13:21 -06:00
e0d81c061b chore: add png logo
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-06-29 14:45:47 -06:00
440d467938 fix(doc): bad link
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-06-26 16:37:55 +02:00
f8d33299b9 fix(doc): typo
All checks were successful
Cadoles/bouncer/pipeline/head This commit looks good
2023-06-26 14:25:42 +02:00
6fed6358b2 fix(doc): typo
Some checks reported errors
Cadoles/bouncer/pipeline/head Something is wrong with the build of this commit
2023-06-26 14:24:34 +02:00
155 changed files with 7000 additions and 418 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
/admin-key.json
/config.yml
/tools
/out
/dist
/data
/bin
/.bouncer-token
/.env
/misc/k8s
/misc/k6s
/misc/grafterm

3
.gitignore vendored
View File

@ -7,4 +7,5 @@
/admin-key.json
/.bouncer-token
/data
/out
/out
.dockerconfigjson

View File

@ -11,7 +11,7 @@ builds:
- -s
- -w
- -X 'main.GitRef={{ .Commit }}'
- -X 'main.ProjectVersion={{ .Version }}'
- -X 'main.ProjectVersion={{ .Version }}'
- -X 'main.BuildDate={{ .Date }}'
- -X 'main.DefaultConfigPath=/etc/bouncer/config.yml'
gcflags:
@ -33,15 +33,15 @@ archives:
- README.md
- misc/packaging/common/config.yml
checksum:
name_template: 'checksums.txt'
name_template: "checksums.txt"
snapshot:
name_template: "{{ .Version }}"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- "^docs:"
- "^test:"
nfpms:
- id: bouncer-bin
builds:
@ -63,6 +63,10 @@ nfpms:
- src: layers
dst: /etc/bouncer/layers
type: config
- dst: /etc/bouncer/bootstrap.d
type: dir
file_info:
mode: 0700
- id: bouncer-admin
meta: true
package_name: bouncer-admin

View File

@ -1,30 +1,55 @@
FROM golang:1.19 AS BUILD
FROM reg.cadoles.com/proxy_cache/library/golang:1.22.0 AS BUILD
RUN apt-get update \
&& apt-get install -y make
COPY . /src
ARG YQ_VERSION=4.34.1
RUN mkdir -p /usr/local/bin \
&& wget -O /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_linux_amd64 \
&& chmod +x /usr/local/bin/yq
WORKDIR /src
COPY go.mod .
COPY go.sum .
RUN go mod download
COPY . /src
RUN make GORELEASER_ARGS='build --rm-dist --single-target --snapshot' goreleaser
FROM busybox:latest AS RUNTIME
# Patch config
RUN /src/dist/bouncer_linux_amd64_v1/bouncer -c '' config dump > /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.layers.queue.templateDir = "/usr/share/bouncer/layers/queue/templates"' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& yq -i '.admin.auth.privateKey = "/etc/bouncer/admin-key.json"' /src/dist/bouncer_linux_amd64_v1/config.yml \
&& 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
ARG DUMB_INIT_VERSION=1.2.5
FROM reg.cadoles.com/proxy_cache/library/alpine:3.19.1 AS RUNTIME
RUN mkdir -p /usr/local/bin \
&& wget -O /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v${DUMB_INIT_VERSION}/dumb-init_${DUMB_INIT_VERSION}_x86_64 \
&& chmod +x /usr/local/bin/dumb-init
RUN apk add --no-cache ca-certificates dumb-init
ENTRYPOINT ["/usr/local/bin/dumb-init", "--"]
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
COPY --from=BUILD /src/dist/bouncer_linux_amd64_v1 /app
COPY --from=BUILD /src/config.yml /etc/bouncer/config.yml
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/dist/bouncer_linux_amd64_v1/config.yml /etc/bouncer/config.yml
RUN ln -s /usr/share/bouncer/bin/bouncer /usr/local/bin/bouncer
EXPOSE 8080
EXPOSE 8081
ENTRYPOINT ["/app/bouncer"]
RUN adduser -D -H bouncer
CMD ["bouncer", "run", "-c", "/etc/bouncer/config.yml"]
ENV BOUNCER_CONFIG=/etc/bouncer/config.yml
USER bouncer
CMD ["bouncer"]

27
Jenkinsfile vendored
View File

@ -29,7 +29,7 @@ pipeline {
}
}
stage('Release') {
stage('Release binaries and packages') {
when {
anyOf {
branch 'master'
@ -50,6 +50,31 @@ pipeline {
}
}
}
stage('Build and release Docker image') {
when {
anyOf {
branch 'master'
branch 'develop'
}
}
steps {
script {
withCredentials([
usernamePassword([
credentialsId: 'kipp-credentials',
usernameVariable: 'DOCKER_REGISTRY_USERNAME',
passwordVariable: 'DOCKER_REGISTRY_PASSWORD'
])
]) {
sh """
echo '${env.DOCKER_REGISTRY_PASSWORD}' | docker login --username '${env.DOCKER_REGISTRY_USERNAME}' --password-stdin reg.cadoles.com
make docker-build docker-release
"""
}
}
}
}
}
post {

View File

@ -5,18 +5,25 @@ GITCHLOG_ARGS ?=
SHELL := /bin/bash
BOUNCER_VERSION ?=
GIT_VERSION := $(shell git describe --always)
GIT_COMMIT := $(shell git rev-parse --short HEAD)
DATE_VERSION := $(shell date +%Y.%-m.%-d)
FULL_VERSION := v$(DATE_VERSION)-$(GIT_VERSION)$(if $(shell git diff --stat),-dirty,)
FULL_VERSION := v$(DATE_VERSION)-$(GIT_COMMIT)$(if $(shell git diff --stat),-dirty,)
DOCKER_IMAGE_NAME ?= cadoles/bouncer
DOCKER_IMAGE_NAME ?= reg.cadoles.com/cadoles/bouncer
DOCKER_IMAGE_TAG ?= $(FULL_VERSION)
GOTEST_ARGS ?= -short
OPENWRT_DEVICE ?= 192.168.1.1
watch: tools/modd/bin/modd deps ## Watching updated files - live reload
SIEGE_URLS_FILE ?= misc/siege/urls.txt
SIEGE_CONCURRENCY ?= 100
data/bootstrap.d/dummy.yml:
mkdir -p data/bootstrap.d
cp misc/bootstrap.d/dummy.yml data/bootstrap.d/dummy.yml
watch: tools/modd/bin/modd deps data/bootstrap.d/dummy.yml ## Watching updated files - live reload
( set -o allexport && source .env && set +o allexport && tools/modd/bin/modd )
.PHONY: test
@ -25,16 +32,6 @@ test: test-go ## Executing tests
test-go: deps
( set -o allexport && source .env && set +o allexport && go test -v -count=1 $(GOTEST_ARGS) ./... )
test-install-script: tools/bin/bash_unit
tools/bin/bash_unit ./misc/script/test_install.sh
tools/bin/bash_unit:
mkdir -p tools/bin
cd tools/bin && bash <(curl -s https://raw.githubusercontent.com/pgrange/bash_unit/master/install.sh)
lint: ## Lint sources code
golangci-lint run --enable-all $(LINT_ARGS)
build: build-bouncer ## Build artefacts
build-bouncer: deps ## Build executable
@ -83,14 +80,13 @@ finish-release:
git push --all
git push --tags
install-git-hooks:
git config core.hooksPath .githooks
docker-build:
docker build -t $(DOCKER_IMAGE_NAME):$(DOCKER_IMAGE_TAG) .
docker tag $(DOCKER_IMAGE_NAME):$(DOCKER_IMAGE_TAG) $(DOCKER_IMAGE_NAME):latest
docker-release:
docker push $(DOCKER_IMAGE_NAME):$(DOCKER_IMAGE_TAG)
docker push $(DOCKER_IMAGE_NAME):latest
gitea-release: tools/gitea-release/bin/gitea-release.sh goreleaser
mkdir -p .gitea-release
@ -106,12 +102,21 @@ gitea-release: tools/gitea-release/bin/gitea-release.sh goreleaser
GITEA_RELEASE_BASE_URL="https://forge.cadoles.com" \
GITEA_RELEASE_VERSION="$(FULL_VERSION)" \
GITEA_RELEASE_NAME="$(FULL_VERSION)" \
GITEA_RELEASE_COMMITISH_TARGET="$(GIT_VERSION)" \
GITEA_RELEASE_COMMITISH_TARGET="$(GIT_COMMIT)" \
GITEA_RELEASE_IS_DRAFT="false" \
GITEA_RELEASE_BODY="" \
GITEA_RELEASE_ATTACHMENTS="$$(find .gitea-release/* -type f)" \
tools/gitea-release/bin/gitea-release.sh
grafterm: tools/grafterm/bin/grafterm
tools/grafterm/bin/grafterm -c ./misc/grafterm/dashboard.json -v job=bouncer-proxy -r 5s
siege:
$(eval TMP := $(shell mktemp))
cat $(SIEGE_URLS_FILE) | envsubst > $(TMP)
siege -i -b -c $(SIEGE_CONCURRENCY) -f $(TMP)
rm -rf $(TMP)
tools/gitea-release/bin/gitea-release.sh:
mkdir -p tools/gitea-release/bin
curl --output tools/gitea-release/bin/gitea-release.sh https://forge.cadoles.com/Cadoles/Jenkins/raw/branch/master/resources/com/cadoles/gitea/gitea-release.sh
@ -121,6 +126,10 @@ tools/modd/bin/modd:
mkdir -p tools/modd/bin
GOBIN=$(PWD)/tools/modd/bin go install github.com/cortesi/modd/cmd/modd@latest
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
full-version:
@echo $(FULL_VERSION)
@ -139,4 +148,12 @@ run-redis:
redis-shell:
docker exec -it \
bouncer-redis \
redis-cli
redis-cli
run-prometheus:
docker kill bouncer-prometheus || exit 0
docker run --rm -t \
--name bouncer-prometheus \
--network host \
-v $(PWD)/misc/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml \
prom/prometheus

View File

@ -3,17 +3,27 @@
- [(FR) - Premiers pas](./fr/getting-started.md)
- [(FR) - Architecture générale](./fr/general-architecture.md)
## Exemples
- [(FR) - Exemple de déploiement multi-noeuds](../misc/docker-compose/README.md)
## Référence
- [(FR) - Layers](./fr/references/layers/README.md)
- [Fichier de configuration](../misc/packaging/common/config.yml)
- [(FR) - Métriques](./fr/references/metrics.md)
- [(FR) - Fichier de configuration](../misc/packaging/common/config.yml)
- [(FR) - API d'administration](./fr/references/admin_api.md)
## Tutoriels
### Utilisation
- [(FR) - Ajouter un calque de type "file d'attente"](./fr/tutorials/add-queue-layer.md)
- [(FR) - Ajouter un layer de type "file d'attente"](./fr/tutorials/add-queue-layer.md)
- [(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)
### Développement
- [(FR) - Créer son propre layer](./fr/tutorials/create-custom-layer.md)
- [(FR) - Démarrer avec les sources](./fr/tutorials/getting-started-with-sources.md)
- [(FR) - Créer son propre layer](./fr/tutorials/create-custom-layer.md)

View File

@ -5,14 +5,16 @@
### Déploiement mono-noeud
![](../resources/deployment_fr.png)
## Terminologie
Voici une liste des termes utilisés dans le lexique Bouncer.
### Proxy
Un "proxy" est une entité logique définissant le relation suivante:
- Un ou plusieurs patrons de filtrage sous la forme `<host>:<port>`. Ceux ci identifient le ou les domaines associés à l'entité;
- Un ou plusieurs patrons de filtrage sous la forme d'un patron d'URL avec le caractère `*` comme caractère générique. Ceux ci identifient le ou les domaines/chemins associés à l'entité;
- Une URL cible qui servira de base pour la réécriture des requêtes.
Un "proxy" peut avoir zéro ou plusieurs "layers" associés.
@ -27,4 +29,4 @@ Un "layer" (calque) est une entité logique définissant un traitement à appliq
Un "layer" peut être activé ou désactivé.
Un "layer" a un poids qui définit son niveau de priorité dans la pile de traitement (plus son poids est élevé plus il est prioritaire).
Un "layer" a un poids qui définit son niveau de priorité dans la pile de traitement (plus son poids est élevé plus il est prioritaire).

View File

@ -41,7 +41,7 @@
5. Tester que le CLI est en capacité d'interroger l'API d'administration
```bash
bouncer admin query proxy
bouncer admin proxy query
```
Un message équivalent à celui ci devrait s'afficher:
@ -92,4 +92,4 @@
3. Ouvrir la page `https://<ip_serveur>:8080/` dans un navigateur. Le site Cadoles s'affiche !
**Bravo, vous avez créé votre premier proxy avec Bouncer !**
**Bravo, vous avez créé votre premier proxy avec Bouncer !**

View File

@ -0,0 +1,349 @@
# API d'administration
## Authentification
L'ensemble des appels aux APIs HTTP du service `bouncer-admin` sont authentifiées via l'utilisation d'un jeton [JWT](https://datatracker.ietf.org/doc/html/rfc7519) signé par la clé privée du serveur.
Le jeton d'accès doit être transmis avec l'ensemble des appels aux points d'entrée via l'entête HTTP `Authorization` en respectant la forme suivante:
```
Authorization: Bearer <jwt>
```
### Génération d'un jeton d'authentification
La génération d'un jeton d'authentification s'effectue via la commande suivante:
```shell
bouncer auth create-token --subject "<subject>" --role "<role>"
```
Où:
- `"<subject>"` est une chaîne de caractère arbitraire ayant pour objectif d'identifier de manière unique l'utilisateur associé au jeton;
- `"<role>"` peut prendre une des deux valeurs `reader` ou `writer` correspondant aux droits suivants respectifs:
- droit en lecture sur l'ensemble des entités (proxy, layer);
- droit en lecture ET en écriture sur l'ensemble des entités.
## Points d'entrée
### `POST /api/v1/proxies`
Créer un nouveau proxy
#### Exemple de corps de requête
```json
{
"name": "myproxy", // OBLIGATOIRE - Nom du proxy
"to": "https://www.cadoles.com", // OBLIGATOIRE - Site distant ciblé par le proxy
"from": ["*"] // OPTIONNEL - Liste de patrons de filtrage associés au proxy
}
```
#### Exemple de résultat
```json
{
"data": {
"proxy": {
"name": "myproxy",
"weight": 0,
"enabled": false,
"to": "https://www.cadoles.com",
"from": ["*"],
"createdAt": "2018-12-10T13:45:00.000Z",
"updatedAt": "2018-12-10T13:45:00.000Z"
}
}
}
```
#### Source
Voir [`internal/admin/proxy_route.go#createProxy()`](../../../internal/admin/proxy_route.go#createProxy)
### `GET /api/v1/proxies/{proxyName}`
Récupérer les informations complètes sur un proxy
#### Paramètres
- `{proxyName}` - Nom du proxy
#### Exemple de résultat
```json
{
"data": {
"proxy": {
"name": "myproxy",
"weight": 0,
"enabled": false,
"to": "https://www.cadoles.com",
"from": ["*"],
"createdAt": "2018-12-10T13:45:00.000Z",
"updatedAt": "2018-12-10T13:45:00.000Z"
}
}
}
```
#### Source
Voir [`internal/admin/proxy_route.go#getProxy()`](../../../internal/admin/proxy_route.go#getProxy)
### `PUT /api/v1/proxies/{proxyName}`
Modifier un proxy
#### Exemple de corps de requête
```json
{
"to": "https://www.cadoles.com", // OPTIONNEL - Site distant ciblé par le proxy
"from": ["mylocalproxydomain:*"], // OPTIONNEL - Liste de patrons de filtrage associés au proxy
"weight": 100, // OPTIONNEL - Poids à associer au proxy
"enabled": true // OPTIONNEL - Activer/désactiver le proxy
}
```
#### Exemple de résultat
```json
{
"data": {
"proxy": {
"name": "myproxy",
"weight": 100,
"enabled": true,
"to": "https://www.cadoles.com",
"from": ["mylocalproxydomain:*"],
"createdAt": "2018-12-10T13:45:00.000Z",
"updatedAt": "2020-10-02T15:09:00.000Z"
}
}
}
```
#### Source
Voir [`internal/admin/proxy_route.go#updateProxy()`](../../../internal/admin/proxy_route.go#updateProxy)
### `GET /api/v1/proxies?names={name1,name2,...}`
Lister les proxies existants
#### Paramètres
- `{names}` - Optionnel - Liste des noms de proxy à appliquer en tant que filtre
#### Exemple de résultat
```json
{
"data": {
"proxies": [
{
"name": "myproxy",
"weight": 0,
"enabled": false,
"createdAt": "2018-12-10T13:45:00.000Z",
"updatedAt": "2018-12-10T13:45:00.000Z"
}
]
}
}
```
#### Source
Voir [`internal/admin/proxy_route.go#queryProxy()`](../../../internal/admin/proxy_route.go#queryProxy)
## `DELETE /api/v1/proxies/{proxyName}`
Supprimer le proxy
#### Paramètres
- `{proxyName}` - Nom du proxy
#### Exemple de résultat
```json
{
"data": {
"proxyName": "myproxy"
}
}
```
#### Source
Voir [`internal/admin/proxy_route.go#deleteProxy()`](../../../internal/admin/proxy_route.go#deleteProxy)
### `POST /api/v1/proxies/{proxyName}/layers`
Créer un nouveau layer pour un proxy donné
#### Paramètres
- `{proxyName}` - Nom du proxy sur lequel créer le layer
#### Exemple de corps de requête
```json
{
"name": "mylayer", // OBLIGATOIRE - Nom du layer
"type": "<layer_type>", // OBLIGATOIRE - Type du layer, voir doc/fr/references/layers
"options": {} // OPTIONNEL - Options associées au layer, voir doc/fr/references/layers
}
```
#### Exemple de résultat
```json
{
"data": {
"layer": {
"name": "mylayer",
"type": "<layer_type>",
"enabled": false,
"weight": 0,
"options": {},
"createdAt": "2018-12-10T13:45:00.000Z",
"updatedAt": "2018-12-10T13:45:00.000Z"
}
}
}
```
#### Source
Voir [`internal/admin/layer_route.go#createLayer()`](../../../internal/admin/layer_route.go#createLayer)
### `GET /api/v1/proxies/{proxyName}/layers/{layerName}`
Récupérer les informations complètes sur un layer
#### Paramètres
- `{proxyName}` - Nom du proxy parent
- `{layerName}` - Nom du layer
#### Exemple de résultat
```json
{
"data": {
"layer": {
"name": "mylayer",
"type": "<layer_type>",
"enabled": false,
"weight": 0,
"options": {},
"createdAt": "2018-12-10T13:45:00.000Z",
"updatedAt": "2018-12-10T13:45:00.000Z"
}
}
}
```
#### Source
Voir [`internal/admin/layer_route.go#getLayer()`](../../../internal/admin/layer_route.go#getLayer)
### `PUT /api/v1/proxies/{proxyName}/layers/{layerName}`
Modifier un layer
#### Paramètres
- `{proxyName}` - Nom du proxy parent
- `{layerName}` - Nom du layer
#### Exemple de corps de requête
```json
{
"weight": 100, // OPTIONNEL - Poids à associer au layer
"enabled": true, // OPTIONNEL - Activer/désactiver le layer
"options": {} // OPTIONNEL - Modifier les options associées au layer, voir doc/fr/references/layers
}
```
#### Exemple de résultat
```json
{
"data": {
"layer": {
"name": "mylayer",
"type": "<layer_type>",
"enabled": false,
"weight": 0,
"options": {},
"createdAt": "2018-12-10T13:45:00.000Z",
"updatedAt": "2018-12-10T13:45:00.000Z"
}
}
}
```
#### Source
Voir [`internal/admin/layer_route.go#updateLayer()`](../../../internal/admin/layer_route.go#updateLayer)
### `GET /api/v1/proxies/{proxyName}/layers?names={name1,name2,...}`
Lister les layers existants
#### Paramètres
- `{proxyName}` - Nom du proxy parent
- `{names}` - Optionnel - Liste des noms de proxy à appliquer en tant que filtre
#### Exemple de résultat
```json
{
"data": {
"layers": [
{
"name": "mylayer",
"weight": 0,
"enabled": false,
"createdAt": "2018-12-10T13:45:00.000Z",
"updatedAt": "2018-12-10T13:45:00.000Z"
}
]
}
}
```
#### Source
Voir [`internal/admin/layer_route.go#queryLayers()`](../../../internal/admin/layer_route.go#queryLayers)
## `DELETE /api/v1/proxies/{proxyName}/layers/{layerName}`
Supprimer le layer
#### Paramètres
- `{proxyName}` - Nom du proxy parent
- `{layerName}` - Nom du layer
#### Exemple de résultat
```json
{
"data": {
"layerName": "mylayer"
}
}
```
#### Source
Voir [`internal/admin/layer_route.go#deleteLayer()`](../../../internal/admin/layer_route.go#deleteLayer)

View File

@ -2,4 +2,6 @@
Vous trouverez ci-dessous la liste des entités "Layer" activables sur vos entité "Proxy":
- [Queue](./queue) - File d'attente dynamique
- [Authn (`authn-*`)](./authn/README.md) - Authentification des accès (SSO)
- [Queue](./queue.md) - File d'attente dynamique
- [Circuit Breaker](./circuitbreaker.md) - Coupure d'accès à un site ou une sous section de celui ci

View File

@ -0,0 +1,61 @@
# Les layers `authn-*`
Les layers `authn-*` permettent d'activer différents modes d'authentification au sein d'un proxy Bouncer.
Les informations liées à l'utilisateur authentifié sont ensuite injectables dans les entêtes HTTP de la requête permettant ainsi une authentification unique("SSO") basée sur les entêtes HTTP ("Trusted headers SSO").
## Layers
- [`authn-oidc`](./oidc.md) - Authentification OpenID Connect
## Schéma des options
En plus de leurs options spécifiques tous les layers `authn-*` partagent un certain nombre d'options communes.
Voir le [schéma](../../../../../internal/proxy/director/layer/authn/layer-options.json).
## Règles d'injection d'entêtes
L'option `headers.rules` permet de définir une liste de règles utilisant un DSL définissant de manière dynamique quels entêtes seront injectés dans la requête transitant par le layer.
La liste des instructions est exécutée séquentiellement.
Bouncer utilise le projet [`expr`](https://expr-lang.org/) comme DSL. En plus des fonctionnalités natives du langage, Bouncer ajoute un certain nombre de fonctions spécifiques à son contexte.
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 `-`.
### Fonctions
#### `set_header(name string, value string)`
Définir la valeur d'une entête HTTP via son nom `name` et sa valeur `value`.
#### `del_headers(pattern string)`
Supprimer un ou plusieurs entêtes HTTP dont le nom correspond au patron `pattern`.
Le patron est défini par une chaîne comprenant un ou plusieurs caractères `*`, signifiant un ou plusieurs caractères arbitraires.
### Environnement
Les règles ont accès aux variables suivantes pendant leur exécution.
#### `user`
L'utilisateur identifié par le layer.
```json
{
// Identifiant de l'utilisateur, tel que récupéré par le layer
"subject": "<string>",
// Table associative des attributs associés à l'utilisateur
// La liste de ces attributs dépend du layer d'authentification
"attrs": {
"key": "<value>"
}
}
```

View File

@ -0,0 +1,72 @@
# Layer `authn-oidc`
## Description
Ce layer permet d'ajouter une authentification OpenID Connect au service distant.
Voir le tutoriel ["Ajouter une authentification OpenID Connect"](../../../tutorials/add-oidc-authn-layer.md) pour plus d'informations quant à son utilisation.
## Type
`authn-oidc`
## Schéma des options
Les options disponibles pour le layer sont décrites via un [schéma JSON](https://json-schema.org/specification). Elles sont documentées dans le [schéma visible ici](../../../../../internal/proxy/director/layer/authn/oidc/layer-options.json).
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
L'objet `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:
- 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.
**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`).
## Métriques
Les [métriques Prometheus](../../metrics.md) suivantes sont exposées par ce layer.
### `bouncer_layer_authn_oidc_login_requests_total{layer=<layerName>,proxy=<proxyName>}`
- **Type:** `counter`
- **Description**: Nombre total de demandes d'authentification
- **Exemple**
```
# HELP bouncer_layer_authn_oidc_login_requests_total Bouncer's authn-oidc layer total login requests
# TYPE bouncer_layer_authn_oidc_login_requests_total counter
bouncer_layer_authn_oidc_login_requests_total{layer="my-layer",proxy="my-proxy"} 1
```
### `bouncer_layer_authn_oidc_login_successes_total{layer=<layerName>,proxy=<proxyName>}`
- **Type:** `counter`
- **Description**: Nombre total d'authentifications réussies
- **Exemple**
```
# HELP bouncer_layer_authn_oidc_login_successes_total Bouncer's authn-oidc layer total login successes
# TYPE bouncer_layer_authn_oidc_login_successes_total counter
bouncer_layer_authn_oidc_login_successes_total{layer="my-layer",proxy="my-proxy"} 1
```
### `bouncer_layer_authn_oidc_logout_total{layer=<layerName>,proxy=<proxyName>}`
- **Type:** `counter`
- **Description**: Nombre total de déconnexions
- **Exemple**
```
# HELP bouncer_layer_authn_oidc_logout_total Bouncer's authn-oidc layer total logouts
# TYPE bouncer_layer_authn_oidc_logout_total counter
bouncer_layer_authn_oidc_logout_total{layer="my-layer",proxy="my-proxy"} 1
```

View File

@ -0,0 +1,41 @@
# Layer "Circuit Breaker"
## Description
Ce layer permet de bloquer l'accès à un site (ou une section de celui ci) ciblé par un proxy.
## Type
`circuitbreaker`
## Options
### `authorizedCIDRs`
- **Type:** `[]string`
- **Valeur par défaut:** `[]`
- **Description:** Autoriser les adresses distantes contenues dans un des masques réseau (en notation ["CIDR"](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing#CIDR_notation) définis à contourner la restriction d'accès.
### `matchURLs`
- **Type:** `[]string`
- **Valeur par défaut:** `["*"]`
- **Description:** Limiter l'action du layer à cette liste de patrons d'URLs.
Par exemple, si vous souhaitez limiter votre restriction d'accès à l'ensemble d'une section "`/blog`" d'un site, vous pouvez déclarer la valeur `["*/blog*"]`. Les autres URLs du site ne seront pas affectées par la restriction.
### `templateBlock`
- **Type:** `string`
- **Valeur par défaut:** `"default"`
- **Description:** Bloc du template HTML pour effectuer le rendu de la page indiquant la restriction d'accès.
Voir le [fichier de configuration de référence](../../../../misc/packaging/common/config.yml), section `layers.circuitbreaker` pour voir les options permettant de personnaliser le chemin du répertoire contenant les templates.
## Schéma
Voir le [schéma JSON](../../../../internal/proxy/director/layer/circuitbreaker/layer-options.json).
## Métriques
_Aucune [métrique Prometheus](../metrics.md) n'est exportée par ce layer._

View File

@ -22,6 +22,42 @@ Ce layer permet d'ajouter un mécanisme de file d'attente dynamique au proxy ass
- **Valeur par défaut:** `1m`
- **Description:** Durée de vie d'une session dans la file d'attente sans activité avant expiration.
### Schéma
### `matchURLs`
Voir le [schéma JSON](../../../../internal/queue/schema/layer-options.json).
- **Type:** `[]string`
- **Valeur par défaut:** `["*"]`
- **Description:** Limiter l'action de la file d'attente à cette liste de patrons d'URLs.
Par exemple, si vous souhaitez limiter votre file à l'ensemble d'une section "`/blog`" d'un site, vous pouvez déclarer la valeur `["*/blog*"]`. Les autres URLs du site ne seront pas affectées par cette file d'attente.
## Schéma
Voir le [schéma JSON](../../../../internal/proxy/director/layer/queue/schema/layer-options.json).
## Métriques
Les [métriques Prometheus](../metrics.md) suivantes sont exposées par ce layer.
### `bouncer_layer_queue_capacity{layer=<layerName>,proxy=<proxyName>}`
- **Type:** `gauge`
- **Description**: Capacité maximale de la queue
- **Exemple**
```
# HELP bouncer_layer_queue_capacity Bouncer's queue layer capacity
# TYPE bouncer_layer_queue_capacity gauge
bouncer_layer_queue_capacity{layer="queue",proxy="cadoles"} 2
```
### `bouncer_layer_queue_sessions{layer=<layerName>,proxy=<proxyName>}`
- **Type:** `gauge`
- **Description**: Nombre courant de sessions ouvertes
- **Exemple**
```
# HELP bouncer_layer_queue_sessions Bouncer's queue layer current sessions
# TYPE bouncer_layer_queue_sessions gauge
bouncer_layer_queue_sessions{layer="queue",proxy="cadoles"} 3
```

View File

@ -0,0 +1,29 @@
# Métriques
Bouncer expose un certain nombre de métriques Prometheus sur le serveur proxy ainsi que sur le serveur d'administration. Ces métriques sont par défaut accessibles sur `/.bouncer/metrics`.
Il est possible de configurer le point d'entrée de ces métriques ainsi que d'ajouter une authentification de type `Basic Auth` [via la configuration](../../../misc/packaging/common/config.yml) (voir les clés `admin.metrics` et `proxy.metrics`).
Outre les métriques par défaut fournies par la librairie [Prometheus](https://prometheus.io/docs/guides/go-application/#instrumenting-a-go-application-for-prometheus), les serveurs Bouncer exposent également des métriques propres.
Chaque layer associé à un proxy peut également ses propres métriques spécifiques. [Voir la page de documentation](./layers/README.md) de chaque layer pour plus d'informations.
## Métriques spécifiques
### Serveur proxy
#### `bouncer_proxy_director_proxy_requests_total{proxy=<proxyName>}`
- **Type:** `counter`
- **Description**: Nombre total de requêtes ayant transité par le proxy
- **Exemple**
```
# HELP bouncer_proxy_director_proxy_requests_total Bouncer proxy total requests
# TYPE bouncer_proxy_director_proxy_requests_total counter
bouncer_proxy_director_proxy_requests_total{proxy="cadoles"} 64
```
### Serveur d'administration
_Pas de métrique supplémentaire._

View File

@ -0,0 +1,81 @@
# Ajouter une authentification OpenID Connect
Dans ce tutoriel nous verrons comment ajouter un layer de type `oidc-authn` à un proxy pour ajouter une authentification OpenID Connect à notre service distant.
## Prérequis
### Création d'une application OAuth2
Pour réaliser ce tutoriel nous allons utiliser la forge Cadoles comme fournisseur d'identité. Vous devrez donc créer une application OAuth2 avec votre compte Cadoles sur https://forge.cadoles.com/user/settings/applications et collecter les informations suivantes:
- Identifiant du client ;
- Secret du client.
Concernant l'URL de redirection, si vous ne modifiez pas l'option `oidc.loginCallbackPath` vous devrez renseigner une URL répondant au modèle suivant:
```
<base_url>/.bouncer/authn/oidc/<proxy_name>/<layer_name>/callback
```
- `<base_url>` est l'URL de base d'accès à votre instance Bouncer, par exemple `http://localhost:8080` si vous avez travaillez avec une instance Bouncer locale avec la configuration par défaut ;
- `<proxy_name>` est le nom du proxy créé dans Bouncer, dans ce tutoriel `my-proxy` ;
- `<layer_name>` est le nom du layer créé dans Bouncer, dans ce tutoriel `my-layer`.
### Démarrer le serveur `dummy` pour l'introspection des entêtes reçus
Bouncer intègre un serveur de test qui permet l'introspection des entêtes HTTP reçus dans la requête. Nous allons utiliser celui ci comme service distant afin de visualiser les entêtes générés par notre layer d'authentification.
Pour le lancer:
```shell
# Avec le binaire
bouncer server dummy run
# Avec Docker
docker run -it --rm -p 8082:8082 --read-only reg.cadoles.com/cadoles/bouncer:latest bouncer server dummy run
```
Par défaut ce serveur écoute sur le port 8082. Il est possible de modifier l'adresse d'écoute via le drapeau `--address`.
## Étapes
1. Avec le client d'administration de Bouncer en ligne de commande, créer un nouveau proxy
```shell
bouncer admin proxy create --proxy-name my-proxy --proxy-to http://localhost:8082
```
Où http://localhost:8082 est l'adresse de notre serveur `dummy` de test, lancé dans les prérequis.
2. Activer le proxy `my-proxy`
```shell
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
4. Créer un layer de type `authn-oidc` pour notre nouveau proxy
```shell
bouncer admin layer create --proxy-name my-proxy --layer-name my-layer --layer-type authn-oidc
```
5. Configurer le nouveau layer `my-layer` avec les options collectée dans les prérequis et l'activer
```shell
bouncer admin layer update --proxy-name my-proxy --layer-name my-layer --layer-options '{ "oidc":{"clientId": "<clientId>", "clientSecret":"<clientSecret>", "issuerURL": "https://forge.cadoles.com/" }}' --layer-enabled
```
Où:
- `<clientId>` est l'identifiant du client OIDC récupéré dans les prérequis ;
- `<clientSecret>` est le secret du client OIDC récupéré dans les prérequis.
6. À ce stade en ouvrant l'URL de votre instance Bouncer vous devriez être redirigé vers la forge Cadoles vous demandant de vous authentifier. Une fois authentifié vous devriez arriver sur la page du serveur `dummy` avec les nouveaux entêtes liés à votre authentification (entêtes `Remote-User-*`).
## Ressources
- [Référence du layer `authn-oidc`](../../fr/references/layers/authn/oidc.md)

View File

@ -2,7 +2,7 @@
## Étapes
1. Sur le serveur hébergeant les services Bouncer, utiliser le CLI pour créer un nouveau calque ("layer") pour votre proxy. Dans l'exemple, nous utiliserons le proxy `cadoles` créé dans le cadre du tutoriels ["Premiers pas"](../getting-started.md).
1. Sur le serveur hébergeant les services Bouncer, utiliser le CLI pour créer un nouveau layer pour votre proxy. Dans l'exemple, nous utiliserons le proxy `cadoles` créé dans le cadre du tutoriels ["Premiers pas"](../getting-started.md).
```bash
# Création d'un calque nommé 'my-queue' pour le proxy 'cadoles' de type 'queue'
@ -19,7 +19,7 @@
+----------+-------+---------+--------+---------+-------------------------+-------------------------+
```
2. À ce stade, le calque est encore inactif. Définir la capacité de la file d'attente à 1 et activer le calque en utilisant le CLI
2. À ce stade, le layer est encore inactif. Définir la capacité de la file d'attente à 1 et activer le layer en utilisant le CLI:
```bash
bouncer admin layer update --proxy-name cadoles --layer-name my-queue --layer-enabled=true --layer-options '{"capacity": 1}'

View File

@ -0,0 +1,47 @@
# Amorçage d'un serveur Bouncer via la configuration
Il est possible d'amorcer des données par défaut (i.e. des "proxies" et "layers" associés) via la configuration du serveur d'administration.
> **Attention** Ce mécanisme de modifiera pas des proxies déjà existants dans la base de données du serveur Bouncer. Autrement dit, si un proxy est déjà pré-existant lors du démarrage du serveur Bouncer, il ne sera pas modifié.
La définition des proxies et layers par défaut s'effectue dans la section `bootstrap` du fichier de configuration. Deux possibilités pour définir les proxys à charger par défaut:
- Utiliser un répertoire contenant des fichiers YAML (un par proxy) en définissant le chemin du répertoire via l'attribut `bootstrap.dir`;
- Définir directement la liste des proxies via l'attribut `bootstrap.proxies`.
```yaml
# Configuration d'une série de proxy/layers
# à créer par défaut par le serveur d'administration
bootstrap:
# Répertoire contenant les définitions de proxy à créer
# par défaut. Les fichiers seront récupérés si ils
# correspondent au patron de nommage suivant:
#
# <bootstrap_dir>/<proxy_name>.yml
#
# Voir ci-dessous pour les attributs possibles dans les fichiers.
#
# Si l'attribut est vide ou absent le chargement des fichiers
# est désactivé.
dir: /etc/bouncer/bootstrap.d
# Tableau associatif de définition de proxies à créer par
# défaut par le serveur d'administration.
# Si `proxies` et `dir` sont tous les deux définis, les fichiers
# présents dans le répertoire `dir` surchargeront les valeurs définies
# dans `proxies`.
#
# Par défaut vide.
proxies:
# my-proxy:
# enabled: true # Activer/désactiver le proxy
# from: ["*"] # Filtre d'origine d'activation du proxy
# to: "https://example.net" # Destination du proxy
# weight: 0 # Priorité du proxy
# layers: # Layers associés au proxy
# my-layer:
# type: queue # Type du proxy
# enabled: false # Activer/désactiver le layer
# weight: 0 # Priorité du layer
# options: {"capacity": 100} # Options associées au layer
```

View File

@ -1,64 +1,11 @@
# Créer son propre layer
Dans ce tutoriel, nous allons voir comme implémenter un layer personnalisé qui permettra d'ajouter une authentification de type [`Basic Auth](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication) à un proxy.
Dans ce tutoriel, nous verrons comment implémenter un layer personnalisé qui permettra d'ajouter une authentification de type [`Basic Auth](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication) à un proxy.
## Prérequis
Les éléments suivants doivent être installés sur votre machine:
- [Golang > 1.20](https://go.dev/)
- [Docker](https://www.docker.com/)
- [Git](https://git-scm.com/)
- [GNU Make](https://www.gnu.org/software/make/)
Avoir un environnement de développement local fonctionnel. Voir tutoriel ["Démarrer avec les sources"](./getting-started-with-sources.md).
## Étapes
### Préparer son environnement de développement
1. Cloner le dépôt des sources du projet Bouncer
```
git clone https://forge.cadoles.com/Cadoles/bouncer
```
2. Se positionner dans le répertoire du projet
```
cd bouncer
```
3. Lancer le projet en mode "développement"
```
make watch
```
Si toutes les dépendances sont correctement installées et configurées sur votre machine, la console devrait afficher une série de messages pour ensuite s'arrêter sur quelque chose ressemblant à:
```
14:47:06: daemon: make run BOUNCER_CMD="--config config.yml server admin run"
2023-06-23 20:47:06.095 [INFO] <./internal/command/server/admin/run.go:42> RunCommand.func1 listening {"url": "http://127.0.0.1:8081"}
2023-06-23 20:47:06.095 [INFO] <./internal/admin/server.go:126> (*Server).run http server listening
14:47:06: daemon: make run-redis
bouncer-redis
docker run --rm -t \
--name bouncer-redis \
-v /home/wpetit/workspace/bouncer/data/redis:/data \
-p 6379:6379 \
redis:alpine3.17 \
redis-server --save 60 1 --loglevel warning
1:C 23 Jun 2023 20:47:06.754 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 23 Jun 2023 20:47:06.754 # Redis version=7.0.11, bits=64, commit=00000000, modified=0, pid=1, just started
1:C 23 Jun 2023 20:47:06.754 # Configuration loaded
1:M 23 Jun 2023 20:47:06.759 # Warning: Could not create server TCP listening socket ::*:6379: unable to bind socket, errno: 97
1:M 23 Jun 2023 20:47:06.760 # Server initialized
1:M 23 Jun 2023 20:47:06.760 # WARNING Memory overcommit must be enabled! Without it, a background save or replication may fail under low memory condition. Being disabled, it can can also cause failures without low memory condition, see https://github.com/jemalloc/jemalloc/issues/1328. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect
```
À ce stade, le serveur `bouncer-admin` écoute sur http://127.0.0.1:8081 et le serveur `bouncer-proxy` sur http://127.0.0.1:8080.
L'outil [`modd`](https://github.com/cortesi/modd) est utilisé pour surveiller les modifications sur les sources et relancer automatiquement la compilation et les services en cas de changement.
### Préparer la structure de base du nouveau layer
Une implémetation d'un layer se compose majoritairement de 3 éléments:

View File

@ -0,0 +1,132 @@
# Démarrer avec les sources
Dans ce tutoriel, nous verrons comment lancer un environnement de développement en local sur notre machine afin de travailler sur les sources de Bouncer.
## Prérequis
Les éléments suivants doivent être installés sur votre machine:
- [Golang > 1.20](https://go.dev/)
- [Docker](https://www.docker.com/)
- [Git](https://git-scm.com/)
- [GNU Make](https://www.gnu.org/software/make/)
Les ports suivants doivent être disponibles sur votre machine:
- `8080`
- `8081`
## Étapes
1. Cloner le dépôt des sources du projet Bouncer
```
git clone https://forge.cadoles.com/Cadoles/bouncer
```
2. Se positionner dans le répertoire du projet
```
cd bouncer
```
3. Lancer le projet en mode "développement"
```
make watch
```
Si toutes les dépendances sont correctement installées et configurées sur votre machine, la console devrait afficher une série de messages pour ensuite s'arrêter sur quelque chose ressemblant à:
```
14:47:06: daemon: make run BOUNCER_CMD="--config config.yml server admin run"
2023-06-23 20:47:06.095 [INFO] <./internal/command/server/admin/run.go:42> RunCommand.func1 listening {"url": "http://127.0.0.1:8081"}
2023-06-23 20:47:06.095 [INFO] <./internal/admin/server.go:126> (*Server).run http server listening
14:47:06: daemon: make run-redis
bouncer-redis
docker run --rm -t \
--name bouncer-redis \
-v /home/wpetit/workspace/bouncer/data/redis:/data \
-p 6379:6379 \
redis:alpine3.17 \
redis-server --save 60 1 --loglevel warning
1:C 23 Jun 2023 20:47:06.754 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 23 Jun 2023 20:47:06.754 # Redis version=7.0.11, bits=64, commit=00000000, modified=0, pid=1, just started
1:C 23 Jun 2023 20:47:06.754 # Configuration loaded
1:M 23 Jun 2023 20:47:06.759 # Warning: Could not create server TCP listening socket ::*:6379: unable to bind socket, errno: 97
1:M 23 Jun 2023 20:47:06.760 # Server initialized
1:M 23 Jun 2023 20:47:06.760 # WARNING Memory overcommit must be enabled! Without it, a background save or replication may fail under low memory condition. Being disabled, it can can also cause failures without low memory condition, see https://github.com/jemalloc/jemalloc/issues/1328. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect
```
À ce stade, le serveur `bouncer-admin` écoute sur http://127.0.0.1:8081 et le serveur `bouncer-proxy` sur http://127.0.0.1:8080.
> **Note**
>
> L'outil [`modd`](https://github.com/cortesi/modd) est utilisé pour surveiller les modifications sur les sources et relancer automatiquement la compilation et les services en cas de changement.
## Commandes `make` utiles
### `make watch`
Surveiller les sources, compiler celles ci en cas de modifications et lancer les services `bouncer-proxy` et `bouncer-admin`.
### `make test`
Exécuter les tests unitaires/d'intégration du projet.
### `make build`
Compiler une version de développement du binaire `bouncer`.
### `make docker-build`
Construire une image Docker pour Bouncer.
Vous pouvez ensuite lancer l'image localement avec la commande:
```
docker run \
-it --rm \
reg.cadoles.com/cadoles/bouncer:<tag> \
-p 8080:8080 \
bouncer server proxy run
```
### `make grafterm`
Afficher un tableau de bord [`grafterm`](https://github.com/slok/grafterm) branché sur l'instance Prometheus locale.
### `make siege`
Lancer une session de test [`siege`](https://github.com/JoeDog/siege) sur l'instance `bouncer-proxy` locale.
## Arborescence du projet
```bash
.
├── bin # Répertoire de destination des binaires Go de développement
├── cmd # Package principal (main) du binaire Bouncer
├── data # Répertoire des données de développement (Redis)
├── dist # Répertoire de destination des archives/paquets pour la publication
├── doc # Répertoire de documentation du projet
├── internal # Source Go du projet
│   ├── admin
│   ├── auth
│   ├── chi
│   ├── client
│   ├── command
│   ├── config
│   ├── format
│   ├── imports
│   ├── jwk
│   ├── proxy
│   ├── schema
│   ├── setup
│   └── store
├── layers # Fichiers annexes liés aux layers (templates HTML)
│   └── queue
├── misc # Fichiers annexes
│   ├── jenkins # Fichiers liés au pipeline d'intégration continue Jenkins
│   ├── logo # Logo du projet
│   └── packaging # Fichiers liés à l'empaquetage des binaires
└── tools # Outils utilisés en développement
```

View File

@ -0,0 +1,61 @@
# Intégration avec Kubernetes
Dans le cadre du déploiement de Bouncer dans un environnement Kubernetes, il est possible d'activer un mode d'intégration permettant à Bouncer d'exposer des jetons d'authentification directement sous forme de [`Secret`](https://kubernetes.io/fr/docs/concepts/configuration/secret/).
L'activation et configuration de l'intégration Kubernetes s'effectue dans le fichier de configuration du serveur d'administration via la section `integrations.kubernetes`:
```yaml
# Section de configuration des intégrations
# avec des produits externes
integrations:
# Intégration avec Kubernetes
kubernetes:
# Activer/désactiver l'intégration Kubernetes
enabled: true
# Créer/mettre à jour un Secret automatiquement avec un jeton d'authentification
# avec le rôle "writer".
# Désactivé si l'attribut est vide ou absent.
writerTokenSecret: my-bouncer-admin-writer-token
# Namespace de destination du Secret pour le jeton d'authentification
# avec le rôle "reader".
# Utilise par défaut le namespace courant si absent ou vide.
writerTokenSecretNamespace: "my-namespace"
# Créer/mettre à jour un Secret automatiquement avec un jeton d'authentification
# avec le rôle "reader".
# Désactivé si l'attribut est vide ou absent.
readerTokenSecret: my-bouncer-admin-reader-token
# Namespace de destination du Secret pour le jeton d'authentification
# avec le rôle "reader".
# Utilise par défaut le namespace courant si absent ou vide.
readerTokenSecretNamespace: "my-namespace"
# Délai maximum alloué au verrou distribué pour la mise à jour
# des secrets.
lockTimeout: 30s
```
Vous devrez également définir un `ServiceAccount` pour votre `Pod` avec un `Role` équivalent au suivant (dans le cas nominal où le `Pod` créait les `Secrets` dans son même namespace):
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: bouncer-admin
rules:
- apiGroups:
- ""
- v1
resources:
- secrets
verbs:
- create
- get
- update
```
> **Note**
>
> La génération des jetons d'authentification s'effectue à chaque démarrage du serveur d'administration. Un verrou partagé permet d'éviter que plusieurs instances fonctionnant en parallèle essayent de mettre à jour les ressources Kubernetes au même moment.
>
> De plus, les jetons seront laissés en l'état si la clé de génération n'a pas été modifiée pour éviter de changer les jetons à chaque redémarrage d'un `Pod` (voir l'annotation `bouncer.cadoles.com/public-key` sur les `Secrets` créés.).
Un exemple fonctionnel de déploiement Kubernetes est disponible dans le répertoire `misc/k8s` du projet.

82
go.mod
View File

@ -1,17 +1,32 @@
module forge.cadoles.com/cadoles/bouncer
go 1.20
go 1.21
toolchain go1.22.0
require (
forge.cadoles.com/Cadoles/go-proxy v0.0.0-20230512083245-e2dc3e1a0333
forge.cadoles.com/Cadoles/go-proxy v0.0.0-20230701194111-c6b3d482cca6
github.com/Masterminds/sprig/v3 v3.2.3
github.com/bsm/redislock v0.9.4
github.com/btcsuite/btcd/btcutil v1.1.3
github.com/coreos/go-oidc/v3 v3.10.0
github.com/dchest/uniuri v1.2.0
github.com/drone/envsubst v1.0.3
github.com/expr-lang/expr v1.16.7
github.com/getsentry/sentry-go v0.22.0
github.com/go-chi/chi/v5 v5.0.8
github.com/gorilla/sessions v1.2.2
github.com/jedib0t/go-pretty/v6 v6.4.6
github.com/mitchellh/mapstructure v1.4.1
github.com/oklog/ulid/v2 v2.1.0
github.com/ory/dockertest/v3 v3.10.0
github.com/prometheus/client_golang v1.16.0
github.com/qri-io/jsonschema v0.2.1
github.com/redis/go-redis/v9 v9.0.4
golang.org/x/oauth2 v0.13.0
k8s.io/api v0.29.3
k8s.io/apimachinery v0.29.3
k8s.io/client-go v0.29.3
)
require (
@ -21,60 +36,91 @@ require (
github.com/Masterminds/semver/v3 v3.2.0 // indirect
github.com/Microsoft/go-winio v0.6.0 // indirect
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.1.3 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/containerd/continuity v0.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/docker/cli v20.10.17+incompatible // indirect
github.com/docker/docker v20.10.13+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.1 // indirect
github.com/go-logr/logr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/huandu/xstrings v1.3.3 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mitchellh/copystructure v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.0 // indirect
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/opencontainers/runc v1.1.5 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.42.0 // indirect
github.com/prometheus/procfs v0.10.1 // indirect
github.com/qri-io/jsonpointer v0.1.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/shopspring/decimal v1.2.0 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect
github.com/sirupsen/logrus v1.9.0 // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
golang.org/x/net v0.19.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
google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/klog/v2 v2.110.1 // indirect
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
require (
cdr.dev/slog v1.4.2 // indirect
github.com/alecthomas/chroma v0.10.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/dlclark/regexp2 v1.9.0 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/go-chi/cors v1.2.1
github.com/go-playground/locales v0.12.1 // indirect
github.com/go-playground/universal-translator v0.16.0 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/uuid v1.3.0
github.com/leodido/go-urn v1.1.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.4 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.0.9
github.com/lestrrat-go/jwx/v2 v2.0.19
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lib/pq v1.10.0 // indirect
github.com/lithammer/shortuuid/v4 v4.0.0
@ -86,11 +132,11 @@ require (
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
gitlab.com/wpetit/goweb v0.0.0-20230419082146-a94d9ed7202b
go.opencensus.io v0.24.0 // indirect
golang.org/x/crypto v0.8.0 // indirect
golang.org/x/mod v0.9.0 // indirect
golang.org/x/sys v0.7.0 // indirect
golang.org/x/term v0.7.0 // indirect
golang.org/x/tools v0.7.0 // indirect
golang.org/x/crypto v0.19.0 // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/sys v0.17.0 // indirect
golang.org/x/term v0.17.0 // indirect
golang.org/x/tools v0.16.1 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
gopkg.in/go-playground/validator.v9 v9.29.1 // indirect
gopkg.in/yaml.v3 v3.0.1

197
go.sum
View File

@ -49,8 +49,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
forge.cadoles.com/Cadoles/go-proxy v0.0.0-20230512083245-e2dc3e1a0333 h1:dAajr9wX8WuFPrwjbKNXRmbF+4AaAT7bUj66G7gdZ+c=
forge.cadoles.com/Cadoles/go-proxy v0.0.0-20230512083245-e2dc3e1a0333/go.mod h1:o8ZK5v/3J1dRmklFVn1l6WHAyQ3LgegyHjRIT8KLAFw=
forge.cadoles.com/Cadoles/go-proxy v0.0.0-20230701194111-c6b3d482cca6 h1:FTk0ZoaV5N8Tkps5Da5RrDMZZXSHZIuD67Hy1Y4fsos=
forge.cadoles.com/Cadoles/go-proxy v0.0.0-20230701194111-c6b3d482cca6/go.mod h1:o8ZK5v/3J1dRmklFVn1l6WHAyQ3LgegyHjRIT8KLAFw=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@ -80,8 +80,14 @@ github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUS
github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y=
github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bsm/redislock v0.9.4 h1:X/Wse1DPpiQgHbVYRE9zv6m070UcKoOGekgvpNhiSvw=
github.com/bsm/redislock v0.9.4/go.mod h1:Epf7AJLiSFwLCiZcfi6pWFO/8eAYrYpQXFxEDPoDeAk=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.0/go.mod h1:0QJIIN1wwIXF/3G/m87gIwGniDMDQqjVn4SZgnFpsYY=
@ -126,6 +132,8 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg=
github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM=
github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU=
github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
@ -140,10 +148,12 @@ github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g=
github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY=
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
@ -160,6 +170,10 @@ github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKoh
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw=
github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g=
github.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@ -169,6 +183,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.m
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/expr-lang/expr v1.16.7 h1:gCIiHt5ODA0xIaDbD0DPKyZpM9Drph3b3lolYAYq2Kw=
github.com/expr-lang/expr v1.16.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
@ -176,20 +192,39 @@ github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBD
github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/getsentry/sentry-go v0.22.0 h1:XNX9zKbv7baSEI65l+H1GEJgSeIC1c7EN5kluWaP6dM=
github.com/getsentry/sentry-go v0.22.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0=
github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc=
github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U=
github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@ -228,12 +263,15 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@ -247,8 +285,12 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@ -266,6 +308,7 @@ github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
@ -282,7 +325,11 @@ github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAk
github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@ -299,7 +346,11 @@ github.com/jedib0t/go-pretty/v6 v6.4.6 h1:v6aG9h6Uby3IusSSEjHaZNXpHFhzqMmjXcPq1R
github.com/jedib0t/go-pretty/v6 v6.4.6/go.mod h1:Ndk3ase2CkQbXLLNf5QDHoYb6J9WtVfmHZu9n8rk2xs=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@ -307,31 +358,33 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8=
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
github.com/lestrrat-go/blackmagic v1.0.1 h1:lS5Zts+5HIC/8og6cGHb0uCcNCa3OUt1ygh3Qz2Fe80=
github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8=
github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.0.9 h1:TRX4Q630UXxPVLvP5vGaqVJO7S+0PE6msRZUsFSBoC8=
github.com/lestrrat-go/jwx/v2 v2.0.9/go.mod h1:K68euYaR95FnL0hIQB8VvzL70vB7pSifbJUydCTPmgM=
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/jwx/v2 v2.0.19 h1:ekv1qEZE6BVct89QA+pRF6+4pCpfVrOnEJnTnT4RXoY=
github.com/lestrrat-go/jwx/v2 v2.0.19/go.mod h1:l3im3coce1lL2cDeAjqmaR+Awx+X8Ih+2k8BuHNJ4CU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.10.0 h1:Zx5DJFEYQXio93kgXnQ09fXNiUKsqv4OUEu2UtGcB1E=
github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lithammer/shortuuid/v4 v4.0.0 h1:QRbbVkfgNippHOS8PXDkti4NaWeyYfcBTHtw7k08o4c=
github.com/lithammer/shortuuid/v4 v4.0.0/go.mod h1:Zs8puNcrvf2rV9rTH51ZLLcj7ZXqQI3lv67aw4KiB1Y=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@ -347,6 +400,8 @@ github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp9
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
@ -357,17 +412,31 @@ github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx
github.com/moby/sys/mountinfo v0.5.0/go.mod h1:3bMD3Rg+zkqx8MRYPi7Pyb0Ie97QEBmdxbhnCLlSvSU=
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 h1:dcztxKSvZ4Id8iPpHERQBbIJfabdt4wUm5qy3wOL2Zc=
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.0 h1:2mOpI4JVVPBN+WQRa0WKH2eXR+Ey+uK4n7Zj0aYpIQA=
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4=
github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o=
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg=
github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
@ -379,6 +448,9 @@ github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuh
github.com/ory/dockertest/v3 v3.10.0 h1:4K3z2VMe8Woe++invjaTB7VRyQXQy5UY+loujO4aNE4=
github.com/ory/dockertest/v3 v3.10.0/go.mod h1:nr57ZbRWMqfsdGdFNLHz5jjNdDb7VVFnzAeW1n5N1Lg=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -386,7 +458,15 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8=
github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg=
github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
github.com/qri-io/jsonpointer v0.1.1 h1:prVZBZLL6TW5vsSB9fFHFAMBLI4b0ri5vribQlTJiBA=
github.com/qri-io/jsonpointer v0.1.1/go.mod h1:DnJPaYgiKu56EuDp8TU5wFLdZIcAnb/uH9v37ZaMV64=
github.com/qri-io/jsonschema v0.2.1 h1:NNFoKms+kut6ABPf6xiKNM5214jzxAhDBrPHCJ97Wg0=
@ -397,24 +477,28 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -428,8 +512,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
@ -475,9 +559,8 @@ golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -514,9 +597,8 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -559,9 +641,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -578,6 +659,8 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -590,8 +673,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/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/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -657,21 +740,19 @@ golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -681,13 +762,15 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@ -743,9 +826,8 @@ golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -789,6 +871,8 @@ google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@ -889,8 +973,9 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@ -901,11 +986,14 @@ gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXa
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v9 v9.29.1 h1:SvGtYmN60a5CVKTOzMSyfzWDeZRxRuGvRQyEAKbw1xc=
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
@ -914,6 +1002,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
gotest.tools/v3 v3.3.0 h1:MfDY1b1/0xN1CyMlQDac0ziEy9zJQd9CXBRRDHw2jJo=
gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@ -921,6 +1010,24 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw=
k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80=
k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU=
k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU=
k8s.io/client-go v0.29.3 h1:R/zaZbEAxqComZ9FHeQwOh3Y1ZUs7FaHKZdQtIc2WZg=
k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0=
k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780=
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA=
k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI=
k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=

122
internal/admin/bootstrap.go Normal file
View File

@ -0,0 +1,122 @@
package admin
import (
"context"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/lock/redis"
"forge.cadoles.com/cadoles/bouncer/internal/schema"
"forge.cadoles.com/cadoles/bouncer/internal/setup"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
func (s *Server) bootstrapProxies(ctx context.Context) error {
if err := s.validateBootstrap(ctx); err != nil {
return errors.Wrap(err, "could not validate bootstrapped proxies")
}
proxyRepo := s.proxyRepository
layerRepo := s.layerRepository
lockTimeout := time.Duration(s.bootstrapConfig.LockTimeout)
locker := redis.NewLocker(s.redisClient, int(s.bootstrapConfig.MaxConnectionRetries))
err := locker.WithLock(ctx, "bouncer-admin-bootstrap", lockTimeout, func(ctx context.Context) error {
logger.Info(ctx, "bootstrapping proxies")
for proxyName, proxyConfig := range s.bootstrapConfig.Proxies {
loopCtx := logger.With(ctx, logger.F("proxyName", proxyName), logger.F("proxyFrom", proxyConfig.From), logger.F("proxyTo", proxyConfig.To))
_, err := s.proxyRepository.GetProxy(ctx, proxyName)
if !errors.Is(err, store.ErrNotFound) {
if err != nil {
return errors.WithStack(err)
}
if proxyConfig.Recreate {
logger.Info(loopCtx, "force recreating proxy")
if err := s.deleteProxyAndLayers(ctx, proxyName); err != nil {
return errors.WithStack(err)
}
} else {
logger.Info(loopCtx, "ignoring existing proxy")
continue
}
}
logger.Info(loopCtx, "creating proxy")
if _, err := proxyRepo.CreateProxy(ctx, proxyName, string(proxyConfig.To), proxyConfig.From...); err != nil {
return errors.WithStack(err)
}
_, err = proxyRepo.UpdateProxy(
ctx, proxyName,
store.WithProxyUpdateEnabled(bool(proxyConfig.Enabled)),
store.WithProxyUpdateWeight(int(proxyConfig.Weight)),
)
if err != nil {
return errors.WithStack(err)
}
for layerName, layerConfig := range proxyConfig.Layers {
layerType := store.LayerType(layerConfig.Type)
layerOptions := store.LayerOptions(layerConfig.Options)
if _, err := layerRepo.CreateLayer(ctx, proxyName, layerName, layerType, layerOptions); err != nil {
return errors.WithStack(err)
}
_, err := layerRepo.UpdateLayer(
ctx,
proxyName, layerName,
store.WithLayerUpdateEnabled(bool(layerConfig.Enabled)),
store.WithLayerUpdateOptions(layerOptions),
store.WithLayerUpdateWeight(int(layerConfig.Weight)),
)
if err != nil {
return errors.WithStack(err)
}
}
}
return nil
})
if err != nil {
return errors.WithStack(err)
}
return nil
}
const validateErrMessage = "could not validate proxy '%s': could not validate layer '%s'"
func (s *Server) validateBootstrap(ctx context.Context) error {
for proxyName, proxyConf := range s.bootstrapConfig.Proxies {
for layerName, layerConf := range proxyConf.Layers {
layerType := store.LayerType(layerConf.Type)
if !setup.LayerTypeExists(layerType) {
return errors.Errorf(validateErrMessage+": could not find layer type '%s'", proxyName, layerName, layerType)
}
layerOptionsSchema, err := setup.GetLayerOptionsSchema(layerType)
if err != nil {
return errors.Wrapf(err, validateErrMessage, proxyName, layerName)
}
rawOptions := func(opts config.InterpolatedMap) map[string]any {
return opts
}(layerConf.Options)
if err := schema.Validate(ctx, layerOptionsSchema, rawOptions); err != nil {
return errors.Wrapf(err, validateErrMessage, proxyName, layerName)
}
}
}
return nil
}

View File

@ -1,11 +1,14 @@
package admin
import (
"context"
"fmt"
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/schema"
"github.com/getsentry/sentry-go"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
const ErrCodeAlreadyExist api.ErrorCode = "already-exist"
@ -29,3 +32,8 @@ func invalidDataErrorResponse(w http.ResponseWriter, r *http.Request, err *schem
return
}
func logAndCaptureError(ctx context.Context, message string, err error) {
sentry.CaptureException(err)
logger.Error(ctx, message, logger.E(err))
}

View File

@ -3,11 +3,18 @@ package admin
import (
"context"
"forge.cadoles.com/cadoles/bouncer/internal/integration"
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
"forge.cadoles.com/cadoles/bouncer/internal/setup"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
func (s *Server) initRepositories(ctx context.Context) error {
if err := s.initRedisClient(ctx); err != nil {
return errors.WithStack(err)
}
if err := s.initLayerRepository(ctx); err != nil {
return errors.WithStack(err)
}
@ -19,8 +26,16 @@ func (s *Server) initRepositories(ctx context.Context) error {
return nil
}
func (s *Server) initRedisClient(ctx context.Context) error {
client := setup.NewRedisClient(ctx, s.redisConfig)
s.redisClient = client
return nil
}
func (s *Server) initLayerRepository(ctx context.Context) error {
layerRepository, err := setup.NewLayerRepository(ctx, s.redisConfig)
layerRepository, err := setup.NewLayerRepository(ctx, s.redisClient)
if err != nil {
return errors.WithStack(err)
}
@ -31,7 +46,7 @@ func (s *Server) initLayerRepository(ctx context.Context) error {
}
func (s *Server) initProxyRepository(ctx context.Context) error {
proxyRepository, err := setup.NewProxyRepository(ctx, s.redisConfig)
proxyRepository, err := setup.NewProxyRepository(ctx, s.redisClient)
if err != nil {
return errors.WithStack(err)
}
@ -40,3 +55,34 @@ func (s *Server) initProxyRepository(ctx context.Context) error {
return nil
}
func (s *Server) initPrivateKey(ctx context.Context) error {
localKey, err := jwk.LoadOrGenerate(string(s.serverConfig.Auth.PrivateKey), jwk.DefaultKeySize)
if err != nil {
return errors.WithStack(err)
}
ctx = integration.WithPrivateKey(ctx, localKey)
key, err := integration.RunOnKeyLoad(ctx, s.integrations)
if err != nil {
return errors.WithStack(err)
}
if key != nil {
s.privateKey = key
} else {
s.privateKey = localKey
}
logger.Info(ctx, "using private key", logger.F("keyID", s.privateKey.KeyID()))
publicKeys, err := jwk.PublicKeySet(s.privateKey)
if err != nil {
return errors.WithStack(err)
}
s.publicKeys = publicKeys
return nil
}

View File

@ -1,6 +1,7 @@
package admin
import (
"fmt"
"net/http"
"sort"
@ -10,7 +11,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type QueryLayerResponse struct {
@ -38,7 +38,7 @@ func (s *Server) queryLayer(w http.ResponseWriter, r *http.Request) {
options...,
)
if err != nil {
logger.Error(ctx, "could not list layers", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not list layers", errors.WithStack(err))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
@ -51,15 +51,6 @@ func (s *Server) queryLayer(w http.ResponseWriter, r *http.Request) {
})
}
func validateLayerName(v string) (store.LayerName, error) {
name, err := store.ValidateName(v)
if err != nil {
return "", errors.WithStack(err)
}
return store.LayerName(name), nil
}
type GetLayerResponse struct {
Layer *store.Layer `json:"layer"`
}
@ -85,7 +76,7 @@ func (s *Server) getLayer(w http.ResponseWriter, r *http.Request) {
return
}
logger.Error(ctx, "could not get layer", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not get layer", errors.WithStack(err))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
@ -120,7 +111,7 @@ func (s *Server) deleteLayer(w http.ResponseWriter, r *http.Request) {
return
}
logger.Error(ctx, "could not delete layer", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not delete layer", errors.WithStack(err))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
@ -156,7 +147,7 @@ func (s *Server) createLayer(w http.ResponseWriter, r *http.Request) {
layerName, err := store.ValidateName(createLayerReq.Name)
if err != nil {
logger.Error(r.Context(), "invalid 'name' parameter", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "invalid 'name' parameter", errors.WithStack(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, nil)
return
@ -165,7 +156,7 @@ func (s *Server) createLayer(w http.ResponseWriter, r *http.Request) {
layerType := store.LayerType(createLayerReq.Type)
if !setup.LayerTypeExists(layerType) {
logger.Error(r.Context(), "unknown layer type", logger.E(errors.WithStack(err)), logger.F("layerType", layerType))
logAndCaptureError(ctx, fmt.Sprintf("unknown layer type '%s'", layerType), errors.WithStack(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, nil)
return
@ -179,7 +170,7 @@ func (s *Server) createLayer(w http.ResponseWriter, r *http.Request) {
return
}
logger.Error(ctx, "could not create layer", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not create layer", errors.WithStack(err))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
@ -223,7 +214,7 @@ func (s *Server) updateLayer(w http.ResponseWriter, r *http.Request) {
return
}
logger.Error(ctx, "could not get layer", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not get layer", errors.WithStack(err))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
@ -247,7 +238,7 @@ func (s *Server) updateLayer(w http.ResponseWriter, r *http.Request) {
if updateLayerReq.Options != nil {
layerOptionsSchema, err := setup.GetLayerOptionsSchema(layer.Type)
if err != nil {
logger.Error(r.Context(), "could not retrieve layer options schema", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not retrieve layer options schema", errors.WithStack(err))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
@ -258,7 +249,7 @@ func (s *Server) updateLayer(w http.ResponseWriter, r *http.Request) {
}(updateLayerReq.Options)
if err := schema.Validate(ctx, layerOptionsSchema, rawOptions); err != nil {
logger.Error(r.Context(), "could not validate layer options", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not validate layer options", errors.WithStack(err))
var invalidDataErr *schema.InvalidDataError
if errors.As(err, &invalidDataErr) {
@ -286,7 +277,7 @@ func (s *Server) updateLayer(w http.ResponseWriter, r *http.Request) {
return
}
logger.Error(ctx, "could not update layer", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not update layer", errors.WithStack(err))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
@ -300,21 +291,7 @@ func getLayerName(w http.ResponseWriter, r *http.Request) (store.LayerName, bool
name, err := store.ValidateName(rawLayerName)
if err != nil {
logger.Error(r.Context(), "could not parse layer name", logger.E(errors.WithStack(err)))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return "", false
}
return store.LayerName(name), true
}
func geLayerName(w http.ResponseWriter, r *http.Request) (store.LayerName, bool) {
rawLayerName := chi.URLParam(r, "layerName")
name, err := store.ValidateName(rawLayerName)
if err != nil {
logger.Error(r.Context(), "could not parse layer name", logger.E(errors.WithStack(err)))
logAndCaptureError(r.Context(), "could not parse layer name", errors.WithStack(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return "", false

View File

@ -2,11 +2,14 @@ package admin
import (
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/integration"
)
type Option struct {
ServerConfig config.AdminServerConfig
RedisConfig config.RedisConfig
BootstrapConfig config.BootstrapConfig
ServerConfig config.AdminServerConfig
RedisConfig config.RedisConfig
Integrations []integration.Integration
}
type OptionFunc func(*Option)
@ -15,6 +18,7 @@ func defaultOption() *Option {
return &Option{
ServerConfig: config.NewDefaultAdminServerConfig(),
RedisConfig: config.NewDefaultRedisConfig(),
Integrations: make([]integration.Integration, 0),
}
}
@ -29,3 +33,15 @@ func WithRedisConfig(conf config.RedisConfig) OptionFunc {
opt.RedisConfig = conf
}
}
func WithBootstrapConfig(conf config.BootstrapConfig) OptionFunc {
return func(opt *Option) {
opt.BootstrapConfig = conf
}
}
func WithIntegrations(integrations ...integration.Integration) OptionFunc {
return func(opt *Option) {
opt.Integrations = integrations
}
}

View File

@ -11,7 +11,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/api"
"gitlab.com/wpetit/goweb/logger"
)
type QueryProxyResponse struct {
@ -37,7 +36,7 @@ func (s *Server) queryProxy(w http.ResponseWriter, r *http.Request) {
options...,
)
if err != nil {
logger.Error(ctx, "could not list proxies", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not list proxies", errors.WithStack(err))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
@ -79,7 +78,7 @@ func (s *Server) getProxy(w http.ResponseWriter, r *http.Request) {
return
}
logger.Error(ctx, "could not get proxy", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not get proxy", errors.WithStack(err))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
@ -102,14 +101,14 @@ func (s *Server) deleteProxy(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := s.proxyRepository.DeleteProxy(ctx, proxyName); err != nil {
if err := s.deleteProxyAndLayers(ctx, proxyName); err != nil {
if errors.Is(err, store.ErrNotFound) {
api.ErrorResponse(w, http.StatusNotFound, api.ErrCodeNotFound, nil)
return
}
logger.Error(ctx, "could not delete proxy", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not delete proxy", errors.WithStack(err))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
@ -140,14 +139,14 @@ func (s *Server) createProxy(w http.ResponseWriter, r *http.Request) {
name, err := store.ValidateName(createProxyReq.Name)
if err != nil {
logger.Error(r.Context(), "could not parse 'name' parameter", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not parse 'name' parameter", errors.WithStack(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return
}
if _, err := url.Parse(createProxyReq.To); err != nil {
logger.Error(r.Context(), "could not parse 'to' parameter", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not parse 'to' parameter", errors.WithStack(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return
@ -161,7 +160,7 @@ func (s *Server) createProxy(w http.ResponseWriter, r *http.Request) {
return
}
logger.Error(ctx, "could not create proxy", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not create proxy", errors.WithStack(err))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
@ -207,7 +206,7 @@ func (s *Server) updateProxy(w http.ResponseWriter, r *http.Request) {
if updateProxyReq.To != nil {
_, err := url.Parse(*updateProxyReq.To)
if err != nil {
logger.Error(r.Context(), "could not parse 'to' parameter", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not parse 'to' parameter", errors.WithStack(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return
@ -235,7 +234,7 @@ func (s *Server) updateProxy(w http.ResponseWriter, r *http.Request) {
return
}
logger.Error(ctx, "could not update proxy", logger.E(errors.WithStack(err)))
logAndCaptureError(ctx, "could not update proxy", errors.WithStack(err))
api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil)
return
@ -249,7 +248,7 @@ func getProxyName(w http.ResponseWriter, r *http.Request) (store.ProxyName, bool
name, err := store.ValidateName(rawProxyName)
if err != nil {
logger.Error(r.Context(), "could not parse proxy name", logger.E(errors.WithStack(err)))
logAndCaptureError(r.Context(), "could not parse proxy name", errors.WithStack(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return "", false
@ -263,7 +262,7 @@ func getIntQueryParam(w http.ResponseWriter, r *http.Request, param string, defa
if rawValue != "" {
value, err := strconv.ParseInt(rawValue, 10, 64)
if err != nil {
logger.Error(r.Context(), "could not parse int param", logger.F("param", param), logger.E(errors.WithStack(err)))
logAndCaptureError(r.Context(), "could not parse int param", errors.WithStack(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return 0, false
@ -296,7 +295,7 @@ func getStringableSliceValues[T ~string](w http.ResponseWriter, r *http.Request,
for _, rv := range rawValues {
v, err := validate(rv)
if err != nil {
logger.Error(r.Context(), "could not parse ids slice param", logger.F("param", param), logger.E(errors.WithStack(err)))
logAndCaptureError(r.Context(), "could not parse ids slice param", errors.WithStack(err))
api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil)
return nil, false

View File

@ -9,21 +9,35 @@ import (
"forge.cadoles.com/cadoles/bouncer/internal/auth"
"forge.cadoles.com/cadoles/bouncer/internal/auth/jwt"
bouncerChi "forge.cadoles.com/cadoles/bouncer/internal/chi"
"forge.cadoles.com/cadoles/bouncer/internal/config"
"forge.cadoles.com/cadoles/bouncer/internal/integration"
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
"forge.cadoles.com/cadoles/bouncer/internal/store"
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/redis/go-redis/v9"
"gitlab.com/wpetit/goweb/logger"
)
type Server struct {
serverConfig config.AdminServerConfig
redisConfig config.RedisConfig
serverConfig config.AdminServerConfig
redisConfig config.RedisConfig
redisClient redis.UniversalClient
integrations []integration.Integration
bootstrapConfig config.BootstrapConfig
proxyRepository store.ProxyRepository
layerRepository store.LayerRepository
privateKey jwk.Key
publicKeys jwk.Set
}
func (s *Server) Start(ctx context.Context) (<-chan net.Addr, <-chan error) {
@ -50,6 +64,27 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
return
}
if err := s.bootstrapProxies(ctx); err != nil {
errs <- errors.WithStack(err)
return
}
if err := s.initPrivateKey(ctx); err != nil {
errs <- errors.WithStack(err)
return
}
ctx = integration.WithPrivateKey(ctx, s.privateKey)
ctx = integration.WithPublicKeySet(ctx, s.publicKeys)
if err := integration.RunOnStartup(ctx, s.integrations); err != nil {
errs <- errors.WithStack(err)
return
}
listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", s.serverConfig.HTTP.Host, s.serverConfig.HTTP.Port))
if err != nil {
errs <- errors.WithStack(err)
@ -73,23 +108,23 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
}
}()
key, err := jwk.LoadOrGenerate(string(s.serverConfig.Auth.PrivateKey), jwk.DefaultKeySize)
if err != nil {
errs <- errors.WithStack(err)
return
}
keys, err := jwk.PublicKeySet(key)
if err != nil {
errs <- errors.WithStack(err)
return
}
router := chi.NewRouter()
router.Use(middleware.Logger)
if s.serverConfig.HTTP.UseRealIP {
router.Use(middleware.RealIP)
}
router.Use(middleware.RequestLogger(bouncerChi.NewLogFormatter()))
if s.serverConfig.Sentry.DSN != "" {
logger.Info(ctx, "enabling sentry http middleware")
sentryMiddleware := sentryhttp.New(sentryhttp.Options{
Repanic: true,
})
router.Use(sentryMiddleware.Handle)
}
corsMiddleware := cors.New(cors.Options{
AllowedOrigins: s.serverConfig.CORS.AllowedOrigins,
@ -101,10 +136,29 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e
router.Use(corsMiddleware.Handler)
if s.serverConfig.Metrics.Enabled {
metrics := s.serverConfig.Metrics
logger.Info(ctx, "enabling metrics", logger.F("endpoint", metrics.Endpoint))
router.Group(func(r chi.Router) {
if metrics.BasicAuth != nil {
logger.Info(ctx, "enabling authentication on metrics endpoint")
r.Use(middleware.BasicAuth(
"metrics",
metrics.BasicAuth.CredentialsMap(),
))
}
r.Handle(string(metrics.Endpoint), promhttp.Handler())
})
}
router.Route("/api/v1", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(auth.Middleware(
jwt.NewAuthenticator(keys, string(s.serverConfig.Auth.Issuer), jwt.DefaultAcceptableSkew),
jwt.NewAuthenticator(s.publicKeys, string(s.serverConfig.Auth.Issuer), jwt.DefaultAcceptableSkew),
))
r.Route("/proxies", func(r chi.Router) {
@ -139,7 +193,9 @@ func NewServer(funcs ...OptionFunc) *Server {
}
return &Server{
serverConfig: opt.ServerConfig,
redisConfig: opt.RedisConfig,
serverConfig: opt.ServerConfig,
redisConfig: opt.RedisConfig,
bootstrapConfig: opt.BootstrapConfig,
integrations: opt.Integrations,
}
}

29
internal/admin/util.go Normal file
View File

@ -0,0 +1,29 @@
package admin
import (
"context"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
func (s *Server) deleteProxyAndLayers(ctx context.Context, proxyName store.ProxyName) error {
if err := s.proxyRepository.DeleteProxy(ctx, proxyName); err != nil {
if !errors.Is(err, store.ErrNotFound) {
return errors.WithStack(err)
}
}
layers, err := s.layerRepository.QueryLayers(ctx, proxyName)
if err != nil {
return errors.WithStack(err)
}
for _, layer := range layers {
if err := s.layerRepository.DeleteLayer(ctx, proxyName, layer.Name); err != nil {
return errors.WithStack(err)
}
}
return nil
}

View File

@ -16,6 +16,7 @@ const keyRole = "role"
func parseToken(ctx context.Context, keys jwk.Set, issuer string, rawToken string, acceptableSkew time.Duration) (jwt.Token, error) {
token, err := jwt.Parse(
[]byte(rawToken),
jwt.WithContext(ctx),
jwt.WithKeySet(keys, jws.WithRequireKid(false)),
jwt.WithIssuer(issuer),
jwt.WithValidate(true),
@ -60,3 +61,17 @@ func GenerateToken(ctx context.Context, key jwk.Key, issuer, subject string, rol
return string(rawToken), nil
}
func GenerateTokenWithPrivateKey(ctx context.Context, privateKeyFile string, issuer string, subject string, role Role) (string, jwk.Key, error) {
key, err := jwk.LoadOrGenerate(privateKeyFile, jwk.DefaultKeySize)
if err != nil {
return "", nil, errors.WithStack(err)
}
token, err := GenerateToken(ctx, key, issuer, subject, role)
if err != nil {
return "", nil, errors.WithStack(err)
}
return token, key, nil
}

View File

@ -16,6 +16,7 @@ type LogFormatter struct{}
func (*LogFormatter) NewLogEntry(r *http.Request) middleware.LogEntry {
return &LogEntry{
method: r.Method,
host: r.Host,
path: r.URL.Path,
ctx: r.Context(),
}
@ -29,6 +30,7 @@ var _ middleware.LogFormatter = &LogFormatter{}
type LogEntry struct {
method string
host string
path string
ctx context.Context
}
@ -41,6 +43,7 @@ func (e *LogEntry) Panic(v interface{}, stack []byte) {
// 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.F("host", e.host),
logger.F("status", status),
logger.F("bytes", bytes),
logger.F("elapsed", elapsed),

View File

@ -5,7 +5,6 @@ import (
"forge.cadoles.com/cadoles/bouncer/internal/auth/jwt"
"forge.cadoles.com/cadoles/bouncer/internal/command/common"
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
"github.com/lithammer/shortuuid/v4"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
@ -30,20 +29,15 @@ func CreateTokenCommand() *cli.Command {
Action: func(ctx *cli.Context) error {
conf, err := common.LoadConfig(ctx)
if err != nil {
return errors.Wrap(err, "Could not load configuration")
return errors.Wrap(err, "could not load configuration")
}
subject := ctx.String("subject")
role := ctx.String("role")
key, err := jwk.LoadOrGenerate(string(conf.Admin.Auth.PrivateKey), jwk.DefaultKeySize)
token, _, err := jwt.GenerateTokenWithPrivateKey(ctx.Context, string(conf.Admin.Auth.PrivateKey), string(conf.Admin.Auth.Issuer), subject, jwt.Role(role))
if err != nil {
return errors.WithStack(err)
}
token, err := jwt.GenerateToken(ctx.Context, key, string(conf.Admin.Auth.Issuer), subject, jwt.Role(role))
if err != nil {
return errors.WithStack(err)
return errors.Wrap(err, "could not generate token")
}
fmt.Println(token)

View File

@ -18,6 +18,8 @@ func Dump() *cli.Command {
Usage: "Dump the current configuration",
Flags: flags,
Action: func(ctx *cli.Context) error {
logger.SetLevel(logger.LevelError)
conf, err := common.LoadConfig(ctx)
if err != nil {
return errors.Wrap(err, "Could not load configuration")

View File

@ -7,6 +7,7 @@ import (
"sort"
"time"
"github.com/getsentry/sentry-go"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
)
@ -50,9 +51,10 @@ func Main(buildDate, projectVersion, gitRef, defaultConfigPath string, commands
},
Flags: []cli.Flag{
&cli.StringFlag{
Name: "workdir",
Value: "",
Usage: "The working directory",
Name: "workdir",
Value: "",
EnvVars: []string{"BOUNCER_WORKDIR"},
Usage: "The working directory",
},
&cli.StringFlag{
Name: "projectVersion",
@ -89,6 +91,8 @@ func Main(buildDate, projectVersion, gitRef, defaultConfigPath string, commands
return
}
sentry.CaptureException(err)
debug := ctx.Bool("debug")
if !debug {

View File

@ -6,13 +6,20 @@ import (
"forge.cadoles.com/cadoles/bouncer/internal/admin"
"forge.cadoles.com/cadoles/bouncer/internal/command/common"
"forge.cadoles.com/cadoles/bouncer/internal/setup"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/logger"
)
const (
flagPrintDefaultToken = "print-default-token"
)
func RunCommand() *cli.Command {
flags := common.Flags()
flags := append(
common.Flags(),
)
return &cli.Command{
Name: "run",
@ -27,9 +34,24 @@ 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")
}
defer flushSentry()
integrations, err := setup.SetupIntegrations(ctx.Context, conf)
if err != nil {
return errors.Wrap(err, "could not setup integrations")
}
srv := admin.NewServer(
admin.WithServerConfig(conf.Admin),
admin.WithRedisConfig(conf.Redis),
admin.WithBootstrapConfig(conf.Bootstrap),
admin.WithIntegrations(integrations...),
)
addrs, srvErrs := srv.Start(ctx.Context)

View File

@ -0,0 +1,65 @@
<html>
<body>
<h1>Received request</h1>
<h2>Incoming headers</h2>
<table style="width: 100%">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{{ range $key, $val := .Request.Header }}
<tr>
<td>
<b>{{ $key }}</b>
</td>
<td>
<code>{{ $val }}</code>
</td>
</tr>
{{
end
}}
</tbody>
</table>
<h2>Incoming cookies</h2>
<table style="width: 100%">
<thead>
<tr>
<th>Name</th>
<th>Domain</th>
<th>Path</th>
<th>Secure</th>
<th>MaxAge</th>
<th>HttpOnly</th>
<th>SameSite</th>
<th>Expires</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{{ range $cookie := .Request.Cookies }}
<tr>
<td>
<b>{{ $cookie.Name }}</b>
</td>
<td>{{ $cookie.Domain }}</td>
<td>{{ $cookie.Path }}</td>
<td>{{ $cookie.Secure }}</td>
<td>{{ $cookie.MaxAge }}</td>
<td>{{ $cookie.HttpOnly }}</td>
<td>{{ $cookie.SameSite }}</td>
<td>{{ $cookie.Expires }}</td>
<td>
<code>{{ $cookie.Value }}</code>
</td>
</tr>
{{
end
}}
</tbody>
</table>
</body>
</html>

View File

@ -0,0 +1,15 @@
package dummy
import (
"github.com/urfave/cli/v2"
)
func Root() *cli.Command {
return &cli.Command{
Name: "dummy",
Usage: "Dummy server related commands",
Subcommands: []*cli.Command{
RunCommand(),
},
}
}

View File

@ -0,0 +1,69 @@
package dummy
import (
"html/template"
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/command/common"
"github.com/pkg/errors"
"github.com/urfave/cli/v2"
"gitlab.com/wpetit/goweb/logger"
_ "embed"
)
var (
//go:embed index.gohtml
indexTmpl string
)
func RunCommand() *cli.Command {
flags := common.Flags()
return &cli.Command{
Name: "run",
Usage: "Run the dummy server",
Description: "The dummy server is a very basic web application allowing the debug of incoming requests",
Flags: append(flags, &cli.StringFlag{
Name: "address",
Usage: "the dummy server listening address",
Value: ":8082",
}),
Action: func(ctx *cli.Context) error {
address := ctx.String("address")
conf, err := common.LoadConfig(ctx)
if err != nil {
return errors.Wrap(err, "could not load configuration")
}
logger.SetFormat(logger.Format(conf.Logger.Format))
logger.SetLevel(logger.Level(conf.Logger.Level))
tmpl, err := template.New("").Parse(indexTmpl)
if err != nil {
return errors.WithStack(err)
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data := struct {
Request *http.Request
}{
Request: r,
}
if err := tmpl.Execute(w, data); err != nil {
logger.Error(ctx.Context, "could not execute template", logger.E(errors.WithStack(err)))
}
})
logger.Info(ctx.Context, "listening", logger.F("address", address))
if err := http.ListenAndServe(address, handler); err != nil {
return errors.WithStack(err)
}
return nil
},
}
}

View File

@ -28,6 +28,14 @@ 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")
}
defer flushSentry()
layers, err := setup.GetLayers(ctx.Context, conf)
if err != nil {
return errors.Wrap(err, "could not initialize director layers")

View File

@ -2,6 +2,7 @@ package server
import (
"forge.cadoles.com/cadoles/bouncer/internal/command/server/admin"
"forge.cadoles.com/cadoles/bouncer/internal/command/server/dummy"
"forge.cadoles.com/cadoles/bouncer/internal/command/server/proxy"
"github.com/urfave/cli/v2"
)
@ -13,6 +14,7 @@ func Root() *cli.Command {
Subcommands: []*cli.Command{
proxy.Root(),
admin.Root(),
dummy.Root(),
},
}
}

View File

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

View File

@ -0,0 +1,107 @@
package config
import (
"os"
"path/filepath"
"strings"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)
type BootstrapConfig struct {
Proxies map[store.ProxyName]BootstrapProxyConfig `yaml:"proxies"`
Dir InterpolatedString `yaml:"dir"`
LockTimeout InterpolatedDuration `yaml:"lockTimeout"`
MaxConnectionRetries InterpolatedInt `yaml:"maxRetries"`
}
func (c *BootstrapConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
src := struct {
Proxies map[store.ProxyName]BootstrapProxyConfig `yaml:"proxies"`
Dir InterpolatedString `yaml:"dir"`
}{
Proxies: make(map[store.ProxyName]BootstrapProxyConfig),
Dir: "",
}
if err := unmarshal(&src); err != nil {
return errors.WithStack(err)
}
c.Proxies = src.Proxies
c.Dir = src.Dir
if src.Dir != "" {
proxies, err := loadBootstrapDir(string(src.Dir))
if err != nil {
return errors.Wrapf(err, "could not load bootstrap dir '%s'", src.Dir)
}
c.Proxies = overrideProxies(c.Proxies, proxies)
}
return nil
}
type BootstrapProxyConfig struct {
Enabled InterpolatedBool `yaml:"enabled"`
Weight InterpolatedInt `yaml:"weight"`
To InterpolatedString `yaml:"to"`
From InterpolatedStringSlice `yaml:"from"`
Layers map[store.LayerName]BootstrapLayerConfig `yaml:"layers"`
Recreate InterpolatedBool `yaml:"recreate"`
}
type BootstrapLayerConfig struct {
Enabled InterpolatedBool `yaml:"enabled"`
Type InterpolatedString `yaml:"type"`
Weight InterpolatedInt `yaml:"weight"`
Options InterpolatedMap `yaml:"options"`
}
func NewDefaultBootstrapConfig() BootstrapConfig {
return BootstrapConfig{
Dir: "",
LockTimeout: *NewInterpolatedDuration(30 * time.Second),
MaxConnectionRetries: 10,
}
}
func loadBootstrapDir(dir string) (map[store.ProxyName]BootstrapProxyConfig, error) {
pattern := filepath.Join(dir, "*.yml")
files, err := filepath.Glob(pattern)
if err != nil {
return nil, errors.WithStack(err)
}
proxies := make(map[store.ProxyName]BootstrapProxyConfig)
for _, f := range files {
data, err := os.ReadFile(f)
if err != nil {
return nil, errors.Wrapf(err, "could not read file '%s'", f)
}
proxy := BootstrapProxyConfig{}
if err := yaml.Unmarshal(data, &proxy); err != nil {
return nil, errors.Wrapf(err, "could not unmarshal proxy")
}
name := store.ProxyName(strings.TrimSuffix(filepath.Base(f), filepath.Ext(f)))
proxies[name] = proxy
}
return proxies, nil
}
func overrideProxies(base map[store.ProxyName]BootstrapProxyConfig, proxies map[store.ProxyName]BootstrapProxyConfig) map[store.ProxyName]BootstrapProxyConfig {
for name, proxy := range proxies {
base[name] = proxy
}
return base
}

View File

@ -2,7 +2,7 @@ package config
import (
"io"
"io/ioutil"
"os"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
@ -10,18 +10,20 @@ import (
// Config definition
type Config struct {
Admin AdminServerConfig `yaml:"admin"`
Proxy ProxyServerConfig `yaml:"proxy"`
Redis RedisConfig `yaml:"redis"`
Logger LoggerConfig `yaml:"logger"`
Layers LayersConfig `yaml:"layers"`
Admin AdminServerConfig `yaml:"admin"`
Proxy ProxyServerConfig `yaml:"proxy"`
Redis RedisConfig `yaml:"redis"`
Logger LoggerConfig `yaml:"logger"`
Layers LayersConfig `yaml:"layers"`
Bootstrap BootstrapConfig `yaml:"bootstrap"`
Integrations IntegrationsConfig `yaml:"integrations"`
}
// NewFromFile retrieves the configuration from the given file
func NewFromFile(path string) (*Config, error) {
config := NewDefault()
data, err := ioutil.ReadFile(path)
data, err := os.ReadFile(path)
if err != nil {
return nil, errors.Wrapf(err, "could not read file '%s'", path)
}
@ -43,11 +45,13 @@ func NewDumpDefault() *Config {
// NewDefault return new default configuration
func NewDefault() *Config {
return &Config{
Admin: NewDefaultAdminServerConfig(),
Proxy: NewDefaultProxyServerConfig(),
Logger: NewDefaultLoggerConfig(),
Redis: NewDefaultRedisConfig(),
Layers: NewDefaultLayersConfig(),
Admin: NewDefaultAdminServerConfig(),
Proxy: NewDefaultProxyServerConfig(),
Logger: NewDefaultLoggerConfig(),
Redis: NewDefaultRedisConfig(),
Layers: NewDefaultLayersConfig(),
Bootstrap: NewDefaultBootstrapConfig(),
Integrations: NewDefaultIntegrationsConfig(),
}
}

View File

@ -6,11 +6,13 @@ import (
"strconv"
"time"
"github.com/drone/envsubst"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)
var reVar = regexp.MustCompile(`^\${(\w+)}$`)
// var reVar = regexp.MustCompile(`^\${(\w+)}$`)
var reVar = regexp.MustCompile(`\${(.*?)}`)
type InterpolatedString string
@ -53,6 +55,29 @@ func (ii *InterpolatedInt) UnmarshalYAML(value *yaml.Node) error {
return nil
}
type InterpolatedFloat float64
func (ifl *InterpolatedFloat) UnmarshalYAML(value *yaml.Node) error {
var str string
if err := value.Decode(&str); err != nil {
return errors.Wrapf(err, "could not decode value '%v' (line '%d') into string", value.Value, value.Line)
}
if match := reVar.FindStringSubmatch(str); len(match) > 0 {
str = os.Getenv(match[1])
}
floatVal, err := strconv.ParseFloat(str, 10)
if err != nil {
return errors.Wrapf(err, "could not parse float '%v', line '%d'", str, value.Line)
}
*ifl = InterpolatedFloat(floatVal)
return nil
}
type InterpolatedBool bool
func (ib *InterpolatedBool) UnmarshalYAML(value *yaml.Node) error {
@ -107,14 +132,22 @@ type InterpolatedStringSlice []string
func (iss *InterpolatedStringSlice) UnmarshalYAML(value *yaml.Node) error {
var data []string
var evErr error
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 index, value := range data {
if match := reVar.FindStringSubmatch(value); len(match) > 0 {
value = os.Getenv(match[1])
//match := reVar.FindStringSubmatch(value)
re := regexp.MustCompile(`\${(.*?)}`)
res := re.FindAllStringSubmatch(value, 10)
if len(res) > 0 {
value, evErr = envsubst.EvalEnv(value)
if evErr != nil {
return evErr
}
}
data[index] = value

View File

@ -1,13 +1,15 @@
package config
type HTTPConfig struct {
Host InterpolatedString `yaml:"host"`
Port InterpolatedInt `yaml:"port"`
Host InterpolatedString `yaml:"host"`
Port InterpolatedInt `yaml:"port"`
UseRealIP InterpolatedBool `yaml:"useRealIP"`
}
func NewHTTPConfig(host string, port int) HTTPConfig {
return HTTPConfig{
Host: InterpolatedString(host),
Port: InterpolatedInt(port),
Host: InterpolatedString(host),
Port: InterpolatedInt(port),
UseRealIP: true,
}
}

View File

@ -0,0 +1,33 @@
package config
import "time"
type IntegrationsConfig struct {
Kubernetes KubernetesConfig `yaml:"kubernetes"`
}
func NewDefaultIntegrationsConfig() IntegrationsConfig {
return IntegrationsConfig{
Kubernetes: KubernetesConfig{
Enabled: false,
WriterTokenSecret: "",
WriterTokenSecretNamespace: "",
ReaderTokenSecretNamespace: "",
PrivateKeySecret: "",
PrivateKeySecretNamespace: "",
ReaderTokenSecret: "",
LockTimeout: *NewInterpolatedDuration(30 * time.Second),
},
}
}
type KubernetesConfig struct {
Enabled InterpolatedBool `yaml:"enabled"`
WriterTokenSecret InterpolatedString `yaml:"writerTokenSecret"`
WriterTokenSecretNamespace InterpolatedString `yaml:"writerTokenSecretNamespace"`
ReaderTokenSecret InterpolatedString `yaml:"readerTokenSecret"`
ReaderTokenSecretNamespace InterpolatedString `yaml:"readerTokenSecretNamespace"`
PrivateKeySecret InterpolatedString `yaml:"privateKeySecret"`
PrivateKeySecretNamespace InterpolatedString `yaml:"privateKeySecretNamespace"`
LockTimeout InterpolatedDuration `yaml:"lockTimeout"`
}

View File

@ -3,7 +3,8 @@ package config
import "time"
type LayersConfig struct {
Queue QueueLayerConfig `yaml:"queue"`
Queue QueueLayerConfig `yaml:"queue"`
CircuitBreaker CircuitBreakerLayerConfig `yaml:"circuitbreaker"`
}
func NewDefaultLayersConfig() LayersConfig {
@ -12,6 +13,9 @@ func NewDefaultLayersConfig() LayersConfig {
TemplateDir: "./layers/queue/templates",
DefaultKeepAlive: NewInterpolatedDuration(time.Minute),
},
CircuitBreaker: CircuitBreakerLayerConfig{
TemplateDir: "./layers/circuitbreaker/templates",
},
}
}
@ -19,3 +23,7 @@ type QueueLayerConfig struct {
TemplateDir InterpolatedString `yaml:"templateDir"`
DefaultKeepAlive *InterpolatedDuration `yaml:"defaultKeepAlive"`
}
type CircuitBreakerLayerConfig struct {
TemplateDir InterpolatedString `yaml:"templateDir"`
}

View File

@ -9,7 +9,7 @@ type LoggerConfig struct {
func NewDefaultLoggerConfig() LoggerConfig {
return LoggerConfig{
Level: InterpolatedInt(logger.LevelInfo),
Level: InterpolatedInt(logger.LevelError),
Format: InterpolatedString(logger.FormatHuman),
}
}

View File

@ -0,0 +1,35 @@
package config
import "fmt"
type MetricsConfig struct {
Enabled InterpolatedBool `yaml:"enabled"`
Endpoint InterpolatedString `yaml:"endpoint"`
BasicAuth *BasicAuthConfig `yaml:"basicAuth"`
}
type BasicAuthConfig struct {
Credentials *InterpolatedMap `yaml:"credentials"`
}
func (c *BasicAuthConfig) CredentialsMap() map[string]string {
if c.Credentials == nil {
return map[string]string{}
}
credentials := make(map[string]string, len(*c.Credentials))
for k, v := range *c.Credentials {
credentials[k] = fmt.Sprintf("%v", v)
}
return credentials
}
func NewDefaultMetricsConfig() MetricsConfig {
return MetricsConfig{
Enabled: true,
Endpoint: "/.bouncer/metrics",
BasicAuth: nil,
}
}

View File

@ -1,11 +1,73 @@
package config
import "time"
type ProxyServerConfig struct {
HTTP HTTPConfig `yaml:"http"`
HTTP HTTPConfig `yaml:"http"`
Metrics MetricsConfig `yaml:"metrics"`
Transport TransportConfig `yaml:"transport"`
Dial DialConfig `yaml:"dial"`
Sentry SentryConfig `yaml:"sentry"`
}
// See https://pkg.go.dev/net/http#Transport
type TransportConfig struct {
ForceAttemptHTTP2 InterpolatedBool `yaml:"forceAttemptHTTP2"`
MaxIdleConns InterpolatedInt `yaml:"maxIdleConns"`
MaxIdleConnsPerHost InterpolatedInt `yaml:"maxIdleConnsPerHost"`
MaxConnsPerHost InterpolatedInt `yaml:"maxConnsPerHost"`
IdleConnTimeout *InterpolatedDuration `yaml:"idleConnTimeout"`
TLSHandshakeTimeout *InterpolatedDuration `yaml:"tlsHandshakeTimeout"`
ExpectContinueTimeout *InterpolatedDuration `yaml:"expectContinueTimeout"`
DisableKeepAlives InterpolatedBool `yaml:"disableKeepAlives"`
DisableCompression InterpolatedBool `yaml:"disableCompression"`
ResponseHeaderTimeout *InterpolatedDuration `yaml:"responseHeaderTimeout"`
WriteBufferSize InterpolatedInt `yaml:"writeBufferSize"`
ReadBufferSize InterpolatedInt `yaml:"readBufferSize"`
MaxResponseHeaderBytes InterpolatedInt `yaml:"maxResponseHeaderBytes"`
}
func NewDefaultProxyServerConfig() ProxyServerConfig {
return ProxyServerConfig{
HTTP: NewHTTPConfig("0.0.0.0", 8080),
HTTP: NewHTTPConfig("0.0.0.0", 8080),
Metrics: NewDefaultMetricsConfig(),
Transport: NewDefaultTransportConfig(),
Dial: NewDefaultDialConfig(),
Sentry: NewDefaultSentryConfig(),
}
}
// See https://pkg.go.dev/net#Dialer
type DialConfig struct {
Timeout *InterpolatedDuration `yaml:"timeout"`
KeepAlive *InterpolatedDuration `yaml:"keepAlive"`
FallbackDelay *InterpolatedDuration `yaml:"fallbackDelay"`
DualStack InterpolatedBool `yaml:"dualStack"`
}
func NewDefaultDialConfig() DialConfig {
return DialConfig{
Timeout: NewInterpolatedDuration(30 * time.Second),
KeepAlive: NewInterpolatedDuration(30 * time.Second),
FallbackDelay: NewInterpolatedDuration(300 * time.Millisecond),
DualStack: true,
}
}
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,
}
}

View File

@ -1,5 +1,7 @@
package config
import "time"
const (
RedisModeSimple = "simple"
RedisModeSentinel = "sentinel"
@ -7,13 +9,21 @@ const (
)
type RedisConfig struct {
Adresses InterpolatedStringSlice `yaml:"addresses"`
Master InterpolatedString `yaml:"master"`
Adresses InterpolatedStringSlice `yaml:"addresses"`
Master InterpolatedString `yaml:"master"`
ReadTimeout InterpolatedDuration `yaml:"readTimeout"`
WriteTimeout InterpolatedDuration `yaml:"writeTimeout"`
DialTimeout InterpolatedDuration `yaml:"dialTimeout"`
LockMaxRetries InterpolatedInt `yaml:"lockMaxRetries"`
}
func NewDefaultRedisConfig() RedisConfig {
return RedisConfig{
Adresses: InterpolatedStringSlice{"localhost:6379"},
Master: "",
Adresses: InterpolatedStringSlice{"localhost:6379"},
Master: "",
ReadTimeout: InterpolatedDuration(30 * time.Second),
WriteTimeout: InterpolatedDuration(30 * time.Second),
DialTimeout: InterpolatedDuration(30 * time.Second),
LockMaxRetries: 10,
}
}

43
internal/config/sentry.go Normal file
View File

@ -0,0 +1,43 @@
package config
import "time"
// Sentry configuration
// See https://pkg.go.dev/github.com/getsentry/sentry-go?utm_source=godoc#ClientOptions
type SentryConfig struct {
DSN InterpolatedString `yaml:"dsn"`
Debug InterpolatedBool `yaml:"debug"`
FlushTimeout *InterpolatedDuration `yaml:"flushTimeout"`
AttachStacktrace InterpolatedBool `yaml:"attachStacktrace"`
SampleRate InterpolatedFloat `yaml:"sampleRate"`
EnableTracing InterpolatedBool `yaml:"enableTracing"`
TracesSampleRate InterpolatedFloat `yaml:"tracesSampleRate"`
ProfilesSampleRate InterpolatedFloat `yaml:"profilesSampleRate"`
IgnoreErrors InterpolatedStringSlice `yaml:"ignoreErrors"`
SendDefaultPII InterpolatedBool `yaml:"sendDefaultPII"`
ServerName InterpolatedString `yaml:"serverName"`
Environment InterpolatedString `yaml:"environment"`
MaxBreadcrumbs InterpolatedInt `yaml:"maxBreadcrumbs"`
MaxSpans InterpolatedInt `yaml:"maxSpans"`
MaxErrorDepth InterpolatedInt `yaml:"maxErrorDepth"`
}
func NewDefaultSentryConfig() SentryConfig {
return SentryConfig{
DSN: "",
Debug: false,
FlushTimeout: NewInterpolatedDuration(2 * time.Second),
AttachStacktrace: true,
SampleRate: 1,
EnableTracing: true,
TracesSampleRate: 0.2,
ProfilesSampleRate: 1,
IgnoreErrors: []string{},
SendDefaultPII: false,
ServerName: "",
Environment: "",
MaxBreadcrumbs: 0,
MaxSpans: 1000,
MaxErrorDepth: 10,
}
}

View File

@ -2,5 +2,5 @@ logger:
level: 0
format: human
http:
host: "0.0.0.0"
port: 3000
host: "${LISTEN_ADDR}"
port: 3000

View File

@ -0,0 +1,49 @@
package integration
import (
"context"
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
"github.com/pkg/errors"
)
var (
ErrNotFound = errors.New("not found")
)
type contextKey string
const (
ctxPublicKeySet contextKey = "public-key-set"
ctxPrivateKey contextKey = "private-key"
)
func CtxPublicKeySet(ctx context.Context) (jwk.Set, error) {
return ctxValue[jwk.Set](ctx, ctxPublicKeySet)
}
func WithPublicKeySet(ctx context.Context, set jwk.Set) context.Context {
return context.WithValue(ctx, ctxPublicKeySet, set)
}
func CtxPrivateKey(ctx context.Context) (jwk.Key, error) {
return ctxValue[jwk.Key](ctx, ctxPrivateKey)
}
func WithPrivateKey(ctx context.Context, key jwk.Key) context.Context {
return context.WithValue(ctx, ctxPrivateKey, key)
}
func ctxValue[T any](ctx context.Context, key contextKey) (T, error) {
raw := ctx.Value(key)
if raw == nil {
return *new(T), errors.WithStack(ErrNotFound)
}
value, ok := raw.(T)
if !ok {
return *new(T), errors.Errorf("unexpected value type '%T'", raw)
}
return value, nil
}

View File

@ -0,0 +1,57 @@
package integration
import (
"context"
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
"github.com/pkg/errors"
)
type Integration interface {
Integration()
}
type OnStartup interface {
Integration
OnStartup(ctx context.Context) error
}
type OnKeyLoad interface {
Integration
OnKeyLoad(ctx context.Context) (jwk.Key, error)
}
func RunOnStartup(ctx context.Context, integrations []Integration) error {
for _, it := range integrations {
onStartup, ok := it.(OnStartup)
if !ok {
continue
}
if err := onStartup.OnStartup(ctx); err != nil {
return errors.WithStack(err)
}
}
return nil
}
func RunOnKeyLoad(ctx context.Context, integrations []Integration) (jwk.Key, error) {
for _, it := range integrations {
onKeyLoad, ok := it.(OnKeyLoad)
if !ok {
continue
}
key, err := onKeyLoad.OnKeyLoad(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
if key != nil {
return key, nil
}
}
return nil, nil
}

View File

@ -0,0 +1,293 @@
package kubernetes
import (
"context"
"encoding/json"
"os"
"forge.cadoles.com/cadoles/bouncer/internal/auth/jwt"
"forge.cadoles.com/cadoles/bouncer/internal/integration"
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
v1 "k8s.io/api/core/v1"
k8serr "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
const (
namespaceFile = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
writerTokenSubject = "bouncer-admin-kubernetes-writer"
readerTokenSubject = "bouncer-admin-kubernetes-reader"
)
type Integration struct {
Options *Options
}
// OnKeyLoad implements integration.OnKeyLoad.
func (i *Integration) OnKeyLoad(ctx context.Context) (jwk.Key, error) {
locker := i.Options.Locker
timeout := i.Options.LockTimeout
var key jwk.Key
err := locker.WithLock(ctx, "bouncer-kubernetes-onkeyload", timeout, func(ctx context.Context) error {
client, err := i.getClient()
if err != nil {
return errors.WithStack(err)
}
if i.Options.PrivateKeySecret != "" {
sharedPrivateKey, err := i.getSharedPrivateKey(ctx, client, i.Options.PrivateKeySecretNamespace, i.Options.PrivateKeySecret)
if err != nil {
return errors.WithStack(err)
}
if sharedPrivateKey != nil {
key = sharedPrivateKey
}
}
return nil
})
if err != nil {
return nil, errors.WithStack(err)
}
return key, nil
}
// Integration implements integration.OnStartup.
func (i *Integration) Integration() {}
// OnStartup implements integration.OnStartup.
func (i *Integration) OnStartup(ctx context.Context) error {
locker := i.Options.Locker
timeout := i.Options.LockTimeout
err := locker.WithLock(ctx, "bouncer-kubernetes-onstartup", timeout, func(ctx context.Context) error {
client, err := i.getClient()
if err != nil {
return errors.WithStack(err)
}
if i.Options.WriterTokenSecret != "" {
if err := i.upsertTokenSecret(ctx, client, i.Options.WriterTokenSecretNamespace, i.Options.WriterTokenSecret, writerTokenSubject, jwt.RoleWriter); err != nil {
return errors.Wrap(err, "could not upsert writer token secret")
}
}
if i.Options.ReaderTokenSecret != "" {
if err := i.upsertTokenSecret(ctx, client, i.Options.ReaderTokenSecretNamespace, i.Options.ReaderTokenSecret, readerTokenSubject, jwt.RoleReader); err != nil {
return errors.Wrap(err, "could not upsert reader token secret")
}
}
return nil
})
if err != nil {
return errors.WithStack(err)
}
return nil
}
func (i *Integration) getClient() (*kubernetes.Clientset, error) {
config, err := rest.InClusterConfig()
if err != nil {
return nil, errors.WithStack(err)
}
client, err := kubernetes.NewForConfig(config)
if err != nil {
return nil, errors.WithStack(err)
}
return client, nil
}
const (
annotationPublicKey = "bouncer.cadoles.com/public-key"
)
func (i *Integration) upsertTokenSecret(ctx context.Context, client *kubernetes.Clientset, namespace string, name string, subject string, role jwt.Role) error {
if namespace == "" {
defaultNamespace, err := i.getCurrentNamespace()
if err != nil {
return errors.WithStack(err)
}
namespace = defaultNamespace
}
ctx = logger.With(ctx,
logger.F("secretNamespace", namespace),
logger.F("secretName", name),
logger.F("tokenRole", role),
logger.F("tokenSubject", subject),
)
logger.Debug(ctx, "generating new token")
alreadyExists := true
secret, err := client.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
if k8serr.IsNotFound(err) {
alreadyExists = false
} else {
return errors.WithStack(err)
}
}
privateKey, err := integration.CtxPrivateKey(ctx)
if err != nil {
return errors.WithStack(err)
}
keySet, err := integration.CtxPublicKeySet(ctx)
if err != nil {
return errors.WithStack(err)
}
publicKeyThumbprint, err := getKeySetThumbprint(keySet)
if err != nil {
return errors.WithStack(err)
}
if !alreadyExists {
token, err := jwt.GenerateToken(ctx, privateKey, i.Options.Issuer, subject, role)
if err != nil {
return errors.WithStack(err)
}
secret := &v1.Secret{
Type: v1.SecretTypeOpaque,
ObjectMeta: metav1.ObjectMeta{
Name: name,
Annotations: map[string]string{
annotationPublicKey: publicKeyThumbprint,
},
},
StringData: map[string]string{
"token": token,
},
}
logger.Info(ctx, "creating token secret")
if _, err := client.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{}); err != nil {
return errors.WithStack(err)
}
} else {
existingPublicKeyHash, exists := secret.Annotations[annotationPublicKey]
if !exists || publicKeyThumbprint != existingPublicKeyHash {
token, err := jwt.GenerateToken(ctx, privateKey, i.Options.Issuer, subject, role)
if err != nil {
return errors.WithStack(err)
}
secret.StringData = map[string]string{
"token": token,
}
if secret.Annotations == nil {
secret.Annotations = make(map[string]string)
}
secret.Annotations[annotationPublicKey] = publicKeyThumbprint
logger.Info(ctx, "updating token secret")
if _, err := client.CoreV1().Secrets(namespace).Update(ctx, secret, metav1.UpdateOptions{}); err != nil {
return errors.WithStack(err)
}
} else {
logger.Info(ctx, "key did not changed, doing nothing")
}
}
return nil
}
func (i *Integration) getSharedPrivateKey(ctx context.Context, client *kubernetes.Clientset, namespace string, name string) (jwk.Key, error) {
if namespace == "" {
defaultNamespace, err := i.getCurrentNamespace()
if err != nil {
return nil, errors.WithStack(err)
}
namespace = defaultNamespace
}
ctx = logger.With(ctx,
logger.F("secretNamespace", namespace),
logger.F("secretName", name),
)
logger.Debug(ctx, "searching shared private key from secret")
secret, err := client.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil && !k8serr.IsNotFound(err) {
return nil, errors.WithStack(err)
}
rawPrivateKey, exists := secret.Data["key"]
if exists && len(rawPrivateKey) != 0 {
key, err := jwk.ParseKey(rawPrivateKey)
if err != nil {
return nil, errors.WithStack(err)
}
return key, nil
}
localKey, err := integration.CtxPrivateKey(ctx)
if err != nil {
return nil, errors.WithStack(err)
}
rawLocalKey, err := json.Marshal(localKey)
if err != nil {
return nil, errors.WithStack(err)
}
secret = &v1.Secret{
Type: v1.SecretTypeOpaque,
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
Data: map[string][]byte{
"key": rawLocalKey,
},
}
if _, err := client.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{}); err != nil {
return nil, errors.WithStack(err)
}
return localKey, nil
}
func (i *Integration) getCurrentNamespace() (string, error) {
namespace, err := os.ReadFile(namespaceFile)
if err != nil {
return "", errors.Wrap(err, "could not retrieve current namespace")
}
return string(namespace), nil
}
func NewIntegration(funcs ...OptionFunc) *Integration {
opts := NewOptions(funcs...)
return &Integration{
Options: opts,
}
}
var (
_ integration.OnStartup = &Integration{}
_ integration.OnKeyLoad = &Integration{}
)

View File

@ -0,0 +1,41 @@
package kubernetes
import (
"bytes"
"crypto"
"crypto/sha256"
"fmt"
"slices"
"forge.cadoles.com/cadoles/bouncer/internal/jwk"
"github.com/pkg/errors"
)
func getKeySetThumbprint(set jwk.Set) (string, error) {
data := make([][]byte, 0, set.Len())
for i := 0; i < set.Len(); i++ {
key, exists := set.Key(i)
if !exists {
continue
}
thumbprint, err := key.Thumbprint(crypto.SHA256)
if err != nil {
return "", errors.WithStack(err)
}
data = append(data, thumbprint)
}
slices.SortFunc(data, bytes.Compare)
hash := sha256.New()
for _, d := range data {
if _, err := hash.Write(d); err != nil {
return "", errors.WithStack(err)
}
}
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}

View File

@ -0,0 +1,95 @@
package kubernetes
import (
"time"
"forge.cadoles.com/cadoles/bouncer/internal/lock"
"forge.cadoles.com/cadoles/bouncer/internal/lock/memory"
)
type Options struct {
WriterTokenSecret string
WriterTokenSecretNamespace string
ReaderTokenSecret string
ReaderTokenSecretNamespace string
PrivateKeySecret string
PrivateKeySecretNamespace string
Issuer string
Locker lock.Locker
LockTimeout time.Duration
}
type OptionFunc func(opts *Options)
func NewOptions(funcs ...OptionFunc) *Options {
opts := &Options{
WriterTokenSecret: "",
WriterTokenSecretNamespace: "",
ReaderTokenSecret: "",
ReaderTokenSecretNamespace: "",
PrivateKeySecret: "",
PrivateKeySecretNamespace: "",
Issuer: "",
Locker: memory.NewLocker(),
LockTimeout: 30 * time.Second,
}
for _, fn := range funcs {
fn(opts)
}
return opts
}
func WithWriterTokenSecret(secretName string) OptionFunc {
return func(opts *Options) {
opts.WriterTokenSecret = secretName
}
}
func WithWriterTokenSecretNamespace(namespace string) OptionFunc {
return func(opts *Options) {
opts.WriterTokenSecretNamespace = namespace
}
}
func WithReaderTokenSecret(secretName string) OptionFunc {
return func(opts *Options) {
opts.ReaderTokenSecret = secretName
}
}
func WithReaderTokenSecretNamespace(namespace string) OptionFunc {
return func(opts *Options) {
opts.ReaderTokenSecretNamespace = namespace
}
}
func WithPrivateKeySecret(secretName string) OptionFunc {
return func(opts *Options) {
opts.PrivateKeySecret = secretName
}
}
func WithPrivateKeySecretNamespace(namespace string) OptionFunc {
return func(opts *Options) {
opts.PrivateKeySecretNamespace = namespace
}
}
func WithIssuer(issuer string) OptionFunc {
return func(opts *Options) {
opts.Issuer = issuer
}
}
func WithLocker(locker lock.Locker) OptionFunc {
return func(opts *Options) {
opts.Locker = locker
}
}
func WithLockTimeout(timeout time.Duration) OptionFunc {
return func(opts *Options) {
opts.LockTimeout = timeout
}
}

View File

@ -4,13 +4,13 @@ import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"io/ioutil"
"os"
"github.com/btcsuite/btcd/btcutil/base58"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/oklog/ulid/v2"
"github.com/pkg/errors"
)
@ -24,8 +24,9 @@ type (
)
var (
FromRaw = jwk.FromRaw
NewSet = jwk.NewSet
FromRaw = jwk.FromRaw
NewSet = jwk.NewSet
ParseKey = jwk.ParseKey
)
const AlgorithmKey = jwk.AlgorithmKey
@ -56,7 +57,7 @@ func PublicKeySet(keys ...jwk.Key) (jwk.Set, error) {
}
func LoadOrGenerate(path string, size int) (jwk.Key, error) {
data, err := ioutil.ReadFile(path)
data, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, errors.WithStack(err)
}
@ -72,7 +73,7 @@ func LoadOrGenerate(path string, size int) (jwk.Key, error) {
return nil, errors.WithStack(err)
}
if err := ioutil.WriteFile(path, data, 0o640); err != nil {
if err := os.WriteFile(path, data, 0o640); err != nil {
return nil, errors.WithStack(err)
}
}
@ -96,6 +97,12 @@ func Generate(size int) (jwk.Key, error) {
return nil, errors.WithStack(err)
}
keyID := ulid.Make().String()
if err := key.Set(jwk.KeyIDKey, keyID); err != nil {
return nil, errors.WithStack(err)
}
return key, nil
}

10
internal/lock/locker.go Normal file
View File

@ -0,0 +1,10 @@
package lock
import (
"context"
"time"
)
type Locker interface {
WithLock(ctx context.Context, key string, timeout time.Duration, fn func(ctx context.Context) error) error
}

View File

@ -0,0 +1,45 @@
package memory
import (
"context"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/lock"
"github.com/pkg/errors"
)
var (
ErrTimeout = errors.New("timeout")
)
type Locker struct {
lock chan struct{}
}
// WithLock implements lock.Locker.
func (l *Locker) WithLock(ctx context.Context, key string, timeout time.Duration, fn func(ctx context.Context) error) error {
select {
case l.lock <- struct{}{}:
defer func() {
<-l.lock
}()
if err := fn(ctx); err != nil {
return errors.WithStack(err)
}
case <-ctx.Done():
return errors.WithStack(ctx.Err())
case <-time.After(timeout):
return errors.WithStack(ErrTimeout)
}
return nil
}
func NewLocker() *Locker {
return &Locker{
lock: make(chan struct{}, 1),
}
}
var _ lock.Locker = &Locker{}

View File

@ -0,0 +1,67 @@
package redis
import (
"context"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/lock"
"github.com/bsm/redislock"
"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
"gitlab.com/wpetit/goweb/logger"
)
type Locker struct {
client redis.UniversalClient
maxRetries int
}
// WithLock implements lock.Locker.
func (l *Locker) WithLock(ctx context.Context, key string, timeout time.Duration, fn func(ctx context.Context) error) error {
locker := redislock.New(l.client)
backoff := redislock.ExponentialBackoff(time.Second, timeout*2)
ctx = logger.With(ctx, logger.F("lockTimeout", timeout), logger.F("lockKey", key))
logger.Debug(ctx, "acquiring lock")
err := retryWithBackoff(ctx, l.maxRetries, func(ctx context.Context) error {
lock, err := locker.Obtain(ctx, key, timeout, &redislock.Options{
RetryStrategy: backoff,
})
if err != nil {
return errors.WithStack(err)
}
logger.Debug(ctx, "lock obtained")
defer func() {
if err := lock.Release(ctx); err != nil {
logger.Error(ctx, "could not release lock", logger.E(errors.WithStack(err)))
}
logger.Debug(ctx, "lock released")
}()
if err := fn(ctx); err != nil {
return errors.WithStack(err)
}
return nil
})
if err != nil {
return errors.WithStack(err)
}
return nil
}
func NewLocker(client redis.UniversalClient, maxRetries int) *Locker {
return &Locker{
client: client,
maxRetries: maxRetries,
}
}
var _ lock.Locker = &Locker{}

View File

@ -0,0 +1,42 @@
package redis
import (
"context"
"time"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
const (
baseWatchBackoffDelay = time.Millisecond * 500
maxDelay = time.Minute * 10
)
func retryWithBackoff(ctx context.Context, attempts int, fn func(ctx context.Context) error) error {
backoffDelay := baseWatchBackoffDelay
count := 0
for {
err := fn(ctx)
if err == nil {
return nil
}
err = errors.WithStack(err)
count++
if count >= attempts {
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))
time.Sleep(backoffDelay)
backoffDelay *= 2
if backoffDelay > maxDelay {
backoffDelay = maxDelay
}
}
}

43
internal/logger/writer.go Normal file
View File

@ -0,0 +1,43 @@
package logger
import (
"context"
"io"
"gitlab.com/wpetit/goweb/logger"
)
type Writer struct {
ctx context.Context
level logger.Level
}
// Write implements io.Writer.
func (w *Writer) Write(p []byte) (n int, err error) {
w.log(string(p))
return len(p), nil
}
func (w *Writer) log(message string) {
switch w.level {
case logger.LevelDebug:
logger.Debug(w.ctx, message)
case logger.LevelInfo:
logger.Info(w.ctx, message)
case logger.LevelWarn:
logger.Warn(w.ctx, message)
case logger.LevelError:
logger.Error(w.ctx, message)
case logger.LevelCritical:
logger.Critical(w.ctx, message)
default:
logger.Debug(w.ctx, message)
}
}
func NewWriter(ctx context.Context, level logger.Level) *Writer {
return &Writer{ctx, level}
}
var _ io.Writer = &Writer{}

View File

@ -2,6 +2,7 @@ package director
import (
"context"
"net/url"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
@ -10,8 +11,9 @@ import (
type contextKey string
const (
contextKeyProxy contextKey = "proxy"
contextKeyLayers contextKey = "layers"
contextKeyProxy contextKey = "proxy"
contextKeyLayers contextKey = "layers"
contextKeyOriginalURL contextKey = "originalURL"
)
var (
@ -19,6 +21,19 @@ var (
errUnexpectedContextValue = errors.New("unexpected context value")
)
func withOriginalURL(ctx context.Context, url *url.URL) context.Context {
return context.WithValue(ctx, contextKeyOriginalURL, url)
}
func OriginalURL(ctx context.Context) (*url.URL, error) {
url, err := ctxValue[*url.URL](ctx, contextKeyOriginalURL)
if err != nil {
return nil, errors.WithStack(err)
}
return url, nil
}
func withProxy(ctx context.Context, proxy *store.Proxy) context.Context {
return context.WithValue(ctx, contextKeyProxy, proxy)
}

View File

@ -3,13 +3,13 @@ package director
import (
"context"
"net/http"
"net/url"
"sort"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
"gitlab.com/wpetit/goweb/logger"
)
@ -27,15 +27,29 @@ func (d *Director) rewriteRequest(r *http.Request) (*http.Request, error) {
return r, errors.WithStack(err)
}
url := getRequestURL(r)
ctx = withOriginalURL(ctx, url)
ctx = logger.With(ctx, logger.F("url", url.String()))
var match *store.Proxy
MAIN:
for _, p := range proxies {
for _, from := range p.From {
if matches := wildcard.Match(r.Host, from); !matches {
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),
)
match = p
break MAIN
}
@ -59,6 +73,8 @@ MAIN:
logger.F("remoteAddr", r.RemoteAddr),
)
metricProxyRequestsTotal.With(prometheus.Labels{metricLabelProxy: string(match.Name)}).Add(1)
ctx = withProxy(ctx, match)
layers, err := d.getLayers(ctx, match.Name)

View File

@ -0,0 +1,26 @@
package authn
import (
"net/http"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
var (
ErrUnauthorized = errors.New("unauthorized")
ErrForbidden = errors.New("forbidden")
ErrSkipRequest = errors.New("skip request")
)
type Authenticator interface {
Authenticate(w http.ResponseWriter, r *http.Request, layer *store.Layer) (*User, error)
}
type PreAuthentication interface {
PreAuthentication(w http.ResponseWriter, r *http.Request, layer *store.Layer) error
}
type PostAuthentication interface {
PostAuthentication(w http.ResponseWriter, r *http.Request, layer *store.Layer, user *User) error
}

View File

@ -0,0 +1,94 @@
package authn
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"github.com/expr-lang/expr"
"github.com/pkg/errors"
)
func (l *Layer) getRuleOptions(r *http.Request) []expr.Option {
options := make([]expr.Option, 0)
setHeader := expr.Function(
"set_header",
func(params ...any) (any, error) {
name := params[0].(string)
rawValue := params[1]
var value string
switch v := rawValue.(type) {
case []string:
value = strings.Join(v, ",")
case time.Time:
value = strconv.FormatInt(v.UTC().Unix(), 10)
case time.Duration:
value = strconv.FormatInt(int64(v.Seconds()), 10)
default:
value = fmt.Sprintf("%v", rawValue)
}
r.Header.Set(name, value)
return true, nil
},
new(func(string, string) bool),
)
options = append(options, setHeader)
delHeaders := expr.Function(
"del_headers",
func(params ...any) (any, error) {
pattern := params[0].(string)
deleted := false
for key := range r.Header {
if !wildcard.Match(key, pattern) {
continue
}
r.Header.Del(key)
deleted = true
}
return deleted, nil
},
new(func(string) bool),
)
options = append(options, delHeaders)
return options
}
func (l *Layer) injectHeaders(r *http.Request, options *LayerOptions, user *User) error {
rules := options.Headers.Rules
if len(rules) == 0 {
return nil
}
env := map[string]any{
"user": user,
}
rulesOptions := l.getRuleOptions(r)
for i, r := range rules {
program, err := expr.Compile(r, rulesOptions...)
if err != nil {
return errors.Wrapf(err, "could not compile header rule #%d", i)
}
if _, err := expr.Run(program, env); err != nil {
return errors.Wrapf(err, "could not execute header rule #%d", i)
}
}
return nil
}

View File

@ -0,0 +1,38 @@
{
"$id": "https://forge.cadoles.com/cadoles/bouncer/schemas/authn-options",
"title": "Options de configuration commune des layers 'authn-*'",
"type": "object",
"properties": {
"matchURLs": {
"title": "Liste de filtrage des URLs sur lesquelles le layer est actif.",
"description": "Par exemple, si vous souhaitez limiter votre layer à l'ensemble d'une section '`/blog`' d'un site, vous pouvez déclarer la valeur `['*/blog*']`. Les autres URLs du site ne seront pas affectées par ce layer.",
"default": [
"*"
],
"type": "array",
"items": {
"type": "string"
}
},
"headers": {
"title": "Options de configuration du mécanisme d'injection d'entêtes HTTP liés à l'authentification",
"type": "object",
"properties": {
"rules": {
"title": "Liste des règles définissant les actions d'injection/réécriture d'entêtes HTTP",
"description": "Voir la documentation (ficher 'doc/fr/references/layers/authn/README.md', section 'Règles d'injection d'entêtes') pour plus d'informations sur le fonctionnement des règles",
"type": "array",
"default": [
"del_headers('Remote-*')",
"set_header('Remote-User', user.subject)",
"map( toPairs(user.attrs), { let name = replace(lower(string(get(#, 0))), '_', '-'); set_header('Remote-User-Attr-' + name, get(#, 1)) })"
],
"item": {
"type": "string"
}
}
},
"additionalProperties": false
}
}
}

View File

@ -0,0 +1,103 @@
package authn
import (
"net/http"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
type Layer struct {
layerType store.LayerType
auth Authenticator
}
func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
options, err := fromStoreOptions(layer.Options)
if err != nil {
logger.Error(ctx, "could not parse layer options", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if preAuth, ok := l.auth.(PreAuthentication); ok {
if err := preAuth.PreAuthentication(w, r, layer); err != nil {
if errors.Is(err, ErrSkipRequest) {
return
}
logger.Error(ctx, "could not execute pre-auth hook", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
matches := wildcard.MatchAny(r.URL.String(), options.MatchURLs...)
if !matches {
next.ServeHTTP(w, r)
return
}
user, err := l.auth.Authenticate(w, r, layer)
if err != nil {
if errors.Is(err, ErrSkipRequest) {
return
}
logger.Error(ctx, "could not authenticate user", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if err := l.injectHeaders(r, options, user); err != nil {
logger.Error(ctx, "could not inject headers", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if postAuth, ok := l.auth.(PostAuthentication); ok {
if err := postAuth.PostAuthentication(w, r, layer, user); err != nil {
if errors.Is(err, ErrSkipRequest) {
return
}
logger.Error(ctx, "could not execute post-auth hook", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
}
}
// LayerType implements director.MiddlewareLayer
func (l *Layer) LayerType() store.LayerType {
return l.layerType
}
func NewLayer(layerType store.LayerType, auth Authenticator) *Layer {
return &Layer{
layerType: layerType,
auth: auth,
}
}
var _ director.MiddlewareLayer = &Layer{}

View File

@ -0,0 +1,92 @@
package authn
import (
"reflect"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/mitchellh/mapstructure"
"github.com/pkg/errors"
)
type LayerOptions struct {
MatchURLs []string `mapstructure:"matchURLs"`
Headers HeadersOptions `mapstructure:"headers"`
}
type HeadersOptions struct {
Rules []string `mapstructure:"rules"`
}
func DefaultLayerOptions() LayerOptions {
return LayerOptions{
MatchURLs: []string{"*"},
Headers: HeadersOptions{
Rules: []string{
"del_headers('Remote-*')",
"set_header('Remote-User', user.subject)",
`map(
toPairs(user.attrs), {
let name = replace(lower(string(get(#, 0))), '_', '-');
set_header(
'Remote-User-Attr-' + name,
get(#, 1)
)
})
`,
},
},
}
}
func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
layerOptions := DefaultLayerOptions()
if err := FromStoreOptions(storeOptions, &layerOptions); err != nil {
return nil, errors.WithStack(err)
}
return &layerOptions, nil
}
func FromStoreOptions(storeOptions store.LayerOptions, dest any) error {
config := mapstructure.DecoderConfig{
Result: dest,
ZeroFields: true,
DecodeHook: mapstructure.ComposeDecodeHookFunc(
toDurationHookFunc(),
),
}
decoder, err := mapstructure.NewDecoder(&config)
if err != nil {
return errors.WithStack(err)
}
if err := decoder.Decode(storeOptions); err != nil {
return errors.WithStack(err)
}
return nil
}
func toDurationHookFunc() mapstructure.DecodeHookFunc {
return func(
f reflect.Type,
t reflect.Type,
data interface{}) (interface{}, error) {
if t != reflect.TypeOf(*new(time.Duration)) {
return data, nil
}
switch f.Kind() {
case reflect.String:
return time.ParseDuration(data.(string))
case reflect.Int64:
return time.Duration(data.(int64) * int64(time.Second)), nil
default:
return data, nil
}
// Convert it by parsing
}
}

View File

@ -0,0 +1,278 @@
package oidc
import (
"context"
"fmt"
"net/http"
"net/url"
"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"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gorilla/sessions"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
"gitlab.com/wpetit/goweb/logger"
)
type Authenticator struct {
store sessions.Store
}
func (a *Authenticator) PreAuthentication(w http.ResponseWriter, r *http.Request, layer *store.Layer) error {
ctx := r.Context()
originalURL, err := director.OriginalURL(ctx)
if err != nil {
return errors.WithStack(err)
}
options, err := fromStoreOptions(layer.Options)
if err != nil {
return errors.WithStack(err)
}
sess, err := a.store.Get(r, a.getCookieName(options.Cookie.Name, layer.Name))
if err != nil {
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())
if err != nil {
return errors.WithStack(err)
}
switch r.URL.Path {
case redirectURL.Path:
if err := client.HandleCallback(w, r, sess); err != nil {
return errors.WithStack(err)
}
metricLoginSuccessesTotal.With(prometheus.Labels{
metricLabelLayer: string(layer.Name),
metricLabelProxy: string(layer.Proxy),
}).Add(1)
case logoutURL.Path:
postLogoutRedirectURL := options.OIDC.PostLogoutRedirectURL
if options.OIDC.PostLogoutRedirectURL == "" {
postLogoutRedirectURL = originalURL.Scheme + "://" + originalURL.Host
}
if err := client.HandleLogout(w, r, sess, postLogoutRedirectURL); err != nil {
return errors.WithStack(err)
}
metricLogoutsTotal.With(prometheus.Labels{
metricLabelLayer: string(layer.Name),
metricLabelProxy: string(layer.Proxy),
}).Add(1)
}
return nil
}
// Authenticate implements authn.Authenticator.
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)
}
sess, err := a.store.Get(r, a.getCookieName(options.Cookie.Name, layer.Name))
if err != nil {
return nil, errors.WithStack(err)
}
defer func() {
if err := sess.Save(r, w); err != nil {
logger.Error(ctx, "could not save session", logger.E(errors.WithStack(err)))
}
}()
sess.Options.Domain = options.Cookie.Domain
sess.Options.HttpOnly = options.Cookie.HTTPOnly
sess.Options.MaxAge = int(options.Cookie.MaxAge.Seconds())
sess.Options.Path = options.Cookie.Path
switch options.Cookie.SameSite {
case "lax":
sess.Options.SameSite = http.SameSiteLaxMode
case "strict":
sess.Options.SameSite = http.SameSiteStrictMode
case "none":
sess.Options.SameSite = http.SameSiteNoneMode
default:
sess.Options.SameSite = http.SameSiteDefaultMode
}
redirectURL := a.getRedirectURL(layer.Proxy, layer.Name, originalURL, options)
client, err := a.getClient(options, redirectURL.String())
if err != nil {
return nil, errors.WithStack(err)
}
idToken, err := client.Authenticate(w, r, sess)
if err != nil {
if errors.Is(err, ErrLoginRequired) {
metricLoginRequestsTotal.With(prometheus.Labels{
metricLabelLayer: string(layer.Name),
metricLabelProxy: string(layer.Proxy),
}).Add(1)
return nil, errors.WithStack(authn.ErrSkipRequest)
}
return nil, errors.WithStack(err)
}
user, err := a.toUser(idToken, layer.Proxy, layer.Name, originalURL, options, sess)
if err != nil {
return nil, errors.WithStack(err)
}
return user, nil
}
type claims struct {
Issuer string `json:"iss"`
Subject string `json:"sub"`
Expiration int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
AuthTime int64 `json:"auth_time"`
Nonce string `json:"nonce"`
ACR string `json:"acr"`
AMR string `json:"amr"`
AZP string `json:"amp"`
Others map[string]any `json:"-"`
}
func (c claims) AsAttrs() map[string]any {
attrs := make(map[string]any)
for key, val := range c.Others {
if val != nil {
attrs["claim_"+key] = val
}
}
attrs["claim_iss"] = c.Issuer
attrs["claim_sub"] = c.Subject
attrs["claim_exp"] = c.Expiration
attrs["claim_iat"] = c.IssuedAt
if c.AuthTime != 0 {
attrs["claim_auth_time"] = c.AuthTime
}
if c.Nonce != "" {
attrs["claim_nonce"] = c.Nonce
}
if c.ACR != "" {
attrs["claim_arc"] = c.ACR
}
if c.AMR != "" {
attrs["claim_amr"] = c.AMR
}
if c.AZP != "" {
attrs["claim_azp"] = c.AZP
}
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) {
var claims claims
if err := idToken.Claims(&claims); err != nil {
return nil, errors.WithStack(err)
}
if err := idToken.Claims(&claims.Others); err != nil {
return nil, errors.WithStack(err)
}
attrs := claims.AsAttrs()
logoutURL := a.getLogoutURL(proxyName, layerName, originalURL, options)
attrs["logout_url"] = logoutURL.String()
if accessToken, exists := sess.Values[sessionKeyAccessToken]; exists && accessToken != nil {
attrs["access_token"] = accessToken
}
if refreshToken, exists := sess.Values[sessionKeyRefreshToken]; exists && refreshToken != nil {
attrs["refresh_token"] = refreshToken
}
if tokenExpiry, exists := sess.Values[sessionKeyTokenExpiry]; exists && tokenExpiry != nil {
attrs["token_expiry"] = tokenExpiry
}
user := authn.NewUser(idToken.Subject, attrs)
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) 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) getClient(options *LayerOptions, redirectURL string) (*Client, error) {
ctx := context.Background()
if options.OIDC.SkipIssuerVerification {
ctx = oidc.InsecureIssuerURLContext(ctx, options.OIDC.IssuerURL)
}
provider, err := oidc.NewProvider(ctx, options.OIDC.IssuerURL)
if err != nil {
return nil, errors.Wrap(err, "could not create oidc provider")
}
client := NewClient(
WithCredentials(options.OIDC.ClientID, options.OIDC.ClientSecret),
WithProvider(provider),
WithRedirectURL(redirectURL),
WithScopes(options.OIDC.Scopes...),
WithAuthParams(options.OIDC.AuthParams),
)
return client, nil
}
func (a *Authenticator) getCookieName(cookieName string, layerName store.LayerName) string {
return fmt.Sprintf("%s_%s", cookieName, layerName)
}
var (
_ authn.PreAuthentication = &Authenticator{}
_ authn.Authenticator = &Authenticator{}
)

View File

@ -0,0 +1,291 @@
package oidc
import (
"bytes"
"net/http"
"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"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
"golang.org/x/oauth2"
)
const (
sessionKeyAccessToken = "access-token"
sessionKeyRefreshToken = "refresh-token"
sessionKeyTokenExpiry = "token-expiry"
sessionKeyIDToken = "id-token"
sessionKeyPostLoginRedirectURL = "post-login-redirect-url"
sessionKeyLoginState = "login-state"
sessionKeyLoginNonce = "login-nonce"
)
var (
ErrLoginRequired = errors.New("login required")
)
type Client struct {
oauth2 *oauth2.Config
provider *oidc.Provider
verifier *oidc.IDTokenVerifier
authParams map[string]string
}
func (c *Client) Verifier() *oidc.IDTokenVerifier {
return c.verifier
}
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) {
idToken, err := c.getIDToken(r, sess)
if err != nil {
logger.Error(r.Context(), "could not retrieve idtoken", logger.E(errors.WithStack(err)))
c.login(w, r, sess)
return nil, errors.WithStack(ErrLoginRequired)
}
return idToken, nil
}
func (c *Client) login(w http.ResponseWriter, r *http.Request, sess *sessions.Session) {
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()
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)
return
}
authCodeOptions := []oauth2.AuthCodeOption{}
authCodeOptions = append(authCodeOptions, oidc.Nonce(nonce))
for key, val := range c.authParams {
authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam(key, val))
}
authCodeURL := c.oauth2.AuthCodeURL(
state,
authCodeOptions...,
)
http.Redirect(w, r, authCodeURL, http.StatusFound)
}
func (c *Client) HandleCallback(w http.ResponseWriter, r *http.Request, sess *sessions.Session) error {
token, _, rawIDToken, err := c.validate(r, sess)
if err != nil {
return errors.Wrap(err, "could not validate oidc token")
}
sess.Values[sessionKeyIDToken] = rawIDToken
sess.Values[sessionKeyAccessToken] = token.AccessToken
sess.Values[sessionKeyRefreshToken] = token.RefreshToken
sess.Values[sessionKeyTokenExpiry] = token.Expiry.UTC().Unix()
if err := sess.Save(r, w); err != nil {
return errors.WithStack(err)
}
rawPostLoginRedirectURL, exists := sess.Values[sessionKeyPostLoginRedirectURL]
if !exists {
return errors.Wrap(err, "could not find post login redirect url")
}
postLoginRedirectURL, ok := rawPostLoginRedirectURL.(string)
if !ok {
return errors.Wrapf(err, "unexpected value '%v' for post login redirect url", rawPostLoginRedirectURL)
}
http.Redirect(w, r, postLoginRedirectURL, http.StatusTemporaryRedirect)
return nil
}
func (c *Client) HandleLogout(w http.ResponseWriter, r *http.Request, sess *sessions.Session, postLogoutRedirectURL string) error {
state := uniuri.New()
sess.Values[sessionKeyLoginState] = state
ctx := r.Context()
rawIDToken, err := c.getRawIDToken(sess)
if err != nil {
logger.Error(ctx, "could not retrieve raw id token", logger.E(errors.WithStack(err)))
}
sess.Values[sessionKeyIDToken] = nil
sess.Values[sessionKeyAccessToken] = nil
sess.Values[sessionKeyRefreshToken] = nil
sess.Values[sessionKeyTokenExpiry] = nil
sess.Options.MaxAge = -1
if err := sess.Save(r, w); err != nil {
return errors.Wrap(err, "could not save session")
}
if rawIDToken == "" {
http.Redirect(w, r, postLogoutRedirectURL, http.StatusFound)
return nil
}
sessionEndURL, err := c.sessionEndURL(rawIDToken, state, postLogoutRedirectURL)
if err != nil {
return errors.Wrap(err, "could not retrieve session end url")
}
if sessionEndURL != "" {
http.Redirect(w, r, sessionEndURL, http.StatusFound)
} else {
http.Redirect(w, r, postLogoutRedirectURL, http.StatusFound)
}
return nil
}
func (c *Client) sessionEndURL(idTokenHint, state, postLogoutRedirectURL string) (string, error) {
sessionEndEndpoint := &struct {
URL string `json:"end_session_endpoint"`
}{}
if err := c.provider.Claims(&sessionEndEndpoint); err != nil {
return "", errors.Wrap(err, "could not unmarshal claims")
}
if sessionEndEndpoint.URL == "" {
return "", nil
}
var buf bytes.Buffer
buf.WriteString(sessionEndEndpoint.URL)
v := url.Values{}
if idTokenHint != "" {
v.Set("id_token_hint", idTokenHint)
}
if postLogoutRedirectURL != "" {
v.Set("post_logout_redirect_uri", postLogoutRedirectURL)
}
if state != "" {
v.Set("state", state)
}
if strings.Contains(sessionEndEndpoint.URL, "?") {
buf.WriteByte('&')
} else {
buf.WriteByte('?')
}
buf.WriteString(v.Encode())
return buf.String(), nil
}
func (c *Client) validate(r *http.Request, sess *sessions.Session) (*oauth2.Token, *oidc.IDToken, string, error) {
ctx := r.Context()
rawStoredState := sess.Values[sessionKeyLoginState]
receivedState := r.URL.Query().Get("state")
storedState, ok := rawStoredState.(string)
if !ok {
return nil, nil, "", errors.New("could not find state in session")
}
if receivedState != storedState {
return nil, nil, "", errors.New("state mismatch")
}
code := r.URL.Query().Get("code")
token, err := c.oauth2.Exchange(ctx, code)
if err != nil {
return nil, nil, "", errors.Wrap(err, "could not exchange token")
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, nil, "", errors.New("could not find id token")
}
idToken, err := c.verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, nil, "", errors.Wrap(err, "could not verify id token")
}
return token, idToken, rawIDToken, nil
}
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 rawIDToken, nil
}
func (c *Client) getIDToken(r *http.Request, sess *sessions.Session) (*oidc.IDToken, error) {
rawIDToken, err := c.getRawIDToken(sess)
if err != nil {
return nil, errors.Wrap(err, "could not retrieve raw idtoken")
}
idToken, err := c.verifier.Verify(r.Context(), rawIDToken)
if err != nil {
return nil, errors.Wrap(err, "could not verify id token")
}
return idToken, nil
}
func NewClient(funcs ...ClientOptionFunc) *Client {
opts := NewClientOptions(funcs...)
oauth2 := &oauth2.Config{
ClientID: opts.ClientID,
ClientSecret: opts.ClientSecret,
Endpoint: opts.Provider.Endpoint(),
RedirectURL: opts.RedirectURL,
Scopes: opts.Scopes,
}
verifier := opts.Provider.Verifier(&oidc.Config{
ClientID: opts.ClientID,
SkipIssuerCheck: opts.SkipIssuerCheck,
})
return &Client{
oauth2: oauth2,
provider: opts.Provider,
verifier: verifier,
authParams: opts.AuthParams,
}
}

View File

@ -0,0 +1,76 @@
package oidc
import (
"context"
"github.com/coreos/go-oidc/v3/oidc"
)
type ClientOptions struct {
Provider *oidc.Provider
ClientID string
ClientSecret string
RedirectURL string
Scopes []string
AuthParams map[string]string
SkipIssuerCheck bool
}
type ClientOptionFunc func(*ClientOptions)
func WithRedirectURL(url string) ClientOptionFunc {
return func(opt *ClientOptions) {
opt.RedirectURL = url
}
}
func WithCredentials(clientID, clientSecret string) ClientOptionFunc {
return func(opt *ClientOptions) {
opt.ClientID = clientID
opt.ClientSecret = clientSecret
}
}
func WithScopes(scopes ...string) ClientOptionFunc {
return func(opt *ClientOptions) {
opt.Scopes = scopes
}
}
func WithAuthParams(params map[string]string) ClientOptionFunc {
return func(opt *ClientOptions) {
opt.AuthParams = params
}
}
func WithSkipIssuerCheck(skip bool) ClientOptionFunc {
return func(opt *ClientOptions) {
opt.SkipIssuerCheck = skip
}
}
func NewProvider(ctx context.Context, issuer string, skipIssuerVerification bool) (*oidc.Provider, error) {
if skipIssuerVerification {
ctx = oidc.InsecureIssuerURLContext(ctx, issuer)
}
return oidc.NewProvider(ctx, issuer)
}
func WithProvider(provider *oidc.Provider) ClientOptionFunc {
return func(opt *ClientOptions) {
opt.Provider = provider
}
}
func NewClientOptions(funcs ...ClientOptionFunc) *ClientOptions {
opt := &ClientOptions{
Scopes: []string{oidc.ScopeOpenID, "profile"},
}
for _, f := range funcs {
f(opt)
}
return opt
}

View File

@ -0,0 +1,127 @@
{
"$id": "https://forge.cadoles.com/cadoles/bouncer/schemas/authn-oidc-layer-options",
"title": "Options de configuration du layer 'authn-oidc'",
"type": "object",
"properties": {
"oidc": {
"title": "Configuration du client OpenID Connect",
"type": "object",
"properties": {
"clientId": {
"title": "Identifiant du client OpenID Connect",
"type": "string"
},
"clientSecret": {
"title": "Secret du client OpenID Connect",
"type": "string"
},
"issuerURL": {
"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"
},
"scopes": {
"title": "Scopes associés au client OpenID Connect",
"default": [
"openid"
],
"type": "array",
"item": {
"type": "string"
}
},
"authParams": {
"title": "Paramètres d'URL supplémentaires à ajouter à la requête d'authentification OpenID Connect",
"default": {},
"description": "L'ensemble des clés valeurs renseignées seront transformées en variables d'URL lors de la requête d'authentification initiale. Permet par exemple d'ajouter les 'acr_values' requises par certains fournisseurs d'identité OpenID Connect.",
"type": "object",
"patternProperties": {
".*": {
"type": "string"
}
}
},
"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>'.",
"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>'.",
"type": "string"
},
"skipIssuerVerification": {
"title": "Activer/désactiver la vérification de concordance de l'identifiant du fournisseur d'identité",
"default": false,
"type": "boolean"
}
},
"additionalProperties": false,
"required": [
"clientId",
"clientSecret",
"issuerURL"
]
},
"cookie": {
"title": "Configuration du cookie porteur de la session utilisateur",
"type": "object",
"properties": {
"name": {
"title": "Nom du cookie",
"default": "_bouncer_authn_oidc",
"type": "string"
},
"domain": {
"title": "Domaine associé au cookie",
"description": "Par défaut le domaine associé à la requête HTTP",
"type": "string"
},
"path": {
"title": "Chemin associé au cookie",
"type": "string",
"default": "/"
},
"sameSite": {
"title": "Attribut sameSite du cookie",
"description": "Voir https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value",
"type": "string",
"enum": [
"lax",
"none",
"strict",
""
],
"default": ""
},
"httpOnly": {
"title": "Interdire ou non l'accès au cookie en Javascript",
"type": "boolean",
"default": false
},
"secure": {
"title": "Transmettre le cookie uniquement en HTTPS",
"type": "boolean",
"default": false
},
"maxAge": {
"title": "Temps de vie du cookie et de la session associée.",
"description": "Voir https://pkg.go.dev/time#ParseDuration pour le format attendu.",
"default": "1h",
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
"required": [
"oidc"
]
}

View File

@ -0,0 +1,13 @@
package oidc
import (
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/gorilla/sessions"
)
const LayerType store.LayerType = "authn-oidc"
func NewLayer(store sessions.Store) *authn.Layer {
return authn.NewLayer(LayerType, &Authenticator{store: store})
}

View File

@ -0,0 +1,62 @@
package oidc
import (
"time"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/authn"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/pkg/errors"
)
const defaultCookieName = "_bouncer_authn_oidc"
type LayerOptions struct {
authn.LayerOptions
OIDC OIDCOptions `mapstructure:"oidc"`
Cookie CookieOptions `mapstructure:"cookie"`
}
type OIDCOptions struct {
ClientID string `mapstructure:"clientId"`
ClientSecret string `mapstructure:"clientSecret"`
LoginCallbackPath string `mapstructure:"loginCallbackPath"`
LogoutPath string `mapstructure:"logoutPath"`
IssuerURL string `mapstructure:"issuerURL"`
SkipIssuerVerification bool `mapstructure:"skipIssuerVerification"`
PostLogoutRedirectURL string `mapstructure:"postLogoutRedirectURL"`
Scopes []string `mapstructure:"scopes"`
AuthParams map[string]string `mapstructure:"authParams"`
}
type CookieOptions struct {
Name string `mapstructure:"name"`
Domain string `mapstructure:"domain"`
Path string `mapstructure:"path"`
SameSite string `mapstructure:"sameSite"`
Secure bool `mapstructure:"secure"`
HTTPOnly bool `mapstructure:"httpOnly"`
MaxAge time.Duration `mapstructure:"maxAge"`
}
func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
layerOptions := LayerOptions{
LayerOptions: authn.DefaultLayerOptions(),
OIDC: OIDCOptions{
LoginCallbackPath: "/.bouncer/authn/oidc/%s/callback",
LogoutPath: "/.bouncer/authn/oidc/%s/logout",
Scopes: []string{"openid"},
},
Cookie: CookieOptions{
Name: defaultCookieName,
Path: "/",
HTTPOnly: true,
MaxAge: time.Hour,
},
}
if err := authn.FromStoreOptions(storeOptions, &layerOptions); err != nil {
return nil, errors.WithStack(err)
}
return &layerOptions, nil
}

View File

@ -0,0 +1,39 @@
package oidc
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
const (
metricNamespace = "bouncer_layer_authn_oidc"
metricLabelProxy = "proxy"
metricLabelLayer = "layer"
)
var (
metricLoginRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "login_requests_total",
Help: "Bouncer's authn-oidc layer total login requests",
Namespace: metricNamespace,
},
[]string{metricLabelProxy, metricLabelLayer},
)
metricLoginSuccessesTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "login_successes_total",
Help: "Bouncer's authn-oidc layer total login successes",
Namespace: metricNamespace,
},
[]string{metricLabelProxy, metricLabelLayer},
)
metricLogoutsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "logout_total",
Help: "Bouncer's authn-oidc layer total logouts",
Namespace: metricNamespace,
},
[]string{metricLabelProxy, metricLabelLayer},
)
)

View File

@ -0,0 +1,8 @@
package oidc
import (
_ "embed"
)
//go:embed layer-options.json
var RawLayerOptionsSchema []byte

View File

@ -0,0 +1,8 @@
package authn
import (
_ "embed"
)
//go:embed layer-options.json
var RawLayerOptionsSchema []byte

View File

@ -0,0 +1,17 @@
package authn
type User struct {
Subject string `json:"subject" expr:"subject"`
Attrs map[string]any `json:"attrs" expr:"attrs"`
}
func NewUser(subject string, attrs map[string]any) *User {
if attrs == nil {
attrs = make(map[string]any)
}
return &User{
Subject: subject,
Attrs: attrs,
}
}

View File

@ -0,0 +1,23 @@
{
"$id": "https://forge.cadoles.com/cadoles/bouncer/schemas/circuitbreaker-layer-options",
"title": "Circuit breaker layer options",
"type": "object",
"properties": {
"matchURLs": {
"type": "array",
"items": {
"type": "string"
}
},
"authorizedCIDRs": {
"type": "array",
"items": {
"type": "string"
}
},
"templateBlock": {
"type": "string"
}
},
"additionalProperties": false
}

View File

@ -0,0 +1,151 @@
package circuitbreaker
import (
"context"
"html/template"
"net"
"net/http"
"path/filepath"
"sync"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/Masterminds/sprig/v3"
"github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger"
)
const LayerType store.LayerType = "circuitbreaker"
type Layer struct {
templateDir string
loadOnce sync.Once
tmpl *template.Template
}
// LayerType implements director.MiddlewareLayer
func (l *Layer) LayerType() store.LayerType {
return LayerType
}
// Middleware implements director.MiddlewareLayer
func (l *Layer) Middleware(layer *store.Layer) proxy.Middleware {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
options, err := fromStoreOptions(layer.Options)
if err != nil {
logger.Error(ctx, "could not parse layer options", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
matches, err := l.matchAnyAuthorizedCIDRs(ctx, r.RemoteAddr, options.AuthorizedCIDRs)
if err != nil {
logger.Error(ctx, "could not match authorized cidrs", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if matches {
h.ServeHTTP(w, r)
return
}
matches = wildcard.MatchAny(r.URL.String(), options.MatchURLs...)
if !matches {
h.ServeHTTP(w, r)
return
}
l.renderCircuitBreakerPage(w, r, layer, options)
}
return http.HandlerFunc(fn)
}
}
func (l *Layer) matchAnyAuthorizedCIDRs(ctx context.Context, remoteHostPort string, CIDRs []string) (bool, error) {
remoteHost, _, err := net.SplitHostPort(remoteHostPort)
if err != nil {
return false, errors.WithStack(err)
}
remoteAddr := net.ParseIP(remoteHost)
if remoteAddr == nil {
return false, errors.Errorf("remote host '%s' is not a valid ip address", remoteHost)
}
for _, rawCIDR := range CIDRs {
_, net, err := net.ParseCIDR(rawCIDR)
if err != nil {
return false, errors.WithStack(err)
}
match := net.Contains(remoteAddr)
if !match {
continue
}
return true, nil
}
logger.Debug(ctx, "comparing remote host with authorized cidrs", logger.F("remoteAddr", remoteAddr))
return false, nil
}
func (l *Layer) renderCircuitBreakerPage(w http.ResponseWriter, r *http.Request, layer *store.Layer, options *LayerOptions) {
ctx := r.Context()
pattern := filepath.Join(l.templateDir, "*.gohtml")
logger.Info(ctx, "loading circuit breaker page templates", logger.F("pattern", pattern))
tmpl, err := template.New("").Funcs(sprig.FuncMap()).ParseGlob(pattern)
if err != nil {
logger.Error(ctx, "could not load circuit breaker templates", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
templateData := struct {
Layer *store.Layer
LayerOptions *LayerOptions
}{
Layer: layer,
LayerOptions: options,
}
w.Header().Add("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
if err := tmpl.ExecuteTemplate(w, options.TemplateBlock, templateData); err != nil {
logger.Error(ctx, "could not render circuit breaker page", logger.E(errors.WithStack(err)))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
func New(funcs ...OptionFunc) *Layer {
opts := defaultOptions()
for _, fn := range funcs {
fn(opts)
}
return &Layer{
templateDir: opts.TemplateDir,
}
}
var _ director.MiddlewareLayer = &Layer{}

View File

@ -0,0 +1,36 @@
package circuitbreaker
import (
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/mitchellh/mapstructure"
"github.com/pkg/errors"
)
type LayerOptions struct {
MatchURLs []string `mapstructure:"matchURLs"`
AuthorizedCIDRs []string `mapstructure:"authorizedCIDRs"`
TemplateBlock string `mapstructure:"templateBlock"`
}
func fromStoreOptions(storeOptions store.LayerOptions) (*LayerOptions, error) {
layerOptions := LayerOptions{
MatchURLs: []string{"*"},
AuthorizedCIDRs: []string{},
TemplateBlock: "default",
}
config := mapstructure.DecoderConfig{
Result: &layerOptions,
}
decoder, err := mapstructure.NewDecoder(&config)
if err != nil {
return nil, err
}
if err := decoder.Decode(storeOptions); err != nil {
return nil, errors.WithStack(err)
}
return &layerOptions, nil
}

View File

@ -0,0 +1,19 @@
package circuitbreaker
type Options struct {
TemplateDir string
}
type OptionFunc func(*Options)
func defaultOptions() *Options {
return &Options{
TemplateDir: "./templates",
}
}
func WithTemplateDir(templateDir string) OptionFunc {
return func(o *Options) {
o.TemplateDir = templateDir
}
}

View File

@ -0,0 +1,8 @@
package circuitbreaker
import (
_ "embed"
)
//go:embed layer-options.json
var RawLayerOptionsSchema []byte

View File

@ -0,0 +1,73 @@
package queue
import (
"sync"
"time"
"github.com/pkg/errors"
)
type DebouncerMap struct {
debouncers sync.Map
}
func NewDebouncerMap() *DebouncerMap {
return &DebouncerMap{
debouncers: sync.Map{},
}
}
func (m *DebouncerMap) Do(key string, after time.Duration, fn func()) {
newDebouncer := NewDebouncer(after)
rawDebouncer, loaded := m.debouncers.LoadOrStore(key, newDebouncer)
debouncer, ok := rawDebouncer.(*Debouncer)
if !ok {
panic(errors.Errorf("unexpected debouncer value, expected '%T', got '%T'", newDebouncer, rawDebouncer))
}
if loaded {
debouncer.Update(after)
}
debouncer.Do(fn)
}
func NewDebouncer(after time.Duration) *Debouncer {
return &Debouncer{after: after}
}
type Debouncer struct {
mu sync.Mutex
after time.Duration
timer *time.Timer
fn func()
}
func (d *Debouncer) Do(fn func()) {
d.mu.Lock()
defer d.mu.Unlock()
if d.timer != nil {
d.timer.Stop()
}
d.fn = fn
d.timer = time.AfterFunc(d.after, d.fn)
}
func (d *Debouncer) Update(after time.Duration) {
d.mu.Lock()
defer d.mu.Unlock()
if after == d.after {
return
}
if d.timer != nil {
d.timer.Stop()
}
d.after = after
d.timer = time.AfterFunc(d.after, d.fn)
}

View File

@ -11,15 +11,15 @@ import (
type LayerOptions struct {
Capacity int64 `mapstructure:"capacity"`
Matchers []string `mapstructure:"matchers"`
KeepAlive time.Duration `mapstructure:"keepAlive"`
MatchURLs []string `mapstructure:"matchURLs"`
}
func fromStoreOptions(storeOptions store.LayerOptions, defaultKeepAlive time.Duration) (*LayerOptions, error) {
layerOptions := LayerOptions{
Capacity: 1000,
Matchers: []string{"*"},
KeepAlive: defaultKeepAlive,
MatchURLs: []string{"*"},
}
config := mapstructure.DecoderConfig{

View File

@ -0,0 +1,31 @@
package queue
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
const (
metricNamespace = "bouncer_layer_queue"
metricLabelProxy = "proxy"
metricLabelLayer = "layer"
)
var (
metricQueueSessions = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "sessions",
Help: "Bouncer's queue layer current sessions",
Namespace: metricNamespace,
},
[]string{metricLabelProxy, metricLabelLayer},
)
metricQueueCapacity = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "capacity",
Help: "Bouncer's queue layer capacity",
Namespace: metricNamespace,
},
[]string{metricLabelProxy, metricLabelLayer},
)
)

View File

@ -1,6 +1,8 @@
package queue
import "time"
import (
"time"
)
type Options struct {
TemplateDir string

View File

@ -4,18 +4,22 @@ import (
"context"
"fmt"
"html/template"
"math/rand"
"net/http"
"path/filepath"
"strconv"
"sync"
"sync/atomic"
"time"
"forge.cadoles.com/Cadoles/go-proxy"
"forge.cadoles.com/Cadoles/go-proxy/wildcard"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director"
"forge.cadoles.com/cadoles/bouncer/internal/store"
"github.com/Masterminds/sprig/v3"
"github.com/google/uuid"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
"gitlab.com/wpetit/goweb/logger"
)
@ -30,7 +34,9 @@ type Queue struct {
loadOnce sync.Once
tmpl *template.Template
refreshJobRunning uint32
refreshJobRunning uint32
updateMetricsJobRunning uint32
postKeepAliveDebouncer *DebouncerMap
}
// LayerType implements director.MiddlewareLayer
@ -52,6 +58,15 @@ func (q *Queue) Middleware(layer *store.Layer) proxy.Middleware {
return
}
matches := wildcard.MatchAny(r.URL.String(), options.MatchURLs...)
if !matches {
h.ServeHTTP(w, r)
return
}
defer q.updateMetrics(ctx, layer.Proxy, layer.Name, options)
cookieName := q.getCookieName(layer.Name)
cookie, err := r.Cookie(cookieName)
@ -72,8 +87,6 @@ func (q *Queue) Middleware(layer *store.Layer) proxy.Middleware {
sessionID := cookie.Value
queueName := string(layer.Name)
q.refreshQueue(queueName, options.KeepAlive)
rank, err := q.adapter.Touch(ctx, queueName, sessionID)
if err != nil {
logger.Error(ctx, "could not retrieve session rank", logger.E(errors.WithStack(err)))
@ -102,6 +115,30 @@ func (q *Queue) Middleware(layer *store.Layer) proxy.Middleware {
}
}
func (q *Queue) updateSessionsMetric(ctx context.Context, proxyName store.ProxyName, layerName store.LayerName) {
if !atomic.CompareAndSwapUint32(&q.updateMetricsJobRunning, 0, 1) {
return
}
defer atomic.StoreUint32(&q.updateMetricsJobRunning, 0)
queueName := string(layerName)
status, err := q.adapter.Status(ctx, queueName)
if err != nil {
logger.Error(ctx, "could not retrieve queue status", logger.E(errors.WithStack(err)))
return
}
metricQueueSessions.With(
prometheus.Labels{
metricLabelLayer: string(layerName),
metricLabelProxy: string(proxyName),
},
).Set(float64(status.Sessions))
}
func (q *Queue) renderQueuePage(w http.ResponseWriter, r *http.Request, queueName string, options *LayerOptions, rank int64) {
ctx := r.Context()
@ -135,22 +172,26 @@ func (q *Queue) renderQueuePage(w http.ResponseWriter, r *http.Request, queueNam
return
}
refreshRate := time.Duration(int64(options.KeepAlive.Seconds()/2)) * time.Second
templateData := struct {
QueueName string
LayerOptions *LayerOptions
Rank int64
CurrentSessions int64
MaxSessions int64
RefreshRate int64
RefreshRate time.Duration
}{
QueueName: queueName,
LayerOptions: options,
Rank: rank + 1,
CurrentSessions: status.Sessions,
MaxSessions: options.Capacity,
RefreshRate: 5,
RefreshRate: refreshRate,
}
w.Header().Add("Cache-Control", "no-cache")
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 {
@ -161,28 +202,59 @@ func (q *Queue) renderQueuePage(w http.ResponseWriter, r *http.Request, queueNam
}
}
func (q *Queue) refreshQueue(queueName string, keepAlive time.Duration) {
func (q *Queue) refreshQueue(ctx context.Context, layerName store.LayerName, keepAlive time.Duration) {
if !atomic.CompareAndSwapUint32(&q.refreshJobRunning, 0, 1) {
return
}
go func() {
defer atomic.StoreUint32(&q.refreshJobRunning, 0)
defer atomic.StoreUint32(&q.refreshJobRunning, 0)
ctx, cancel := context.WithTimeout(context.Background(), keepAlive*2)
defer cancel()
if err := q.adapter.Refresh(ctx, string(layerName), keepAlive); err != nil {
logger.Error(ctx, "could not refresh queue",
logger.E(errors.WithStack(err)),
logger.F("queue", layerName),
)
}
}
if err := q.adapter.Refresh(ctx, queueName, keepAlive); err != nil {
logger.Error(ctx, "could not refresh queue",
logger.E(errors.WithStack(err)),
logger.F("queue", queueName),
)
}
}()
func (q *Queue) updateMetrics(ctx context.Context, proxyName store.ProxyName, layerName store.LayerName, options *LayerOptions) {
// Update queue capacity metric
metricQueueCapacity.With(
prometheus.Labels{
metricLabelLayer: string(layerName),
metricLabelProxy: string(proxyName),
},
).Set(float64(options.Capacity))
// Refresh queue data and metrics
q.refreshQueue(ctx, layerName, options.KeepAlive)
q.updateSessionsMetric(ctx, proxyName, layerName)
// (Re)schedule an update job after session ttl + semi-random time padding
// to update metrics after last session expiration
randDuration := rand.Int63n(int64(options.KeepAlive))
timePadding := options.KeepAlive/2 + time.Duration(randDuration)
after := options.KeepAlive + timePadding
debouncingKey := fmt.Sprintf("%s/%s", proxyName, layerName)
q.postKeepAliveDebouncer.Do(debouncingKey, after, func() {
ctx := logger.With(
context.Background(),
logger.F("proxy", proxyName),
logger.F("layer", layerName),
logger.F("after", after),
)
logger.Info(ctx, "running post keep alive refresh job")
q.refreshQueue(ctx, layerName, options.KeepAlive)
q.updateSessionsMetric(ctx, proxyName, layerName)
})
}
func (q *Queue) getCookieName(layerName store.LayerName) string {
return fmt.Sprintf("_%s_%s", LayerType, layerName)
return fmt.Sprintf("_bouncer_%s_%s", LayerType, layerName)
}
func New(adapter Adapter, funcs ...OptionFunc) *Queue {
@ -192,9 +264,10 @@ func New(adapter Adapter, funcs ...OptionFunc) *Queue {
}
return &Queue{
adapter: adapter,
templateDir: opts.TemplateDir,
defaultKeepAlive: opts.DefaultKeepAlive,
adapter: adapter,
templateDir: opts.TemplateDir,
defaultKeepAlive: opts.DefaultKeepAlive,
postKeepAliveDebouncer: NewDebouncerMap(),
}
}

View File

@ -30,7 +30,7 @@ func (a *Adapter) Refresh(ctx context.Context, queueName string, keepAlive time.
cmd := tx.ZRangeByScore(ctx, lastSeenKey, &redis.ZRangeBy{
Min: "0",
Max: strconv.FormatInt(expires.Unix(), 10),
Max: strconv.FormatInt(expires.UnixNano(), 10),
})
members, err := cmd.Result()
@ -75,7 +75,7 @@ func (a *Adapter) Touch(ctx context.Context, queueName string, sessionId string)
for retry > 0 {
err := withTx(ctx, a.client, func(ctx context.Context, tx *redis.Tx) error {
now := time.Now().UTC().Unix()
now := time.Now().UTC().UnixNano()
err := tx.ZAddNX(ctx, rankKey, redis.Z{Score: float64(now), Member: sessionId}).Err()
if err != nil {

View File

@ -0,0 +1,12 @@
package redis
import (
"testing"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/queue/testsuite"
)
func TestAdapter(t *testing.T) {
adapter := NewAdapter(client, 3)
testsuite.TestAdapter(t, adapter)
}

View File

@ -0,0 +1,58 @@
package redis
import (
"context"
"log"
"os"
"testing"
"github.com/ory/dockertest/v3"
"github.com/pkg/errors"
"github.com/redis/go-redis/v9"
)
var client redis.UniversalClient
func TestMain(m *testing.M) {
// uses a sensible default on windows (tcp/http) and linux/osx (socket)
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("%+v", errors.WithStack(err))
}
// uses pool to try to connect to Docker
err = pool.Client.Ping()
if err != nil {
log.Fatalf("%+v", errors.WithStack(err))
}
// pulls an image, creates a container based on it and runs it
resource, err := pool.Run("redis", "alpine3.17", []string{})
if err != nil {
log.Fatalf("%+v", errors.WithStack(err))
}
if err := pool.Retry(func() error {
client = redis.NewUniversalClient(&redis.UniversalOptions{
Addrs: []string{resource.GetHostPort("6379/tcp")},
})
ctx := context.Background()
if cmd := client.Ping(ctx); cmd.Err() != nil {
return errors.WithStack(err)
}
return nil
}); err != nil {
log.Fatalf("%+v", errors.WithStack(err))
}
code := m.Run()
if err := pool.Purge(resource); err != nil {
log.Fatalf("%+v", errors.WithStack(err))
}
os.Exit(code)
}

View File

@ -9,6 +9,12 @@
},
"keepAlive": {
"type": "string"
},
"matchURLs": {
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false

View File

@ -0,0 +1,99 @@
package testsuite
import (
"context"
"fmt"
"testing"
"time"
"forge.cadoles.com/cadoles/bouncer/internal/proxy/director/layer/queue"
"github.com/pkg/errors"
)
type adapterTestCase struct {
Name string
Do func(adapter queue.Adapter) error
}
var adapterTestCases = []adapterTestCase{
{
Name: "Test queue ranking",
Do: func(adapter queue.Adapter) error {
ctx := context.Background()
queueName := "test_queue_ranking"
sessionIdPattern := "session-%d"
totalSessions := int64(100)
for idx := int64(0); idx < totalSessions; idx++ {
sessionId := fmt.Sprintf(sessionIdPattern, idx)
rank, err := adapter.Touch(ctx, queueName, sessionId)
if err != nil {
return errors.Wrapf(err, "could not touch session '%s' (index: %d, rank: %d)", sessionId, idx, rank)
}
if e, g := int64(idx), rank; e != g {
return errors.Errorf("rank('%s'): expected '%v', got '%v'", sessionId, e, g)
}
}
status, err := adapter.Status(ctx, queueName)
if err != nil {
return errors.Wrap(err, "could not retrieve queue status")
}
if e, g := totalSessions, status.Sessions; e != g {
return errors.Errorf("status.Sessions: expected '%v', got '%v'", e, g)
}
return nil
},
},
{
Name: "Test session expiration",
Do: func(adapter queue.Adapter) error {
ctx := context.Background()
queueName := "test_session_expiration"
sessionId := "session-1"
rank, err := adapter.Touch(ctx, queueName, sessionId)
if err != nil {
return errors.Wrapf(err, "could not touch session '%s'", sessionId)
}
if e, g := int64(0), rank; e != g {
return errors.Errorf("rank('%s'): expected '%v', got '%v'", sessionId, e, g)
}
<-time.After(time.Second)
if err := adapter.Refresh(ctx, queueName, time.Second); err != nil {
return errors.Wrap(err, "could not refresh queue")
}
status, err := adapter.Status(ctx, queueName)
if err != nil {
return errors.Wrap(err, "could not retrieve queue status")
}
if e, g := int64(0), status.Sessions; e != g {
return errors.Errorf("status.Sessions: expected '%v', got '%v'", e, g)
}
return nil
},
},
}
func TestAdapter(t *testing.T, adapter queue.Adapter) {
for _, tc := range adapterTestCases {
func(tc adapterTestCase) {
t.Run(tc.Name, func(t *testing.T) {
if err := tc.Do(adapter); err != nil {
t.Fatalf("%+v", errors.WithStack(err))
}
})
}(tc)
}
}

View File

@ -0,0 +1,20 @@
package director
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
const (
metricNamespace = "bouncer_proxy_director"
metricLabelProxy = "proxy"
)
var metricProxyRequestsTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "proxy_requests_total",
Help: "Bouncer proxy total requests",
Namespace: metricNamespace,
},
[]string{metricLabelProxy},
)

Some files were not shown because too many files have changed in this diff Show More