From a6d462f6e83915b6c6816c3c483d217157690728 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 19 Jan 2024 15:12:05 +0100 Subject: [PATCH 01/14] feat: upgrade gh-action os --- .github/workflows/build.yaml | 4 ++-- .github/workflows/check_doc.yml | 2 +- .github/workflows/documentation.yml | 2 +- .github/workflows/experimental.yaml | 2 +- .github/workflows/test-integration.yaml | 4 ++-- .github/workflows/test-unit.yaml | 2 +- .github/workflows/validate.yaml | 4 ++-- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d7cf99bcb..b0c36ebe8 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -12,7 +12,7 @@ env: jobs: build-webui: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Check out code @@ -35,7 +35,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ ubuntu-20.04, macos-latest, windows-latest ] + os: [ ubuntu-22.04, macos-latest, windows-latest ] needs: - build-webui diff --git a/.github/workflows/check_doc.yml b/.github/workflows/check_doc.yml index 9f08efbb1..9851e1a54 100644 --- a/.github/workflows/check_doc.yml +++ b/.github/workflows/check_doc.yml @@ -9,7 +9,7 @@ jobs: docs: name: Check, verify and build documentation - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Check out code diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 5a1f215d3..fd08fe4de 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -14,7 +14,7 @@ jobs: docs: name: Doc Process - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 if: github.repository == 'traefik/traefik' steps: diff --git a/.github/workflows/experimental.yaml b/.github/workflows/experimental.yaml index a26178315..489401f50 100644 --- a/.github/workflows/experimental.yaml +++ b/.github/workflows/experimental.yaml @@ -15,7 +15,7 @@ jobs: experimental: if: github.repository == 'traefik/traefik' name: Build experimental image on branch - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index d5c4e5ca1..5155fa78f 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -15,7 +15,7 @@ env: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Check out code @@ -35,7 +35,7 @@ jobs: run: make binary test-integration: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 needs: - build strategy: diff --git a/.github/workflows/test-unit.yaml b/.github/workflows/test-unit.yaml index 7719186f7..0ab4ab0a5 100644 --- a/.github/workflows/test-unit.yaml +++ b/.github/workflows/test-unit.yaml @@ -11,7 +11,7 @@ env: jobs: test-unit: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Check out code diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index f836e94ee..a05fed052 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -13,7 +13,7 @@ env: jobs: validate: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Check out code @@ -39,7 +39,7 @@ jobs: run: make validate validate-generate: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Check out code From 8da38ec0a56f164e37bb87daa8b2a68c1e02f64d Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 19 Jan 2024 15:44:05 +0100 Subject: [PATCH 02/14] fix: tailscale is required for Docker Desktop users --- integration/integration_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/integration/integration_test.go b/integration/integration_test.go index 0ded401de..4ce934535 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -37,6 +37,8 @@ import ( var showLog = flag.Bool("tlog", false, "always show Traefik logs") +const tailscaleSecretFilePath = "tailscale.secret" + type composeConfig struct { Services map[string]composeService `yaml:"services"` } @@ -99,6 +101,11 @@ func (s *BaseSuite) displayTraefikLogFile(path string) { } func (s *BaseSuite) SetupSuite() { + if isDockerDesktop(context.Background(), s.T()) { + _, err := os.Stat(tailscaleSecretFilePath) + require.NoError(s.T(), err, "Tailscale need to be configured when running integration tests with Docker Desktop: (https://doc.traefik.io/traefik/v2.11/contributing/building-testing/#testing)") + } + // configure default standard log. stdlog.SetFlags(stdlog.Lshortfile | stdlog.LstdFlags) // TODO @@ -124,7 +131,7 @@ func (s *BaseSuite) SetupSuite() { s.hostIP = "172.31.42.1" if isDockerDesktop(ctx, s.T()) { s.hostIP = getDockerDesktopHostIP(ctx, s.T()) - s.setupVPN("tailscale.secret") + s.setupVPN(tailscaleSecretFilePath) } } From 5e0855ecc7c5e9422cc7d6df565ca12523346b94 Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 22 Jan 2024 15:30:05 +0100 Subject: [PATCH 03/14] feat: adds conformance test gateway api --- .github/workflows/test-conformance.yaml | 37 +++ .github/workflows/test-integration.yaml | 3 - .gitignore | 1 + Makefile | 5 + go.mod | 10 +- go.sum | 24 +- .../fixtures/k8s_gateway_conformance.toml | 21 ++ integration/integration_test.go | 6 +- integration/k8s_conformance_test.go | 229 ++++++++++++++++++ pkg/provider/kubernetes/gateway/kubernetes.go | 12 +- 10 files changed, 333 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/test-conformance.yaml create mode 100644 integration/fixtures/k8s_gateway_conformance.toml create mode 100644 integration/k8s_conformance_test.go diff --git a/.github/workflows/test-conformance.yaml b/.github/workflows/test-conformance.yaml new file mode 100644 index 000000000..70e2cbe7c --- /dev/null +++ b/.github/workflows/test-conformance.yaml @@ -0,0 +1,37 @@ +name: Test K8s Gateway API conformance + +on: + pull_request: + branches: + - '*' + paths: + - 'pkg/provider/kubernetes/gateway' + +env: + GO_VERSION: '1.21' + CGO_ENABLED: 0 + +jobs: + + test-conformance: + runs-on: ubuntu-20.04 + + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Avoid generating webui + run: touch webui/static/index.html + + - name: Build binary + run: make binary + + - name: K8s Gateway API conformance test + run: sudo make test-gateway-api-conformance diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 5155fa78f..0625da388 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -4,9 +4,6 @@ on: pull_request: branches: - '*' - push: - branches: - - 'gh-actions' env: GO_VERSION: '1.21' diff --git a/.gitignore b/.gitignore index 3fd473f24..03a5e9369 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ plugins-storage/ plugins-local/ traefik_changelog.md integration/tailscale.secret +integration/conformance-reports/ diff --git a/Makefile b/Makefile index 15565a3ce..93498e5c9 100644 --- a/Makefile +++ b/Makefile @@ -99,6 +99,11 @@ test-unit: test-integration: binary GOOS=$(GOOS) GOARCH=$(GOARCH) go test ./integration -test.timeout=20m -failfast -v $(TESTFLAGS) +## Run the conformance tests +.PHONY: test-gateway-api-conformance +test-gateway-api-conformance: binary + GOOS=$(GOOS) GOARCH=$(GOARCH) go test ./integration -v -test.run K8sConformanceSuite -k8sConformance=true $(TESTFLAGS) + ## Pull all Docker images to avoid timeout during integration tests .PHONY: pull-images pull-images: diff --git a/go.mod b/go.mod index 1e3228e15..17fee2a7f 100644 --- a/go.mod +++ b/go.mod @@ -85,12 +85,13 @@ require ( golang.org/x/tools v0.14.0 google.golang.org/grpc v1.59.0 gopkg.in/yaml.v3 v3.0.1 - k8s.io/api v0.28.3 + k8s.io/api v0.28.4 k8s.io/apiextensions-apiserver v0.28.3 - k8s.io/apimachinery v0.28.3 - k8s.io/client-go v0.28.3 + k8s.io/apimachinery v0.28.4 + k8s.io/client-go v0.28.4 k8s.io/utils v0.0.0-20230726121419-3b25d923346b mvdan.cc/xurls/v2 v2.5.0 + sigs.k8s.io/controller-runtime v0.16.3 sigs.k8s.io/gateway-api v1.0.0 ) @@ -162,6 +163,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/evanphx/json-patch v5.7.0+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.7.0 // indirect github.com/exoscale/egoscale v0.100.1 // indirect github.com/fatih/color v1.15.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect @@ -236,6 +238,7 @@ require ( github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/spdystream v0.2.0 // indirect github.com/moby/sys/sequential v0.5.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -254,7 +257,6 @@ require ( github.com/nzdjb/go-metaname v1.0.0 // indirect github.com/onsi/ginkgo v1.16.5 // indirect github.com/onsi/ginkgo/v2 v2.11.0 // indirect - github.com/onsi/gomega v1.27.10 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc5 // indirect github.com/opencontainers/runc v1.1.5 // indirect diff --git a/go.sum b/go.sum index 9c0c8a0eb..28bc21641 100644 --- a/go.sum +++ b/go.sum @@ -114,6 +114,8 @@ github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= @@ -302,6 +304,8 @@ github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go. github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/evanphx/json-patch v5.7.0+incompatible h1:vgGkfT/9f8zE6tvSCe74nfpAVDQ2tG6yudJd8LBksgI= github.com/evanphx/json-patch v5.7.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.7.0 h1:nJqP7uwL84RJInrohHfW0Fx3awjbm8qZeFv0nW9SYGc= +github.com/evanphx/json-patch/v5 v5.7.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= github.com/exoscale/egoscale v0.100.1 h1:iXsV1Ei7daqe/6FYSCSDyrFs1iUG1l1X9qNh2uMw6z0= github.com/exoscale/egoscale v0.100.1/go.mod h1:BAb9p4rmyU+Wl400CJZO5270H2sXtdsZjLcm5xMKkz4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -358,6 +362,8 @@ 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-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.2.4 h1:QHVo+6stLbfJmYGkQ7uGHUCu5hnAFAj6mDe6Ea0SeOo= +github.com/go-logr/zapr v1.2.4/go.mod h1:FyHWQIzQORZ0QVE1BtVHv3cKtNLuXsbNLtpuhNapBOA= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -796,6 +802,8 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= +github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= github.com/moby/sys/mountinfo v0.5.0/go.mod h1:3bMD3Rg+zkqx8MRYPi7Pyb0Ie97QEBmdxbhnCLlSvSU= github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= @@ -1485,6 +1493,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T 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= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= gonum.org/v1/gonum v0.8.2 h1:CCXrcPKiGGotvnN6jfUsKk4rRqm7q09/YbKb5xCEvtM= gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= @@ -1619,14 +1629,14 @@ honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -k8s.io/api v0.28.3 h1:Gj1HtbSdB4P08C8rs9AR94MfSGpRhJgsS+GF9V26xMM= -k8s.io/api v0.28.3/go.mod h1:MRCV/jr1dW87/qJnZ57U5Pak65LGmQVkKTzf3AtKFHc= +k8s.io/api v0.28.4 h1:8ZBrLjwosLl/NYgv1P7EQLqoO8MGQApnbgH8tu3BMzY= +k8s.io/api v0.28.4/go.mod h1:axWTGrY88s/5YE+JSt4uUi6NMM+gur1en2REMR7IRj0= k8s.io/apiextensions-apiserver v0.28.3 h1:Od7DEnhXHnHPZG+W9I97/fSQkVpVPQx2diy+2EtmY08= k8s.io/apiextensions-apiserver v0.28.3/go.mod h1:NE1XJZ4On0hS11aWWJUTNkmVB03j9LM7gJSisbRt8Lc= -k8s.io/apimachinery v0.28.3 h1:B1wYx8txOaCQG0HmYF6nbpU8dg6HvA06x5tEffvOe7A= -k8s.io/apimachinery v0.28.3/go.mod h1:uQTKmIqs+rAYaq+DFaoD2X7pcjLOqbQX2AOiO0nIpb8= -k8s.io/client-go v0.28.3 h1:2OqNb72ZuTZPKCl+4gTKvqao0AMOl9f3o2ijbAj3LI4= -k8s.io/client-go v0.28.3/go.mod h1:LTykbBp9gsA7SwqirlCXBWtK0guzfhpoW4qSm7i9dxo= +k8s.io/apimachinery v0.28.4 h1:zOSJe1mc+GxuMnFzD4Z/U1wst50X28ZNsn5bhgIIao8= +k8s.io/apimachinery v0.28.4/go.mod h1:wI37ncBvfAoswfq626yPTe6Bz1c22L7uaJ8dho83mgg= +k8s.io/client-go v0.28.4 h1:Np5ocjlZcTrkyRJ3+T3PkXDpe4UpatQxj85+xjaD2wY= +k8s.io/client-go v0.28.4/go.mod h1:0VDZFpgoZfelyP5Wqu0/r/TRYcLYuJ2U1KEeoaPa1N4= k8s.io/klog/v2 v2.100.1 h1:7WCHKK6K8fNhTqfBhISHQ97KrnJNFZMcQvKp7gP/tmg= k8s.io/klog/v2 v2.100.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780= @@ -1640,6 +1650,8 @@ nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0 nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +sigs.k8s.io/controller-runtime v0.16.3 h1:2TuvuokmfXvDUamSx1SuAOO3eTyye+47mJCigwG62c4= +sigs.k8s.io/controller-runtime v0.16.3/go.mod h1:j7bialYoSn142nv9sCOJmQgDXQXxnroFU4VnX/brVJ0= sigs.k8s.io/gateway-api v1.0.0 h1:iPTStSv41+d9p0xFydll6d7f7MOBGuqXM6p2/zVYMAs= sigs.k8s.io/gateway-api v1.0.0/go.mod h1:4cUgr0Lnp5FZ0Cdq8FdRwCvpiWws7LVhLHGIudLlf4c= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= diff --git a/integration/fixtures/k8s_gateway_conformance.toml b/integration/fixtures/k8s_gateway_conformance.toml new file mode 100644 index 000000000..5114f2dfb --- /dev/null +++ b/integration/fixtures/k8s_gateway_conformance.toml @@ -0,0 +1,21 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + noColor = true + +[api] + insecure = true + +[experimental] + kubernetesGateway = true + +[entryPoints] + [entryPoints.web] + address = ":80" + [entryPoints.websecure] + address = ":443" + +[providers.kubernetesGateway] diff --git a/integration/integration_test.go b/integration/integration_test.go index 0e9d22d78..51df6870c 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -35,7 +35,11 @@ import ( "gopkg.in/yaml.v3" ) -var showLog = flag.Bool("tlog", false, "always show Traefik logs") +var ( + showLog = flag.Bool("tlog", false, "always show Traefik logs") + k8sConformance = flag.Bool("k8sConformance", false, "run K8s Gateway API conformance test") + k8sConformanceRunTest = flag.String("k8sConformanceRunTest", "", "run a specific K8s Gateway API conformance test") +) const tailscaleSecretFilePath = "tailscale.secret" diff --git a/integration/k8s_conformance_test.go b/integration/k8s_conformance_test.go new file mode 100644 index 000000000..f5c869f6c --- /dev/null +++ b/integration/k8s_conformance_test.go @@ -0,0 +1,229 @@ +package integration + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "github.com/traefik/traefik/v3/integration/try" + "github.com/traefik/traefik/v3/pkg/version" + "gopkg.in/yaml.v3" + ktypes "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + kclientset "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/controller-runtime/pkg/client" + gatev1 "sigs.k8s.io/gateway-api/apis/v1" + gatev1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gatev1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + conformanceV1alpha1 "sigs.k8s.io/gateway-api/conformance/apis/v1alpha1" + "sigs.k8s.io/gateway-api/conformance/tests" + "sigs.k8s.io/gateway-api/conformance/utils/config" + ksuite "sigs.k8s.io/gateway-api/conformance/utils/suite" +) + +// K8sConformanceSuite tests suite. +type K8sConformanceSuite struct{ BaseSuite } + +func TestK8sConformanceSuite(t *testing.T) { + suite.Run(t, new(K8sConformanceSuite)) +} + +func (s *K8sConformanceSuite) SetupSuite() { + s.BaseSuite.SetupSuite() + + s.createComposeProject("k8s") + s.composeUp() + + abs, err := filepath.Abs("./fixtures/k8s/config.skip/kubeconfig.yaml") + require.NoError(s.T(), err) + + err = try.Do(60*time.Second, func() error { + _, err := os.Stat(abs) + return err + }) + require.NoError(s.T(), err) + + data, err := os.ReadFile(abs) + require.NoError(s.T(), err) + + content := strings.ReplaceAll(string(data), "https://server:6443", fmt.Sprintf("https://%s", net.JoinHostPort(s.getComposeServiceIP("server"), "6443"))) + + err = os.WriteFile(abs, []byte(content), 0o644) + require.NoError(s.T(), err) + + err = os.Setenv("KUBECONFIG", abs) + require.NoError(s.T(), err) +} + +func (s *K8sConformanceSuite) TearDownSuite() { + s.BaseSuite.TearDownSuite() + + generatedFiles := []string{ + "./fixtures/k8s/config.skip/kubeconfig.yaml", + "./fixtures/k8s/config.skip/k3s.log", + "./fixtures/k8s/rolebindings.yaml", + "./fixtures/k8s/ccm.yaml", + } + + for _, filename := range generatedFiles { + if err := os.Remove(filename); err != nil { + log.Warn().Err(err).Send() + } + } +} + +func (s *K8sConformanceSuite) TestK8sGatewayAPIConformance() { + if !*k8sConformance { + s.T().Skip("Skip because it can take a long time to execute. To enable pass the `k8sConformance` flag.") + } + + configFromFlags, err := clientcmd.BuildConfigFromFlags("", os.Getenv("KUBECONFIG")) + if err != nil { + s.T().Fatal(err) + } + + kClient, err := client.New(configFromFlags, client.Options{}) + if err != nil { + s.T().Fatalf("Error initializing Kubernetes client: %v", err) + } + + kClientSet, err := kclientset.NewForConfig(configFromFlags) + if err != nil { + s.T().Fatal(err) + } + + err = gatev1alpha2.AddToScheme(kClient.Scheme()) + require.NoError(s.T(), err) + err = gatev1beta1.AddToScheme(kClient.Scheme()) + require.NoError(s.T(), err) + err = gatev1.AddToScheme(kClient.Scheme()) + require.NoError(s.T(), err) + + s.traefikCmd(withConfigFile("fixtures/k8s_gateway_conformance.toml")) + + // Wait for traefik to start + err = try.GetRequest("http://127.0.0.1:8080/api/entrypoints", 10*time.Second, try.BodyContains(`"name":"web"`)) + require.NoError(s.T(), err) + + err = try.Do(10*time.Second, func() error { + gwc := &gatev1.GatewayClass{} + err := kClient.Get(context.Background(), ktypes.NamespacedName{Name: "my-gateway-class"}, gwc) + if err != nil { + return fmt.Errorf("error fetching GatewayClass: %w", err) + } + + return nil + }) + require.NoError(s.T(), err) + + opts := ksuite.Options{ + Client: kClient, + RestConfig: configFromFlags, + Clientset: kClientSet, + GatewayClassName: "my-gateway-class", + Debug: true, + CleanupBaseResources: true, + TimeoutConfig: config.TimeoutConfig{ + CreateTimeout: 5 * time.Second, + DeleteTimeout: 5 * time.Second, + GetTimeout: 5 * time.Second, + GatewayMustHaveAddress: 5 * time.Second, + GatewayMustHaveCondition: 5 * time.Second, + GatewayStatusMustHaveListeners: 10 * time.Second, + GatewayListenersMustHaveCondition: 5 * time.Second, + GWCMustBeAccepted: 60 * time.Second, // Pod creation in k3s cluster can be long. + HTTPRouteMustNotHaveParents: 5 * time.Second, + HTTPRouteMustHaveCondition: 5 * time.Second, + TLSRouteMustHaveCondition: 5 * time.Second, + RouteMustHaveParents: 5 * time.Second, + ManifestFetchTimeout: 5 * time.Second, + MaxTimeToConsistency: 5 * time.Second, + NamespacesMustBeReady: 60 * time.Second, // Pod creation in k3s cluster can be long. + RequestTimeout: 5 * time.Second, + LatestObservedGenerationSet: 5 * time.Second, + RequiredConsecutiveSuccesses: 0, + }, + SupportedFeatures: sets.New[ksuite.SupportedFeature](). + Insert(ksuite.GatewayCoreFeatures.UnsortedList()...), + EnableAllSupportedFeatures: false, + RunTest: *k8sConformanceRunTest, + // Until the feature are all supported, following tests are skipped. + SkipTests: []string{ + "HTTPExactPathMatching", + "HTTPRouteHostnameIntersection", + "GatewaySecretReferenceGrantAllInNamespace", + "HTTPRouteListenerHostnameMatching", + "HTTPRouteRequestHeaderModifier", + "GatewaySecretInvalidReferenceGrant", + "GatewayClassObservedGenerationBump", + "HTTPRouteInvalidNonExistentBackendRef", + "GatewayWithAttachedRoutes", + "HTTPRouteCrossNamespace", + "HTTPRouteDisallowedKind", + "HTTPRouteInvalidReferenceGrant", + "HTTPRouteObservedGenerationBump", + "GatewayInvalidRouteKind", + "TLSRouteSimpleSameNamespace", + "TLSRouteInvalidReferenceGrant", + "HTTPRouteInvalidCrossNamespaceParentRef", + "HTTPRouteInvalidParentRefNotMatchingSectionName", + "GatewaySecretReferenceGrantSpecific", + "GatewayModifyListeners", + "GatewaySecretMissingReferenceGrant", + "GatewayInvalidTLSConfiguration", + "HTTPRouteInvalidCrossNamespaceBackendRef", + "HTTPRouteMatchingAcrossRoutes", + "HTTPRoutePartiallyInvalidViaInvalidReferenceGrant", + "HTTPRouteRedirectHostAndStatus", + "HTTPRouteInvalidBackendRefUnknownKind", + "HTTPRoutePathMatchOrder", + "HTTPRouteSimpleSameNamespace", + "HTTPRouteMatching", + "HTTPRouteHeaderMatching", + "HTTPRouteReferenceGrant", + }, + } + + cSuite, err := ksuite.NewExperimentalConformanceTestSuite(ksuite.ExperimentalConformanceOptions{ + Options: opts, + Implementation: conformanceV1alpha1.Implementation{ + Organization: "traefik", + Project: "traefik", + URL: "https://traefik.io/", + Version: version.Version, + Contact: []string{"@traefik/maintainers"}, + }, + ConformanceProfiles: sets.New[ksuite.ConformanceProfileName]( + ksuite.HTTPConformanceProfileName, + ksuite.TLSConformanceProfileName, + ), + }) + require.NoError(s.T(), err) + + cSuite.Setup(s.T()) + err = cSuite.Run(s.T(), tests.ConformanceTests) + require.NoError(s.T(), err) + + report, err := cSuite.Report() + require.NoError(s.T(), err, "failed generating conformance report") + + report.GatewayAPIVersion = "1.0.0" + + rawReport, err := yaml.Marshal(report) + require.NoError(s.T(), err) + s.T().Logf("Conformance report:\n%s", string(rawReport)) + + require.NoError(s.T(), os.MkdirAll("./conformance-reports", 0o755)) + outFile := filepath.Join("conformance-reports", fmt.Sprintf("traefik-traefik-%d.yaml", time.Now().UnixNano())) + require.NoError(s.T(), os.WriteFile(outFile, rawReport, 0o600)) + s.T().Logf("Report written to: %s", outFile) +} diff --git a/pkg/provider/kubernetes/gateway/kubernetes.go b/pkg/provider/kubernetes/gateway/kubernetes.go index c86467b0f..e9ed7e6a7 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes.go +++ b/pkg/provider/kubernetes/gateway/kubernetes.go @@ -230,6 +230,7 @@ func (p *Provider) loadConfigurationFromGateway(ctx context.Context, client Clie err := client.UpdateGatewayClassStatus(gatewayClass, metav1.Condition{ Type: string(gatev1.GatewayClassConditionStatusAccepted), Status: metav1.ConditionTrue, + ObservedGeneration: gatewayClass.Generation, Reason: "Handled", Message: "Handled by Traefik controller", LastTransitionTime: metav1.Now(), @@ -587,7 +588,16 @@ func (p *Provider) makeGatewayStatus(gateway *gatev1.Gateway, listenerStatuses [ Type: string(gatev1.GatewayConditionAccepted), Status: metav1.ConditionTrue, ObservedGeneration: gateway.Generation, - Reason: string(gatev1.GatewayConditionAccepted), + Reason: string(gatev1.GatewayReasonAccepted), + Message: "Gateway successfully scheduled", + LastTransitionTime: metav1.Now(), + }, + // update "Programmed" status with "Programmed" reason + metav1.Condition{ + Type: string(gatev1.GatewayConditionProgrammed), + Status: metav1.ConditionTrue, + ObservedGeneration: gateway.Generation, + Reason: string(gatev1.GatewayReasonProgrammed), Message: "Gateway successfully scheduled", LastTransitionTime: metav1.Now(), }, From 6cb2ff2af909d956cf19522143d221b843db9b40 Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 22 Jan 2024 16:04:05 +0100 Subject: [PATCH 04/14] fix: gateway api conformance tests --- .github/workflows/test-conformance.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-conformance.yaml b/.github/workflows/test-conformance.yaml index 70e2cbe7c..488bf452f 100644 --- a/.github/workflows/test-conformance.yaml +++ b/.github/workflows/test-conformance.yaml @@ -5,7 +5,8 @@ on: branches: - '*' paths: - - 'pkg/provider/kubernetes/gateway' + - 'pkg/provider/kubernetes/gateway/**' + - 'integration/k8s_conformance_test.go' env: GO_VERSION: '1.21' From bab48bed223e851414e6a6c52d6ed677e782cf70 Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 22 Jan 2024 16:38:06 +0100 Subject: [PATCH 05/14] fix: OpenTelemetry metrics flaky test --- pkg/metrics/opentelemetry_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/metrics/opentelemetry_test.go b/pkg/metrics/opentelemetry_test.go index c6bfe99eb..93fc31c99 100644 --- a/pkg/metrics/opentelemetry_test.go +++ b/pkg/metrics/opentelemetry_test.go @@ -287,8 +287,6 @@ func TestOpenTelemetry_GaugeCollectorSet(t *testing.T) { } func TestOpenTelemetry(t *testing.T) { - t.Parallel() - c := make(chan *string) defer close(c) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 177c4b0ed1cf04ee884fb8437d042874d6989c2a Mon Sep 17 00:00:00 2001 From: Michael Date: Mon, 22 Jan 2024 16:52:05 +0100 Subject: [PATCH 06/14] fix: flakiness test on configuration watcher --- pkg/server/configurationwatcher_test.go | 69 +++++++++++++------------ 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/pkg/server/configurationwatcher_test.go b/pkg/server/configurationwatcher_test.go index c65e6acf6..1f571effb 100644 --- a/pkg/server/configurationwatcher_test.go +++ b/pkg/server/configurationwatcher_test.go @@ -23,7 +23,7 @@ type mockProvider struct { throttleDuration time.Duration } -func (p *mockProvider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { +func (p *mockProvider) Provide(configurationChan chan<- dynamic.Message, _ *safe.Pool) error { wait := p.wait if wait == 0 { wait = 20 * time.Millisecond @@ -48,7 +48,7 @@ func (p *mockProvider) Provide(configurationChan chan<- dynamic.Message, pool *s } // ThrottleDuration returns the throttle duration. -func (p mockProvider) ThrottleDuration() time.Duration { +func (p *mockProvider) ThrottleDuration() time.Duration { return p.throttleDuration } @@ -122,7 +122,7 @@ func TestWaitForRequiredProvider(t *testing.T) { config := &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("foo")), + th.WithRouters(th.WithRouter("foo", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bar")), ), } @@ -166,19 +166,19 @@ func TestIgnoreTransientConfiguration(t *testing.T) { config := &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("foo")), + th.WithRouters(th.WithRouter("foo", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bar")), ), } config2 := &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("baz")), + th.WithRouters(th.WithRouter("baz", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("toto")), ), } - watcher := NewConfigurationWatcher(routinesPool, &mockProvider{}, []string{"defaultEP"}, "") + watcher := NewConfigurationWatcher(routinesPool, &mockProvider{}, []string{}, "") publishedConfigCount := 0 var lastConfig dynamic.Configuration @@ -209,17 +209,20 @@ func TestIgnoreTransientConfiguration(t *testing.T) { Configuration: config, } - close(blockConfConsumer) - - // give some time so that the configuration can be processed + // give some time before closing the channel. time.Sleep(20 * time.Millisecond) - // after 20 milliseconds we should have 1 configs published + close(blockConfConsumer) + + // give some time so that the configuration can be processed. + time.Sleep(20 * time.Millisecond) + + // after 20 milliseconds we should have 1 config published. assert.Equal(t, 1, publishedConfigCount, "times configs were published") expected := dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("foo@mock", th.WithEntryPoints("defaultEP"))), + th.WithRouters(th.WithRouter("foo@mock", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bar@mock")), th.WithMiddlewares(), ), @@ -256,7 +259,7 @@ func TestListenProvidersThrottleProviderConfigReload(t *testing.T) { ProviderName: "mock", Configuration: &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("foo"+strconv.Itoa(i))), + th.WithRouters(th.WithRouter("foo"+strconv.Itoa(i), th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bar")), ), }, @@ -316,7 +319,7 @@ func TestListenProvidersSkipsSameConfigurationForProvider(t *testing.T) { ProviderName: "mock", Configuration: &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("foo")), + th.WithRouters(th.WithRouter("foo", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bar")), ), }, @@ -348,14 +351,14 @@ func TestListenProvidersDoesNotSkipFlappingConfiguration(t *testing.T) { configuration := &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("foo")), + th.WithRouters(th.WithRouter("foo", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bar")), ), } transientConfiguration := &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("bad")), + th.WithRouters(th.WithRouter("bad", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bad")), ), } @@ -370,7 +373,7 @@ func TestListenProvidersDoesNotSkipFlappingConfiguration(t *testing.T) { }, } - watcher := NewConfigurationWatcher(routinesPool, pvd, []string{"defaultEP"}, "") + watcher := NewConfigurationWatcher(routinesPool, pvd, []string{}, "") var lastConfig dynamic.Configuration watcher.AddListener(func(conf dynamic.Configuration) { @@ -387,7 +390,7 @@ func TestListenProvidersDoesNotSkipFlappingConfiguration(t *testing.T) { expected := dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("foo@mock", th.WithEntryPoints("defaultEP"))), + th.WithRouters(th.WithRouter("foo@mock", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bar@mock")), th.WithMiddlewares(), ), @@ -416,14 +419,14 @@ func TestListenProvidersIgnoreSameConfig(t *testing.T) { configuration := &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("foo")), + th.WithRouters(th.WithRouter("foo", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bar")), ), } transientConfiguration := &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("bad")), + th.WithRouters(th.WithRouter("bad", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bad")), ), } @@ -449,7 +452,7 @@ func TestListenProvidersIgnoreSameConfig(t *testing.T) { err := providerAggregator.AddProvider(pvd) assert.NoError(t, err) - watcher := NewConfigurationWatcher(routinesPool, providerAggregator, []string{"defaultEP"}, "") + watcher := NewConfigurationWatcher(routinesPool, providerAggregator, []string{}, "") var configurationReloads int var lastConfig dynamic.Configuration @@ -476,7 +479,7 @@ func TestListenProvidersIgnoreSameConfig(t *testing.T) { expected := dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("foo@mock", th.WithEntryPoints("defaultEP"))), + th.WithRouters(th.WithRouter("foo@mock", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bar@mock")), th.WithMiddlewares(), ), @@ -505,7 +508,7 @@ func TestListenProvidersIgnoreSameConfig(t *testing.T) { func TestApplyConfigUnderStress(t *testing.T) { routinesPool := safe.NewPool(context.Background()) - watcher := NewConfigurationWatcher(routinesPool, &mockProvider{}, []string{"defaultEP"}, "") + watcher := NewConfigurationWatcher(routinesPool, &mockProvider{}, []string{}, "") routinesPool.GoCtx(func(ctx context.Context) { i := 0 @@ -515,7 +518,7 @@ func TestApplyConfigUnderStress(t *testing.T) { return case watcher.allProvidersConfigs <- dynamic.Message{ProviderName: "mock", Configuration: &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("foo"+strconv.Itoa(i))), + th.WithRouters(th.WithRouter("foo"+strconv.Itoa(i), th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bar")), ), }}: @@ -550,28 +553,28 @@ func TestListenProvidersIgnoreIntermediateConfigs(t *testing.T) { configuration := &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("foo")), + th.WithRouters(th.WithRouter("foo", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bar")), ), } transientConfiguration := &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("bad")), + th.WithRouters(th.WithRouter("bad", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bad")), ), } transientConfiguration2 := &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("bad2")), + th.WithRouters(th.WithRouter("bad2", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bad2")), ), } finalConfiguration := &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("final")), + th.WithRouters(th.WithRouter("final", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("final")), ), } @@ -591,7 +594,7 @@ func TestListenProvidersIgnoreIntermediateConfigs(t *testing.T) { err := providerAggregator.AddProvider(pvd) assert.NoError(t, err) - watcher := NewConfigurationWatcher(routinesPool, providerAggregator, []string{"defaultEP"}, "") + watcher := NewConfigurationWatcher(routinesPool, providerAggregator, []string{}, "") var configurationReloads int var lastConfig dynamic.Configuration @@ -610,7 +613,7 @@ func TestListenProvidersIgnoreIntermediateConfigs(t *testing.T) { expected := dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("final@mock", th.WithEntryPoints("defaultEP"))), + th.WithRouters(th.WithRouter("final@mock", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("final@mock")), th.WithMiddlewares(), ), @@ -641,7 +644,7 @@ func TestListenProvidersPublishesConfigForEachProvider(t *testing.T) { configuration := &dynamic.Configuration{ HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("foo")), + th.WithRouters(th.WithRouter("foo", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bar")), ), } @@ -653,7 +656,7 @@ func TestListenProvidersPublishesConfigForEachProvider(t *testing.T) { }, } - watcher := NewConfigurationWatcher(routinesPool, pvd, []string{"defaultEP"}, "") + watcher := NewConfigurationWatcher(routinesPool, pvd, []string{}, "") var publishedProviderConfig dynamic.Configuration @@ -672,8 +675,8 @@ func TestListenProvidersPublishesConfigForEachProvider(t *testing.T) { expected := dynamic.Configuration{ HTTP: th.BuildConfiguration( th.WithRouters( - th.WithRouter("foo@mock", th.WithEntryPoints("defaultEP")), - th.WithRouter("foo@mock2", th.WithEntryPoints("defaultEP")), + th.WithRouter("foo@mock", th.WithEntryPoints("ep")), + th.WithRouter("foo@mock2", th.WithEntryPoints("ep")), ), th.WithLoadBalancerServices( th.WithService("bar@mock"), From f9831f5b1b8d4630801c5820c88d7d8a743bf4be Mon Sep 17 00:00:00 2001 From: Romain Date: Tue, 23 Jan 2024 09:22:05 +0100 Subject: [PATCH 07/14] Introduce static config hints Co-authored-by: Baptiste Mayelle --- .golangci.yml | 6 + cmd/traefik/traefik.go | 2 +- docs/content/migration/v2-to-v3.md | 214 ++++++- pkg/cli/deprecation.go | 541 ++++++++++++++++++ pkg/cli/deprecation_test.go | 404 +++++++++++++ pkg/cli/fixtures/traefik_deprecated.toml | 5 + .../fixtures/traefik_multiple_deprecated.toml | 8 + pkg/cli/fixtures/traefik_no_deprecated.toml | 3 + 8 files changed, 1176 insertions(+), 7 deletions(-) create mode 100644 pkg/cli/deprecation.go create mode 100644 pkg/cli/deprecation_test.go create mode 100644 pkg/cli/fixtures/traefik_deprecated.toml create mode 100644 pkg/cli/fixtures/traefik_multiple_deprecated.toml create mode 100644 pkg/cli/fixtures/traefik_no_deprecated.toml diff --git a/.golangci.yml b/.golangci.yml index 7128a4177..08685510f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -280,3 +280,9 @@ issues: text: 'unusedwrite: unused write to field' linters: - govet + - path: pkg/cli/deprecation.go + linters: + - goconst + - path: pkg/cli/loader_file.go + linters: + - goconst diff --git a/cmd/traefik/traefik.go b/cmd/traefik/traefik.go index 0ac89fb46..1274e5104 100644 --- a/cmd/traefik/traefik.go +++ b/cmd/traefik/traefik.go @@ -53,7 +53,7 @@ func main() { // traefik config inits tConfig := cmd.NewTraefikConfiguration() - loaders := []cli.ResourceLoader{&tcli.FileLoader{}, &tcli.FlagLoader{}, &tcli.EnvLoader{}} + loaders := []cli.ResourceLoader{&tcli.DeprecationLoader{}, &tcli.FileLoader{}, &tcli.FlagLoader{}, &tcli.EnvLoader{}} cmdTraefik := &cli.Command{ Name: "traefik", diff --git a/docs/content/migration/v2-to-v3.md b/docs/content/migration/v2-to-v3.md index 70b27f820..4396e0887 100644 --- a/docs/content/migration/v2-to-v3.md +++ b/docs/content/migration/v2-to-v3.md @@ -19,6 +19,8 @@ and how it now looks like in v3. ### Docker & Docker Swarm +#### SwarmMode + In v3, the provider Docker has been split into 2 providers: - Docker provider (without Swarm support) @@ -43,7 +45,7 @@ In v3, the provider Docker has been split into 2 providers: This configuration is now unsupported and would prevent Traefik to start. -#### Remediation +##### Remediation In v3, the `swarmMode` should not be used with the Docker provider, and, to use Swarm, the Swarm provider should be used instead. @@ -64,7 +66,35 @@ In v3, the `swarmMode` should not be used with the Docker provider, and, to use --providers.swarm.endpoint=tcp://127.0.0.1:2377 ``` -### HTTP3 Experimental Configuration +#### TLS.CAOptional + +Docker provider `tls.CAOptional` option has been removed in v3, as TLS client authentication is a server side option (see https://pkg.go.dev/crypto/tls#ClientAuthType). + +??? example "An example usage of the TLS.CAOptional option" + + ```yaml tab="File (YAML)" + providers: + docker: + tls: + caOptional: true + ``` + + ```toml tab="File (TOML)" + [providers.docker.tls] + caOptional=true + ``` + + ```bash tab="CLI" + --providers.docker.tls.caOptional=true + ``` + +##### Remediation + +The `tls.caOptional` option should be removed from the Docker provider static configuration. + +### Experimental Configuration + +#### HTTP3 In v3, HTTP/3 is no longer an experimental feature. It can be enabled on entry points without the associated `experimental.http3` option, which is now removed. @@ -86,12 +116,14 @@ It is now unsupported and would prevent Traefik to start. --experimental.http3=true ``` -#### Remediation +##### Remediation The `http3` option should be removed from the static configuration experimental section. ### Consul provider +#### namespace + The Consul provider `namespace` option was deprecated in v2 and is now removed in v3. It is now unsupported and would prevent Traefik to start. @@ -111,7 +143,7 @@ It is now unsupported and would prevent Traefik to start. --consul.namespace=foobar ``` -#### Remediation +##### Remediation In v3, the `namespaces` option should be used instead of the `namespace` option. @@ -132,8 +164,36 @@ In v3, the `namespaces` option should be used instead of the `namespace` option. --consul.namespaces=foobar ``` +#### TLS.CAOptional + +Consul provider `tls.CAOptional` option has been removed in v3, as TLS client authentication is a server side option (see https://pkg.go.dev/crypto/tls#ClientAuthType). + +??? example "An example usage of the TLS.CAOptional option" + + ```yaml tab="File (YAML)" + providers: + consul: + tls: + caOptional: true + ``` + + ```toml tab="File (TOML)" + [providers.consul.tls] + caOptional=true + ``` + + ```bash tab="CLI" + --providers.consul.tls.caOptional=true + ``` + +##### Remediation + +The `tls.caOptional` option should be removed from the Consul provider static configuration. + ### ConsulCatalog provider +#### namespace + The ConsulCatalog provider `namespace` option was deprecated in v2 and is now removed in v3. It is now unsupported and would prevent Traefik to start. @@ -153,7 +213,7 @@ It is now unsupported and would prevent Traefik to start. --consulCatalog.namespace=foobar ``` -#### Remediation +##### Remediation In v3, the `namespaces` option should be used instead of the `namespace` option. @@ -174,8 +234,37 @@ In v3, the `namespaces` option should be used instead of the `namespace` option. --consulCatalog.namespaces=foobar ``` +#### Endpoint.TLS.CAOptional + +ConsulCatalog provider `endpoint.tls.CAOptional` option has been removed in v3, as TLS client authentication is a server side option (see https://pkg.go.dev/crypto/tls#ClientAuthType). + +??? example "An example usage of the Endpoint.TLS.CAOptional option" + + ```yaml tab="File (YAML)" + providers: + consulCatalog: + endpoint: + tls: + caOptional: true + ``` + + ```toml tab="File (TOML)" + [providers.consulCatalog.endpoint.tls] + caOptional=true + ``` + + ```bash tab="CLI" + --providers.consulCatalog.endpoint.tls.caOptional=true + ``` + +##### Remediation + +The `endpoint.tls.caOptional` option should be removed from the ConsulCatalog provider static configuration. + ### Nomad provider +#### namespace + The Nomad provider `namespace` option was deprecated in v2 and is now removed in v3. It is now unsupported and would prevent Traefik to start. @@ -195,7 +284,7 @@ It is now unsupported and would prevent Traefik to start. --nomad.namespace=foobar ``` -#### Remediation +##### Remediation In v3, the `namespaces` option should be used instead of the `namespace` option. @@ -216,6 +305,33 @@ In v3, the `namespaces` option should be used instead of the `namespace` option. --nomad.namespaces=foobar ``` +#### Endpoint.TLS.CAOptional + +Nomad provider `endpoint.tls.CAOptional` option has been removed in v3, as TLS client authentication is a server side option (see https://pkg.go.dev/crypto/tls#ClientAuthType). + +??? example "An example usage of the Endpoint.TLS.CAOptional option" + + ```yaml tab="File (YAML)" + providers: + nomad: + endpoint: + tls: + caOptional: true + ``` + + ```toml tab="File (TOML)" + [providers.nomad.endpoint.tls] + caOptional=true + ``` + + ```bash tab="CLI" + --providers.nomad.endpoint.tls.caOptional=true + ``` + +##### Remediation + +The `endpoint.tls.caOptional` option should be removed from the Nomad provider static configuration. + ### Rancher v1 Provider In v3, the Rancher v1 provider has been removed because Rancher v1 is [no longer actively maintaned](https://rancher.com/docs/os/v1.x/en/support/), @@ -271,6 +387,90 @@ This configuration is now unsupported and would prevent Traefik to start. All Marathon provider related configuration should be removed from the static configuration. +### HTTP Provider + +#### TLS.CAOptional + +HTTP provider `tls.CAOptional` option has been removed in v3, as TLS client authentication is a server side option (see https://pkg.go.dev/crypto/tls#ClientAuthType). + +??? example "An example usage of the TLS.CAOptional option" + + ```yaml tab="File (YAML)" + providers: + http: + tls: + caOptional: true + ``` + + ```toml tab="File (TOML)" + [providers.http.tls] + caOptional=true + ``` + + ```bash tab="CLI" + --providers.http.tls.caOptional=true + ``` + +##### Remediation + +The `tls.caOptional` option should be removed from the HTTP provider static configuration. + +### ETCD Provider + +#### TLS.CAOptional + +ETCD provider `tls.CAOptional` option has been removed in v3, as TLS client authentication is a server side option (see https://pkg.go.dev/crypto/tls#ClientAuthType). + +??? example "An example usage of the TLS.CAOptional option" + + ```yaml tab="File (YAML)" + providers: + etcd: + tls: + caOptional: true + ``` + + ```toml tab="File (TOML)" + [providers.etcd.tls] + caOptional=true + ``` + + ```bash tab="CLI" + --providers.etcd.tls.caOptional=true + ``` + +##### Remediation + +The `tls.caOptional` option should be removed from the ETCD provider static configuration. + +### Redis Provider + +#### TLS.CAOptional + +Redis provider `tls.CAOptional` option has been removed in v3, as TLS client authentication is a server side option (see https://pkg.go.dev/crypto/tls#ClientAuthType). + +??? example "An example usage of the TLS.CAOptional option" + + ```yaml tab="File (YAML)" + providers: + redis: + tls: + caOptional: true + ``` + + ```toml tab="File (TOML)" + [providers.redis.tls] + caOptional=true + ``` + + ```bash tab="CLI" + --providers.redis.tls.caOptional=true + ``` + +##### Remediation + +The `tls.caOptional` option should be removed from the Redis provider static configuration. + ### InfluxDB v1 InfluxDB v1.x maintenance [ended in 2021](https://www.influxdata.com/blog/influxdb-oss-and-enterprise-roadmap-update-from-influxdays-emea/). @@ -415,3 +615,5 @@ Here are two possible transition strategies: For legacy stacks that cannot immediately upgrade to the latest vendor agents supporting OTLP ingestion, using OpenTelemetry (OTel) collectors with appropriate exporters configuration is a viable solution. This allows continued compatibility with the existing infrastructure. + +Please check the [OpenTelemetry Tracing provider documention](../observability/tracing/opentelemetry.md) for more information. diff --git a/pkg/cli/deprecation.go b/pkg/cli/deprecation.go new file mode 100644 index 000000000..379d986a7 --- /dev/null +++ b/pkg/cli/deprecation.go @@ -0,0 +1,541 @@ +package cli + +import ( + "errors" + "os" + "reflect" + "strings" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/traefik/paerser/cli" + "github.com/traefik/paerser/flag" + "github.com/traefik/paerser/parser" +) + +type DeprecationLoader struct{} + +func (d DeprecationLoader) Load(args []string, cmd *cli.Command) (bool, error) { + if logDeprecation(cmd.Configuration, args) { + return true, errors.New("incompatible deprecated static option found") + } + + return false, nil +} + +// logDeprecation prints deprecation hints and returns whether incompatible deprecated options need to be removed. +func logDeprecation(traefikConfiguration interface{}, args []string) bool { + // This part doesn't handle properly a flag defined like this: + // --accesslog true + // where `true` could be considered as a new argument. + // This is not really an issue with the deprecation loader since it will filter the unknown nodes later in this + // function. + for i, arg := range args { + if !strings.Contains(arg, "=") { + args[i] = arg + "=true" + } + } + + labels, err := flag.Parse(args, nil) + if err != nil { + log.Error().Err(err).Msg("deprecated static options analysis failed") + return false + } + + node, err := parser.DecodeToNode(labels, "traefik") + if err != nil { + log.Error().Err(err).Msg("deprecated static options analysis failed") + return false + } + + if node != nil && len(node.Children) > 0 { + config := &configuration{} + filterUnknownNodes(reflect.TypeOf(config), node) + + if len(node.Children) > 0 { + // Telling parser to look for the label struct tag to allow empty values. + err = parser.AddMetadata(config, node, parser.MetadataOpts{TagName: "label"}) + if err != nil { + log.Error().Err(err).Msg("deprecated static options analysis failed") + return false + } + + err = parser.Fill(config, node, parser.FillerOpts{}) + if err != nil { + log.Error().Err(err).Msg("deprecated static options analysis failed") + return false + } + + if config.deprecationNotice(log.With().Str("loader", "FLAG").Logger()) { + return true + } + + // No further deprecation parsing and logging, + // as args configuration contains at least one deprecated option. + return false + } + } + + // FILE + ref, err := flag.Parse(args, traefikConfiguration) + if err != nil { + log.Error().Err(err).Msg("deprecated static options analysis failed") + return false + } + + configFileFlag := "traefik.configfile" + if _, ok := ref["traefik.configFile"]; ok { + configFileFlag = "traefik.configFile" + } + + config := &configuration{} + _, err = loadConfigFiles(ref[configFileFlag], config) + + if err == nil { + if config.deprecationNotice(log.With().Str("loader", "FILE").Logger()) { + return true + } + } + + config = &configuration{} + l := EnvLoader{} + _, err = l.Load(os.Args, &cli.Command{ + Configuration: config, + }) + + if err == nil { + if config.deprecationNotice(log.With().Str("loader", "ENV").Logger()) { + return true + } + } + + return false +} + +func filterUnknownNodes(fType reflect.Type, node *parser.Node) bool { + var children []*parser.Node + for _, child := range node.Children { + if hasKnownNodes(fType, child) { + children = append(children, child) + } + } + + node.Children = children + return len(node.Children) > 0 +} + +func hasKnownNodes(rootType reflect.Type, node *parser.Node) bool { + rType := rootType + if rootType.Kind() == reflect.Pointer { + rType = rootType.Elem() + } + + // unstructured type fitting anything, considering the current node as known. + if rType.Kind() == reflect.Map && rType.Elem().Kind() == reflect.Interface { + return true + } + + // unstructured type fitting anything, considering the current node as known. + if rType.Kind() == reflect.Interface { + return true + } + + // find matching field in struct type. + field, b := findTypedField(rType, node) + if !b { + return b + } + + if len(node.Children) > 0 { + return filterUnknownNodes(field.Type, node) + } + + return true +} + +func findTypedField(rType reflect.Type, node *parser.Node) (reflect.StructField, bool) { + // avoid panicking. + if rType.Kind() != reflect.Struct { + return reflect.StructField{}, false + } + + for i := 0; i < rType.NumField(); i++ { + cField := rType.Field(i) + + // ignore unexported fields. + if cField.PkgPath == "" { + if strings.EqualFold(cField.Name, node.Name) { + node.FieldName = cField.Name + return cField, true + } + } + } + + return reflect.StructField{}, false +} + +// configuration holds the static configuration removed/deprecated options. +type configuration struct { + Experimental *experimental `json:"experimental,omitempty" toml:"experimental,omitempty" yaml:"experimental,omitempty" label:"allowEmpty" file:"allowEmpty"` + Pilot map[string]any `json:"pilot,omitempty" toml:"pilot,omitempty" yaml:"pilot,omitempty" label:"allowEmpty" file:"allowEmpty"` + Providers *providers `json:"providers,omitempty" toml:"providers,omitempty" yaml:"providers,omitempty" label:"allowEmpty" file:"allowEmpty"` + Tracing *tracing `json:"tracing,omitempty" toml:"tracing,omitempty" yaml:"tracing,omitempty" label:"allowEmpty" file:"allowEmpty"` +} + +func (c *configuration) deprecationNotice(logger zerolog.Logger) bool { + if c == nil { + return false + } + + var incompatible bool + if c.Pilot != nil { + incompatible = true + logger.Error().Msg("Pilot configuration has been removed in v3, please remove all Pilot-related static configuration for Traefik to start." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#pilot") + } + + incompatibleExperimental := c.Experimental.deprecationNotice(logger) + incompatibleProviders := c.Providers.deprecationNotice(logger) + incompatibleTracing := c.Tracing.deprecationNotice(logger) + return incompatible || incompatibleExperimental || incompatibleProviders || incompatibleTracing +} + +type providers struct { + Docker *docker `json:"docker,omitempty" toml:"docker,omitempty" yaml:"docker,omitempty" label:"allowEmpty" file:"allowEmpty"` + Swarm *swarm `json:"swarm,omitempty" toml:"swarm,omitempty" yaml:"swarm,omitempty" label:"allowEmpty" file:"allowEmpty"` + Consul *consul `json:"consul,omitempty" toml:"consul,omitempty" yaml:"consul,omitempty" label:"allowEmpty" file:"allowEmpty"` + ConsulCatalog *consulCatalog `json:"consulCatalog,omitempty" toml:"consulCatalog,omitempty" yaml:"consulCatalog,omitempty" label:"allowEmpty" file:"allowEmpty"` + Nomad *nomad `json:"nomad,omitempty" toml:"nomad,omitempty" yaml:"nomad,omitempty" label:"allowEmpty" file:"allowEmpty"` + Marathon map[string]any `json:"marathon,omitempty" toml:"marathon,omitempty" yaml:"marathon,omitempty" label:"allowEmpty" file:"allowEmpty"` + Rancher map[string]any `json:"rancher,omitempty" toml:"rancher,omitempty" yaml:"rancher,omitempty" label:"allowEmpty" file:"allowEmpty"` + ETCD *etcd `json:"etcd,omitempty" toml:"etcd,omitempty" yaml:"etcd,omitempty" label:"allowEmpty" file:"allowEmpty"` + Redis *redis `json:"redis,omitempty" toml:"redis,omitempty" yaml:"redis,omitempty" label:"allowEmpty" file:"allowEmpty"` + HTTP *http `json:"http,omitempty" toml:"http,omitempty" yaml:"http,omitempty" label:"allowEmpty" file:"allowEmpty"` +} + +func (p *providers) deprecationNotice(logger zerolog.Logger) bool { + if p == nil { + return false + } + + var incompatible bool + + if p.Marathon != nil { + incompatible = true + logger.Error().Msg("Marathon provider has been removed in v3, please remove all Marathon-related static configuration for Traefik to start." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#marathon-provider") + } + + if p.Rancher != nil { + incompatible = true + logger.Error().Msg("Rancher provider has been removed in v3, please remove all Rancher-related static configuration for Traefik to start." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#rancher-v1-provider") + } + + dockerIncompatible := p.Docker.deprecationNotice(logger) + consulIncompatible := p.Consul.deprecationNotice(logger) + consulCatalogIncompatible := p.ConsulCatalog.deprecationNotice(logger) + nomadIncompatible := p.Nomad.deprecationNotice(logger) + swarmIncompatible := p.Swarm.deprecationNotice(logger) + etcdIncompatible := p.ETCD.deprecationNotice(logger) + redisIncompatible := p.Redis.deprecationNotice(logger) + httpIncompatible := p.HTTP.deprecationNotice(logger) + return incompatible || + dockerIncompatible || + consulIncompatible || + consulCatalogIncompatible || + nomadIncompatible || + swarmIncompatible || + etcdIncompatible || + redisIncompatible || + httpIncompatible +} + +type tls struct { + CAOptional *bool `json:"caOptional,omitempty" toml:"caOptional,omitempty" yaml:"caOptional,omitempty"` +} + +type docker struct { + SwarmMode *bool `json:"swarmMode,omitempty" toml:"swarmMode,omitempty" yaml:"swarmMode,omitempty"` + TLS *tls `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty"` +} + +func (d *docker) deprecationNotice(logger zerolog.Logger) bool { + if d == nil { + return false + } + + var incompatible bool + + if d.SwarmMode != nil { + incompatible = true + logger.Error().Msg("Docker provider `swarmMode` option has been removed in v3, please use the Swarm Provider instead." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#docker-docker-swarm") + } + + if d.TLS != nil && d.TLS.CAOptional != nil { + incompatible = true + logger.Error().Msg("Docker provider `tls.CAOptional` option has been removed in v3, as TLS client authentication is a server side option (see https://github.com/golang/go/blob/740a490f71d026bb7d2d13cb8fa2d6d6e0572b70/src/crypto/tls/common.go#L634)." + + "Please remove all occurrences from the static configuration for Traefik to start." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#tlscaoptional") + } + + return incompatible +} + +type swarm struct { + TLS *tls `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty"` +} + +func (s *swarm) deprecationNotice(logger zerolog.Logger) bool { + if s == nil { + return false + } + + var incompatible bool + + if s.TLS != nil && s.TLS.CAOptional != nil { + incompatible = true + logger.Error().Msg("Swarm provider `tls.CAOptional` option does not exist, as TLS client authentication is a server side option (see https://github.com/golang/go/blob/740a490f71d026bb7d2d13cb8fa2d6d6e0572b70/src/crypto/tls/common.go#L634)." + + "Please remove all occurrences from the static configuration for Traefik to start.") + } + + return incompatible +} + +type etcd struct { + TLS *tls `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty"` +} + +func (e *etcd) deprecationNotice(logger zerolog.Logger) bool { + if e == nil { + return false + } + + var incompatible bool + + if e.TLS != nil && e.TLS.CAOptional != nil { + incompatible = true + logger.Error().Msg("ETCD provider `tls.CAOptional` option has been removed in v3, as TLS client authentication is a server side option (see https://github.com/golang/go/blob/740a490f71d026bb7d2d13cb8fa2d6d6e0572b70/src/crypto/tls/common.go#L634)." + + "Please remove all occurrences from the static configuration for Traefik to start." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#tlscaoptional_3") + } + + return incompatible +} + +type redis struct { + TLS *tls `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty"` +} + +func (r *redis) deprecationNotice(logger zerolog.Logger) bool { + if r == nil { + return false + } + + var incompatible bool + + if r.TLS != nil && r.TLS.CAOptional != nil { + incompatible = true + logger.Error().Msg("Redis provider `tls.CAOptional` option has been removed in v3, as TLS client authentication is a server side option (see https://github.com/golang/go/blob/740a490f71d026bb7d2d13cb8fa2d6d6e0572b70/src/crypto/tls/common.go#L634)." + + "Please remove all occurrences from the static configuration for Traefik to start." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#tlscaoptional_4") + } + + return incompatible +} + +type consul struct { + Namespace *string `json:"namespace,omitempty" toml:"namespace,omitempty" yaml:"namespace,omitempty"` + TLS *tls `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty"` +} + +func (c *consul) deprecationNotice(logger zerolog.Logger) bool { + if c == nil { + return false + } + + var incompatible bool + + if c.Namespace != nil { + incompatible = true + logger.Error().Msg("Consul provider `namespace` option has been removed, please use the `namespaces` option instead." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#consul-provider") + } + + if c.TLS != nil && c.TLS.CAOptional != nil { + incompatible = true + logger.Error().Msg("Consul provider `tls.CAOptional` option has been removed in v3, as TLS client authentication is a server side option (see https://github.com/golang/go/blob/740a490f71d026bb7d2d13cb8fa2d6d6e0572b70/src/crypto/tls/common.go#L634)." + + "Please remove all occurrences from the static configuration for Traefik to start." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#tlscaoptional_1") + } + + return incompatible +} + +type consulCatalog struct { + Namespace *string `json:"namespace,omitempty" toml:"namespace,omitempty" yaml:"namespace,omitempty"` + Endpoint *endpointConfig `json:"endpoint,omitempty" toml:"endpoint,omitempty" yaml:"endpoint,omitempty" label:"allowEmpty" file:"allowEmpty"` +} + +type endpointConfig struct { + TLS *tls `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty"` +} + +func (c *consulCatalog) deprecationNotice(logger zerolog.Logger) bool { + if c == nil { + return false + } + + var incompatible bool + + if c.Namespace != nil { + incompatible = true + logger.Error().Msg("ConsulCatalog provider `namespace` option has been removed, please use the `namespaces` option instead." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#consulcatalog-provider") + } + + if c.Endpoint != nil && c.Endpoint.TLS != nil && c.Endpoint.TLS.CAOptional != nil { + incompatible = true + logger.Error().Msg("ConsulCatalog provider `tls.CAOptional` option has been removed in v3, as TLS client authentication is a server side option (see https://github.com/golang/go/blob/740a490f71d026bb7d2d13cb8fa2d6d6e0572b70/src/crypto/tls/common.go#L634)." + + "Please remove all occurrences from the static configuration for Traefik to start." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#endpointtlscaoptional") + } + + return incompatible +} + +type nomad struct { + Namespace *string `json:"namespace,omitempty" toml:"namespace,omitempty" yaml:"namespace,omitempty"` + Endpoint *endpointConfig `json:"endpoint,omitempty" toml:"endpoint,omitempty" yaml:"endpoint,omitempty" label:"allowEmpty" file:"allowEmpty"` +} + +func (n *nomad) deprecationNotice(logger zerolog.Logger) bool { + if n == nil { + return false + } + + var incompatible bool + + if n.Namespace != nil { + incompatible = true + logger.Error().Msg("Nomad provider `namespace` option has been removed, please use the `namespaces` option instead." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#nomad-provider") + } + + if n.Endpoint != nil && n.Endpoint.TLS != nil && n.Endpoint.TLS.CAOptional != nil { + incompatible = true + logger.Error().Msg("Nomad provider `tls.CAOptional` option has been removed in v3, as TLS client authentication is a server side option (see https://github.com/golang/go/blob/740a490f71d026bb7d2d13cb8fa2d6d6e0572b70/src/crypto/tls/common.go#L634)." + + "Please remove all occurrences from the static configuration for Traefik to start." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#endpointtlscaoptional_1") + } + + return incompatible +} + +type http struct { + TLS *tls `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty"` +} + +func (h *http) deprecationNotice(logger zerolog.Logger) bool { + if h == nil { + return false + } + + var incompatible bool + + if h.TLS != nil && h.TLS.CAOptional != nil { + incompatible = true + logger.Error().Msg("HTTP provider `tls.CAOptional` option has been removed in v3, as TLS client authentication is a server side option (see https://github.com/golang/go/blob/740a490f71d026bb7d2d13cb8fa2d6d6e0572b70/src/crypto/tls/common.go#L634)." + + "Please remove all occurrences from the static configuration for Traefik to start." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#tlscaoptional_2") + } + + return incompatible +} + +type experimental struct { + HTTP3 *bool `json:"http3,omitempty" toml:"http3,omitempty" yaml:"http3,omitempty"` +} + +func (e *experimental) deprecationNotice(logger zerolog.Logger) bool { + if e == nil { + return false + } + + if e.HTTP3 != nil { + logger.Error().Msg("HTTP3 is not an experimental feature in v3 and the associated enablement has been removed." + + "Please remove its usage from the static configuration for Traefik to start." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#http3-experimental-configuration") + + return true + } + + return false +} + +type tracing struct { + SpanNameLimit *int `json:"spanNameLimit,omitempty" toml:"spanNameLimit,omitempty" yaml:"spanNameLimit,omitempty"` + Jaeger map[string]any `json:"jaeger,omitempty" toml:"jaeger,omitempty" yaml:"jaeger,omitempty" label:"allowEmpty" file:"allowEmpty"` + Zipkin map[string]any `json:"zipkin,omitempty" toml:"zipkin,omitempty" yaml:"zipkin,omitempty" label:"allowEmpty" file:"allowEmpty"` + Datadog map[string]any `json:"datadog,omitempty" toml:"datadog,omitempty" yaml:"datadog,omitempty" label:"allowEmpty" file:"allowEmpty"` + Instana map[string]any `json:"instana,omitempty" toml:"instana,omitempty" yaml:"instana,omitempty" label:"allowEmpty" file:"allowEmpty"` + Haystack map[string]any `json:"haystack,omitempty" toml:"haystack,omitempty" yaml:"haystack,omitempty" label:"allowEmpty" file:"allowEmpty"` + Elastic map[string]any `json:"elastic,omitempty" toml:"elastic,omitempty" yaml:"elastic,omitempty" label:"allowEmpty" file:"allowEmpty"` +} + +func (t *tracing) deprecationNotice(logger zerolog.Logger) bool { + if t == nil { + return false + } + var incompatible bool + if t.SpanNameLimit != nil { + incompatible = true + logger.Error().Msg("SpanNameLimit option for Tracing has been removed in v3, as Span names are now of a fixed length." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#tracing") + } + + if t.Jaeger != nil { + incompatible = true + logger.Error().Msg("Jaeger Tracing backend has been removed in v3, please remove all Jaeger-related Tracing static configuration for Traefik to start." + + "In v3, Open Telemetry replaces specific tracing backend implementations, and an collector/exporter can be used to export metrics in a vendor specific format." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#tracing") + } + + if t.Zipkin != nil { + incompatible = true + logger.Error().Msg("Zipkin Tracing backend has been removed in v3, please remove all Zipkin-related Tracing static configuration for Traefik to start." + + "In v3, Open Telemetry replaces specific tracing backend implementations, and an collector/exporter can be used to export metrics in a vendor specific format." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#tracing") + } + + if t.Datadog != nil { + incompatible = true + logger.Error().Msg("Datadog Tracing backend has been removed in v3, please remove all Datadog-related Tracing static configuration for Traefik to start." + + "In v3, Open Telemetry replaces specific tracing backend implementations, and an collector/exporter can be used to export metrics in a vendor specific format." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#tracing") + } + + if t.Instana != nil { + incompatible = true + logger.Error().Msg("Instana Tracing backend has been removed in v3, please remove all Instana-related Tracing static configuration for Traefik to start." + + "In v3, Open Telemetry replaces specific tracing backend implementations, and an collector/exporter can be used to export metrics in a vendor specific format." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#tracing") + } + + if t.Haystack != nil { + incompatible = true + logger.Error().Msg("Haystack Tracing backend has been removed in v3, please remove all Haystack-related Tracing static configuration for Traefik to start." + + "In v3, Open Telemetry replaces specific tracing backend implementations, and an collector/exporter can be used to export metrics in a vendor specific format." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#tracing") + } + + if t.Elastic != nil { + incompatible = true + logger.Error().Msg("Elastic Tracing backend has been removed in v3, please remove all Elastic-related Tracing static configuration for Traefik to start." + + "In v3, Open Telemetry replaces specific tracing backend implementations, and an collector/exporter can be used to export metrics in a vendor specific format." + + "For more information please read the migration guide: https://doc.traefik.io/traefik/v3.0/migration/v2-to-v3/#tracing") + } + + return incompatible +} diff --git a/pkg/cli/deprecation_test.go b/pkg/cli/deprecation_test.go new file mode 100644 index 000000000..c225c0e7b --- /dev/null +++ b/pkg/cli/deprecation_test.go @@ -0,0 +1,404 @@ +package cli + +import ( + "testing" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/traefik/paerser/cli" + "github.com/traefik/traefik/v3/cmd" +) + +func ptr[T any](t T) *T { + return &t +} + +func TestDeprecationNotice(t *testing.T) { + tests := []struct { + desc string + config configuration + }{ + { + desc: "Docker provider swarmMode option is incompatible", + config: configuration{ + Providers: &providers{ + Docker: &docker{ + SwarmMode: ptr(true), + }, + }, + }, + }, + { + desc: "Docker provider tls.CAOptional option is incompatible", + config: configuration{ + Providers: &providers{ + Docker: &docker{ + TLS: &tls{ + CAOptional: ptr(true), + }, + }, + }, + }, + }, + { + desc: "Swarm provider tls.CAOptional option is incompatible", + config: configuration{ + Providers: &providers{ + Swarm: &swarm{ + TLS: &tls{ + CAOptional: ptr(true), + }, + }, + }, + }, + }, + { + desc: "Consul provider namespace option is incompatible", + config: configuration{ + Providers: &providers{ + Consul: &consul{ + Namespace: ptr("foobar"), + }, + }, + }, + }, + { + desc: "Consul provider tls.CAOptional option is incompatible", + config: configuration{ + Providers: &providers{ + Consul: &consul{ + TLS: &tls{ + CAOptional: ptr(true), + }, + }, + }, + }, + }, + { + desc: "ConsulCatalog provider namespace option is incompatible", + config: configuration{ + Providers: &providers{ + ConsulCatalog: &consulCatalog{ + Namespace: ptr("foobar"), + }, + }, + }, + }, + { + desc: "ConsulCatalog provider tls.CAOptional option is incompatible", + config: configuration{ + Providers: &providers{ + ConsulCatalog: &consulCatalog{ + Endpoint: &endpointConfig{ + TLS: &tls{ + CAOptional: ptr(true), + }, + }, + }, + }, + }, + }, + { + desc: "Nomad provider namespace option is incompatible", + config: configuration{ + Providers: &providers{ + Nomad: &nomad{ + Namespace: ptr("foobar"), + }, + }, + }, + }, + { + desc: "Nomad provider tls.CAOptional option is incompatible", + config: configuration{ + Providers: &providers{ + Nomad: &nomad{ + Endpoint: &endpointConfig{ + TLS: &tls{ + CAOptional: ptr(true), + }, + }, + }, + }, + }, + }, + { + desc: "Marathon configuration is incompatible", + config: configuration{ + Providers: &providers{ + Marathon: map[string]any{ + "foo": "bar", + }, + }, + }, + }, + { + desc: "Rancher configuration is incompatible", + config: configuration{ + Providers: &providers{ + Rancher: map[string]any{ + "foo": "bar", + }, + }, + }, + }, + { + desc: "ETCD provider tls.CAOptional option is incompatible", + config: configuration{ + Providers: &providers{ + ETCD: &etcd{ + TLS: &tls{ + CAOptional: ptr(true), + }, + }, + }, + }, + }, + { + desc: "Redis provider tls.CAOptional option is incompatible", + config: configuration{ + Providers: &providers{ + Redis: &redis{ + TLS: &tls{ + CAOptional: ptr(true), + }, + }, + }, + }, + }, + { + desc: "HTTP provider tls.CAOptional option is incompatible", + config: configuration{ + Providers: &providers{ + HTTP: &http{ + TLS: &tls{ + CAOptional: ptr(true), + }, + }, + }, + }, + }, + { + desc: "Pilot configuration is incompatible", + config: configuration{ + Pilot: map[string]any{ + "foo": "bar", + }, + }, + }, + { + desc: "Experimental HTTP3 enablement configuration is incompatible", + config: configuration{ + Experimental: &experimental{ + HTTP3: ptr(true), + }, + }, + }, + { + desc: "Tracing SpanNameLimit option is incompatible", + config: configuration{ + Tracing: &tracing{ + SpanNameLimit: ptr(42), + }, + }, + }, + { + desc: "Tracing Jaeger configuration is incompatible", + config: configuration{ + Tracing: &tracing{ + Jaeger: map[string]any{ + "foo": "bar", + }, + }, + }, + }, + { + desc: "Tracing Zipkin configuration is incompatible", + config: configuration{ + Tracing: &tracing{ + Zipkin: map[string]any{ + "foo": "bar", + }, + }, + }, + }, + { + desc: "Tracing Datadog configuration is incompatible", + config: configuration{ + Tracing: &tracing{ + Datadog: map[string]any{ + "foo": "bar", + }, + }, + }, + }, + { + desc: "Tracing Instana configuration is incompatible", + config: configuration{ + Tracing: &tracing{ + Instana: map[string]any{ + "foo": "bar", + }, + }, + }, + }, + { + desc: "Tracing Haystack configuration is incompatible", + config: configuration{ + Tracing: &tracing{ + Haystack: map[string]any{ + "foo": "bar", + }, + }, + }, + }, + { + desc: "Tracing Elastic configuration is incompatible", + config: configuration{ + Tracing: &tracing{ + Elastic: map[string]any{ + "foo": "bar", + }, + }, + }, + }, + } + + for _, test := range tests { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + var gotLog bool + var gotLevel zerolog.Level + testHook := zerolog.HookFunc(func(e *zerolog.Event, level zerolog.Level, message string) { + gotLog = true + gotLevel = level + }) + + logger := log.With().Logger().Hook(testHook) + assert.True(t, test.config.deprecationNotice(logger)) + assert.True(t, gotLog) + assert.Equal(t, zerolog.ErrorLevel, gotLevel) + }) + } +} + +func TestLoad(t *testing.T) { + testCases := []struct { + desc string + args []string + env map[string]string + wantDeprecated bool + }{ + { + desc: "Empty", + args: []string{}, + wantDeprecated: false, + }, + { + desc: "[FLAG] providers.marathon is deprecated", + args: []string{ + "--access-log", + "--log.level=DEBUG", + "--entrypoints.test.http.tls", + "--providers.nomad.endpoint.tls.insecureskipverify=true", + "--providers.marathon", + }, + wantDeprecated: true, + }, + { + desc: "[FLAG] multiple deprecated", + args: []string{ + "--access-log", + "--log.level=DEBUG", + "--entrypoints.test.http.tls", + "--providers.marathon", + "--pilot.token=XXX", + }, + wantDeprecated: true, + }, + { + desc: "[FLAG] no deprecated", + args: []string{ + "--access-log", + "--log.level=DEBUG", + "--entrypoints.test.http.tls", + "--providers.docker", + }, + wantDeprecated: false, + }, + { + desc: "[ENV] providers.marathon is deprecated", + env: map[string]string{ + "TRAEFIK_ACCESS_LOG": "", + "TRAEFIK_LOG_LEVEL": "DEBUG", + "TRAEFIK_ENTRYPOINT_TEST_HTTP_TLS": "true", + "TRAEFIK_PROVIDERS_MARATHON": "true", + }, + wantDeprecated: true, + }, + { + desc: "[ENV] multiple deprecated", + env: map[string]string{ + "TRAEFIK_ACCESS_LOG": "true", + "TRAEFIK_LOG_LEVEL": "DEBUG", + "TRAEFIK_ENTRYPOINT_TEST_HTTP_TLS": "true", + "TRAEFIK_PROVIDERS_MARATHON": "true", + "TRAEFIK_PILOT_TOKEN": "xxx", + }, + wantDeprecated: true, + }, + { + desc: "[ENV] no deprecated", + env: map[string]string{ + "TRAEFIK_ACCESS_LOG": "true", + "TRAEFIK_LOG_LEVEL": "DEBUG", + "TRAEFIK_ENTRYPOINT_TEST_HTTP_TLS": "true", + }, + + wantDeprecated: false, + }, + { + desc: "[FILE] providers.marathon is deprecated", + args: []string{ + "--configfile=./fixtures/traefik_deprecated.toml", + }, + wantDeprecated: true, + }, + { + desc: "[FILE] multiple deprecated", + args: []string{ + "--configfile=./fixtures/traefik_multiple_deprecated.toml", + }, + wantDeprecated: true, + }, + { + desc: "[FILE] no deprecated", + args: []string{ + "--configfile=./fixtures/traefik_no_deprecated.toml", + }, + wantDeprecated: false, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + tconfig := cmd.NewTraefikConfiguration() + c := &cli.Command{Configuration: tconfig} + l := DeprecationLoader{} + + for name, val := range test.env { + t.Setenv(name, val) + } + deprecated, err := l.Load(test.args, c) + assert.Equal(t, test.wantDeprecated, deprecated) + if !test.wantDeprecated { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/cli/fixtures/traefik_deprecated.toml b/pkg/cli/fixtures/traefik_deprecated.toml new file mode 100644 index 000000000..21fa1d1c1 --- /dev/null +++ b/pkg/cli/fixtures/traefik_deprecated.toml @@ -0,0 +1,5 @@ +[accesslog] + +[entrypoints.test.http.tls] + +[providers.marathon] diff --git a/pkg/cli/fixtures/traefik_multiple_deprecated.toml b/pkg/cli/fixtures/traefik_multiple_deprecated.toml new file mode 100644 index 000000000..0847e9da3 --- /dev/null +++ b/pkg/cli/fixtures/traefik_multiple_deprecated.toml @@ -0,0 +1,8 @@ +[accesslog] + +[entrypoints.test.http.tls] + +[providers.marathon] + +[pilot] + token="xxx" diff --git a/pkg/cli/fixtures/traefik_no_deprecated.toml b/pkg/cli/fixtures/traefik_no_deprecated.toml new file mode 100644 index 000000000..282d2f873 --- /dev/null +++ b/pkg/cli/fixtures/traefik_no_deprecated.toml @@ -0,0 +1,3 @@ +[accesslog] + +[entrypoints.test.http.tls] From a3ac456199ecc8287938cc3a4ec7be8308477f21 Mon Sep 17 00:00:00 2001 From: mmatur Date: Tue, 23 Jan 2024 09:48:46 +0100 Subject: [PATCH 08/14] fix: OpenTelemetry unit tests --- pkg/metrics/opentelemetry_test.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/pkg/metrics/opentelemetry_test.go b/pkg/metrics/opentelemetry_test.go index 93fc31c99..7e426cc11 100644 --- a/pkg/metrics/opentelemetry_test.go +++ b/pkg/metrics/opentelemetry_test.go @@ -287,8 +287,8 @@ func TestOpenTelemetry_GaugeCollectorSet(t *testing.T) { } func TestOpenTelemetry(t *testing.T) { - c := make(chan *string) - defer close(c) + c := make(chan *string, 5) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gzr, err := gzip.NewReader(r.Body) require.NoError(t, err) @@ -308,7 +308,11 @@ func TestOpenTelemetry(t *testing.T) { w.WriteHeader(http.StatusOK) })) - defer ts.Close() + + t.Cleanup(func() { + close(c) + ts.Close() + }) sURL, err := url.Parse(ts.URL) require.NoError(t, err) @@ -439,10 +443,11 @@ func TestOpenTelemetry(t *testing.T) { assertMessage(t, *msgEntryPointReqDurationHistogram, expectedEntryPointReqDuration) - // We need to unlock the HTTP Server for the last export call when stopping - // OpenTelemetry. + // Stopping OpenTelemetry. go func() { - <-c + StopOpenTelemetry() }() - StopOpenTelemetry() + + // We need to unlock the HTTP Server for the last export call. + <-c } From 21da705ec9dc312c4aca7676d321d8c5d46ac330 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 23 Jan 2024 11:04:05 +0100 Subject: [PATCH 09/14] fix: gateway api conformance tests --- .github/workflows/test-conformance.yaml | 6 +++++- Makefile | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-conformance.yaml b/.github/workflows/test-conformance.yaml index 488bf452f..707d9d5c0 100644 --- a/.github/workflows/test-conformance.yaml +++ b/.github/workflows/test-conformance.yaml @@ -34,5 +34,9 @@ jobs: - name: Build binary run: make binary + - name: Setcap + run: | + sudo setcap 'cap_net_bind_service=+ep' dist/linux/amd64/traefik + - name: K8s Gateway API conformance test - run: sudo make test-gateway-api-conformance + run: make test-gateway-api-conformance-ci diff --git a/Makefile b/Makefile index 93498e5c9..02a0a0b6d 100644 --- a/Makefile +++ b/Makefile @@ -104,6 +104,12 @@ test-integration: binary test-gateway-api-conformance: binary GOOS=$(GOOS) GOARCH=$(GOARCH) go test ./integration -v -test.run K8sConformanceSuite -k8sConformance=true $(TESTFLAGS) +## TODO: Need to be fixed to work in all situations. +## Run the conformance tests +.PHONY: test-gateway-api-conformance-ci +test-gateway-api-conformance-ci: + GOOS=$(GOOS) GOARCH=$(GOARCH) go test ./integration -v -test.run K8sConformanceSuite -k8sConformance=true $(TESTFLAGS) + ## Pull all Docker images to avoid timeout during integration tests .PHONY: pull-images pull-images: From 683e2ee5c6b120aabfe315fd15b560086f99d368 Mon Sep 17 00:00:00 2001 From: Romain Date: Tue, 23 Jan 2024 11:34:05 +0100 Subject: [PATCH 10/14] Bring back v2 rule matchers --- docs/content/migration/v2-to-v3.md | 100 +- .../dynamic-configuration/docker-labels.yml | 4 + .../reference/dynamic-configuration/file.toml | 4 + .../reference/dynamic-configuration/file.yaml | 4 + .../kubernetes-crd-definition-v1.yml | 8 + .../reference/dynamic-configuration/kv-ref.md | 4 + .../traefik.io_ingressroutes.yaml | 4 + .../traefik.io_ingressroutetcps.yaml | 4 + .../reference/static-configuration/cli-ref.md | 3 + .../reference/static-configuration/env-ref.md | 3 + .../reference/static-configuration/file.toml | 3 + .../reference/static-configuration/file.yaml | 2 + docs/content/routing/routers/index.md | 108 ++ go.mod | 2 +- go.sum | 4 +- integration/fixtures/k8s/01-traefik-crd.yml | 8 + .../fixtures/with_default_rule_syntax.toml | 41 + .../fixtures/without_default_rule_syntax.toml | 38 + integration/simple_test.go | 60 + integration/testdata/rawdata-gateway.json | 5 + pkg/config/dynamic/http_config.go | 6 +- pkg/config/dynamic/tcp_config.go | 9 + pkg/config/dynamic/zz_generated.deepcopy.go | 31 + pkg/config/static/static_config.go | 25 +- pkg/muxer/http/matcher_test.go | 22 +- pkg/muxer/http/matcher_v2.go | 226 +++ pkg/muxer/http/matcher_v2_test.go | 1535 +++++++++++++++++ pkg/muxer/http/mux.go | 57 +- pkg/muxer/http/mux_test.go | 6 +- pkg/muxer/tcp/matcher_test.go | 10 +- pkg/muxer/tcp/matcher_v2.go | 240 +++ pkg/muxer/tcp/matcher_v2_test.go | 1008 +++++++++++ pkg/muxer/tcp/mux.go | 59 +- pkg/muxer/tcp/mux_test.go | 4 +- .../kubernetes/crd/kubernetes_http.go | 1 + pkg/provider/kubernetes/crd/kubernetes_tcp.go | 1 + .../crd/traefikio/v1alpha1/ingressroute.go | 3 + .../crd/traefikio/v1alpha1/ingressroutetcp.go | 3 + pkg/provider/kubernetes/gateway/kubernetes.go | 5 +- .../kubernetes/gateway/kubernetes_test.go | 99 +- .../fixtures/api_insecure_with_dashboard.json | 10 +- .../traefik/fixtures/full_configuration.json | 16 +- .../traefik/fixtures/redirection.json | 8 +- .../traefik/fixtures/redirection_port.json | 8 +- .../fixtures/redirection_with_protocol.json | 8 +- pkg/provider/traefik/internal.go | 20 +- pkg/server/aggregator.go | 27 + pkg/server/aggregator_test.go | 1 + pkg/server/configurationwatcher_test.go | 6 + pkg/server/router/router.go | 2 +- pkg/server/router/tcp/manager.go | 8 +- pkg/server/router/tcp/router.go | 8 +- pkg/server/router/tcp/router_test.go | 4 +- pkg/server/server_entrypoint_tcp_test.go | 2 +- 54 files changed, 3773 insertions(+), 114 deletions(-) create mode 100644 integration/fixtures/with_default_rule_syntax.toml create mode 100644 integration/fixtures/without_default_rule_syntax.toml create mode 100644 pkg/muxer/http/matcher_v2.go create mode 100644 pkg/muxer/http/matcher_v2_test.go create mode 100644 pkg/muxer/tcp/matcher_v2.go create mode 100644 pkg/muxer/tcp/matcher_v2_test.go diff --git a/docs/content/migration/v2-to-v3.md b/docs/content/migration/v2-to-v3.md index 4396e0887..b9f7c5102 100644 --- a/docs/content/migration/v2-to-v3.md +++ b/docs/content/migration/v2-to-v3.md @@ -526,21 +526,16 @@ All Pilot related configuration should be removed from the static configuration. ## Dynamic configuration -### IPWhiteList +### Router Rule Matchers -In v3, we renamed the `IPWhiteList` middleware to `IPAllowList` without changing anything to the configuration. +In v3, a new rule matchers syntax has been introduced for HTTP and TCP routers. +The default rule matchers syntax is now the v3 one, but for backward compatibility this can be configured. +The v2 rule matchers syntax is deprecated and its support will be removed in the next major version. +For this reason, we encourage migrating to the new syntax. -### Deprecated Options Removal +#### New V3 Syntax Notable Changes -- The `tracing.datadog.globaltag` option has been removed. -- The `tls.caOptional` option has been removed from the ForwardAuth middleware, as well as from the HTTP, Consul, Etcd, Redis, ZooKeeper, Consul Catalog, and Docker providers. -- `sslRedirect`, `sslTemporaryRedirect`, `sslHost`, `sslForceHost` and `featurePolicy` options of the Headers middleware have been removed. -- The `forceSlash` option of the StripPrefix middleware has been removed. -- The `preferServerCipherSuites` option has been removed. - -### Matchers - -In v3, the `Headers` and `HeadersRegexp` matchers have been renamed to `Header` and `HeaderRegexp` respectively. +The `Headers` and `HeadersRegexp` matchers have been renamed to `Header` and `HeaderRegexp` respectively. `PathPrefix` no longer uses regular expressions to match path prefixes. @@ -555,6 +550,87 @@ and should be explicitly combined using logical operators to mimic previous beha `HostHeader` has been removed, use `Host` instead. +#### Remediation + +##### Configure the Default Syntax In Static Configuration + +The default rule matchers syntax is the expected syntax for any router that is not self opt-out from this default value. +It can be configured in the static configuration. + +??? example "An example configuration for the default rule matchers syntax" + + ```yaml tab="File (YAML)" + # static configuration + core: + defaultRuleSyntax: v2 + ``` + + ```toml tab="File (TOML)" + # static configuration + [core] + defaultRuleSyntax="v2" + ``` + + ```bash tab="CLI" + # static configuration + --core.defaultRuleSyntax=v2 + ``` + +##### Configure the Syntax Per Router + +The rule syntax can also be configured on a per-router basis. +This allows to have heterogeneous router configurations and ease migration. + +??? example "An example router with syntax configuration" + +```yaml tab="Docker & Swarm" +labels: + - "traefik.http.routers.test.ruleSyntax=v2" +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: test.route + namespace: default + +spec: + routes: + - match: PathPrefix(`/foo`, `/bar`) + syntax: v2 + kind: Rule +``` + +```yaml tab="Consul Catalog" +- "traefik.http.routers.test.ruleSyntax=v2" +``` + +```yaml tab="File (YAML)" +http: + routers: + test: + ruleSyntax: v2 +``` + +```toml tab="File (TOML)" +[http.routers] + [http.routers.test] + ruleSyntax = "v2" +``` + +### IPWhiteList + +In v3, we renamed the `IPWhiteList` middleware to `IPAllowList` without changing anything to the configuration. + +### Deprecated Options Removal + +- The `tracing.datadog.globaltag` option has been removed. +- The `tls.caOptional` option has been removed from the ForwardAuth middleware, as well as from the HTTP, Consul, Etcd, Redis, ZooKeeper, Consul Catalog, and Docker providers. +- `sslRedirect`, `sslTemporaryRedirect`, `sslHost`, `sslForceHost` and `featurePolicy` options of the Headers middleware have been removed. +- The `forceSlash` option of the StripPrefix middleware has been removed. +- The `preferServerCipherSuites` option has been removed. + ### TCP LoadBalancer `terminationDelay` option The TCP LoadBalancer `terminationDelay` option has been removed. diff --git a/docs/content/reference/dynamic-configuration/docker-labels.yml b/docs/content/reference/dynamic-configuration/docker-labels.yml index 93a785ff4..df6db2ff0 100644 --- a/docs/content/reference/dynamic-configuration/docker-labels.yml +++ b/docs/content/reference/dynamic-configuration/docker-labels.yml @@ -132,6 +132,7 @@ - "traefik.http.routers.router0.middlewares=foobar, foobar" - "traefik.http.routers.router0.priority=42" - "traefik.http.routers.router0.rule=foobar" +- "traefik.http.routers.router0.rulesyntax=foobar" - "traefik.http.routers.router0.service=foobar" - "traefik.http.routers.router0.tls=true" - "traefik.http.routers.router0.tls.certresolver=foobar" @@ -144,6 +145,7 @@ - "traefik.http.routers.router1.middlewares=foobar, foobar" - "traefik.http.routers.router1.priority=42" - "traefik.http.routers.router1.rule=foobar" +- "traefik.http.routers.router1.rulesyntax=foobar" - "traefik.http.routers.router1.service=foobar" - "traefik.http.routers.router1.tls=true" - "traefik.http.routers.router1.tls.certresolver=foobar" @@ -183,6 +185,7 @@ - "traefik.tcp.routers.tcprouter0.middlewares=foobar, foobar" - "traefik.tcp.routers.tcprouter0.priority=42" - "traefik.tcp.routers.tcprouter0.rule=foobar" +- "traefik.tcp.routers.tcprouter0.rulesyntax=foobar" - "traefik.tcp.routers.tcprouter0.service=foobar" - "traefik.tcp.routers.tcprouter0.tls=true" - "traefik.tcp.routers.tcprouter0.tls.certresolver=foobar" @@ -196,6 +199,7 @@ - "traefik.tcp.routers.tcprouter1.middlewares=foobar, foobar" - "traefik.tcp.routers.tcprouter1.priority=42" - "traefik.tcp.routers.tcprouter1.rule=foobar" +- "traefik.tcp.routers.tcprouter1.rulesyntax=foobar" - "traefik.tcp.routers.tcprouter1.service=foobar" - "traefik.tcp.routers.tcprouter1.tls=true" - "traefik.tcp.routers.tcprouter1.tls.certresolver=foobar" diff --git a/docs/content/reference/dynamic-configuration/file.toml b/docs/content/reference/dynamic-configuration/file.toml index a8264d2ed..541affb9c 100644 --- a/docs/content/reference/dynamic-configuration/file.toml +++ b/docs/content/reference/dynamic-configuration/file.toml @@ -7,6 +7,7 @@ middlewares = ["foobar", "foobar"] service = "foobar" rule = "foobar" + ruleSyntax = "foobar" priority = 42 [http.routers.Router0.tls] options = "foobar" @@ -24,6 +25,7 @@ middlewares = ["foobar", "foobar"] service = "foobar" rule = "foobar" + ruleSyntax = "foobar" priority = 42 [http.routers.Router1.tls] options = "foobar" @@ -353,6 +355,7 @@ middlewares = ["foobar", "foobar"] service = "foobar" rule = "foobar" + ruleSyntax = "foobar" priority = 42 [tcp.routers.TCPRouter0.tls] passthrough = true @@ -371,6 +374,7 @@ middlewares = ["foobar", "foobar"] service = "foobar" rule = "foobar" + ruleSyntax = "foobar" priority = 42 [tcp.routers.TCPRouter1.tls] passthrough = true diff --git a/docs/content/reference/dynamic-configuration/file.yaml b/docs/content/reference/dynamic-configuration/file.yaml index 7ae72ad7d..4e0019e42 100644 --- a/docs/content/reference/dynamic-configuration/file.yaml +++ b/docs/content/reference/dynamic-configuration/file.yaml @@ -11,6 +11,7 @@ http: - foobar service: foobar rule: foobar + ruleSyntax: foobar priority: 42 tls: options: foobar @@ -33,6 +34,7 @@ http: - foobar service: foobar rule: foobar + ruleSyntax: foobar priority: 42 tls: options: foobar @@ -409,6 +411,7 @@ tcp: - foobar service: foobar rule: foobar + ruleSyntax: foobar priority: 42 tls: passthrough: true @@ -432,6 +435,7 @@ tcp: - foobar service: foobar rule: foobar + ruleSyntax: foobar priority: 42 tls: passthrough: true diff --git a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml index 5a4c63c3c..966a45177 100644 --- a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml +++ b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml @@ -195,6 +195,10 @@ spec: - name type: object type: array + syntax: + description: 'Syntax defines the router''s rule syntax. More + info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax' + type: string required: - kind - match @@ -402,6 +406,10 @@ spec: - port type: object type: array + syntax: + description: 'Syntax defines the router''s rule syntax. More + info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax_1' + type: string required: - match type: object diff --git a/docs/content/reference/dynamic-configuration/kv-ref.md b/docs/content/reference/dynamic-configuration/kv-ref.md index f078b166a..6d80b13b8 100644 --- a/docs/content/reference/dynamic-configuration/kv-ref.md +++ b/docs/content/reference/dynamic-configuration/kv-ref.md @@ -158,6 +158,7 @@ THIS FILE MUST NOT BE EDITED BY HAND | `traefik/http/routers/Router0/middlewares/1` | `foobar` | | `traefik/http/routers/Router0/priority` | `42` | | `traefik/http/routers/Router0/rule` | `foobar` | +| `traefik/http/routers/Router0/ruleSyntax` | `foobar` | | `traefik/http/routers/Router0/service` | `foobar` | | `traefik/http/routers/Router0/tls/certResolver` | `foobar` | | `traefik/http/routers/Router0/tls/domains/0/main` | `foobar` | @@ -173,6 +174,7 @@ THIS FILE MUST NOT BE EDITED BY HAND | `traefik/http/routers/Router1/middlewares/1` | `foobar` | | `traefik/http/routers/Router1/priority` | `42` | | `traefik/http/routers/Router1/rule` | `foobar` | +| `traefik/http/routers/Router1/ruleSyntax` | `foobar` | | `traefik/http/routers/Router1/service` | `foobar` | | `traefik/http/routers/Router1/tls/certResolver` | `foobar` | | `traefik/http/routers/Router1/tls/domains/0/main` | `foobar` | @@ -273,6 +275,7 @@ THIS FILE MUST NOT BE EDITED BY HAND | `traefik/tcp/routers/TCPRouter0/middlewares/1` | `foobar` | | `traefik/tcp/routers/TCPRouter0/priority` | `42` | | `traefik/tcp/routers/TCPRouter0/rule` | `foobar` | +| `traefik/tcp/routers/TCPRouter0/ruleSyntax` | `foobar` | | `traefik/tcp/routers/TCPRouter0/service` | `foobar` | | `traefik/tcp/routers/TCPRouter0/tls/certResolver` | `foobar` | | `traefik/tcp/routers/TCPRouter0/tls/domains/0/main` | `foobar` | @@ -289,6 +292,7 @@ THIS FILE MUST NOT BE EDITED BY HAND | `traefik/tcp/routers/TCPRouter1/middlewares/1` | `foobar` | | `traefik/tcp/routers/TCPRouter1/priority` | `42` | | `traefik/tcp/routers/TCPRouter1/rule` | `foobar` | +| `traefik/tcp/routers/TCPRouter1/ruleSyntax` | `foobar` | | `traefik/tcp/routers/TCPRouter1/service` | `foobar` | | `traefik/tcp/routers/TCPRouter1/tls/certResolver` | `foobar` | | `traefik/tcp/routers/TCPRouter1/tls/domains/0/main` | `foobar` | diff --git a/docs/content/reference/dynamic-configuration/traefik.io_ingressroutes.yaml b/docs/content/reference/dynamic-configuration/traefik.io_ingressroutes.yaml index 41628b58a..8399f56fb 100644 --- a/docs/content/reference/dynamic-configuration/traefik.io_ingressroutes.yaml +++ b/docs/content/reference/dynamic-configuration/traefik.io_ingressroutes.yaml @@ -195,6 +195,10 @@ spec: - name type: object type: array + syntax: + description: 'Syntax defines the router''s rule syntax. More + info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax' + type: string required: - kind - match diff --git a/docs/content/reference/dynamic-configuration/traefik.io_ingressroutetcps.yaml b/docs/content/reference/dynamic-configuration/traefik.io_ingressroutetcps.yaml index 94226e14c..0f95de8d2 100644 --- a/docs/content/reference/dynamic-configuration/traefik.io_ingressroutetcps.yaml +++ b/docs/content/reference/dynamic-configuration/traefik.io_ingressroutetcps.yaml @@ -129,6 +129,10 @@ spec: - port type: object type: array + syntax: + description: 'Syntax defines the router''s rule syntax. More + info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax_1' + type: string required: - match type: object diff --git a/docs/content/reference/static-configuration/cli-ref.md b/docs/content/reference/static-configuration/cli-ref.md index aabed9139..2f655ccb1 100644 --- a/docs/content/reference/static-configuration/cli-ref.md +++ b/docs/content/reference/static-configuration/cli-ref.md @@ -105,6 +105,9 @@ Activate TLS-ALPN-01 Challenge. (Default: ```true```) `--certificatesresolvers..tailscale`: Enables Tailscale certificate resolution. (Default: ```true```) +`--core.defaultrulesyntax`: +Defines the rule parser default syntax (v2 or v3) (Default: ```v3```) + `--entrypoints.`: Entry points definition. (Default: ```false```) diff --git a/docs/content/reference/static-configuration/env-ref.md b/docs/content/reference/static-configuration/env-ref.md index 892fb2369..fdf733783 100644 --- a/docs/content/reference/static-configuration/env-ref.md +++ b/docs/content/reference/static-configuration/env-ref.md @@ -105,6 +105,9 @@ Activate TLS-ALPN-01 Challenge. (Default: ```true```) `TRAEFIK_CERTIFICATESRESOLVERS__TAILSCALE`: Enables Tailscale certificate resolution. (Default: ```true```) +`TRAEFIK_CORE_DEFAULTRULESYNTAX`: +Defines the rule parser default syntax (v2 or v3) (Default: ```v3```) + `TRAEFIK_ENTRYPOINTS_`: Entry points definition. (Default: ```false```) diff --git a/docs/content/reference/static-configuration/file.toml b/docs/content/reference/static-configuration/file.toml index 3edf7ebc9..8f9326416 100644 --- a/docs/content/reference/static-configuration/file.toml +++ b/docs/content/reference/static-configuration/file.toml @@ -453,5 +453,8 @@ [experimental.localPlugins.LocalDescriptor1] moduleName = "foobar" +[core] + defaultRuleSyntax = "foobar" + [spiffe] workloadAPIAddr = "foobar" diff --git a/docs/content/reference/static-configuration/file.yaml b/docs/content/reference/static-configuration/file.yaml index a9ccffe9a..1739759cb 100644 --- a/docs/content/reference/static-configuration/file.yaml +++ b/docs/content/reference/static-configuration/file.yaml @@ -486,5 +486,7 @@ experimental: LocalDescriptor1: moduleName: foobar kubernetesGateway: true +core: + defaultRuleSyntax: foobar spiffe: workloadAPIAddr: foobar diff --git a/docs/content/routing/routers/index.md b/docs/content/routing/routers/index.md index 55b46dc9d..9a4b8c7d0 100644 --- a/docs/content/routing/routers/index.md +++ b/docs/content/routing/routers/index.md @@ -515,6 +515,60 @@ A value of `0` for the priority is ignored: `priority = 0` means that the defaul In this configuration, the priority is configured to allow `Router-2` to handle requests with the `foobar.traefik.com` host. +### RuleSyntax + +In Traefik v3 a new rule syntax has been introduced ([migration guide](../../migration/v2-to-v3.md#router-rule-matchers)). +`ruleSyntax` option allows to configure the rule syntax to be used for parsing the rule on a per-router basis. +This allows to have heterogeneous router configurations and ease migration. + +??? example "Set rule syntax -- using the [File Provider](../../providers/file.md)" + + ```yaml tab="File (YAML)" + ## Dynamic configuration + http: + routers: + Router-v3: + rule: HostRegexp(`[a-z]+\\.traefik\\.com`) + ruleSyntax: v3 + Router-v2: + rule: HostRegexp(`{subdomain:[a-z]+}.traefik.com`) + ruleSyntax: v2 + ``` + + ```toml tab="File (TOML)" + ## Dynamic configuration + [http.routers] + [http.routers.Router-v3] + rule = "HostRegexp(`[a-z]+\\.traefik\\.com`)" + ruleSyntax = v3 + [http.routers.Router-v2] + rule = "HostRegexp(`{subdomain:[a-z]+}.traefik.com`)" + ruleSyntax = v2 + ``` + + ```yaml tab="Kubernetes traefik.io/v1alpha1" + apiVersion: traefik.io/v1alpha1 + kind: IngressRoute + metadata: + name: test.route + namespace: default + + spec: + routes: + # route v3 + - match: HostRegexp(`[a-z]+\\.traefik\\.com`) + syntax: v3 + kind: Rule + + # route v2 + - match: HostRegexp(`{subdomain:[a-z]+}.traefik.com`) + syntax: v2 + kind: Rule + ``` + + In this configuration, the ruleSyntax is configured to allow `Router-v2` to use v2 syntax, + while for `Router-v3` it is configured to use v3 syntax. + ### Middlewares You can attach a list of [middlewares](../../middlewares/overview.md) to each HTTP router. @@ -1161,6 +1215,60 @@ A value of `0` for the priority is ignored: `priority = 0` means that the defaul In this configuration, the priority is configured so that `Router-1` will handle requests from `192.168.0.12`. +### RuleSyntax + +In Traefik v3 a new rule syntax has been introduced ([migration guide](../../migration/v2-to-v3.md#router-rule-matchers)). +`ruleSyntax` option allows to configure the rule syntax to be used for parsing the rule on a per-router basis. +This allows to have heterogeneous router configurations and ease migration. + +??? example "Set rule syntax -- using the [File Provider](../../providers/file.md)" + + ```yaml tab="File (YAML)" + ## Dynamic configuration + tcp: + routers: + Router-v3: + rule: ClientIP(`192.168.0.11`) || ClientIP(`192.168.0.12`) + ruleSyntax: v3 + Router-v2: + rule: ClientIP(`192.168.0.11`, `192.168.0.12`) + ruleSyntax: v2 + ``` + + ```toml tab="File (TOML)" + ## Dynamic configuration + [tcp.routers] + [tcp.routers.Router-v3] + rule = "ClientIP(`192.168.0.11`) || ClientIP(`192.168.0.12`)" + ruleSyntax = v3 + [tcp.routers.Router-v2] + rule = "ClientIP(`192.168.0.11`, `192.168.0.12`)" + ruleSyntax = v2 + ``` + + ```yaml tab="Kubernetes traefik.io/v1alpha1" + apiVersion: traefik.io/v1alpha1 + kind: IngressRouteTCP + metadata: + name: test.route + namespace: default + + spec: + routes: + # route v3 + - match: ClientIP(`192.168.0.11`) || ClientIP(`192.168.0.12`) + syntax: v3 + kind: Rule + + # route v2 + - match: ClientIP(`192.168.0.11`, `192.168.0.12`) + syntax: v2 + kind: Rule + ``` + + In this configuration, the ruleSyntax is configured to allow `Router-v2` to use v2 syntax, + while for `Router-v3` it is configured to use v3 syntax. + ### Middlewares You can attach a list of [middlewares](../../middlewares/overview.md) to each TCP router. diff --git a/go.mod b/go.mod index 17fee2a7f..3685c0c5a 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-retryablehttp v0.7.4 github.com/hashicorp/go-version v1.6.0 - github.com/hashicorp/nomad/api v0.0.0-20231213195942-64e3dca9274b + github.com/hashicorp/nomad/api v0.0.0-20240122103822-8a4bd61caf74 github.com/http-wasm/http-wasm-host-go v0.5.2 github.com/influxdata/influxdb-client-go/v2 v2.7.0 github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d diff --git a/go.sum b/go.sum index 28bc21641..c651525ee 100644 --- a/go.sum +++ b/go.sum @@ -598,8 +598,8 @@ github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/memberlist v0.5.0 h1:EtYPN8DpAURiapus508I4n9CzHs2W+8NZGbmmR/prTM= github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= -github.com/hashicorp/nomad/api v0.0.0-20231213195942-64e3dca9274b h1:R1UDhkwGltpSPY9bCBBxIMQd+NY9BkN0vFHnJo/8o8w= -github.com/hashicorp/nomad/api v0.0.0-20231213195942-64e3dca9274b/go.mod h1:ijDwa6o1uG1jFSq6kERiX2PamKGpZzTmo0XOFNeFZgw= +github.com/hashicorp/nomad/api v0.0.0-20240122103822-8a4bd61caf74 h1:Q+WuGTnZkL2cJ7yNsg4Go4GNnRkcahGLiQP/WD41TTA= +github.com/hashicorp/nomad/api v0.0.0-20240122103822-8a4bd61caf74/go.mod h1:ijDwa6o1uG1jFSq6kERiX2PamKGpZzTmo0XOFNeFZgw= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= diff --git a/integration/fixtures/k8s/01-traefik-crd.yml b/integration/fixtures/k8s/01-traefik-crd.yml index 5a4c63c3c..966a45177 100644 --- a/integration/fixtures/k8s/01-traefik-crd.yml +++ b/integration/fixtures/k8s/01-traefik-crd.yml @@ -195,6 +195,10 @@ spec: - name type: object type: array + syntax: + description: 'Syntax defines the router''s rule syntax. More + info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax' + type: string required: - kind - match @@ -402,6 +406,10 @@ spec: - port type: object type: array + syntax: + description: 'Syntax defines the router''s rule syntax. More + info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax_1' + type: string required: - match type: object diff --git a/integration/fixtures/with_default_rule_syntax.toml b/integration/fixtures/with_default_rule_syntax.toml new file mode 100644 index 000000000..76001eac9 --- /dev/null +++ b/integration/fixtures/with_default_rule_syntax.toml @@ -0,0 +1,41 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[core] + defaultRuleSyntax = "v2" + +[log] + level = "DEBUG" + noColor = true + +[entryPoints] + [entryPoints.web] + address = ":8000" + +[api] + insecure = true + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.routers] + [http.routers.router1] + service = "service1" + rule = "PathPrefix(`/foo`, `/bar`)" + + [http.routers.router2] + service = "service1" + rule = "QueryRegexp(`foo`, `bar`)" + + [http.routers.router3] + service = "service1" + rule = "PathPrefix(`/foo`, `/bar`)" + ruleSyntax = "v3" + +[http.services] + [http.services.service1] + [http.services.service1.loadBalancer] + [http.services.service1.loadBalancer.servers] diff --git a/integration/fixtures/without_default_rule_syntax.toml b/integration/fixtures/without_default_rule_syntax.toml new file mode 100644 index 000000000..772ea7a85 --- /dev/null +++ b/integration/fixtures/without_default_rule_syntax.toml @@ -0,0 +1,38 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + noColor = true + +[entryPoints] + [entryPoints.web] + address = ":8000" + +[api] + insecure = true + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.routers] + [http.routers.router1] + service = "service1" + rule = "PathPrefix(`/foo`) || PathPrefix(`/bar`)" + + [http.routers.router2] + service = "service1" + rule = "PathPrefix(`/foo`, `/bar`)" + + [http.routers.router3] + service = "service1" + rule = "QueryRegexp(`foo`, `bar`)" + ruleSyntax = "v2" + +[http.services] + [http.services.service1] + [http.services.service1.loadBalancer] + [http.services.service1.loadBalancer.servers] diff --git a/integration/simple_test.go b/integration/simple_test.go index d8f0f45b7..94b4ec45e 100644 --- a/integration/simple_test.go +++ b/integration/simple_test.go @@ -659,6 +659,66 @@ func (s *SimpleSuite) TestSimpleConfigurationHostRequestTrailingPeriod() { } } +func (s *SimpleSuite) TestWithDefaultRuleSyntax() { + file := s.adaptFile("fixtures/with_default_rule_syntax.toml", struct{}{}) + + s.traefikCmd(withConfigFile(file)) + + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("PathPrefix")) + require.NoError(s.T(), err) + + // router1 has no error + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/router1@file", 1*time.Second, try.BodyContains(`"status":"enabled"`)) + require.NoError(s.T(), err) + + err = try.GetRequest("http://127.0.0.1:8000/notfound", 1*time.Second, try.StatusCodeIs(http.StatusNotFound)) + require.NoError(s.T(), err) + + err = try.GetRequest("http://127.0.0.1:8000/foo", 1*time.Second, try.StatusCodeIs(http.StatusServiceUnavailable)) + require.NoError(s.T(), err) + + err = try.GetRequest("http://127.0.0.1:8000/bar", 1*time.Second, try.StatusCodeIs(http.StatusServiceUnavailable)) + require.NoError(s.T(), err) + + // router2 has an error because it uses the wrong rule syntax (v3 instead of v2) + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/router2@file", 1*time.Second, try.BodyContains("error while parsing rule QueryRegexp(`foo`, `bar`): unsupported function: QueryRegexp")) + require.NoError(s.T(), err) + + // router3 has an error because it uses the wrong rule syntax (v2 instead of v3) + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/router3@file", 1*time.Second, try.BodyContains("error while adding rule PathPrefix: unexpected number of parameters; got 2, expected one of [1]")) + require.NoError(s.T(), err) +} + +func (s *SimpleSuite) TestWithoutDefaultRuleSyntax() { + file := s.adaptFile("fixtures/without_default_rule_syntax.toml", struct{}{}) + + s.traefikCmd(withConfigFile(file)) + + err := try.GetRequest("http://127.0.0.1:8080/api/rawdata", 1*time.Second, try.BodyContains("PathPrefix")) + require.NoError(s.T(), err) + + // router1 has no error + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/router1@file", 1*time.Second, try.BodyContains(`"status":"enabled"`)) + require.NoError(s.T(), err) + + err = try.GetRequest("http://127.0.0.1:8000/notfound", 1*time.Second, try.StatusCodeIs(http.StatusNotFound)) + require.NoError(s.T(), err) + + err = try.GetRequest("http://127.0.0.1:8000/foo", 1*time.Second, try.StatusCodeIs(http.StatusServiceUnavailable)) + require.NoError(s.T(), err) + + err = try.GetRequest("http://127.0.0.1:8000/bar", 1*time.Second, try.StatusCodeIs(http.StatusServiceUnavailable)) + require.NoError(s.T(), err) + + // router2 has an error because it uses the wrong rule syntax (v3 instead of v2) + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/router2@file", 1*time.Second, try.BodyContains("error while adding rule PathPrefix: unexpected number of parameters; got 2, expected one of [1]")) + require.NoError(s.T(), err) + + // router2 has an error because it uses the wrong rule syntax (v2 instead of v3) + err = try.GetRequest("http://127.0.0.1:8080/api/http/routers/router3@file", 1*time.Second, try.BodyContains("error while parsing rule QueryRegexp(`foo`, `bar`): unsupported function: QueryRegexp")) + require.NoError(s.T(), err) +} + func (s *SimpleSuite) TestRouterConfigErrors() { file := s.adaptFile("fixtures/router_errors.toml", struct{}{}) diff --git a/integration/testdata/rawdata-gateway.json b/integration/testdata/rawdata-gateway.json index f8e364e66..909ce1326 100644 --- a/integration/testdata/rawdata-gateway.json +++ b/integration/testdata/rawdata-gateway.json @@ -34,6 +34,7 @@ ], "service": "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr", "rule": "Host(`foo.com`) \u0026\u0026 Path(`/bar`)", + "ruleSyntax": "v3", "priority": 31, "status": "enabled", "using": [ @@ -46,6 +47,7 @@ ], "service": "default-http-app-1-my-https-gateway-websecure-1c0cf64bde37d9d0df06-wrr", "rule": "Host(`foo.com`) \u0026\u0026 Path(`/bar`)", + "ruleSyntax": "v3", "priority": 31, "tls": {}, "status": "enabled", @@ -152,6 +154,7 @@ ], "service": "default-tcp-app-1-my-tcp-gateway-footcp-e3b0c44298fc1c149afb-wrr-0", "rule": "HostSNI(`*`)", + "ruleSyntax": "v3", "priority": -1, "status": "enabled", "using": [ @@ -164,6 +167,7 @@ ], "service": "default-tcp-app-1-my-tls-gateway-footlsterminate-e3b0c44298fc1c149afb-wrr-0", "rule": "HostSNI(`*`)", + "ruleSyntax": "v3", "priority": -1, "tls": { "passthrough": false @@ -179,6 +183,7 @@ ], "service": "default-tls-app-1-my-tls-gateway-footlspassthrough-2279fe75c5156dc5eb26-wrr-0", "rule": "HostSNI(`foo.bar`)", + "ruleSyntax": "v3", "priority": 18, "tls": { "passthrough": true diff --git a/pkg/config/dynamic/http_config.go b/pkg/config/dynamic/http_config.go index a447e94eb..0783d5592 100644 --- a/pkg/config/dynamic/http_config.go +++ b/pkg/config/dynamic/http_config.go @@ -37,8 +37,9 @@ type HTTPConfiguration struct { // Model is a set of default router's values. type Model struct { - Middlewares []string `json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` - TLS *RouterTLSConfig `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" kv:"allowEmpty" export:"true"` + Middlewares []string `json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` + TLS *RouterTLSConfig `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" kv:"allowEmpty" export:"true"` + DefaultRuleSyntax string `json:"-" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` } // +k8s:deepcopy-gen=true @@ -59,6 +60,7 @@ type Router struct { Middlewares []string `json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` Service string `json:"service,omitempty" toml:"service,omitempty" yaml:"service,omitempty" export:"true"` Rule string `json:"rule,omitempty" toml:"rule,omitempty" yaml:"rule,omitempty"` + RuleSyntax string `json:"ruleSyntax,omitempty" toml:"ruleSyntax,omitempty" yaml:"ruleSyntax,omitempty" export:"true"` Priority int `json:"priority,omitempty" toml:"priority,omitempty,omitzero" yaml:"priority,omitempty" export:"true"` TLS *RouterTLSConfig `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" kv:"allowEmpty" export:"true"` DefaultRule bool `json:"-" toml:"-" yaml:"-" label:"-" file:"-"` diff --git a/pkg/config/dynamic/tcp_config.go b/pkg/config/dynamic/tcp_config.go index adedd0882..4bf82364f 100644 --- a/pkg/config/dynamic/tcp_config.go +++ b/pkg/config/dynamic/tcp_config.go @@ -16,11 +16,19 @@ type TCPConfiguration struct { Routers map[string]*TCPRouter `json:"routers,omitempty" toml:"routers,omitempty" yaml:"routers,omitempty" export:"true"` Services map[string]*TCPService `json:"services,omitempty" toml:"services,omitempty" yaml:"services,omitempty" export:"true"` Middlewares map[string]*TCPMiddleware `json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` + Models map[string]*TCPModel `json:"-" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` ServersTransports map[string]*TCPServersTransport `json:"serversTransports,omitempty" toml:"serversTransports,omitempty" yaml:"serversTransports,omitempty" label:"-" export:"true"` } // +k8s:deepcopy-gen=true +// TCPModel is a set of default router's values. +type TCPModel struct { + DefaultRuleSyntax string `json:"-" toml:"-" yaml:"-" label:"-" file:"-" kv:"-" export:"true"` +} + +// +k8s:deepcopy-gen=true + // TCPService holds a tcp service configuration (can only be of one type at the same time). type TCPService struct { LoadBalancer *TCPServersLoadBalancer `json:"loadBalancer,omitempty" toml:"loadBalancer,omitempty" yaml:"loadBalancer,omitempty" export:"true"` @@ -56,6 +64,7 @@ type TCPRouter struct { Middlewares []string `json:"middlewares,omitempty" toml:"middlewares,omitempty" yaml:"middlewares,omitempty" export:"true"` Service string `json:"service,omitempty" toml:"service,omitempty" yaml:"service,omitempty" export:"true"` Rule string `json:"rule,omitempty" toml:"rule,omitempty" yaml:"rule,omitempty"` + RuleSyntax string `json:"ruleSyntax,omitempty" toml:"ruleSyntax,omitempty" yaml:"ruleSyntax,omitempty" export:"true"` Priority int `json:"priority,omitempty" toml:"priority,omitempty,omitzero" yaml:"priority,omitempty" export:"true"` TLS *RouterTCPTLSConfig `json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" label:"allowEmpty" file:"allowEmpty" kv:"allowEmpty" export:"true"` } diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go index 847e350ba..3969d144d 100644 --- a/pkg/config/dynamic/zz_generated.deepcopy.go +++ b/pkg/config/dynamic/zz_generated.deepcopy.go @@ -1435,6 +1435,21 @@ func (in *TCPConfiguration) DeepCopyInto(out *TCPConfiguration) { (*out)[key] = outVal } } + if in.Models != nil { + in, out := &in.Models, &out.Models + *out = make(map[string]*TCPModel, len(*in)) + for key, val := range *in { + var outVal *TCPModel + if val == nil { + (*out)[key] = nil + } else { + in, out := &val, &outVal + *out = new(TCPModel) + **out = **in + } + (*out)[key] = outVal + } + } if in.ServersTransports != nil { in, out := &in.ServersTransports, &out.ServersTransports *out = make(map[string]*TCPServersTransport, len(*in)) @@ -1552,6 +1567,22 @@ func (in *TCPMiddleware) DeepCopy() *TCPMiddleware { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TCPModel) DeepCopyInto(out *TCPModel) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TCPModel. +func (in *TCPModel) DeepCopy() *TCPModel { + if in == nil { + return nil + } + out := new(TCPModel) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TCPRouter) DeepCopyInto(out *TCPRouter) { *out = *in diff --git a/pkg/config/static/static_config.go b/pkg/config/static/static_config.go index 9f0ce294f..bb5778671 100644 --- a/pkg/config/static/static_config.go +++ b/pkg/config/static/static_config.go @@ -71,11 +71,23 @@ type Configuration struct { CertificatesResolvers map[string]CertificateResolver `description:"Certificates resolvers configuration." json:"certificatesResolvers,omitempty" toml:"certificatesResolvers,omitempty" yaml:"certificatesResolvers,omitempty" export:"true"` - Experimental *Experimental `description:"experimental features." json:"experimental,omitempty" toml:"experimental,omitempty" yaml:"experimental,omitempty" export:"true"` + Experimental *Experimental `description:"Experimental features." json:"experimental,omitempty" toml:"experimental,omitempty" yaml:"experimental,omitempty" export:"true"` + + Core *Core `description:"Core controls." json:"core,omitempty" toml:"core,omitempty" yaml:"core,omitempty" export:"true"` Spiffe *SpiffeClientConfig `description:"SPIFFE integration configuration." json:"spiffe,omitempty" toml:"spiffe,omitempty" yaml:"spiffe,omitempty" export:"true"` } +// Core configures Traefik core behavior. +type Core struct { + DefaultRuleSyntax string `description:"Defines the rule parser default syntax (v2 or v3)" json:"defaultRuleSyntax,omitempty" toml:"defaultRuleSyntax,omitempty" yaml:"defaultRuleSyntax,omitempty"` +} + +// SetDefaults sets the default values. +func (c *Core) SetDefaults() { + c.DefaultRuleSyntax = "v3" +} + // SpiffeClientConfig defines the SPIFFE client configuration. type SpiffeClientConfig struct { WorkloadAPIAddr string `description:"Defines the workload API address." json:"workloadAPIAddr,omitempty" toml:"workloadAPIAddr,omitempty" yaml:"workloadAPIAddr,omitempty"` @@ -317,6 +329,17 @@ func (c *Configuration) ValidateConfiguration() error { acmeEmail = resolver.ACME.Email } + if c.Core != nil { + switch c.Core.DefaultRuleSyntax { + case "v3": // NOOP + case "v2": + // TODO: point to migration guide. + log.Warn().Msgf("v2 rules syntax is now deprecated, please use v3 instead...") + default: + return fmt.Errorf("unsupported default rule syntax configuration: %q", c.Core.DefaultRuleSyntax) + } + } + if c.Tracing != nil && c.Tracing.OTLP != nil { if c.Tracing.OTLP.HTTP == nil && c.Tracing.OTLP.GRPC == nil { return errors.New("tracing OTLP: at least one of HTTP and gRPC options should be defined") diff --git a/pkg/muxer/http/matcher_test.go b/pkg/muxer/http/matcher_test.go index 1b527a392..c4e601d01 100644 --- a/pkg/muxer/http/matcher_test.go +++ b/pkg/muxer/http/matcher_test.go @@ -73,7 +73,7 @@ func TestClientIPMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -147,7 +147,7 @@ func TestMethodMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -265,7 +265,7 @@ func TestHostMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -365,7 +365,7 @@ func TestHostRegexpMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -439,7 +439,7 @@ func TestPathMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -532,7 +532,7 @@ func TestPathRegexpMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -604,7 +604,7 @@ func TestPathPrefixMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -692,7 +692,7 @@ func TestHeaderMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -800,7 +800,7 @@ func TestHeaderRegexpMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -889,7 +889,7 @@ func TestQueryMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -1003,7 +1003,7 @@ func TestQueryRegexpMatcher(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return diff --git a/pkg/muxer/http/matcher_v2.go b/pkg/muxer/http/matcher_v2.go new file mode 100644 index 000000000..72d5d826a --- /dev/null +++ b/pkg/muxer/http/matcher_v2.go @@ -0,0 +1,226 @@ +package http + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gorilla/mux" + "github.com/rs/zerolog/log" + "github.com/traefik/traefik/v3/pkg/ip" + "github.com/traefik/traefik/v3/pkg/middlewares/requestdecorator" +) + +var httpFuncsV2 = map[string]func(*matchersTree, ...string) error{ + "Host": hostV2, + "HostHeader": hostV2, + "HostRegexp": hostRegexpV2, + "ClientIP": clientIPV2, + "Path": pathV2, + "PathPrefix": pathPrefixV2, + "Method": methodsV2, + "Headers": headersV2, + "HeadersRegexp": headersRegexpV2, + "Query": queryV2, +} + +func pathV2(tree *matchersTree, paths ...string) error { + for _, path := range paths { + if !strings.HasPrefix(path, "/") { + return fmt.Errorf("path %q does not start with a '/'", path) + } + } + + tree.matcher = func(req *http.Request) bool { + for _, path := range paths { + if req.URL.Path == path { + return true + } + } + + return false + } + + return nil +} + +func pathPrefixV2(tree *matchersTree, paths ...string) error { + for _, path := range paths { + if !strings.HasPrefix(path, "/") { + return fmt.Errorf("path %q does not start with a '/'", path) + } + } + + tree.matcher = func(req *http.Request) bool { + for _, path := range paths { + if strings.HasPrefix(req.URL.Path, path) { + return true + } + } + + return false + } + + return nil +} + +func hostV2(tree *matchersTree, hosts ...string) error { + for i, host := range hosts { + if !IsASCII(host) { + return fmt.Errorf("invalid value %q for \"Host\" matcher, non-ASCII characters are not allowed", host) + } + + hosts[i] = strings.ToLower(host) + } + + tree.matcher = func(req *http.Request) bool { + reqHost := requestdecorator.GetCanonizedHost(req.Context()) + if len(reqHost) == 0 { + // If the request is an HTTP/1.0 request, then a Host may not be defined. + if req.ProtoAtLeast(1, 1) { + log.Ctx(req.Context()).Warn().Msgf("Could not retrieve CanonizedHost, rejecting %s", req.Host) + } + + return false + } + + flatH := requestdecorator.GetCNAMEFlatten(req.Context()) + if len(flatH) > 0 { + for _, host := range hosts { + if strings.EqualFold(reqHost, host) || strings.EqualFold(flatH, host) { + return true + } + log.Ctx(req.Context()).Debug().Msgf("CNAMEFlattening: request %s which resolved to %s, is not matched to route %s", reqHost, flatH, host) + } + return false + } + + for _, host := range hosts { + if reqHost == host { + return true + } + + // Check for match on trailing period on host + if last := len(host) - 1; last >= 0 && host[last] == '.' { + h := host[:last] + if reqHost == h { + return true + } + } + + // Check for match on trailing period on request + if last := len(reqHost) - 1; last >= 0 && reqHost[last] == '.' { + h := reqHost[:last] + if h == host { + return true + } + } + } + return false + } + + return nil +} + +func clientIPV2(tree *matchersTree, clientIPs ...string) error { + checker, err := ip.NewChecker(clientIPs) + if err != nil { + return fmt.Errorf("could not initialize IP Checker for \"ClientIP\" matcher: %w", err) + } + + strategy := ip.RemoteAddrStrategy{} + + tree.matcher = func(req *http.Request) bool { + ok, err := checker.Contains(strategy.GetIP(req)) + if err != nil { + log.Ctx(req.Context()).Warn().Err(err).Msg("\"ClientIP\" matcher: could not match remote address") + return false + } + + return ok + } + + return nil +} + +func methodsV2(tree *matchersTree, methods ...string) error { + route := mux.NewRouter().NewRoute() + route.Methods(methods...) + if err := route.GetError(); err != nil { + return err + } + + tree.matcher = func(req *http.Request) bool { + return route.Match(req, &mux.RouteMatch{}) + } + + return nil +} + +func headersV2(tree *matchersTree, headers ...string) error { + route := mux.NewRouter().NewRoute() + route.Headers(headers...) + if err := route.GetError(); err != nil { + return err + } + + tree.matcher = func(req *http.Request) bool { + return route.Match(req, &mux.RouteMatch{}) + } + + return nil +} + +func queryV2(tree *matchersTree, query ...string) error { + var queries []string + for _, elem := range query { + queries = append(queries, strings.SplitN(elem, "=", 2)...) + } + + route := mux.NewRouter().NewRoute() + route.Queries(queries...) + if err := route.GetError(); err != nil { + return err + } + + tree.matcher = func(req *http.Request) bool { + return route.Match(req, &mux.RouteMatch{}) + } + + return nil +} + +func hostRegexpV2(tree *matchersTree, hosts ...string) error { + router := mux.NewRouter() + + for _, host := range hosts { + if !IsASCII(host) { + return fmt.Errorf("invalid value %q for HostRegexp matcher, non-ASCII characters are not allowed", host) + } + + tmpRt := router.Host(host) + if tmpRt.GetError() != nil { + return tmpRt.GetError() + } + } + + tree.matcher = func(req *http.Request) bool { + return router.Match(req, &mux.RouteMatch{}) + } + + return nil +} + +func headersRegexpV2(tree *matchersTree, headers ...string) error { + route := mux.NewRouter().NewRoute() + route.HeadersRegexp(headers...) + if err := route.GetError(); err != nil { + return err + } + + tree.matcher = func(req *http.Request) bool { + return route.Match(req, &mux.RouteMatch{}) + } + + return nil +} diff --git a/pkg/muxer/http/matcher_v2_test.go b/pkg/muxer/http/matcher_v2_test.go new file mode 100644 index 000000000..eac62a005 --- /dev/null +++ b/pkg/muxer/http/matcher_v2_test.go @@ -0,0 +1,1535 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/traefik/traefik/v3/pkg/middlewares/requestdecorator" + "github.com/traefik/traefik/v3/pkg/testhelpers" +) + +func TestClientIPV2Matcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid ClientIP matcher", + rule: "ClientIP(`1`)", + expectedError: true, + }, + { + desc: "invalid ClientIP matcher (no parameter)", + rule: "ClientIP()", + expectedError: true, + }, + { + desc: "invalid ClientIP matcher (empty parameter)", + rule: "ClientIP(``)", + expectedError: true, + }, + { + desc: "valid ClientIP matcher (many parameters)", + rule: "ClientIP(`127.0.0.1`, `192.168.1.0/24`)", + expected: map[string]int{ + "127.0.0.1": http.StatusOK, + "192.168.1.1": http.StatusOK, + }, + }, + { + desc: "valid ClientIP matcher", + rule: "ClientIP(`127.0.0.1`)", + expected: map[string]int{ + "127.0.0.1": http.StatusOK, + "192.168.1.1": http.StatusNotFound, + }, + }, + { + desc: "valid ClientIP matcher but invalid remote address", + rule: "ClientIP(`127.0.0.1`)", + expected: map[string]int{ + "1": http.StatusNotFound, + }, + }, + { + desc: "valid ClientIP matcher using CIDR", + rule: "ClientIP(`192.168.1.0/24`)", + expected: map[string]int{ + "192.168.1.1": http.StatusOK, + "192.168.1.100": http.StatusOK, + "192.168.2.1": http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, "v2", 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + results := make(map[string]int) + for remoteAddr := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, "https://example.com", http.NoBody) + req.RemoteAddr = remoteAddr + + muxer.ServeHTTP(w, req) + results[remoteAddr] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} + +func TestMethodV2Matcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid Method matcher (no parameter)", + rule: "Method()", + expectedError: true, + }, + { + desc: "invalid Method matcher (empty parameter)", + rule: "Method(``)", + expectedError: true, + }, + { + desc: "valid Method matcher (many parameters)", + rule: "Method(`GET`, `POST`)", + expected: map[string]int{ + http.MethodGet: http.StatusOK, + http.MethodPost: http.StatusOK, + }, + }, + { + desc: "valid Method matcher", + rule: "Method(`GET`)", + expected: map[string]int{ + http.MethodGet: http.StatusOK, + http.MethodPost: http.StatusNotFound, + strings.ToLower(http.MethodGet): http.StatusNotFound, + }, + }, + { + desc: "valid Method matcher (lower case)", + rule: "Method(`get`)", + expected: map[string]int{ + http.MethodGet: http.StatusOK, + http.MethodPost: http.StatusNotFound, + strings.ToLower(http.MethodGet): http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, "v2", 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + results := make(map[string]int) + for method := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(method, "https://example.com", http.NoBody) + + muxer.ServeHTTP(w, req) + results[method] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} + +func TestHostV2Matcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid Host matcher (no parameter)", + rule: "Host()", + expectedError: true, + }, + { + desc: "invalid Host matcher (empty parameter)", + rule: "Host(``)", + expectedError: true, + }, + { + desc: "invalid Host matcher (non-ASCII)", + rule: "Host(`🦭.com`)", + expectedError: true, + }, + { + desc: "valid Host matcher (many parameters)", + rule: "Host(`example.com`, `example.org`)", + expected: map[string]int{ + "https://example.com": http.StatusOK, + "https://example.com:8080": http.StatusOK, + "https://example.com/path": http.StatusOK, + "https://EXAMPLE.COM/path": http.StatusOK, + "https://example.org": http.StatusOK, + "https://example.org/path": http.StatusOK, + }, + }, + { + desc: "valid Host matcher", + rule: "Host(`example.com`)", + expected: map[string]int{ + "https://example.com": http.StatusOK, + "https://example.com:8080": http.StatusOK, + "https://example.com/path": http.StatusOK, + "https://EXAMPLE.COM/path": http.StatusOK, + "https://example.org": http.StatusNotFound, + "https://example.org/path": http.StatusNotFound, + }, + }, + { + desc: "valid Host matcher - matcher ending with a dot", + rule: "Host(`example.com.`)", + expected: map[string]int{ + "https://example.com": http.StatusOK, + "https://example.com/path": http.StatusOK, + "https://example.org": http.StatusNotFound, + "https://example.org/path": http.StatusNotFound, + "https://example.com.": http.StatusOK, + "https://example.com./path": http.StatusOK, + "https://example.org.": http.StatusNotFound, + "https://example.org./path": http.StatusNotFound, + }, + }, + { + desc: "valid Host matcher - URL ending with a dot", + rule: "Host(`example.com`)", + expected: map[string]int{ + "https://example.com.": http.StatusOK, + "https://example.com./path": http.StatusOK, + "https://example.org.": http.StatusNotFound, + "https://example.org./path": http.StatusNotFound, + }, + }, + { + desc: "valid Host matcher - matcher with UPPER case", + rule: "Host(`EXAMPLE.COM`)", + expected: map[string]int{ + "https://example.com": http.StatusOK, + "https://example.com/path": http.StatusOK, + "https://example.org": http.StatusNotFound, + "https://example.org/path": http.StatusNotFound, + }, + }, + { + desc: "valid Host matcher - puny-coded emoji", + rule: "Host(`xn--9t9h.com`)", + expected: map[string]int{ + "https://xn--9t9h.com": http.StatusOK, + "https://xn--9t9h.com/path": http.StatusOK, + "https://example.com": http.StatusNotFound, + "https://example.com/path": http.StatusNotFound, + // The request's sender must use puny-code. + "https://🦭.com": http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, "v2", 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + // RequestDecorator is necessary for the Host matcher + reqHost := requestdecorator.New(nil) + + results := make(map[string]int) + for calledURL := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody) + + reqHost.ServeHTTP(w, req, muxer.ServeHTTP) + results[calledURL] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} + +func TestHostRegexpV2Matcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid HostRegexp matcher (no parameter)", + rule: "HostRegexp()", + expectedError: true, + }, + { + desc: "invalid HostRegexp matcher (empty parameter)", + rule: "HostRegexp(``)", + expectedError: true, + }, + { + desc: "invalid HostRegexp matcher (non-ASCII)", + rule: "HostRegexp(`🦭.com`)", + expectedError: true, + }, + { + desc: "valid HostRegexp matcher (invalid regexp)", + rule: "HostRegexp(`(example.com`)", + // This is weird. + expectedError: false, + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com:8080": http.StatusNotFound, + "https://example.com/path": http.StatusNotFound, + "https://example.org": http.StatusNotFound, + "https://example.org/path": http.StatusNotFound, + }, + }, + { + desc: "valid HostRegexp matcher (many parameters)", + rule: "HostRegexp(`example.com`, `example.org`)", + expected: map[string]int{ + "https://example.com": http.StatusOK, + "https://example.com:8080": http.StatusOK, + "https://example.com/path": http.StatusOK, + "https://example.org": http.StatusOK, + "https://example.org/path": http.StatusOK, + }, + }, + { + desc: "valid HostRegexp matcher with case sensitive regexp", + rule: "HostRegexp(`^[A-Z]+\\.com$`)", + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://EXAMPLE.com": http.StatusNotFound, + "https://example.com/path": http.StatusNotFound, + "https://example.org": http.StatusNotFound, + "https://example.org/path": http.StatusNotFound, + }, + }, + { + desc: "valid HostRegexp matcher with Traefik v2 syntax", + rule: "HostRegexp(`{domain:[a-zA-Z-]+\\.com}`)", + expected: map[string]int{ + "https://example.com": http.StatusOK, + "https://example.com/path": http.StatusOK, + "https://example.org": http.StatusNotFound, + "https://example.org/path": http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, "v2", 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + require.NoError(t, err) + + // RequestDecorator is necessary for the HostRegexp matcher + reqHost := requestdecorator.New(nil) + + results := make(map[string]int) + for calledURL := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody) + + reqHost.ServeHTTP(w, req, muxer.ServeHTTP) + results[calledURL] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} + +func TestPathV2Matcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid Path matcher (no parameter)", + rule: "Path()", + expectedError: true, + }, + { + desc: "invalid Path matcher (empty parameter)", + rule: "Path(``)", + expectedError: true, + }, + { + desc: "invalid Path matcher (no leading /)", + rule: "Path(`css`)", + expectedError: true, + }, + { + desc: "valid Path matcher (many parameters)", + rule: "Path(`/css`, `/js`)", + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com/html": http.StatusNotFound, + "https://example.org/css": http.StatusOK, + "https://example.com/css": http.StatusOK, + "https://example.com/css/": http.StatusNotFound, + "https://example.com/css/main.css": http.StatusNotFound, + "https://example.com/js": http.StatusOK, + "https://example.com/js/main.js": http.StatusNotFound, + }, + }, + { + desc: "valid Path matcher", + rule: "Path(`/css`)", + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com/html": http.StatusNotFound, + "https://example.org/css": http.StatusOK, + "https://example.com/css": http.StatusOK, + "https://example.com/css/": http.StatusNotFound, + "https://example.com/css/main.css": http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, "v2", 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + results := make(map[string]int) + for calledURL := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody) + + muxer.ServeHTTP(w, req) + results[calledURL] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} + +func TestPathPrefixV2Matcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[string]int + expectedError bool + }{ + { + desc: "invalid PathPrefix matcher (no parameter)", + rule: "PathPrefix()", + expectedError: true, + }, + { + desc: "invalid PathPrefix matcher (empty parameter)", + rule: "PathPrefix(``)", + expectedError: true, + }, + { + desc: "invalid PathPrefix matcher (no leading /)", + rule: "PathPrefix(`css`)", + expectedError: true, + }, + { + desc: "valid PathPrefix matcher (many parameters)", + rule: "PathPrefix(`/css`, `/js`)", + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com/html": http.StatusNotFound, + "https://example.org/css": http.StatusOK, + "https://example.com/css": http.StatusOK, + "https://example.com/css/": http.StatusOK, + "https://example.com/css/main.css": http.StatusOK, + "https://example.com/js/": http.StatusOK, + "https://example.com/js/main.js": http.StatusOK, + }, + }, + { + desc: "valid PathPrefix matcher", + rule: `PathPrefix("/css")`, + expected: map[string]int{ + "https://example.com": http.StatusNotFound, + "https://example.com/html": http.StatusNotFound, + "https://example.org/css": http.StatusOK, + "https://example.com/css": http.StatusOK, + "https://example.com/css/": http.StatusOK, + "https://example.com/css/main.css": http.StatusOK, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, "v2", 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + results := make(map[string]int) + for calledURL := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody) + + muxer.ServeHTTP(w, req) + results[calledURL] = w.Code + } + assert.Equal(t, test.expected, results) + }) + } +} + +func TestHeadersMatcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[*http.Header]int + expectedError bool + }{ + { + desc: "invalid Header matcher (no parameter)", + rule: "Headers()", + expectedError: true, + }, + { + desc: "invalid Header matcher (missing value parameter)", + rule: "Headers(`X-Forwarded-Host`)", + expectedError: true, + }, + { + desc: "invalid Header matcher (missing value parameter)", + rule: "Headers(`X-Forwarded-Host`, ``)", + expectedError: true, + }, + { + desc: "invalid Header matcher (missing key parameter)", + rule: "Headers(``, `example.com`)", + expectedError: true, + }, + { + desc: "invalid Header matcher (too many parameters)", + rule: "Headers(`X-Forwarded-Host`, `example.com`, `example.org`)", + expectedError: true, + }, + { + desc: "valid Header matcher", + rule: "Headers(`X-Forwarded-Proto`, `https`)", + expected: map[*http.Header]int{ + {"X-Forwarded-Proto": []string{"https"}}: http.StatusOK, + {"x-forwarded-proto": []string{"https"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"http", "https"}}: http.StatusOK, + {"X-Forwarded-Proto": []string{"https", "http"}}: http.StatusOK, + {"X-Forwarded-Host": []string{"example.com"}}: http.StatusNotFound, + }, + }, + { + desc: "valid Header matcher (non-canonical form)", + rule: "Headers(`x-forwarded-proto`, `https`)", + expected: map[*http.Header]int{ + {"X-Forwarded-Proto": []string{"https"}}: http.StatusOK, + {"x-forwarded-proto": []string{"https"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"http", "https"}}: http.StatusOK, + {"X-Forwarded-Proto": []string{"https", "http"}}: http.StatusOK, + {"X-Forwarded-Host": []string{"example.com"}}: http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, "v2", 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + for headers := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, "https://example.com", http.NoBody) + req.Header = *headers + + muxer.ServeHTTP(w, req) + assert.Equal(t, test.expected[headers], w.Code, headers) + } + }) + } +} + +func TestHeaderRegexpV2Matcher(t *testing.T) { + testCases := []struct { + desc string + rule string + expected map[*http.Header]int + expectedError bool + }{ + { + desc: "invalid HeaderRegexp matcher (no parameter)", + rule: "HeaderRegexp()", + expectedError: true, + }, + { + desc: "invalid HeaderRegexp matcher (missing value parameter)", + rule: "HeadersRegexp(`X-Forwarded-Host`)", + expectedError: true, + }, + { + desc: "invalid HeaderRegexp matcher (missing value parameter)", + rule: "HeadersRegexp(`X-Forwarded-Host`, ``)", + expectedError: true, + }, + { + desc: "invalid HeaderRegexp matcher (missing key parameter)", + rule: "HeadersRegexp(``, `example.com`)", + expectedError: true, + }, + { + desc: "invalid HeaderRegexp matcher (invalid regexp)", + rule: "HeadersRegexp(`X-Forwarded-Host`,`(example.com`)", + expectedError: true, + }, + { + desc: "invalid HeaderRegexp matcher (too many parameters)", + rule: "HeadersRegexp(`X-Forwarded-Host`, `example.com`, `example.org`)", + expectedError: true, + }, + { + desc: "valid HeaderRegexp matcher", + rule: "HeadersRegexp(`X-Forwarded-Proto`, `^https?$`)", + expected: map[*http.Header]int{ + {"X-Forwarded-Proto": []string{"http"}}: http.StatusOK, + {"x-forwarded-proto": []string{"http"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"https"}}: http.StatusOK, + {"X-Forwarded-Proto": []string{"HTTPS"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"ws", "https"}}: http.StatusOK, + {"X-Forwarded-Host": []string{"example.com"}}: http.StatusNotFound, + }, + }, + { + desc: "valid HeaderRegexp matcher (non-canonical form)", + rule: "HeadersRegexp(`x-forwarded-proto`, `^https?$`)", + expected: map[*http.Header]int{ + {"X-Forwarded-Proto": []string{"http"}}: http.StatusOK, + {"x-forwarded-proto": []string{"http"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"https"}}: http.StatusOK, + {"X-Forwarded-Proto": []string{"HTTPS"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"ws", "https"}}: http.StatusOK, + {"X-Forwarded-Host": []string{"example.com"}}: http.StatusNotFound, + }, + }, + { + desc: "valid HeaderRegexp matcher with Traefik v2 syntax", + rule: "HeadersRegexp(`X-Forwarded-Proto`, `http{secure:s?}`)", + expected: map[*http.Header]int{ + {"X-Forwarded-Proto": []string{"http"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"https"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"http{secure:}"}}: http.StatusOK, + {"X-Forwarded-Proto": []string{"HTTP{secure:}"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"http{secure:s}"}}: http.StatusOK, + {"X-Forwarded-Proto": []string{"http{secure:S}"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"HTTPS"}}: http.StatusNotFound, + {"X-Forwarded-Proto": []string{"ws", "http{secure:s}"}}: http.StatusOK, + {"X-Forwarded-Host": []string{"example.com"}}: http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, "v2", 0, handler) + if test.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + + for headers := range test.expected { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, "https://example.com", http.NoBody) + req.Header = *headers + + muxer.ServeHTTP(w, req) + assert.Equal(t, test.expected[headers], w.Code, *headers) + } + }) + } +} + +func TestHostRegexp(t *testing.T) { + testCases := []struct { + desc string + hostExp string + urls map[string]int + }{ + { + desc: "capturing group", + hostExp: "HostRegexp(`{subdomain:(foo\\.)?bar\\.com}`)", + urls: map[string]int{ + "http://foo.bar.com": http.StatusOK, + "http://bar.com": http.StatusOK, + "http://fooubar.com": http.StatusNotFound, + "http://barucom": http.StatusNotFound, + "http://barcom": http.StatusNotFound, + }, + }, + { + desc: "non capturing group", + hostExp: "HostRegexp(`{subdomain:(?:foo\\.)?bar\\.com}`)", + urls: map[string]int{ + "http://foo.bar.com": http.StatusOK, + "http://bar.com": http.StatusOK, + "http://fooubar.com": http.StatusNotFound, + "http://barucom": http.StatusNotFound, + "http://barcom": http.StatusNotFound, + }, + }, + { + desc: "regex insensitive", + hostExp: "HostRegexp(`{dummy:[A-Za-z-]+\\.bar\\.com}`)", + urls: map[string]int{ + "http://FOO.bar.com": http.StatusOK, + "http://foo.bar.com": http.StatusOK, + "http://fooubar.com": http.StatusNotFound, + "http://barucom": http.StatusNotFound, + "http://barcom": http.StatusNotFound, + }, + }, + { + desc: "insensitive host", + hostExp: "HostRegexp(`{dummy:[a-z-]+\\.bar\\.com}`)", + urls: map[string]int{ + "http://FOO.bar.com": http.StatusOK, + "http://foo.bar.com": http.StatusOK, + "http://fooubar.com": http.StatusNotFound, + "http://barucom": http.StatusNotFound, + "http://barcom": http.StatusNotFound, + }, + }, + { + desc: "insensitive host simple", + hostExp: "HostRegexp(`foo.bar.com`)", + urls: map[string]int{ + "http://FOO.bar.com": http.StatusOK, + "http://foo.bar.com": http.StatusOK, + "http://fooubar.com": http.StatusNotFound, + "http://barucom": http.StatusNotFound, + "http://barcom": http.StatusNotFound, + }, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.hostExp, "v2", 0, handler) + require.NoError(t, err) + + results := make(map[string]int) + for calledURL := range test.urls { + w := httptest.NewRecorder() + + req := httptest.NewRequest(http.MethodGet, calledURL, http.NoBody) + + muxer.ServeHTTP(w, req) + results[calledURL] = w.Code + } + assert.Equal(t, test.urls, results) + }) + } +} + +// This test is a copy from the v2 branch mux_test.go file. +func Test_addRoute(t *testing.T) { + testCases := []struct { + desc string + rule string + headers map[string]string + remoteAddr string + expected map[string]int + expectedError bool + }{ + { + desc: "no tree", + expectedError: true, + }, + { + desc: "Rule with no matcher", + rule: "rulewithnotmatcher", + expectedError: true, + }, + { + desc: "Host empty", + rule: "Host(``)", + expectedError: true, + }, + { + desc: "PathPrefix empty", + rule: "PathPrefix(``)", + expectedError: true, + }, + { + desc: "PathPrefix", + rule: "PathPrefix(`/foo`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "wrong PathPrefix", + rule: "PathPrefix(`/bar`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusNotFound, + }, + }, + { + desc: "Host", + rule: "Host(`localhost`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "Host IPv4", + rule: "Host(`127.0.0.1`)", + expected: map[string]int{ + "http://127.0.0.1/foo": http.StatusOK, + }, + }, + { + desc: "Host IPv6", + rule: "Host(`10::10`)", + expected: map[string]int{ + "http://10::10/foo": http.StatusOK, + }, + }, + { + desc: "Non-ASCII Host", + rule: "Host(`locàlhost`)", + expectedError: true, + }, + { + desc: "Non-ASCII HostRegexp", + rule: "HostRegexp(`locàlhost`)", + expectedError: true, + }, + { + desc: "HostHeader equivalent to Host", + rule: "HostHeader(`localhost`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + "http://bar/foo": http.StatusNotFound, + }, + }, + { + desc: "Host with trailing period in rule", + rule: "Host(`localhost.`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "Host with trailing period in domain", + rule: "Host(`localhost`)", + expected: map[string]int{ + "http://localhost./foo": http.StatusOK, + }, + }, + { + desc: "Host with trailing period in domain and rule", + rule: "Host(`localhost.`)", + expected: map[string]int{ + "http://localhost./foo": http.StatusOK, + }, + }, + { + desc: "wrong Host", + rule: "Host(`nope`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusNotFound, + }, + }, + { + desc: "Host and PathPrefix", + rule: "Host(`localhost`) && PathPrefix(`/foo`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "Host and PathPrefix wrong PathPrefix", + rule: "Host(`localhost`) && PathPrefix(`/bar`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusNotFound, + }, + }, + { + desc: "Host and PathPrefix wrong Host", + rule: "Host(`nope`) && PathPrefix(`/foo`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusNotFound, + }, + }, + { + desc: "Host and PathPrefix Host OR, first host", + rule: "Host(`nope`,`localhost`) && PathPrefix(`/foo`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "Host and PathPrefix Host OR, second host", + rule: "Host(`nope`,`localhost`) && PathPrefix(`/foo`)", + expected: map[string]int{ + "http://nope/foo": http.StatusOK, + }, + }, + { + desc: "Host and PathPrefix Host OR, first host and wrong PathPrefix", + rule: "Host(`nope,localhost`) && PathPrefix(`/bar`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusNotFound, + }, + }, + { + desc: "HostRegexp with capturing group", + rule: "HostRegexp(`{subdomain:(foo\\.)?bar\\.com}`)", + expected: map[string]int{ + "http://foo.bar.com": http.StatusOK, + "http://bar.com": http.StatusOK, + "http://fooubar.com": http.StatusNotFound, + "http://barucom": http.StatusNotFound, + "http://barcom": http.StatusNotFound, + }, + }, + { + desc: "HostRegexp with non capturing group", + rule: "HostRegexp(`{subdomain:(?:foo\\.)?bar\\.com}`)", + expected: map[string]int{ + "http://foo.bar.com": http.StatusOK, + "http://bar.com": http.StatusOK, + "http://fooubar.com": http.StatusNotFound, + "http://barucom": http.StatusNotFound, + "http://barcom": http.StatusNotFound, + }, + }, + { + desc: "Methods with GET", + rule: "Method(`GET`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "Methods with GET and POST", + rule: "Method(`GET`,`POST`)", + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "Methods with POST", + rule: "Method(`POST`)", + expected: map[string]int{ + // On v2 this test expect a http.StatusMethodNotAllowed status code. + // This was due to a custom behavior of mux https://github.com/containous/mux/blob/b2dd784e613f218225150a5e8b5742c5733bc1b6/mux.go#L130-L132. + // Unfortunately, this behavior cannot be ported easily due to the matcher func signature. + "http://localhost/foo": http.StatusNotFound, + }, + }, + { + desc: "Header with matching header", + rule: "Headers(`Content-Type`,`application/json`)", + headers: map[string]string{ + "Content-Type": "application/json", + }, + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "Header without matching header", + rule: "Headers(`Content-Type`,`application/foo`)", + headers: map[string]string{ + "Content-Type": "application/json", + }, + expected: map[string]int{ + "http://localhost/foo": http.StatusNotFound, + }, + }, + { + desc: "HeaderRegExp with matching header", + rule: "HeadersRegexp(`Content-Type`, `application/(text|json)`)", + headers: map[string]string{ + "Content-Type": "application/json", + }, + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "HeaderRegExp without matching header", + rule: "HeadersRegexp(`Content-Type`, `application/(text|json)`)", + headers: map[string]string{ + "Content-Type": "application/foo", + }, + expected: map[string]int{ + "http://localhost/foo": http.StatusNotFound, + }, + }, + { + desc: "HeaderRegExp with matching second header", + rule: "HeadersRegexp(`Content-Type`, `application/(text|json)`)", + headers: map[string]string{ + "Content-Type": "application/text", + }, + expected: map[string]int{ + "http://localhost/foo": http.StatusOK, + }, + }, + { + desc: "Query with multiple params", + rule: "Query(`foo=bar`, `bar=baz`)", + expected: map[string]int{ + "http://localhost/foo?foo=bar&bar=baz": http.StatusOK, + "http://localhost/foo?bar=baz": http.StatusNotFound, + }, + }, + { + desc: "Query with multiple equals", + rule: "Query(`foo=b=ar`)", + expected: map[string]int{ + "http://localhost/foo?foo=b=ar": http.StatusOK, + "http://localhost/foo?foo=bar": http.StatusNotFound, + }, + }, + { + desc: "Rule with simple path", + rule: `Path("/a")`, + expected: map[string]int{ + "http://plop/a": http.StatusOK, + }, + }, + { + desc: `Rule with a simple host`, + rule: `Host("plop")`, + expected: map[string]int{ + "http://plop": http.StatusOK, + }, + }, + { + desc: "Rule with Path AND Host", + rule: `Path("/a") && Host("plop")`, + expected: map[string]int{ + "http://plop/a": http.StatusOK, + "http://plopi/a": http.StatusNotFound, + }, + }, + { + desc: "Rule with Host OR Host", + rule: `Host("tchouk") || Host("pouet")`, + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + "http://pouet/a": http.StatusOK, + "http://plopi/a": http.StatusNotFound, + }, + }, + { + desc: "Rule with host OR (host AND path)", + rule: `Host("tchouk") || (Host("pouet") && Path("/powpow"))`, + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + "http://tchouk/powpow": http.StatusOK, + "http://pouet/powpow": http.StatusOK, + "http://pouet/toto": http.StatusNotFound, + "http://plopi/a": http.StatusNotFound, + }, + }, + { + desc: "Rule with host OR host AND path", + rule: `Host("tchouk") || Host("pouet") && Path("/powpow")`, + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + "http://tchouk/powpow": http.StatusOK, + "http://pouet/powpow": http.StatusOK, + "http://pouet/toto": http.StatusNotFound, + "http://plopi/a": http.StatusNotFound, + }, + }, + { + desc: "Rule with (host OR host) AND path", + rule: `(Host("tchouk") || Host("pouet")) && Path("/powpow")`, + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + "http://tchouk/powpow": http.StatusOK, + "http://pouet/powpow": http.StatusOK, + "http://pouet/toto": http.StatusNotFound, + "http://plopi/a": http.StatusNotFound, + }, + }, + { + desc: "Rule with multiple host AND path", + rule: `(Host("tchouk","pouet")) && Path("/powpow")`, + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + "http://tchouk/powpow": http.StatusOK, + "http://pouet/powpow": http.StatusOK, + "http://pouet/toto": http.StatusNotFound, + "http://plopi/a": http.StatusNotFound, + }, + }, + { + desc: "Rule with multiple host AND multiple path", + rule: `Host("tchouk","pouet") && Path("/powpow", "/titi")`, + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + "http://tchouk/powpow": http.StatusOK, + "http://pouet/powpow": http.StatusOK, + "http://tchouk/titi": http.StatusOK, + "http://pouet/titi": http.StatusOK, + "http://pouet/toto": http.StatusNotFound, + "http://plopi/a": http.StatusNotFound, + }, + }, + { + desc: "Rule with (host AND path) OR (host AND path)", + rule: `(Host("tchouk") && Path("/titi")) || ((Host("pouet")) && Path("/powpow"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusOK, + "http://tchouk/powpow": http.StatusNotFound, + "http://pouet/powpow": http.StatusOK, + "http://pouet/toto": http.StatusNotFound, + "http://plopi/a": http.StatusNotFound, + }, + }, + { + desc: "Rule without quote", + rule: `Host(tchouk)`, + expectedError: true, + }, + { + desc: "Rule case UPPER", + rule: `(HOST("tchouk") && PATHPREFIX("/titi"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusOK, + "http://tchouk/powpow": http.StatusNotFound, + }, + }, + { + desc: "Rule case lower", + rule: `(host("tchouk") && pathprefix("/titi"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusOK, + "http://tchouk/powpow": http.StatusNotFound, + }, + }, + { + desc: "Rule case CamelCase", + rule: `(Host("tchouk") && PathPrefix("/titi"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusOK, + "http://tchouk/powpow": http.StatusNotFound, + }, + }, + { + desc: "Rule case Title", + rule: `(Host("tchouk") && Pathprefix("/titi"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusOK, + "http://tchouk/powpow": http.StatusNotFound, + }, + }, + { + desc: "Rule Path with error", + rule: `Path("titi")`, + expectedError: true, + }, + { + desc: "Rule PathPrefix with error", + rule: `PathPrefix("titi")`, + expectedError: true, + }, + { + desc: "Rule HostRegexp with error", + rule: `HostRegexp("{test")`, + expectedError: true, + }, + { + desc: "Rule Headers with error", + rule: `Headers("titi")`, + expectedError: true, + }, + { + desc: "Rule HeadersRegexp with error", + rule: `HeadersRegexp("titi")`, + expectedError: true, + }, + { + desc: "Rule Query", + rule: `Query("titi")`, + expectedError: true, + }, + { + desc: "Rule Query with bad syntax", + rule: `Query("titi={test")`, + expectedError: true, + }, + { + desc: "Rule with Path without args", + rule: `Host("tchouk") && Path()`, + expectedError: true, + }, + { + desc: "Rule with an empty path", + rule: `Host("tchouk") && Path("")`, + expectedError: true, + }, + { + desc: "Rule with an empty path", + rule: `Host("tchouk") && Path("", "/titi")`, + expectedError: true, + }, + { + desc: "Rule with not", + rule: `!Host("tchouk")`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusNotFound, + "http://test/powpow": http.StatusOK, + }, + }, + { + desc: "Rule with not on Path", + rule: `!Path("/titi")`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusNotFound, + "http://tchouk/powpow": http.StatusOK, + }, + }, + { + desc: "Rule with not on multiple route with or", + rule: `!(Host("tchouk") || Host("toto"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusNotFound, + "http://toto/powpow": http.StatusNotFound, + "http://test/powpow": http.StatusOK, + }, + }, + { + desc: "Rule with not on multiple route with and", + rule: `!(Host("tchouk") && Path("/titi"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusNotFound, + "http://tchouk/toto": http.StatusOK, + "http://test/titi": http.StatusOK, + }, + }, + { + desc: "Rule with not on multiple route with and another not", + rule: `!(Host("tchouk") && !Path("/titi"))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusOK, + "http://toto/titi": http.StatusOK, + "http://tchouk/toto": http.StatusNotFound, + }, + }, + { + desc: "Rule with not on two rule", + rule: `!Host("tchouk") || !Path("/titi")`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusNotFound, + "http://tchouk/toto": http.StatusOK, + "http://test/titi": http.StatusOK, + }, + }, + { + desc: "Rule case with double not", + rule: `!(!(Host("tchouk") && Pathprefix("/titi")))`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusOK, + "http://tchouk/powpow": http.StatusNotFound, + "http://test/titi": http.StatusNotFound, + }, + }, + { + desc: "Rule case with not domain", + rule: `!Host("tchouk") && Pathprefix("/titi")`, + expected: map[string]int{ + "http://tchouk/titi": http.StatusNotFound, + "http://tchouk/powpow": http.StatusNotFound, + "http://toto/powpow": http.StatusNotFound, + "http://toto/titi": http.StatusOK, + }, + }, + { + desc: "Rule with multiple host AND multiple path AND not", + rule: `!(Host("tchouk","pouet") && Path("/powpow", "/titi"))`, + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + "http://tchouk/powpow": http.StatusNotFound, + "http://pouet/powpow": http.StatusNotFound, + "http://tchouk/titi": http.StatusNotFound, + "http://pouet/titi": http.StatusNotFound, + "http://pouet/toto": http.StatusOK, + "http://plopi/a": http.StatusOK, + }, + }, + { + desc: "ClientIP empty", + rule: "ClientIP(``)", + expectedError: true, + }, + { + desc: "Invalid ClientIP", + rule: "ClientIP(`invalid`)", + expectedError: true, + }, + { + desc: "Non matching ClientIP", + rule: "ClientIP(`10.10.1.1`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + }, + }, + { + desc: "Non matching IPv6", + rule: "ClientIP(`10::10`)", + remoteAddr: "::1", + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + }, + }, + { + desc: "Matching IP", + rule: "ClientIP(`10.0.0.0`)", + remoteAddr: "10.0.0.0:8456", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IPv6", + rule: "ClientIP(`10::10`)", + remoteAddr: "10::10", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IP among several IP", + rule: "ClientIP(`10.0.0.1`, `10.0.0.0`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Non Matching IP with CIDR", + rule: "ClientIP(`11.0.0.0/24`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + }, + }, + { + desc: "Non Matching IPv6 with CIDR", + rule: "ClientIP(`11::/16`)", + remoteAddr: "10::", + expected: map[string]int{ + "http://tchouk/toto": http.StatusNotFound, + }, + }, + { + desc: "Matching IP with CIDR", + rule: "ClientIP(`10.0.0.0/16`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IPv6 with CIDR", + rule: "ClientIP(`10::/16`)", + remoteAddr: "10::10", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IP among several CIDR", + rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0/16`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IP among non matching CIDR and matching IP", + rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + { + desc: "Matching IP among matching CIDR and non matching IP", + rule: "ClientIP(`11.0.0.0`, `10.0.0.0/16`)", + remoteAddr: "10.0.0.0", + expected: map[string]int{ + "http://tchouk/toto": http.StatusOK, + }, + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, "v2", 0, handler) + if test.expectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + + // RequestDecorator is necessary for the hostV2 rule + reqHost := requestdecorator.New(nil) + + results := make(map[string]int) + for calledURL := range test.expected { + w := httptest.NewRecorder() + + req := testhelpers.MustNewRequest(http.MethodGet, calledURL, nil) + + // Useful for the ClientIP matcher + req.RemoteAddr = test.remoteAddr + + for key, value := range test.headers { + req.Header.Set(key, value) + } + reqHost.ServeHTTP(w, req, muxer.ServeHTTP) + results[calledURL] = w.Code + } + assert.Equal(t, test.expected, results) + } + }) + } +} diff --git a/pkg/muxer/http/mux.go b/pkg/muxer/http/mux.go index 979634aa1..33e700697 100644 --- a/pkg/muxer/http/mux.go +++ b/pkg/muxer/http/mux.go @@ -12,8 +12,9 @@ import ( // Muxer handles routing with rules. type Muxer struct { - routes routes - parser predicate.Parser + routes routes + parser predicate.Parser + parserV2 predicate.Parser } // NewMuxer returns a new muxer instance. @@ -28,8 +29,19 @@ func NewMuxer() (*Muxer, error) { return nil, fmt.Errorf("error while creating parser: %w", err) } + var matchersV2 []string + for matcher := range httpFuncsV2 { + matchersV2 = append(matchersV2, matcher) + } + + parserV2, err := rules.NewParser(matchersV2) + if err != nil { + return nil, fmt.Errorf("error while creating v2 parser: %w", err) + } + return &Muxer{ - parser: parser, + parser: parser, + parserV2: parserV2, }, nil } @@ -53,10 +65,26 @@ func GetRulePriority(rule string) int { } // AddRoute add a new route to the router. -func (m *Muxer) AddRoute(rule string, priority int, handler http.Handler) error { - parse, err := m.parser.Parse(rule) - if err != nil { - return fmt.Errorf("error while parsing rule %s: %w", rule, err) +func (m *Muxer) AddRoute(rule string, syntax string, priority int, handler http.Handler) error { + var parse interface{} + var err error + var matcherFuncs map[string]func(*matchersTree, ...string) error + + switch syntax { + case "v2": + parse, err = m.parserV2.Parse(rule) + if err != nil { + return fmt.Errorf("error while parsing rule %s: %w", rule, err) + } + + matcherFuncs = httpFuncsV2 + default: + parse, err = m.parser.Parse(rule) + if err != nil { + return fmt.Errorf("error while parsing rule %s: %w", rule, err) + } + + matcherFuncs = httpFuncs } buildTree, ok := parse.(rules.TreeBuilder) @@ -65,7 +93,7 @@ func (m *Muxer) AddRoute(rule string, priority int, handler http.Handler) error } var matchers matchersTree - err = matchers.addRule(buildTree()) + err = matchers.addRule(buildTree(), matcherFuncs) if err != nil { return fmt.Errorf("error while adding rule %s: %w", rule, err) } @@ -87,6 +115,9 @@ func ParseDomains(rule string) ([]string, error) { for matcher := range httpFuncs { matchers = append(matchers, matcher) } + for matcher := range httpFuncsV2 { + matchers = append(matchers, matcher) + } parser, err := rules.NewParser(matchers) if err != nil { @@ -166,25 +197,27 @@ func (m *matchersTree) match(req *http.Request) bool { } } -func (m *matchersTree) addRule(rule *rules.Tree) error { +type matcherFuncs map[string]func(*matchersTree, ...string) error + +func (m *matchersTree) addRule(rule *rules.Tree, funcs matcherFuncs) error { switch rule.Matcher { case "and", "or": m.operator = rule.Matcher m.left = &matchersTree{} - err := m.left.addRule(rule.RuleLeft) + err := m.left.addRule(rule.RuleLeft, funcs) if err != nil { return fmt.Errorf("error while adding rule %s: %w", rule.Matcher, err) } m.right = &matchersTree{} - return m.right.addRule(rule.RuleRight) + return m.right.addRule(rule.RuleRight, funcs) default: err := rules.CheckRule(rule) if err != nil { return fmt.Errorf("error while checking rule %s: %w", rule.Matcher, err) } - err = httpFuncs[rule.Matcher](m, rule.Value...) + err = funcs[rule.Matcher](m, rule.Value...) if err != nil { return fmt.Errorf("error while adding rule %s: %w", rule.Matcher, err) } diff --git a/pkg/muxer/http/mux_test.go b/pkg/muxer/http/mux_test.go index a31a37881..efa8486a3 100644 --- a/pkg/muxer/http/mux_test.go +++ b/pkg/muxer/http/mux_test.go @@ -231,7 +231,7 @@ func TestMuxer(t *testing.T) { require.NoError(t, err) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) if test.expectedError { require.Error(t, err) return @@ -394,7 +394,7 @@ func Test_addRoutePriority(t *testing.T) { route.priority = GetRulePriority(route.rule) } - err := muxer.AddRoute(route.rule, route.priority, handler) + err := muxer.AddRoute(route.rule, "", route.priority, handler) require.NoError(t, err, route.rule) } @@ -519,7 +519,7 @@ func TestEmptyHost(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, handler) + err = muxer.AddRoute(test.rule, "", 0, handler) require.NoError(t, err) // RequestDecorator is necessary for the host rule diff --git a/pkg/muxer/tcp/matcher_test.go b/pkg/muxer/tcp/matcher_test.go index 3ba833d49..531492e89 100644 --- a/pkg/muxer/tcp/matcher_test.go +++ b/pkg/muxer/tcp/matcher_test.go @@ -38,7 +38,7 @@ func Test_HostSNICatchAll(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + err = muxer.AddRoute(test.rule, "", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) require.NoError(t, err) handler, catchAll := muxer.Match(ConnData{ @@ -144,7 +144,7 @@ func Test_HostSNI(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + err = muxer.AddRoute(test.rule, "", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) if test.buildErr { require.Error(t, err) return @@ -227,7 +227,7 @@ func Test_HostSNIRegexp(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + err = muxer.AddRoute(test.rule, "", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) if test.buildErr { require.Error(t, err) return @@ -299,7 +299,7 @@ func Test_ClientIP(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + err = muxer.AddRoute(test.rule, "", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) if test.buildErr { require.Error(t, err) return @@ -363,7 +363,7 @@ func Test_ALPN(t *testing.T) { muxer, err := NewMuxer() require.NoError(t, err) - err = muxer.AddRoute(test.rule, 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + err = muxer.AddRoute(test.rule, "", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) if test.buildErr { require.Error(t, err) return diff --git a/pkg/muxer/tcp/matcher_v2.go b/pkg/muxer/tcp/matcher_v2.go new file mode 100644 index 000000000..3d918b02e --- /dev/null +++ b/pkg/muxer/tcp/matcher_v2.go @@ -0,0 +1,240 @@ +package tcp + +import ( + "bytes" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/go-acme/lego/v4/challenge/tlsalpn01" + "github.com/rs/zerolog/log" + "github.com/traefik/traefik/v3/pkg/ip" +) + +var tcpFuncsV2 = map[string]func(*matchersTree, ...string) error{ + "ALPN": alpnV2, + "ClientIP": clientIPV2, + "HostSNI": hostSNIV2, + "HostSNIRegexp": hostSNIRegexpV2, +} + +func clientIPV2(tree *matchersTree, clientIPs ...string) error { + checker, err := ip.NewChecker(clientIPs) + if err != nil { + return fmt.Errorf("could not initialize IP Checker for \"ClientIP\" matcher: %w", err) + } + + tree.matcher = func(meta ConnData) bool { + if meta.remoteIP == "" { + return false + } + + ok, err := checker.Contains(meta.remoteIP) + if err != nil { + log.Warn().Err(err).Msg("ClientIP matcher: could not match remote address") + return false + } + return ok + } + + return nil +} + +// alpnV2 checks if any of the connection ALPN protocols matches one of the matcher protocols. +func alpnV2(tree *matchersTree, protos ...string) error { + if len(protos) == 0 { + return errors.New("empty value for \"ALPN\" matcher is not allowed") + } + + for _, proto := range protos { + if proto == tlsalpn01.ACMETLS1Protocol { + return fmt.Errorf("invalid protocol value for \"ALPN\" matcher, %q is not allowed", proto) + } + } + + tree.matcher = func(meta ConnData) bool { + for _, proto := range meta.alpnProtos { + for _, filter := range protos { + if proto == filter { + return true + } + } + } + + return false + } + + return nil +} + +// hostSNIV2 checks if the SNI Host of the connection match the matcher host. +func hostSNIV2(tree *matchersTree, hosts ...string) error { + if len(hosts) == 0 { + return errors.New("empty value for \"HostSNI\" matcher is not allowed") + } + + for i, host := range hosts { + // Special case to allow global wildcard + if host == "*" { + continue + } + + if !hostOrIP.MatchString(host) { + return fmt.Errorf("invalid value for \"HostSNI\" matcher, %q is not a valid hostname or IP", host) + } + + hosts[i] = strings.ToLower(host) + } + + tree.matcher = func(meta ConnData) bool { + // Since a HostSNI(`*`) rule has been provided as catchAll for non-TLS TCP, + // it allows matching with an empty serverName. + // Which is why we make sure to take that case into account before + // checking meta.serverName. + if hosts[0] == "*" { + return true + } + + if meta.serverName == "" { + return false + } + + for _, host := range hosts { + if host == "*" { + return true + } + + if host == meta.serverName { + return true + } + + // trim trailing period in case of FQDN + host = strings.TrimSuffix(host, ".") + if host == meta.serverName { + return true + } + } + + return false + } + + return nil +} + +// hostSNIRegexpV2 checks if the SNI Host of the connection matches the matcher host regexp. +func hostSNIRegexpV2(tree *matchersTree, templates ...string) error { + if len(templates) == 0 { + return fmt.Errorf("empty value for \"HostSNIRegexp\" matcher is not allowed") + } + + var regexps []*regexp.Regexp + + for _, template := range templates { + preparedPattern, err := preparePattern(template) + if err != nil { + return fmt.Errorf("invalid pattern value for \"HostSNIRegexp\" matcher, %q is not a valid pattern: %w", template, err) + } + + regexp, err := regexp.Compile(preparedPattern) + if err != nil { + return err + } + + regexps = append(regexps, regexp) + } + + tree.matcher = func(meta ConnData) bool { + for _, regexp := range regexps { + if regexp.MatchString(meta.serverName) { + return true + } + } + + return false + } + + return nil +} + +// preparePattern builds a regexp pattern from the initial user defined expression. +// This function reuses the code dedicated to host matching of the newRouteRegexp func from the gorilla/mux library. +// https://github.com/containous/mux/tree/8ffa4f6d063c1e2b834a73be6a1515cca3992618. +func preparePattern(template string) (string, error) { + // Check if it is well-formed. + idxs, errBraces := braceIndices(template) + if errBraces != nil { + return "", errBraces + } + + defaultPattern := "[^.]+" + pattern := bytes.NewBufferString("") + + // Host SNI matching is case-insensitive + _, _ = fmt.Fprint(pattern, "(?i)") + + pattern.WriteByte('^') + var end int + for i := 0; i < len(idxs); i += 2 { + // Set all values we are interested in. + raw := template[end:idxs[i]] + end = idxs[i+1] + parts := strings.SplitN(template[idxs[i]+1:end-1], ":", 2) + name := parts[0] + + patt := defaultPattern + if len(parts) == 2 { + patt = parts[1] + } + + // Name or pattern can't be empty. + if name == "" || patt == "" { + return "", fmt.Errorf("mux: missing name or pattern in %q", + template[idxs[i]:end]) + } + + // Build the regexp pattern. + _, _ = fmt.Fprintf(pattern, "%s(?P<%s>%s)", regexp.QuoteMeta(raw), varGroupName(i/2), patt) + } + + // Add the remaining. + raw := template[end:] + pattern.WriteString(regexp.QuoteMeta(raw)) + pattern.WriteByte('$') + + return pattern.String(), nil +} + +// varGroupName builds a capturing group name for the indexed variable. +// This function is a copy of varGroupName func from the gorilla/mux library. +// https://github.com/containous/mux/tree/8ffa4f6d063c1e2b834a73be6a1515cca3992618. +func varGroupName(idx int) string { + return "v" + strconv.Itoa(idx) +} + +// braceIndices returns the first level curly brace indices from a string. +// This function is a copy of braceIndices func from the gorilla/mux library. +// https://github.com/containous/mux/tree/8ffa4f6d063c1e2b834a73be6a1515cca3992618. +func braceIndices(s string) ([]int, error) { + var level, idx int + var idxs []int + for i := 0; i < len(s); i++ { + switch s[i] { + case '{': + if level++; level == 1 { + idx = i + } + case '}': + if level--; level == 0 { + idxs = append(idxs, idx, i+1) + } else if level < 0 { + return nil, fmt.Errorf("mux: unbalanced braces in %q", s) + } + } + } + if level != 0 { + return nil, fmt.Errorf("mux: unbalanced braces in %q", s) + } + return idxs, nil +} diff --git a/pkg/muxer/tcp/matcher_v2_test.go b/pkg/muxer/tcp/matcher_v2_test.go new file mode 100644 index 000000000..74ffcff80 --- /dev/null +++ b/pkg/muxer/tcp/matcher_v2_test.go @@ -0,0 +1,1008 @@ +package tcp + +import ( + "fmt" + "testing" + + "github.com/go-acme/lego/v4/challenge/tlsalpn01" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/traefik/traefik/v3/pkg/tcp" +) + +// All the tests in the suite are a copy of tcp muxer tests on branch v2. +// Only the test for route priority has not been copied here, +// because the priority computation is no longer done when calling the muxer AddRoute method. +func Test_addTCPRouteV2(t *testing.T) { + testCases := []struct { + desc string + rule string + serverName string + remoteAddr string + protos []string + routeErr bool + matchErr bool + }{ + { + desc: "no tree", + routeErr: true, + }, + { + desc: "Rule with no matcher", + rule: "rulewithnotmatcher", + routeErr: true, + }, + { + desc: "Empty HostSNI rule", + rule: "HostSNI()", + serverName: "foobar", + routeErr: true, + }, + { + desc: "Empty HostSNI rule", + rule: "HostSNI(``)", + serverName: "foobar", + routeErr: true, + }, + { + desc: "Valid HostSNI rule matching", + rule: "HostSNI(`foobar`)", + serverName: "foobar", + }, + { + desc: "Valid negative HostSNI rule matching", + rule: "!HostSNI(`bar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNI rule matching with alternative case", + rule: "hostsni(`foobar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNI rule matching with alternative case", + rule: "HOSTSNI(`foobar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNI rule not matching", + rule: "HostSNI(`foobar`)", + serverName: "bar", + matchErr: true, + }, + { + desc: "Empty HostSNIRegexp rule", + rule: "HostSNIRegexp()", + serverName: "foobar", + routeErr: true, + }, + { + desc: "Empty HostSNIRegexp rule", + rule: "HostSNIRegexp(``)", + serverName: "foobar", + routeErr: true, + }, + { + desc: "Valid HostSNIRegexp rule matching", + rule: "HostSNIRegexp(`{subdomain:[a-z]+}.foobar`)", + serverName: "sub.foobar", + }, + { + desc: "Valid negative HostSNIRegexp rule matching", + rule: "!HostSNIRegexp(`bar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNIRegexp rule matching with alternative case", + rule: "hostsniregexp(`foobar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNIRegexp rule matching with alternative case", + rule: "HOSTSNIREGEXP(`foobar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNIRegexp rule not matching", + rule: "HostSNIRegexp(`foobar`)", + serverName: "bar", + matchErr: true, + }, + { + desc: "Valid negative HostSNI rule not matching", + rule: "!HostSNI(`bar`)", + serverName: "bar", + matchErr: true, + }, + { + desc: "Valid HostSNIRegexp rule matching empty servername", + rule: "HostSNIRegexp(`{subdomain:[a-z]*}`)", + serverName: "", + }, + { + desc: "Valid HostSNIRegexp rule with one name", + rule: "HostSNIRegexp(`{dummy}`)", + serverName: "toto", + }, + { + desc: "Valid HostSNIRegexp rule with one name 2", + rule: "HostSNIRegexp(`{dummy}`)", + serverName: "toto.com", + matchErr: true, + }, + { + desc: "Empty ClientIP rule", + rule: "ClientIP()", + routeErr: true, + }, + { + desc: "Empty ClientIP rule", + rule: "ClientIP(``)", + routeErr: true, + }, + { + desc: "Invalid ClientIP", + rule: "ClientIP(`invalid`)", + routeErr: true, + }, + { + desc: "Invalid remoteAddr", + rule: "ClientIP(`10.0.0.1`)", + remoteAddr: "not.an.IP:80", + matchErr: true, + }, + { + desc: "Valid ClientIP rule matching", + rule: "ClientIP(`10.0.0.1`)", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid negative ClientIP rule matching", + rule: "!ClientIP(`20.0.0.1`)", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid ClientIP rule matching with alternative case", + rule: "clientip(`10.0.0.1`)", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid ClientIP rule matching with alternative case", + rule: "CLIENTIP(`10.0.0.1`)", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid ClientIP rule not matching", + rule: "ClientIP(`10.0.0.1`)", + remoteAddr: "10.0.0.2:80", + matchErr: true, + }, + { + desc: "Valid negative ClientIP rule not matching", + rule: "!ClientIP(`10.0.0.2`)", + remoteAddr: "10.0.0.2:80", + matchErr: true, + }, + { + desc: "Valid ClientIP rule matching IPv6", + rule: "ClientIP(`10::10`)", + remoteAddr: "[10::10]:80", + }, + { + desc: "Valid negative ClientIP rule matching IPv6", + rule: "!ClientIP(`10::10`)", + remoteAddr: "[::1]:80", + }, + { + desc: "Valid ClientIP rule not matching IPv6", + rule: "ClientIP(`10::10`)", + remoteAddr: "[::1]:80", + matchErr: true, + }, + { + desc: "Valid ClientIP rule matching multiple IPs", + rule: "ClientIP(`10.0.0.1`, `10.0.0.0`)", + remoteAddr: "10.0.0.0:80", + }, + { + desc: "Valid ClientIP rule matching CIDR", + rule: "ClientIP(`11.0.0.0/24`)", + remoteAddr: "11.0.0.0:80", + }, + { + desc: "Valid ClientIP rule not matching CIDR", + rule: "ClientIP(`11.0.0.0/24`)", + remoteAddr: "10.0.0.0:80", + matchErr: true, + }, + { + desc: "Valid ClientIP rule matching CIDR IPv6", + rule: "ClientIP(`11::/16`)", + remoteAddr: "[11::]:80", + }, + { + desc: "Valid ClientIP rule not matching CIDR IPv6", + rule: "ClientIP(`11::/16`)", + remoteAddr: "[10::]:80", + matchErr: true, + }, + { + desc: "Valid ClientIP rule matching multiple CIDR", + rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0/16`)", + remoteAddr: "10.0.0.0:80", + }, + { + desc: "Valid ClientIP rule not matching CIDR and matching IP", + rule: "ClientIP(`11.0.0.0/16`, `10.0.0.0`)", + remoteAddr: "10.0.0.0:80", + }, + { + desc: "Valid ClientIP rule matching CIDR and not matching IP", + rule: "ClientIP(`11.0.0.0`, `10.0.0.0/16`)", + remoteAddr: "10.0.0.0:80", + }, + { + desc: "Valid HostSNI and ClientIP rule matching", + rule: "HostSNI(`foobar`) && ClientIP(`10.0.0.1`)", + serverName: "foobar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid negative HostSNI and ClientIP rule matching", + rule: "!HostSNI(`bar`) && ClientIP(`10.0.0.1`)", + serverName: "foobar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid HostSNI and negative ClientIP rule matching", + rule: "HostSNI(`foobar`) && !ClientIP(`10.0.0.2`)", + serverName: "foobar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid negative HostSNI and negative ClientIP rule matching", + rule: "!HostSNI(`bar`) && !ClientIP(`10.0.0.2`)", + serverName: "foobar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid negative HostSNI or negative ClientIP rule matching", + rule: "!(HostSNI(`bar`) || ClientIP(`10.0.0.2`))", + serverName: "foobar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid negative HostSNI and negative ClientIP rule matching", + rule: "!(HostSNI(`bar`) && ClientIP(`10.0.0.2`))", + serverName: "foobar", + remoteAddr: "10.0.0.2:80", + }, + { + desc: "Valid negative HostSNI and negative ClientIP rule matching", + rule: "!(HostSNI(`bar`) && ClientIP(`10.0.0.2`))", + serverName: "bar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid negative HostSNI and negative ClientIP rule matching", + rule: "!(HostSNI(`bar`) && ClientIP(`10.0.0.2`))", + serverName: "bar", + remoteAddr: "10.0.0.2:80", + matchErr: true, + }, + { + desc: "Valid negative HostSNI and negative ClientIP rule matching", + rule: "!(HostSNI(`bar`) && ClientIP(`10.0.0.2`))", + serverName: "foobar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid HostSNI and ClientIP rule not matching", + rule: "HostSNI(`foobar`) && ClientIP(`10.0.0.1`)", + serverName: "bar", + remoteAddr: "10.0.0.1:80", + matchErr: true, + }, + { + desc: "Valid HostSNI and ClientIP rule not matching", + rule: "HostSNI(`foobar`) && ClientIP(`10.0.0.1`)", + serverName: "foobar", + remoteAddr: "10.0.0.2:80", + matchErr: true, + }, + { + desc: "Valid HostSNI or ClientIP rule matching", + rule: "HostSNI(`foobar`) || ClientIP(`10.0.0.1`)", + serverName: "foobar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid HostSNI or ClientIP rule matching", + rule: "HostSNI(`foobar`) || ClientIP(`10.0.0.1`)", + serverName: "bar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid HostSNI or ClientIP rule matching", + rule: "HostSNI(`foobar`) || ClientIP(`10.0.0.1`)", + serverName: "foobar", + remoteAddr: "10.0.0.2:80", + }, + { + desc: "Valid HostSNI or ClientIP rule not matching", + rule: "HostSNI(`foobar`) || ClientIP(`10.0.0.1`)", + serverName: "bar", + remoteAddr: "10.0.0.2:80", + matchErr: true, + }, + { + desc: "Valid HostSNI x 3 OR rule matching", + rule: "HostSNI(`foobar`) || HostSNI(`foo`) || HostSNI(`bar`)", + serverName: "foobar", + }, + { + desc: "Valid HostSNI x 3 OR rule not matching", + rule: "HostSNI(`foobar`) || HostSNI(`foo`) || HostSNI(`bar`)", + serverName: "baz", + matchErr: true, + }, + { + desc: "Valid HostSNI and ClientIP Combined rule matching", + rule: "HostSNI(`foobar`) || HostSNI(`bar`) && ClientIP(`10.0.0.1`)", + serverName: "foobar", + remoteAddr: "10.0.0.2:80", + }, + { + desc: "Valid HostSNI and ClientIP Combined rule matching", + rule: "HostSNI(`foobar`) || HostSNI(`bar`) && ClientIP(`10.0.0.1`)", + serverName: "bar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid HostSNI and ClientIP Combined rule not matching", + rule: "HostSNI(`foobar`) || HostSNI(`bar`) && ClientIP(`10.0.0.1`)", + serverName: "bar", + remoteAddr: "10.0.0.2:80", + matchErr: true, + }, + { + desc: "Valid HostSNI and ClientIP Combined rule not matching", + rule: "HostSNI(`foobar`) || HostSNI(`bar`) && ClientIP(`10.0.0.1`)", + serverName: "baz", + remoteAddr: "10.0.0.1:80", + matchErr: true, + }, + { + desc: "Valid HostSNI and ClientIP complex combined rule matching", + rule: "(HostSNI(`foobar`) || HostSNI(`bar`)) && (ClientIP(`10.0.0.1`) || ClientIP(`10.0.0.2`))", + serverName: "bar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Valid HostSNI and ClientIP complex combined rule not matching", + rule: "(HostSNI(`foobar`) || HostSNI(`bar`)) && (ClientIP(`10.0.0.1`) || ClientIP(`10.0.0.2`))", + serverName: "baz", + remoteAddr: "10.0.0.1:80", + matchErr: true, + }, + { + desc: "Valid HostSNI and ClientIP complex combined rule not matching", + rule: "(HostSNI(`foobar`) || HostSNI(`bar`)) && (ClientIP(`10.0.0.1`) || ClientIP(`10.0.0.2`))", + serverName: "bar", + remoteAddr: "10.0.0.3:80", + matchErr: true, + }, + { + desc: "Valid HostSNI and ClientIP more complex (but absurd) combined rule matching", + rule: "(HostSNI(`foobar`) || (HostSNI(`bar`) && !HostSNI(`foobar`))) && ((ClientIP(`10.0.0.1`) && !ClientIP(`10.0.0.2`)) || ClientIP(`10.0.0.2`)) ", + serverName: "bar", + remoteAddr: "10.0.0.1:80", + }, + { + desc: "Invalid ALPN rule matching ACME-TLS/1", + rule: fmt.Sprintf("ALPN(`%s`)", tlsalpn01.ACMETLS1Protocol), + protos: []string{"foo"}, + routeErr: true, + }, + { + desc: "Valid ALPN rule matching single protocol", + rule: "ALPN(`foo`)", + protos: []string{"foo"}, + }, + { + desc: "Valid ALPN rule matching ACME-TLS/1 protocol", + rule: "ALPN(`foo`)", + protos: []string{tlsalpn01.ACMETLS1Protocol}, + matchErr: true, + }, + { + desc: "Valid ALPN rule not matching single protocol", + rule: "ALPN(`foo`)", + protos: []string{"bar"}, + matchErr: true, + }, + { + desc: "Valid alternative case ALPN rule matching single protocol without another being supported", + rule: "ALPN(`foo`) && !alpn(`h2`)", + protos: []string{"foo", "bar"}, + }, + { + desc: "Valid alternative case ALPN rule not matching single protocol because of another being supported", + rule: "ALPN(`foo`) && !alpn(`h2`)", + protos: []string{"foo", "h2", "bar"}, + matchErr: true, + }, + { + desc: "Valid complex alternative case ALPN and HostSNI rule", + rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))", + protos: []string{"foo", "bar"}, + serverName: "foo", + }, + { + desc: "Valid complex alternative case ALPN and HostSNI rule not matching by SNI", + rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))", + protos: []string{"foo", "bar", "h2"}, + serverName: "bar", + matchErr: true, + }, + { + desc: "Valid complex alternative case ALPN and HostSNI rule matching by ALPN", + rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))", + protos: []string{"foo", "bar"}, + serverName: "bar", + }, + { + desc: "Valid complex alternative case ALPN and HostSNI rule not matching by protos", + rule: "ALPN(`foo`) && (!alpn(`h2`) || hostsni(`foo`))", + protos: []string{"h2", "bar"}, + serverName: "bar", + matchErr: true, + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + msg := "BYTES" + handler := tcp.HandlerFunc(func(conn tcp.WriteCloser) { + _, err := conn.Write([]byte(msg)) + require.NoError(t, err) + }) + + router, err := NewMuxer() + require.NoError(t, err) + + err = router.AddRoute(test.rule, "v2", 0, handler) + if test.routeErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + + addr := "0.0.0.0:0" + if test.remoteAddr != "" { + addr = test.remoteAddr + } + + conn := &fakeConn{ + call: map[string]int{}, + remoteAddr: fakeAddr{addr: addr}, + } + + connData, err := NewConnData(test.serverName, conn, test.protos) + require.NoError(t, err) + + matchingHandler, _ := router.Match(connData) + if test.matchErr { + require.Nil(t, matchingHandler) + return + } + + require.NotNil(t, matchingHandler) + + matchingHandler.ServeTCP(conn) + + n, ok := conn.call[msg] + assert.Equal(t, 1, n) + assert.True(t, ok) + }) + } +} + +func TestParseHostSNIV2(t *testing.T) { + testCases := []struct { + description string + expression string + domain []string + errorExpected bool + }{ + { + description: "Unknown rule", + expression: "Foobar(`foo.bar`,`test.bar`)", + errorExpected: true, + }, + { + description: "Many hostSNI rules", + expression: "HostSNI(`foo.bar`,`test.bar`)", + domain: []string{"foo.bar", "test.bar"}, + }, + { + description: "Many hostSNI rules upper", + expression: "HOSTSNI(`foo.bar`,`test.bar`)", + domain: []string{"foo.bar", "test.bar"}, + }, + { + description: "Many hostSNI rules lower", + expression: "hostsni(`foo.bar`,`test.bar`)", + domain: []string{"foo.bar", "test.bar"}, + }, + { + description: "No hostSNI rule", + expression: "ClientIP(`10.1`)", + }, + { + description: "HostSNI rule and another rule", + expression: "HostSNI(`foo.bar`) && ClientIP(`10.1`)", + domain: []string{"foo.bar"}, + }, + { + description: "HostSNI rule to lower and another rule", + expression: "HostSNI(`Foo.Bar`) && ClientIP(`10.1`)", + domain: []string{"foo.bar"}, + }, + { + description: "HostSNI rule with no domain", + expression: "HostSNI() && ClientIP(`10.1`)", + }, + } + + for _, test := range testCases { + test := test + t.Run(test.expression, func(t *testing.T) { + t.Parallel() + + domains, err := ParseHostSNI(test.expression) + + if test.errorExpected { + require.Errorf(t, err, "unable to parse correctly the domains in the HostSNI rule from %q", test.expression) + } else { + require.NoError(t, err, "%s: Error while parsing domain.", test.expression) + } + + assert.EqualValues(t, test.domain, domains, "%s: Error parsing domains from expression.", test.expression) + }) + } +} + +func Test_HostSNICatchAllV2(t *testing.T) { + testCases := []struct { + desc string + rule string + isCatchAll bool + }{ + { + desc: "HostSNI(`foobar`) is not catchAll", + rule: "HostSNI(`foobar`)", + }, + { + desc: "HostSNI(`*`) is catchAll", + rule: "HostSNI(`*`)", + isCatchAll: true, + }, + { + desc: "HOSTSNI(`*`) is catchAll", + rule: "HOSTSNI(`*`)", + isCatchAll: true, + }, + { + desc: `HostSNI("*") is catchAll`, + rule: `HostSNI("*")`, + isCatchAll: true, + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + muxer, err := NewMuxer() + require.NoError(t, err) + + err = muxer.AddRoute(test.rule, "v2", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) {})) + require.NoError(t, err) + + handler, catchAll := muxer.Match(ConnData{ + serverName: "foobar", + }) + require.NotNil(t, handler) + assert.Equal(t, test.isCatchAll, catchAll) + }) + } +} + +func Test_HostSNIV2(t *testing.T) { + testCases := []struct { + desc string + ruleHosts []string + serverName string + buildErr bool + matchErr bool + }{ + { + desc: "Empty", + buildErr: true, + }, + { + desc: "Non ASCII host", + ruleHosts: []string{"héhé"}, + buildErr: true, + }, + { + desc: "Not Matching hosts", + ruleHosts: []string{"foobar"}, + serverName: "bar", + matchErr: true, + }, + { + desc: "Matching globing host `*`", + ruleHosts: []string{"*"}, + serverName: "foobar", + }, + { + desc: "Matching globing host `*` and empty serverName", + ruleHosts: []string{"*"}, + serverName: "", + }, + { + desc: "Matching globing host `*` and another non matching host", + ruleHosts: []string{"foo", "*"}, + serverName: "bar", + }, + { + desc: "Matching globing host `*` and another non matching host, and empty servername", + ruleHosts: []string{"foo", "*"}, + serverName: "", + matchErr: true, + }, + { + desc: "Not Matching globing host with subdomain", + ruleHosts: []string{"*.bar"}, + buildErr: true, + }, + { + desc: "Not Matching host with trailing dot with ", + ruleHosts: []string{"foobar."}, + serverName: "foobar.", + }, + { + desc: "Matching host with trailing dot", + ruleHosts: []string{"foobar."}, + serverName: "foobar", + }, + { + desc: "Matching hosts", + ruleHosts: []string{"foobar", "foo-bar.baz"}, + serverName: "foobar", + }, + { + desc: "Matching hosts with subdomains", + ruleHosts: []string{"foo.bar"}, + serverName: "foo.bar", + }, + { + desc: "Matching IPv4", + ruleHosts: []string{"127.0.0.1"}, + serverName: "127.0.0.1", + }, + { + desc: "Matching IPv6", + ruleHosts: []string{"10::10"}, + serverName: "10::10", + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + matcherTree := &matchersTree{} + err := hostSNIV2(matcherTree, test.ruleHosts...) + if test.buildErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + meta := ConnData{ + serverName: test.serverName, + } + + assert.Equal(t, test.matchErr, !matcherTree.match(meta)) + }) + } +} + +func Test_HostSNIRegexpV2(t *testing.T) { + testCases := []struct { + desc string + pattern string + serverNames map[string]bool + buildErr bool + }{ + { + desc: "unbalanced braces", + pattern: "subdomain:(foo\\.)?bar\\.com}", + buildErr: true, + }, + { + desc: "empty group name", + pattern: "{:(foo\\.)?bar\\.com}", + buildErr: true, + }, + { + desc: "empty capturing group", + pattern: "{subdomain:}", + buildErr: true, + }, + { + desc: "malformed capturing group", + pattern: "{subdomain:(foo\\.?bar\\.com}", + buildErr: true, + }, + { + desc: "not interpreted as a regexp", + pattern: "bar.com", + serverNames: map[string]bool{ + "bar.com": true, + "barucom": false, + }, + }, + { + desc: "capturing group", + pattern: "{subdomain:(foo\\.)?bar\\.com}", + serverNames: map[string]bool{ + "foo.bar.com": true, + "bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + { + desc: "non capturing group", + pattern: "{subdomain:(?:foo\\.)?bar\\.com}", + serverNames: map[string]bool{ + "foo.bar.com": true, + "bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + { + desc: "regex insensitive", + pattern: "{dummy:[A-Za-z-]+\\.bar\\.com}", + serverNames: map[string]bool{ + "FOO.bar.com": true, + "foo.bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + { + desc: "insensitive host", + pattern: "{dummy:[a-z-]+\\.bar\\.com}", + serverNames: map[string]bool{ + "FOO.bar.com": true, + "foo.bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + { + desc: "insensitive host simple", + pattern: "foo.bar.com", + serverNames: map[string]bool{ + "FOO.bar.com": true, + "foo.bar.com": true, + "fooubar.com": false, + "barucom": false, + "barcom": false, + }, + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + matchersTree := &matchersTree{} + err := hostSNIRegexpV2(matchersTree, test.pattern) + if test.buildErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + for serverName, match := range test.serverNames { + meta := ConnData{ + serverName: serverName, + } + + assert.Equal(t, match, matchersTree.match(meta)) + } + }) + } +} + +func Test_ClientIPV2(t *testing.T) { + testCases := []struct { + desc string + ruleCIDRs []string + remoteIP string + buildErr bool + matchErr bool + }{ + { + desc: "Empty", + buildErr: true, + }, + { + desc: "Malformed CIDR", + ruleCIDRs: []string{"héhé"}, + buildErr: true, + }, + { + desc: "Not matching empty remote IP", + ruleCIDRs: []string{"20.20.20.20"}, + matchErr: true, + }, + { + desc: "Not matching IP", + ruleCIDRs: []string{"20.20.20.20"}, + remoteIP: "10.10.10.10", + matchErr: true, + }, + { + desc: "Matching IP", + ruleCIDRs: []string{"10.10.10.10"}, + remoteIP: "10.10.10.10", + }, + { + desc: "Not matching multiple IPs", + ruleCIDRs: []string{"20.20.20.20", "30.30.30.30"}, + remoteIP: "10.10.10.10", + matchErr: true, + }, + { + desc: "Matching multiple IPs", + ruleCIDRs: []string{"10.10.10.10", "20.20.20.20", "30.30.30.30"}, + remoteIP: "20.20.20.20", + }, + { + desc: "Not matching CIDR", + ruleCIDRs: []string{"20.0.0.0/24"}, + remoteIP: "10.10.10.10", + matchErr: true, + }, + { + desc: "Matching CIDR", + ruleCIDRs: []string{"20.0.0.0/8"}, + remoteIP: "20.10.10.10", + }, + { + desc: "Not matching multiple CIDRs", + ruleCIDRs: []string{"10.0.0.0/24", "20.0.0.0/24"}, + remoteIP: "10.10.10.10", + matchErr: true, + }, + { + desc: "Matching multiple CIDRs", + ruleCIDRs: []string{"10.0.0.0/8", "20.0.0.0/8"}, + remoteIP: "20.10.10.10", + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + matchersTree := &matchersTree{} + err := clientIPV2(matchersTree, test.ruleCIDRs...) + if test.buildErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + meta := ConnData{ + remoteIP: test.remoteIP, + } + + assert.Equal(t, test.matchErr, !matchersTree.match(meta)) + }) + } +} + +func Test_ALPNV2(t *testing.T) { + testCases := []struct { + desc string + ruleALPNProtos []string + connProto string + buildErr bool + matchErr bool + }{ + { + desc: "Empty", + buildErr: true, + }, + { + desc: "ACME TLS proto", + ruleALPNProtos: []string{tlsalpn01.ACMETLS1Protocol}, + buildErr: true, + }, + { + desc: "Not matching empty proto", + ruleALPNProtos: []string{"h2"}, + matchErr: true, + }, + { + desc: "Not matching ALPN", + ruleALPNProtos: []string{"h2"}, + connProto: "mqtt", + matchErr: true, + }, + { + desc: "Matching ALPN", + ruleALPNProtos: []string{"h2"}, + connProto: "h2", + }, + { + desc: "Not matching multiple ALPNs", + ruleALPNProtos: []string{"h2", "mqtt"}, + connProto: "h2c", + matchErr: true, + }, + { + desc: "Matching multiple ALPNs", + ruleALPNProtos: []string{"h2", "h2c", "mqtt"}, + connProto: "h2c", + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + matchersTree := &matchersTree{} + err := alpnV2(matchersTree, test.ruleALPNProtos...) + if test.buildErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + meta := ConnData{ + alpnProtos: []string{test.connProto}, + } + + assert.Equal(t, test.matchErr, !matchersTree.match(meta)) + }) + } +} diff --git a/pkg/muxer/tcp/mux.go b/pkg/muxer/tcp/mux.go index f23ce629d..35c4be8a6 100644 --- a/pkg/muxer/tcp/mux.go +++ b/pkg/muxer/tcp/mux.go @@ -41,8 +41,9 @@ func NewConnData(serverName string, conn tcp.WriteCloser, alpnProtos []string) ( // Muxer defines a muxer that handles TCP routing with rules. type Muxer struct { - routes routes - parser predicate.Parser + routes routes + parser predicate.Parser + parserV2 predicate.Parser } // NewMuxer returns a TCP muxer. @@ -57,7 +58,20 @@ func NewMuxer() (*Muxer, error) { return nil, fmt.Errorf("error while creating rules parser: %w", err) } - return &Muxer{parser: parser}, nil + var matchersV2 []string + for matcher := range tcpFuncsV2 { + matchersV2 = append(matchersV2, matcher) + } + + parserV2, err := rules.NewParser(matchersV2) + if err != nil { + return nil, fmt.Errorf("error while creating v2 rules parser: %w", err) + } + + return &Muxer{ + parser: parser, + parserV2: parserV2, + }, nil } // Match returns the handler of the first route matching the connection metadata, @@ -106,10 +120,26 @@ func GetRulePriority(rule string) int { // AddRoute adds a new route, associated to the given handler, at the given // priority, to the muxer. -func (m *Muxer) AddRoute(rule string, priority int, handler tcp.Handler) error { - parse, err := m.parser.Parse(rule) - if err != nil { - return fmt.Errorf("error while parsing rule %s: %w", rule, err) +func (m *Muxer) AddRoute(rule string, syntax string, priority int, handler tcp.Handler) error { + var parse interface{} + var err error + var matcherFuncs map[string]func(*matchersTree, ...string) error + + switch syntax { + case "v2": + parse, err = m.parserV2.Parse(rule) + if err != nil { + return fmt.Errorf("error while parsing rule %s: %w", rule, err) + } + + matcherFuncs = tcpFuncsV2 + default: + parse, err = m.parser.Parse(rule) + if err != nil { + return fmt.Errorf("error while parsing rule %s: %w", rule, err) + } + + matcherFuncs = tcpFuncs } buildTree, ok := parse.(rules.TreeBuilder) @@ -120,7 +150,7 @@ func (m *Muxer) AddRoute(rule string, priority int, handler tcp.Handler) error { ruleTree := buildTree() var matchers matchersTree - err = matchers.addRule(ruleTree) + err = matchers.addRule(ruleTree, matcherFuncs) if err != nil { return fmt.Errorf("error while adding rule %s: %w", rule, err) } @@ -155,6 +185,9 @@ func ParseHostSNI(rule string) ([]string, error) { for matcher := range tcpFuncs { matchers = append(matchers, matcher) } + for matcher := range tcpFuncsV2 { + matchers = append(matchers, matcher) + } parser, err := rules.NewParser(matchers) if err != nil { @@ -237,25 +270,27 @@ func (m *matchersTree) match(meta ConnData) bool { } } -func (m *matchersTree) addRule(rule *rules.Tree) error { +type matcherFuncs map[string]func(*matchersTree, ...string) error + +func (m *matchersTree) addRule(rule *rules.Tree, funcs matcherFuncs) error { switch rule.Matcher { case "and", "or": m.operator = rule.Matcher m.left = &matchersTree{} - err := m.left.addRule(rule.RuleLeft) + err := m.left.addRule(rule.RuleLeft, funcs) if err != nil { return err } m.right = &matchersTree{} - return m.right.addRule(rule.RuleRight) + return m.right.addRule(rule.RuleRight, funcs) default: err := rules.CheckRule(rule) if err != nil { return err } - err = tcpFuncs[rule.Matcher](m, rule.Value...) + err = funcs[rule.Matcher](m, rule.Value...) if err != nil { return err } diff --git a/pkg/muxer/tcp/mux_test.go b/pkg/muxer/tcp/mux_test.go index 5c52089b4..3b108b7ea 100644 --- a/pkg/muxer/tcp/mux_test.go +++ b/pkg/muxer/tcp/mux_test.go @@ -277,7 +277,7 @@ func Test_addTCPRoute(t *testing.T) { router, err := NewMuxer() require.NoError(t, err) - err = router.AddRoute(test.rule, 0, handler) + err = router.AddRoute(test.rule, "", 0, handler) if test.routeErr { require.Error(t, err) return @@ -447,7 +447,7 @@ func Test_Priority(t *testing.T) { matchedRule := "" for rule, priority := range test.rules { rule := rule - err := muxer.AddRoute(rule, priority, tcp.HandlerFunc(func(conn tcp.WriteCloser) { + err := muxer.AddRoute(rule, "", priority, tcp.HandlerFunc(func(conn tcp.WriteCloser) { matchedRule = rule })) require.NoError(t, err) diff --git a/pkg/provider/kubernetes/crd/kubernetes_http.go b/pkg/provider/kubernetes/crd/kubernetes_http.go index ca4f78219..6e3b1f2a5 100644 --- a/pkg/provider/kubernetes/crd/kubernetes_http.go +++ b/pkg/provider/kubernetes/crd/kubernetes_http.go @@ -112,6 +112,7 @@ func (p *Provider) loadIngressRouteConfiguration(ctx context.Context, client Cli r := &dynamic.Router{ Middlewares: mds, Priority: route.Priority, + RuleSyntax: route.Syntax, EntryPoints: ingressRoute.Spec.EntryPoints, Rule: route.Match, Service: serviceName, diff --git a/pkg/provider/kubernetes/crd/kubernetes_tcp.go b/pkg/provider/kubernetes/crd/kubernetes_tcp.go index b0759786f..d5fb434a3 100644 --- a/pkg/provider/kubernetes/crd/kubernetes_tcp.go +++ b/pkg/provider/kubernetes/crd/kubernetes_tcp.go @@ -102,6 +102,7 @@ func (p *Provider) loadIngressRouteTCPConfiguration(ctx context.Context, client Middlewares: mds, Rule: route.Match, Priority: route.Priority, + RuleSyntax: route.Syntax, Service: serviceName, } diff --git a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroute.go b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroute.go index 6471381ae..475d160d4 100644 --- a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroute.go +++ b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroute.go @@ -33,6 +33,9 @@ type Route struct { // Priority defines the router's priority. // More info: https://doc.traefik.io/traefik/v3.0/routing/routers/#priority Priority int `json:"priority,omitempty"` + // Syntax defines the router's rule syntax. + // More info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax + Syntax string `json:"syntax,omitempty"` // Services defines the list of Service. // It can contain any combination of TraefikService and/or reference to a Kubernetes Service. Services []Service `json:"services,omitempty"` diff --git a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroutetcp.go b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroutetcp.go index 5669e8f4f..5cfd69d4a 100644 --- a/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroutetcp.go +++ b/pkg/provider/kubernetes/crd/traefikio/v1alpha1/ingressroutetcp.go @@ -29,6 +29,9 @@ type RouteTCP struct { // Priority defines the router's priority. // More info: https://doc.traefik.io/traefik/v3.0/routing/routers/#priority_1 Priority int `json:"priority,omitempty"` + // Syntax defines the router's rule syntax. + // More info: https://doc.traefik.io/traefik/v3.0/routing/routers/#rulesyntax_1 + Syntax string `json:"syntax,omitempty"` // Services defines the list of TCP services. Services []ServiceTCP `json:"services,omitempty"` // Middlewares defines the list of references to MiddlewareTCP resources. diff --git a/pkg/provider/kubernetes/gateway/kubernetes.go b/pkg/provider/kubernetes/gateway/kubernetes.go index e9ed7e6a7..c632cde73 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes.go +++ b/pkg/provider/kubernetes/gateway/kubernetes.go @@ -770,6 +770,7 @@ func (p *Provider) gatewayHTTPRouteToHTTPConf(ctx context.Context, ep string, li router := dynamic.Router{ Rule: rule, + RuleSyntax: "v3", EntryPoints: []string{ep}, } @@ -908,6 +909,7 @@ func gatewayTCPRouteToTCPConf(ctx context.Context, ep string, listener gatev1.Li router := dynamic.TCPRouter{ Rule: "HostSNI(`*`)", EntryPoints: []string{ep}, + RuleSyntax: "v3", } if listener.Protocol == gatev1.TLSProtocolType && listener.TLS != nil { @@ -1072,6 +1074,7 @@ func gatewayTLSRouteToTCPConf(ctx context.Context, ep string, listener gatev1.Li router := dynamic.TCPRouter{ Rule: rule, + RuleSyntax: "v3", EntryPoints: []string{ep}, TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: listener.TLS.Mode != nil && *listener.TLS.Mode == gatev1.TLSModePassthrough, @@ -1395,7 +1398,7 @@ func extractHeaderRules(headers []gatev1.HTTPHeaderMatch) ([]string, error) { switch *header.Type { case gatev1.HeaderMatchExact: - headerRules = append(headerRules, fmt.Sprintf("Headers(`%s`,`%s`)", header.Name, header.Value)) + headerRules = append(headerRules, fmt.Sprintf("Header(`%s`,`%s`)", header.Name, header.Value)) default: return nil, fmt.Errorf("unsupported header match type %s", *header.Type) } diff --git a/pkg/provider/kubernetes/gateway/kubernetes_test.go b/pkg/provider/kubernetes/gateway/kubernetes_test.go index 7c747a270..df3b95a0b 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes_test.go +++ b/pkg/provider/kubernetes/gateway/kubernetes_test.go @@ -550,6 +550,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -609,6 +610,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "api@internal", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -641,6 +643,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -704,6 +707,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"websecure"}, Service: "default-http-app-1-my-gateway-websecure-1c0cf64bde37d9d0df06-wrr", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -773,6 +777,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-66e726cd8903b49727ae-wrr", Rule: "(Host(`foo.com`) || Host(`bar.com`)) && PathPrefix(`/`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -832,6 +837,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-3b78e2feb3295ddd87f0-wrr", Rule: "(Host(`foo.com`) || HostRegexp(`^[a-zA-Z0-9-]+\\.bar\\.com$`)) && PathPrefix(`/`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -891,6 +897,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-b0521a61fb43068694b4-wrr", Rule: "(Host(`foo.com`) || HostRegexp(`^[a-zA-Z0-9-]+\\.foo\\.com$`)) && PathPrefix(`/`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -949,11 +956,13 @@ func TestLoadHTTPRoutes(t *testing.T) { "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06": { EntryPoints: []string{"web"}, Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", Service: "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr", }, "default-http-app-1-my-gateway-web-d737b4933fa88e68ab8a": { EntryPoints: []string{"web"}, Rule: "Host(`foo.com`) && Path(`/bir`)", + RuleSyntax: "v3", Service: "default-http-app-1-my-gateway-web-d737b4933fa88e68ab8a-wrr", }, }, @@ -1039,6 +1048,7 @@ func TestLoadHTTPRoutes(t *testing.T) { "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06": { EntryPoints: []string{"web"}, Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", Service: "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr", }, }, @@ -1124,11 +1134,13 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-http-web-1c0cf64bde37d9d0df06-wrr", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", }, "default-http-app-1-my-gateway-https-websecure-1c0cf64bde37d9d0df06": { EntryPoints: []string{"websecure"}, Service: "default-http-app-1-my-gateway-https-websecure-1c0cf64bde37d9d0df06-wrr", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -1213,11 +1225,13 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", }, "default-http-app-1-my-gateway-websecure-1c0cf64bde37d9d0df06": { EntryPoints: []string{"websecure"}, Service: "default-http-app-1-my-gateway-websecure-1c0cf64bde37d9d0df06-wrr", Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -1293,20 +1307,22 @@ func TestLoadHTTPRoutes(t *testing.T) { }, HTTP: &dynamic.HTTPConfiguration{ Routers: map[string]*dynamic.Router{ - "default-http-app-1-my-gateway-web-330d644a7f2079e8f454": { + "default-http-app-1-my-gateway-web-4a1b73e6f83804949a37": { EntryPoints: []string{"web"}, - Service: "default-http-app-1-my-gateway-web-330d644a7f2079e8f454-wrr", - Rule: "Host(`foo.com`) && PathPrefix(`/bar`) && Headers(`my-header`,`foo`) && Headers(`my-header2`,`bar`)", + Service: "default-http-app-1-my-gateway-web-4a1b73e6f83804949a37-wrr", + Rule: "Host(`foo.com`) && PathPrefix(`/bar`) && Header(`my-header`,`foo`) && Header(`my-header2`,`bar`)", + RuleSyntax: "v3", }, - "default-http-app-1-my-gateway-web-fe80e69a38713941ea22": { + "default-http-app-1-my-gateway-web-aaba0f24fd26e1ca2276": { EntryPoints: []string{"web"}, - Service: "default-http-app-1-my-gateway-web-fe80e69a38713941ea22-wrr", - Rule: "Host(`foo.com`) && Path(`/bar`) && Headers(`my-header`,`bar`)", + Service: "default-http-app-1-my-gateway-web-aaba0f24fd26e1ca2276-wrr", + Rule: "Host(`foo.com`) && Path(`/bar`) && Header(`my-header`,`bar`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, Services: map[string]*dynamic.Service{ - "default-http-app-1-my-gateway-web-330d644a7f2079e8f454-wrr": { + "default-http-app-1-my-gateway-web-4a1b73e6f83804949a37-wrr": { Weighted: &dynamic.WeightedRoundRobin{ Services: []dynamic.WRRService{ { @@ -1316,7 +1332,7 @@ func TestLoadHTTPRoutes(t *testing.T) { }, }, }, - "default-http-app-1-my-gateway-web-fe80e69a38713941ea22-wrr": { + "default-http-app-1-my-gateway-web-aaba0f24fd26e1ca2276-wrr": { Weighted: &dynamic.WeightedRoundRobin{ Services: []dynamic.WRRService{ { @@ -1371,6 +1387,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-default-my-gateway-web-efde1997778109a1f6eb-wrr", Rule: "Host(`foo.com`) && Path(`/foo`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -1430,11 +1447,13 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-default-my-gateway-web-efde1997778109a1f6eb-wrr", Rule: "Host(`foo.com`) && Path(`/foo`)", + RuleSyntax: "v3", }, "bar-http-app-bar-my-gateway-web-66f5c78d03d948e36597": { EntryPoints: []string{"web"}, Service: "bar-http-app-bar-my-gateway-web-66f5c78d03d948e36597-wrr", Rule: "Host(`bar.com`) && Path(`/bar`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -1520,6 +1539,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "bar-http-app-bar-my-gateway-web-66f5c78d03d948e36597-wrr", Rule: "Host(`bar.com`) && Path(`/bar`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, @@ -1579,6 +1599,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-wrr", Rule: "Host(`example.org`) && PathPrefix(`/`)", + RuleSyntax: "v3", Middlewares: []string{"default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-requestredirect-0"}, }, }, @@ -1647,6 +1668,7 @@ func TestLoadHTTPRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-wrr", Rule: "Host(`example.org`) && PathPrefix(`/`)", + RuleSyntax: "v3", Middlewares: []string{"default-http-app-1-my-gateway-web-364ce6ec04c3d49b19c4-requestredirect-0"}, }, }, @@ -1912,6 +1934,7 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-1-my-tcp-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.TCPMiddleware{}, @@ -1969,11 +1992,13 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tcp-1"}, Service: "default-tcp-app-1-my-tcp-gateway-tcp-1-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "default-tcp-app-2-my-tcp-gateway-tcp-2-e3b0c44298fc1c149afb": { EntryPoints: []string{"tcp-2"}, Service: "default-tcp-app-2-my-tcp-gateway-tcp-2-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.TCPMiddleware{}, @@ -2053,6 +2078,7 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tcp-1"}, Service: "default-tcp-app-my-tcp-gateway-tcp-1-e3b0c44298fc1c149afb-wrr", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.TCPMiddleware{}, @@ -2144,6 +2170,7 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-1-my-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.TCPMiddleware{}, @@ -2203,6 +2230,7 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tcp-app-1-my-gateway-tls-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, }, @@ -2266,6 +2294,7 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-default-my-tcp-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.TCPMiddleware{}, @@ -2321,11 +2350,13 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-default-my-tcp-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "bar-tcp-app-bar-my-tcp-gateway-tcp-e3b0c44298fc1c149afb": { EntryPoints: []string{"tcp"}, Service: "bar-tcp-app-bar-my-tcp-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.TCPMiddleware{}, @@ -2403,6 +2434,7 @@ func TestLoadTCPRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "bar-tcp-app-bar-my-tcp-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.TCPMiddleware{}, @@ -2696,6 +2728,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-1-my-tls-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, }, @@ -2761,6 +2794,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-1-my-tls-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -2819,6 +2853,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tls-app-1-my-tls-gateway-tcp-f0dd0dd89f82eae1c270-wrr-0", Rule: "HostSNI(`foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -2878,12 +2913,14 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tcp-app-1-my-tls-gateway-tls-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, "default-tls-app-1-my-tls-gateway-tcp-673acf455cb2dab0b43a": { EntryPoints: []string{"tcp"}, Service: "default-tls-app-1-my-tls-gateway-tcp-673acf455cb2dab0b43a-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -2973,6 +3010,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tcp-app-1-my-gateway-tls-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, }, @@ -3042,6 +3080,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tls-app-1-my-gateway-tls-f0dd0dd89f82eae1c270-wrr-0", Rule: "HostSNI(`foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3100,6 +3139,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tls-app-1-my-gateway-tls-f0dd0dd89f82eae1c270-wrr-0", Rule: "HostSNI(`foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3158,6 +3198,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tls-app-1-my-gateway-tls-f0dd0dd89f82eae1c270-wrr-0", Rule: "HostSNI(`foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3216,6 +3257,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tls-app-1-my-gateway-tls-d5342d75658583f03593-wrr-0", Rule: "HostSNI(`foo.example.com`) || HostSNI(`bar.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3274,6 +3316,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tls-app-default-my-gateway-tls-06ae57dcf13ab4c60ee5-wrr-0", Rule: "HostSNI(`foo.default`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3332,6 +3375,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "default-tls-app-default-my-gateway-tls-06ae57dcf13ab4c60ee5-wrr-0", Rule: "HostSNI(`foo.default`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3340,6 +3384,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "bar-tls-app-bar-my-gateway-tls-2279fe75c5156dc5eb26-wrr-0", Rule: "HostSNI(`foo.bar`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3420,6 +3465,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tls"}, Service: "bar-tls-app-bar-my-gateway-tls-2279fe75c5156dc5eb26-wrr-0", Rule: "HostSNI(`foo.bar`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3478,6 +3524,7 @@ func TestLoadTLSRoutes(t *testing.T) { EntryPoints: []string{"tcp-1"}, Service: "default-tls-app-my-gateway-tcp-1-673acf455cb2dab0b43a-wrr", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3702,17 +3749,20 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-1-my-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "default-tcp-app-1-my-gateway-tls-1-e3b0c44298fc1c149afb": { EntryPoints: []string{"tls-1"}, Service: "default-tcp-app-1-my-gateway-tls-1-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, "default-tls-app-1-my-gateway-tls-2-59130f7db6718b7700c1": { EntryPoints: []string{"tls-2"}, Service: "default-tls-app-1-my-gateway-tls-2-59130f7db6718b7700c1-wrr-0", Rule: "HostSNI(`pass.tls.foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3771,11 +3821,13 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-1-my-gateway-web-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", }, "default-http-app-1-my-gateway-websecure-a431b128267aabc954fd": { EntryPoints: []string{"websecure"}, Service: "default-http-app-1-my-gateway-websecure-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -3881,17 +3933,20 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-default-my-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "default-tcp-app-default-my-gateway-tls-1-e3b0c44298fc1c149afb": { EntryPoints: []string{"tls-1"}, Service: "default-tcp-app-default-my-gateway-tls-1-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, "default-tls-app-default-my-gateway-tls-2-59130f7db6718b7700c1": { EntryPoints: []string{"tls-2"}, Service: "default-tls-app-default-my-gateway-tls-2-59130f7db6718b7700c1-wrr-0", Rule: "HostSNI(`pass.tls.foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -3950,11 +4005,13 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-default-my-gateway-web-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", }, "default-http-app-default-my-gateway-websecure-a431b128267aabc954fd": { EntryPoints: []string{"websecure"}, Service: "default-http-app-default-my-gateway-websecure-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -4032,17 +4089,20 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-default-my-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "default-tcp-app-default-my-gateway-tls-1-e3b0c44298fc1c149afb": { EntryPoints: []string{"tls-1"}, Service: "default-tcp-app-default-my-gateway-tls-1-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, "default-tls-app-default-my-gateway-tls-2-59130f7db6718b7700c1": { EntryPoints: []string{"tls-2"}, Service: "default-tls-app-default-my-gateway-tls-2-59130f7db6718b7700c1-wrr-0", Rule: "HostSNI(`pass.tls.foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -4051,11 +4111,13 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "bar-tcp-app-bar-my-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "bar-tcp-app-bar-my-gateway-tls-1-e3b0c44298fc1c149afb": { EntryPoints: []string{"tls-1"}, Service: "bar-tcp-app-bar-my-gateway-tls-1-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, }, @@ -4144,22 +4206,26 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-default-my-gateway-web-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", }, "default-http-app-default-my-gateway-websecure-a431b128267aabc954fd": { EntryPoints: []string{"websecure"}, Service: "default-http-app-default-my-gateway-websecure-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, "bar-http-app-bar-my-gateway-web-a431b128267aabc954fd": { EntryPoints: []string{"web"}, Service: "bar-http-app-bar-my-gateway-web-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", }, "bar-http-app-bar-my-gateway-websecure-a431b128267aabc954fd": { EntryPoints: []string{"websecure"}, Service: "bar-http-app-bar-my-gateway-websecure-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -4273,17 +4339,20 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "bar-tcp-app-bar-my-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "bar-tcp-app-bar-my-gateway-tls-1-e3b0c44298fc1c149afb": { EntryPoints: []string{"tls-1"}, Service: "bar-tcp-app-bar-my-gateway-tls-1-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, "bar-tls-app-bar-my-gateway-tls-2-59130f7db6718b7700c1": { EntryPoints: []string{"tls-2"}, Service: "bar-tls-app-bar-my-gateway-tls-2-59130f7db6718b7700c1-wrr-0", Rule: "HostSNI(`pass.tls.foo.example.com`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{ Passthrough: true, }, @@ -4342,11 +4411,13 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "bar-http-app-bar-my-gateway-web-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", }, "bar-http-app-bar-my-gateway-websecure-a431b128267aabc954fd": { EntryPoints: []string{"websecure"}, Service: "bar-http-app-bar-my-gateway-websecure-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -4423,11 +4494,13 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"tcp"}, Service: "default-tcp-app-default-my-gateway-tcp-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", }, "default-tcp-app-default-my-gateway-tls-e3b0c44298fc1c149afb": { EntryPoints: []string{"tls"}, Service: "default-tcp-app-default-my-gateway-tls-e3b0c44298fc1c149afb-wrr-0", Rule: "HostSNI(`*`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTCPTLSConfig{}, }, }, @@ -4474,11 +4547,13 @@ func TestLoadMixedRoutes(t *testing.T) { EntryPoints: []string{"web"}, Service: "default-http-app-default-my-gateway-web-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", }, "default-http-app-default-my-gateway-websecure-a431b128267aabc954fd": { EntryPoints: []string{"websecure"}, Service: "default-http-app-default-my-gateway-websecure-a431b128267aabc954fd-wrr", Rule: "PathPrefix(`/`)", + RuleSyntax: "v3", TLS: &dynamic.RouterTLSConfig{}, }, }, @@ -4807,7 +4882,7 @@ func Test_extractRule(t *testing.T) { }, }, }, - expectedRule: "Path(`/foo/`) || Headers(`my-header`,`foo`)", + expectedRule: "Path(`/foo/`) || Header(`my-header`,`foo`)", }, { desc: "Path && Header rules", @@ -4828,7 +4903,7 @@ func Test_extractRule(t *testing.T) { }, }, }, - expectedRule: "Path(`/foo/`) && Headers(`my-header`,`foo`)", + expectedRule: "Path(`/foo/`) && Header(`my-header`,`foo`)", }, { desc: "Host && Path && Header rules", @@ -4850,7 +4925,7 @@ func Test_extractRule(t *testing.T) { }, }, }, - expectedRule: "Host(`foo.com`) && Path(`/foo/`) && Headers(`my-header`,`foo`)", + expectedRule: "Host(`foo.com`) && Path(`/foo/`) && Header(`my-header`,`foo`)", }, { desc: "Host && (Path || Header) rules", @@ -4874,7 +4949,7 @@ func Test_extractRule(t *testing.T) { }, }, }, - expectedRule: "Host(`foo.com`) && (Path(`/foo/`) || Headers(`my-header`,`foo`))", + expectedRule: "Host(`foo.com`) && (Path(`/foo/`) || Header(`my-header`,`foo`))", }, } diff --git a/pkg/provider/traefik/fixtures/api_insecure_with_dashboard.json b/pkg/provider/traefik/fixtures/api_insecure_with_dashboard.json index 992a447d1..1dec58871 100644 --- a/pkg/provider/traefik/fixtures/api_insecure_with_dashboard.json +++ b/pkg/provider/traefik/fixtures/api_insecure_with_dashboard.json @@ -22,6 +22,11 @@ "priority": 2147483645 } }, + "services": { + "api": {}, + "dashboard": {}, + "noop": {} + }, "middlewares": { "dashboard_redirect": { "redirectRegex": { @@ -38,11 +43,6 @@ ] } } - }, - "services": { - "api": {}, - "dashboard": {}, - "noop": {} } }, "tcp": {}, diff --git a/pkg/provider/traefik/fixtures/full_configuration.json b/pkg/provider/traefik/fixtures/full_configuration.json index f09614e2e..6e9f2d4b3 100644 --- a/pkg/provider/traefik/fixtures/full_configuration.json +++ b/pkg/provider/traefik/fixtures/full_configuration.json @@ -54,6 +54,14 @@ "priority": 2147483647 } }, + "services": { + "api": {}, + "dashboard": {}, + "noop": {}, + "ping": {}, + "prometheus": {}, + "rest": {} + }, "middlewares": { "dashboard_redirect": { "redirectRegex": { @@ -70,14 +78,6 @@ ] } } - }, - "services": { - "api": {}, - "dashboard": {}, - "noop": {}, - "ping": {}, - "prometheus": {}, - "rest": {} } }, "tcp": {}, diff --git a/pkg/provider/traefik/fixtures/redirection.json b/pkg/provider/traefik/fixtures/redirection.json index 2b3b271fa..73ae77db3 100644 --- a/pkg/provider/traefik/fixtures/redirection.json +++ b/pkg/provider/traefik/fixtures/redirection.json @@ -12,6 +12,9 @@ "rule": "HostRegexp(`^.+$`)" } }, + "services": { + "noop": {} + }, "middlewares": { "redirect-web-to-websecure": { "redirectScheme": { @@ -20,11 +23,8 @@ "permanent": true } } - }, - "services": { - "noop": {} } }, "tcp": {}, "tls": {} -} +} \ No newline at end of file diff --git a/pkg/provider/traefik/fixtures/redirection_port.json b/pkg/provider/traefik/fixtures/redirection_port.json index ead9bc0b1..a9e75438a 100644 --- a/pkg/provider/traefik/fixtures/redirection_port.json +++ b/pkg/provider/traefik/fixtures/redirection_port.json @@ -12,6 +12,9 @@ "rule": "HostRegexp(`^.+$`)" } }, + "services": { + "noop": {} + }, "middlewares": { "redirect-web-to-443": { "redirectScheme": { @@ -20,11 +23,8 @@ "permanent": true } } - }, - "services": { - "noop": {} } }, "tcp": {}, "tls": {} -} +} \ No newline at end of file diff --git a/pkg/provider/traefik/fixtures/redirection_with_protocol.json b/pkg/provider/traefik/fixtures/redirection_with_protocol.json index 2b3b271fa..73ae77db3 100644 --- a/pkg/provider/traefik/fixtures/redirection_with_protocol.json +++ b/pkg/provider/traefik/fixtures/redirection_with_protocol.json @@ -12,6 +12,9 @@ "rule": "HostRegexp(`^.+$`)" } }, + "services": { + "noop": {} + }, "middlewares": { "redirect-web-to-websecure": { "redirectScheme": { @@ -20,11 +23,8 @@ "permanent": true } } - }, - "services": { - "noop": {} } }, "tcp": {}, "tls": {} -} +} \ No newline at end of file diff --git a/pkg/provider/traefik/internal.go b/pkg/provider/traefik/internal.go index 71a6c321a..9a4105623 100644 --- a/pkg/provider/traefik/internal.go +++ b/pkg/provider/traefik/internal.go @@ -65,6 +65,7 @@ func (i *Provider) createConfiguration(ctx context.Context) *dynamic.Configurati TCP: &dynamic.TCPConfiguration{ Routers: make(map[string]*dynamic.TCPRouter), Services: make(map[string]*dynamic.TCPService), + Models: make(map[string]*dynamic.TCPModel), ServersTransports: make(map[string]*dynamic.TCPServersTransport), }, TLS: &dynamic.TLSConfiguration{ @@ -191,8 +192,13 @@ func (i *Provider) getEntryPointPort(name string, def *static.Redirections) (str } func (i *Provider) entryPointModels(cfg *dynamic.Configuration) { + defaultRuleSyntax := "" + if i.staticCfg.Core != nil && i.staticCfg.Core.DefaultRuleSyntax != "" { + defaultRuleSyntax = i.staticCfg.Core.DefaultRuleSyntax + } + for name, ep := range i.staticCfg.EntryPoints { - if len(ep.HTTP.Middlewares) == 0 && ep.HTTP.TLS == nil { + if len(ep.HTTP.Middlewares) == 0 && ep.HTTP.TLS == nil && defaultRuleSyntax == "" { continue } @@ -208,7 +214,19 @@ func (i *Provider) entryPointModels(cfg *dynamic.Configuration) { } } + m.DefaultRuleSyntax = defaultRuleSyntax + cfg.HTTP.Models[name] = m + + if cfg.TCP == nil { + continue + } + + mTCP := &dynamic.TCPModel{ + DefaultRuleSyntax: defaultRuleSyntax, + } + + cfg.TCP.Models[name] = mTCP } } diff --git a/pkg/server/aggregator.go b/pkg/server/aggregator.go index bd4fda0f5..c6a88e590 100644 --- a/pkg/server/aggregator.go +++ b/pkg/server/aggregator.go @@ -24,6 +24,7 @@ func mergeConfiguration(configurations dynamic.Configurations, defaultEntryPoint Routers: make(map[string]*dynamic.TCPRouter), Services: make(map[string]*dynamic.TCPService), Middlewares: make(map[string]*dynamic.TCPMiddleware), + Models: make(map[string]*dynamic.TCPModel), ServersTransports: make(map[string]*dynamic.TCPServersTransport), }, UDP: &dynamic.UDPConfiguration{ @@ -152,6 +153,13 @@ func applyModel(cfg dynamic.Configuration) dynamic.Configuration { for name, rt := range cfg.HTTP.Routers { router := rt.DeepCopy() + if !router.DefaultRule && router.RuleSyntax == "" { + for _, model := range cfg.HTTP.Models { + router.RuleSyntax = model.DefaultRuleSyntax + break + } + } + eps := router.EntryPoints router.EntryPoints = nil @@ -183,6 +191,25 @@ func applyModel(cfg dynamic.Configuration) dynamic.Configuration { cfg.HTTP.Routers = rts + if cfg.TCP == nil || len(cfg.TCP.Models) == 0 { + return cfg + } + + tcpRouters := make(map[string]*dynamic.TCPRouter) + + for _, rt := range cfg.TCP.Routers { + router := rt.DeepCopy() + + if router.RuleSyntax == "" { + for _, model := range cfg.TCP.Models { + router.RuleSyntax = model.DefaultRuleSyntax + break + } + } + } + + cfg.TCP.Routers = tcpRouters + return cfg } diff --git a/pkg/server/aggregator_test.go b/pkg/server/aggregator_test.go index ea1fb639f..1ef3bd8ac 100644 --- a/pkg/server/aggregator_test.go +++ b/pkg/server/aggregator_test.go @@ -473,6 +473,7 @@ func Test_mergeConfiguration_defaultTCPEntryPoint(t *testing.T) { Services: map[string]*dynamic.TCPService{ "service-1@provider-1": {}, }, + Models: map[string]*dynamic.TCPModel{}, ServersTransports: make(map[string]*dynamic.TCPServersTransport), } diff --git a/pkg/server/configurationwatcher_test.go b/pkg/server/configurationwatcher_test.go index 36125db64..a0fed4cae 100644 --- a/pkg/server/configurationwatcher_test.go +++ b/pkg/server/configurationwatcher_test.go @@ -92,6 +92,7 @@ func TestNewConfigurationWatcher(t *testing.T) { Routers: map[string]*dynamic.TCPRouter{}, Middlewares: map[string]*dynamic.TCPMiddleware{}, Services: map[string]*dynamic.TCPService{}, + Models: map[string]*dynamic.TCPModel{}, ServersTransports: map[string]*dynamic.TCPServersTransport{}, }, TLS: &dynamic.TLSConfiguration{ @@ -231,6 +232,7 @@ func TestIgnoreTransientConfiguration(t *testing.T) { Routers: map[string]*dynamic.TCPRouter{}, Middlewares: map[string]*dynamic.TCPMiddleware{}, Services: map[string]*dynamic.TCPService{}, + Models: map[string]*dynamic.TCPModel{}, ServersTransports: map[string]*dynamic.TCPServersTransport{}, }, UDP: &dynamic.UDPConfiguration{ @@ -400,6 +402,7 @@ func TestListenProvidersDoesNotSkipFlappingConfiguration(t *testing.T) { Routers: map[string]*dynamic.TCPRouter{}, Middlewares: map[string]*dynamic.TCPMiddleware{}, Services: map[string]*dynamic.TCPService{}, + Models: map[string]*dynamic.TCPModel{}, ServersTransports: map[string]*dynamic.TCPServersTransport{}, }, UDP: &dynamic.UDPConfiguration{ @@ -490,6 +493,7 @@ func TestListenProvidersIgnoreSameConfig(t *testing.T) { Routers: map[string]*dynamic.TCPRouter{}, Middlewares: map[string]*dynamic.TCPMiddleware{}, Services: map[string]*dynamic.TCPService{}, + Models: map[string]*dynamic.TCPModel{}, ServersTransports: map[string]*dynamic.TCPServersTransport{}, }, UDP: &dynamic.UDPConfiguration{ @@ -625,6 +629,7 @@ func TestListenProvidersIgnoreIntermediateConfigs(t *testing.T) { Routers: map[string]*dynamic.TCPRouter{}, Middlewares: map[string]*dynamic.TCPMiddleware{}, Services: map[string]*dynamic.TCPService{}, + Models: map[string]*dynamic.TCPModel{}, ServersTransports: map[string]*dynamic.TCPServersTransport{}, }, UDP: &dynamic.UDPConfiguration{ @@ -693,6 +698,7 @@ func TestListenProvidersPublishesConfigForEachProvider(t *testing.T) { Routers: map[string]*dynamic.TCPRouter{}, Middlewares: map[string]*dynamic.TCPMiddleware{}, Services: map[string]*dynamic.TCPService{}, + Models: map[string]*dynamic.TCPModel{}, ServersTransports: map[string]*dynamic.TCPServersTransport{}, }, TLS: &dynamic.TLSConfiguration{ diff --git a/pkg/server/router/router.go b/pkg/server/router/router.go index 4b383e076..bfa99cfb0 100644 --- a/pkg/server/router/router.go +++ b/pkg/server/router/router.go @@ -131,7 +131,7 @@ func (m *Manager) buildEntryPointHandler(ctx context.Context, configs map[string continue } - if err = muxer.AddRoute(routerConfig.Rule, routerConfig.Priority, handler); err != nil { + if err = muxer.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, handler); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() continue diff --git a/pkg/server/router/tcp/manager.go b/pkg/server/router/tcp/manager.go index df24ed704..cd10d5ccf 100644 --- a/pkg/server/router/tcp/manager.go +++ b/pkg/server/router/tcp/manager.go @@ -311,7 +311,7 @@ func (m *Manager) addTCPHandlers(ctx context.Context, configs map[string]*runtim if routerConfig.TLS == nil { logger.Debug().Msgf("Adding route for %q", routerConfig.Rule) - if err := router.AddRoute(routerConfig.Rule, routerConfig.Priority, handler); err != nil { + if err := router.muxerTCP.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, handler); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() } @@ -321,7 +321,7 @@ func (m *Manager) addTCPHandlers(ctx context.Context, configs map[string]*runtim if routerConfig.TLS.Passthrough { logger.Debug().Msgf("Adding Passthrough route for %q", routerConfig.Rule) - if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.Priority, handler); err != nil { + if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, handler); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() } @@ -355,7 +355,7 @@ func (m *Manager) addTCPHandlers(ctx context.Context, configs map[string]*runtim logger.Debug().Msgf("Adding special TLS closing route for %q because broken TLS options %s", routerConfig.Rule, tlsOptionsName) - if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.Priority, &brokenTLSRouter{}); err != nil { + if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, &brokenTLSRouter{}); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() } @@ -389,7 +389,7 @@ func (m *Manager) addTCPHandlers(ctx context.Context, configs map[string]*runtim logger.Debug().Msgf("Adding TLS route for %q", routerConfig.Rule) - if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.Priority, handler); err != nil { + if err := router.muxerTCPTLS.AddRoute(routerConfig.Rule, routerConfig.RuleSyntax, routerConfig.Priority, handler); err != nil { routerConfig.AddError(err, true) logger.Error().Err(err).Send() continue diff --git a/pkg/server/router/tcp/router.go b/pkg/server/router/tcp/router.go index 711a60313..74f563486 100644 --- a/pkg/server/router/tcp/router.go +++ b/pkg/server/router/tcp/router.go @@ -201,9 +201,9 @@ func (r *Router) ServeTCP(conn tcp.WriteCloser) { conn.Close() } -// AddRoute defines a handler for the given rule. -func (r *Router) AddRoute(rule string, priority int, target tcp.Handler) error { - return r.muxerTCP.AddRoute(rule, priority, target) +// AddTCPRoute defines a handler for the given rule. +func (r *Router) AddTCPRoute(rule string, priority int, target tcp.Handler) error { + return r.muxerTCP.AddRoute(rule, "", priority, target) } // AddHTTPTLSConfig defines a handler for a given sniHost and sets the matching tlsConfig. @@ -267,7 +267,7 @@ func (r *Router) SetHTTPSForwarder(handler tcp.Handler) { } rule := "HostSNI(`" + sniHost + "`)" - if err := r.muxerHTTPS.AddRoute(rule, tcpmuxer.GetRulePriority(rule), tcpHandler); err != nil { + if err := r.muxerHTTPS.AddRoute(rule, "", tcpmuxer.GetRulePriority(rule), tcpHandler); err != nil { log.Error().Err(err).Msg("Error while adding route for host") } } diff --git a/pkg/server/router/tcp/router_test.go b/pkg/server/router/tcp/router_test.go index 29bb2c418..1bcd987b1 100644 --- a/pkg/server/router/tcp/router_test.go +++ b/pkg/server/router/tcp/router_test.go @@ -947,10 +947,10 @@ func TestPostgres(t *testing.T) { // This test requires to have a TLS route, but does not actually check the // content of the handler. It would require to code a TLS handshake to // check the SNI and content of the handlerFunc. - err = router.muxerTCPTLS.AddRoute("HostSNI(`test.localhost`)", 0, nil) + err = router.muxerTCPTLS.AddRoute("HostSNI(`test.localhost`)", "", 0, nil) require.NoError(t, err) - err = router.AddRoute("HostSNI(`*`)", 0, tcp2.HandlerFunc(func(conn tcp2.WriteCloser) { + err = router.muxerTCP.AddRoute("HostSNI(`*`)", "", 0, tcp2.HandlerFunc(func(conn tcp2.WriteCloser) { _, _ = conn.Write([]byte("OK")) _ = conn.Close() })) diff --git a/pkg/server/server_entrypoint_tcp_test.go b/pkg/server/server_entrypoint_tcp_test.go index d050803f3..d53f160f8 100644 --- a/pkg/server/server_entrypoint_tcp_test.go +++ b/pkg/server/server_entrypoint_tcp_test.go @@ -47,7 +47,7 @@ func TestShutdownTCP(t *testing.T) { router, err := tcprouter.NewRouter() require.NoError(t, err) - err = router.AddRoute("HostSNI(`*`)", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) { + err = router.AddTCPRoute("HostSNI(`*`)", 0, tcp.HandlerFunc(func(conn tcp.WriteCloser) { _, err := http.ReadRequest(bufio.NewReader(conn)) if err != nil { return From 9befe0dd51d35cd020d173ccd66e79a8b8707dbb Mon Sep 17 00:00:00 2001 From: Julien Salleyron Date: Tue, 23 Jan 2024 16:46:05 +0100 Subject: [PATCH 11/14] Fix flaky test --- pkg/server/configurationwatcher_test.go | 153 ++++++++++++++++-------- 1 file changed, 102 insertions(+), 51 deletions(-) diff --git a/pkg/server/configurationwatcher_test.go b/pkg/server/configurationwatcher_test.go index 1f571effb..70794f550 100644 --- a/pkg/server/configurationwatcher_test.go +++ b/pkg/server/configurationwatcher_test.go @@ -171,56 +171,7 @@ func TestIgnoreTransientConfiguration(t *testing.T) { ), } - config2 := &dynamic.Configuration{ - HTTP: th.BuildConfiguration( - th.WithRouters(th.WithRouter("baz", th.WithEntryPoints("ep"))), - th.WithLoadBalancerServices(th.WithService("toto")), - ), - } - - watcher := NewConfigurationWatcher(routinesPool, &mockProvider{}, []string{}, "") - - publishedConfigCount := 0 - var lastConfig dynamic.Configuration - blockConfConsumer := make(chan struct{}) - watcher.AddListener(func(config dynamic.Configuration) { - publishedConfigCount++ - lastConfig = config - <-blockConfConsumer - }) - - watcher.Start() - - t.Cleanup(watcher.Stop) - t.Cleanup(routinesPool.Stop) - - watcher.allProvidersConfigs <- dynamic.Message{ - ProviderName: "mock", - Configuration: config, - } - - watcher.allProvidersConfigs <- dynamic.Message{ - ProviderName: "mock", - Configuration: config2, - } - - watcher.allProvidersConfigs <- dynamic.Message{ - ProviderName: "mock", - Configuration: config, - } - - // give some time before closing the channel. - time.Sleep(20 * time.Millisecond) - - close(blockConfConsumer) - - // give some time so that the configuration can be processed. - time.Sleep(20 * time.Millisecond) - - // after 20 milliseconds we should have 1 config published. - assert.Equal(t, 1, publishedConfigCount, "times configs were published") - - expected := dynamic.Configuration{ + expectedConfig := dynamic.Configuration{ HTTP: th.BuildConfiguration( th.WithRouters(th.WithRouter("foo@mock", th.WithEntryPoints("ep"))), th.WithLoadBalancerServices(th.WithService("bar@mock")), @@ -243,7 +194,107 @@ func TestIgnoreTransientConfiguration(t *testing.T) { }, } - assert.Equal(t, expected, lastConfig) + expectedConfig3 := dynamic.Configuration{ + HTTP: th.BuildConfiguration( + th.WithRouters(th.WithRouter("foo@mock", th.WithEntryPoints("ep"))), + th.WithLoadBalancerServices(th.WithService("bar-config3@mock")), + th.WithMiddlewares(), + ), + TCP: &dynamic.TCPConfiguration{ + Routers: map[string]*dynamic.TCPRouter{}, + Middlewares: map[string]*dynamic.TCPMiddleware{}, + Services: map[string]*dynamic.TCPService{}, + }, + UDP: &dynamic.UDPConfiguration{ + Routers: map[string]*dynamic.UDPRouter{}, + Services: map[string]*dynamic.UDPService{}, + }, + TLS: &dynamic.TLSConfiguration{ + Options: map[string]tls.Options{ + "default": tls.DefaultTLSOptions, + }, + Stores: map[string]tls.Store{}, + }, + } + + config2 := &dynamic.Configuration{ + HTTP: th.BuildConfiguration( + th.WithRouters(th.WithRouter("baz", th.WithEntryPoints("ep"))), + th.WithLoadBalancerServices(th.WithService("toto")), + ), + } + + config3 := &dynamic.Configuration{ + HTTP: th.BuildConfiguration( + th.WithRouters(th.WithRouter("foo", th.WithEntryPoints("ep"))), + th.WithLoadBalancerServices(th.WithService("bar-config3")), + ), + } + watcher := NewConfigurationWatcher(routinesPool, &mockProvider{}, []string{}, "") + + // To be able to "block" the writes, we change the chan to remove buffering. + watcher.allProvidersConfigs = make(chan dynamic.Message) + + publishedConfigCount := 0 + + firstConfigHandled := make(chan struct{}) + blockConfConsumer := make(chan struct{}) + blockConfConsumerAssert := make(chan struct{}) + watcher.AddListener(func(config dynamic.Configuration) { + publishedConfigCount++ + + if publishedConfigCount > 2 { + t.Fatal("More than 2 published configuration") + } + + if publishedConfigCount == 1 { + assert.Equal(t, expectedConfig, config) + close(firstConfigHandled) + + <-blockConfConsumer + time.Sleep(500 * time.Millisecond) + } + + if publishedConfigCount == 2 { + assert.Equal(t, expectedConfig3, config) + close(blockConfConsumerAssert) + } + }) + + watcher.Start() + + t.Cleanup(watcher.Stop) + t.Cleanup(routinesPool.Stop) + + watcher.allProvidersConfigs <- dynamic.Message{ + ProviderName: "mock", + Configuration: config, + } + + <-firstConfigHandled + + watcher.allProvidersConfigs <- dynamic.Message{ + ProviderName: "mock", + Configuration: config2, + } + + watcher.allProvidersConfigs <- dynamic.Message{ + ProviderName: "mock", + Configuration: config, + } + + close(blockConfConsumer) + + watcher.allProvidersConfigs <- dynamic.Message{ + ProviderName: "mock", + Configuration: config3, + } + + select { + case <-blockConfConsumerAssert: + case <-time.After(10 * time.Second): + t.Fatal("Timeout") + } } func TestListenProvidersThrottleProviderConfigReload(t *testing.T) { From aece9a1051fbf5a5f44fb1a9b2d23bdb76b9eb4c Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 24 Jan 2024 16:58:05 +0100 Subject: [PATCH 12/14] fix: opentelemetry unit tests --- pkg/metrics/datadog_test.go | 7 +-- pkg/metrics/influxdb2_test.go | 19 ++---- pkg/metrics/opentelemetry_test.go | 100 +++++++++++++++++++----------- 3 files changed, 71 insertions(+), 55 deletions(-) diff --git a/pkg/metrics/datadog_test.go b/pkg/metrics/datadog_test.go index 68e79ba71..6a53bf065 100644 --- a/pkg/metrics/datadog_test.go +++ b/pkg/metrics/datadog_test.go @@ -13,12 +13,13 @@ import ( ) func TestDatadog(t *testing.T) { + t.Cleanup(StopDatadog) + udp.SetAddr(":18125") // This is needed to make sure that UDP Listener listens for data a bit longer, otherwise it will quit after a millisecond udp.Timeout = 5 * time.Second datadogRegistry := RegisterDatadog(context.Background(), &types.Datadog{Address: ":18125", PushInterval: ptypes.Duration(time.Second), AddEntryPointsLabels: true, AddRoutersLabels: true, AddServicesLabels: true}) - defer StopDatadog() if !datadogRegistry.IsEpEnabled() || !datadogRegistry.IsRouterEnabled() || !datadogRegistry.IsSvcEnabled() { t.Errorf("DatadogRegistry should return true for IsEnabled(), IsRouterEnabled() and IsSvcEnabled()") @@ -27,9 +28,7 @@ func TestDatadog(t *testing.T) { } func TestDatadogWithPrefix(t *testing.T) { - t.Cleanup(func() { - StopDatadog() - }) + t.Cleanup(StopDatadog) udp.SetAddr(":18125") // This is needed to make sure that UDP Listener listens for data a bit longer, otherwise it will quit after a millisecond diff --git a/pkg/metrics/influxdb2_test.go b/pkg/metrics/influxdb2_test.go index 89b558075..e75141ff4 100644 --- a/pkg/metrics/influxdb2_test.go +++ b/pkg/metrics/influxdb2_test.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "net/http/httptest" - "regexp" "strconv" "testing" "time" @@ -26,7 +25,6 @@ func TestInfluxDB2(t *testing.T) { c <- &bodyStr _, _ = fmt.Fprintln(w, "ok") })) - defer ts.Close() influxDB2Registry := RegisterInfluxDB2(context.Background(), &types.InfluxDB2{ @@ -39,7 +37,11 @@ func TestInfluxDB2(t *testing.T) { AddRoutersLabels: true, AddServicesLabels: true, }) - defer StopInfluxDB2() + + t.Cleanup(func() { + StopInfluxDB2() + ts.Close() + }) if !influxDB2Registry.IsEpEnabled() || !influxDB2Registry.IsRouterEnabled() || !influxDB2Registry.IsSvcEnabled() { t.Fatalf("InfluxDB2Registry should return true for IsEnabled(), IsRouterEnabled() and IsSvcEnabled()") @@ -137,14 +139,3 @@ func TestInfluxDB2(t *testing.T) { assertMessage(t, *msgServiceRetries, expectedServiceRetries) } - -func assertMessage(t *testing.T, msg string, patterns []string) { - t.Helper() - for _, pattern := range patterns { - re := regexp.MustCompile(pattern) - match := re.FindStringSubmatch(msg) - if len(match) != 2 { - t.Errorf("Got %q %v, want %q", msg, match, pattern) - } - } -} diff --git a/pkg/metrics/opentelemetry_test.go b/pkg/metrics/opentelemetry_test.go index 7e426cc11..28c660357 100644 --- a/pkg/metrics/opentelemetry_test.go +++ b/pkg/metrics/opentelemetry_test.go @@ -4,10 +4,12 @@ import ( "compress/gzip" "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" "net/url" + "regexp" "strconv" "testing" "time" @@ -310,7 +312,7 @@ func TestOpenTelemetry(t *testing.T) { })) t.Cleanup(func() { - close(c) + StopOpenTelemetry() ts.Close() }) @@ -335,57 +337,53 @@ func TestOpenTelemetry(t *testing.T) { `({"key":"service.name","value":{"stringValue":"traefik"}})`, `({"key":"service.version","value":{"stringValue":"` + version.Version + `"}})`, } - msgMisc := <-c - assertMessage(t, *msgMisc, expected) + tryAssertMessage(t, c, expected) // TODO: the len of startUnixNano is no supposed to be 20, it should be 19 - expected = append(expected, + expectedConfig := []string{ `({"name":"traefik_config_reloads_total","description":"Config reloads","unit":"1","sum":{"dataPoints":\[{"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1}\],"aggregationTemporality":2,"isMonotonic":true}})`, `({"name":"traefik_config_last_reload_success","description":"Last config reload success","unit":"ms","gauge":{"dataPoints":\[{"timeUnixNano":"[\d]{19}","asDouble":1}\]}})`, `({"name":"traefik_open_connections","description":"How many open connections exist, by entryPoint and protocol","unit":"1","gauge":{"dataPoints":\[{"attributes":\[{"key":"entrypoint","value":{"stringValue":"test"}},{"key":"protocol","value":{"stringValue":"TCP"}}\],"timeUnixNano":"[\d]{19}","asDouble":1}\]}})`, - ) + } registry.ConfigReloadsCounter().Add(1) registry.LastConfigReloadSuccessGauge().Set(1) registry.OpenConnectionsGauge().With("entrypoint", "test", "protocol", "TCP").Set(1) - msgServer := <-c - assertMessage(t, *msgServer, expected) + tryAssertMessage(t, c, expectedConfig) - expected = append(expected, + expectedTLSCerts := []string{ `({"name":"traefik_tls_certs_not_after","description":"Certificate expiration timestamp","unit":"ms","gauge":{"dataPoints":\[{"attributes":\[{"key":"key","value":{"stringValue":"value"}}\],"timeUnixNano":"[\d]{19}","asDouble":1}\]}})`, - ) + } registry.TLSCertsNotAfterTimestampGauge().With("key", "value").Set(1) - msgTLS := <-c - assertMessage(t, *msgTLS, expected) + tryAssertMessage(t, c, expectedTLSCerts) - expected = append(expected, + expectedEntryPoints := []string{ `({"name":"traefik_entrypoint_requests_total","description":"How many HTTP requests processed on an entrypoint, partitioned by status code, protocol, and method.","unit":"1","sum":{"dataPoints":\[{"attributes":\[{"key":"code","value":{"stringValue":"200"}},{"key":"entrypoint","value":{"stringValue":"test1"}},{"key":"method","value":{"stringValue":"GET"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1}\],"aggregationTemporality":2,"isMonotonic":true}})`, `({"name":"traefik_entrypoint_requests_tls_total","description":"How many HTTP requests with TLS processed on an entrypoint, partitioned by TLS Version and TLS cipher Used.","unit":"1","sum":{"dataPoints":\[{"attributes":\[{"key":"entrypoint","value":{"stringValue":"test2"}},{"key":"tls_cipher","value":{"stringValue":"bar"}},{"key":"tls_version","value":{"stringValue":"foo"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1}\],"aggregationTemporality":2,"isMonotonic":true}})`, `({"name":"traefik_entrypoint_request_duration_seconds","description":"How long it took to process the request on an entrypoint, partitioned by status code, protocol, and method.","unit":"ms","histogram":{"dataPoints":\[{"attributes":\[{"key":"entrypoint","value":{"stringValue":"test3"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","count":"1","sum":10000,"bucketCounts":\["0","0","0","0","0","0","0","0","0","0","0","1"\],"explicitBounds":\[0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2.5,5,10\],"min":10000,"max":10000}\],"aggregationTemporality":2}})`, `({"name":"traefik_entrypoint_requests_bytes_total","description":"The total size of requests in bytes handled by an entrypoint, partitioned by status code, protocol, and method.","unit":"1","sum":{"dataPoints":\[{"attributes":\[{"key":"code","value":{"stringValue":"200"}},{"key":"entrypoint","value":{"stringValue":"test1"}},{"key":"method","value":{"stringValue":"GET"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1}\],"aggregationTemporality":2,"isMonotonic":true}})`, `({"name":"traefik_entrypoint_responses_bytes_total","description":"The total size of responses in bytes handled by an entrypoint, partitioned by status code, protocol, and method.","unit":"1","sum":{"dataPoints":\[{"attributes":\[{"key":"code","value":{"stringValue":"200"}},{"key":"entrypoint","value":{"stringValue":"test1"}},{"key":"method","value":{"stringValue":"GET"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1}\],"aggregationTemporality":2,"isMonotonic":true}})`, - ) + } registry.EntryPointReqsCounter().With(nil, "entrypoint", "test1", "code", strconv.Itoa(http.StatusOK), "method", http.MethodGet).Add(1) registry.EntryPointReqsTLSCounter().With("entrypoint", "test2", "tls_version", "foo", "tls_cipher", "bar").Add(1) registry.EntryPointReqDurationHistogram().With("entrypoint", "test3").Observe(10000) registry.EntryPointReqsBytesCounter().With("entrypoint", "test1", "code", strconv.Itoa(http.StatusOK), "method", http.MethodGet).Add(1) registry.EntryPointRespsBytesCounter().With("entrypoint", "test1", "code", strconv.Itoa(http.StatusOK), "method", http.MethodGet).Add(1) - msgEntrypoint := <-c - assertMessage(t, *msgEntrypoint, expected) + tryAssertMessage(t, c, expectedEntryPoints) - expected = append(expected, + expectedRouters := []string{ `({"name":"traefik_router_requests_total","description":"How many HTTP requests are processed on a router, partitioned by service, status code, protocol, and method.","unit":"1","sum":{"dataPoints":\[{"attributes":\[{"key":"code","value":{"stringValue":"(?:200|404)"}},{"key":"method","value":{"stringValue":"GET"}},{"key":"router","value":{"stringValue":"RouterReqsCounter"}},{"key":"service","value":{"stringValue":"test"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1},{"attributes":\[{"key":"code","value":{"stringValue":"(?:200|404)"}},{"key":"method","value":{"stringValue":"GET"}},{"key":"router","value":{"stringValue":"RouterReqsCounter"}},{"key":"service","value":{"stringValue":"test"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1}\],"aggregationTemporality":2,"isMonotonic":true}})`, `({"name":"traefik_router_requests_tls_total","description":"How many HTTP requests with TLS are processed on a router, partitioned by service, TLS Version, and TLS cipher Used.","unit":"1","sum":{"dataPoints":\[{"attributes":\[{"key":"router","value":{"stringValue":"demo"}},{"key":"service","value":{"stringValue":"test"}},{"key":"tls_cipher","value":{"stringValue":"bar"}},{"key":"tls_version","value":{"stringValue":"foo"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1}\],"aggregationTemporality":2,"isMonotonic":true}})`, `({"name":"traefik_router_request_duration_seconds","description":"How long it took to process the request on a router, partitioned by service, status code, protocol, and method.","unit":"ms","histogram":{"dataPoints":\[{"attributes":\[{"key":"code","value":{"stringValue":"200"}},{"key":"router","value":{"stringValue":"demo"}},{"key":"service","value":{"stringValue":"test"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","count":"1","sum":10000,"bucketCounts":\["0","0","0","0","0","0","0","0","0","0","0","1"\],"explicitBounds":\[0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2.5,5,10\],"min":10000,"max":10000}\],"aggregationTemporality":2}})`, `({"name":"traefik_router_requests_bytes_total","description":"The total size of requests in bytes handled by a router, partitioned by status code, protocol, and method.","unit":"1","sum":{"dataPoints":\[{"attributes":\[{"key":"code","value":{"stringValue":"404"}},{"key":"method","value":{"stringValue":"GET"}},{"key":"router","value":{"stringValue":"RouterReqsCounter"}},{"key":"service","value":{"stringValue":"test"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1}\],"aggregationTemporality":2,"isMonotonic":true}})`, `({"name":"traefik_router_responses_bytes_total","description":"The total size of responses in bytes handled by a router, partitioned by status code, protocol, and method.","unit":"1","sum":{"dataPoints":\[{"attributes":\[{"key":"code","value":{"stringValue":"404"}},{"key":"method","value":{"stringValue":"GET"}},{"key":"router","value":{"stringValue":"RouterReqsCounter"}},{"key":"service","value":{"stringValue":"test"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1}\],"aggregationTemporality":2,"isMonotonic":true}})`, - ) + } registry.RouterReqsCounter().With(nil, "router", "RouterReqsCounter", "service", "test", "code", strconv.Itoa(http.StatusNotFound), "method", http.MethodGet).Add(1) registry.RouterReqsCounter().With(nil, "router", "RouterReqsCounter", "service", "test", "code", strconv.Itoa(http.StatusOK), "method", http.MethodGet).Add(1) @@ -393,18 +391,17 @@ func TestOpenTelemetry(t *testing.T) { registry.RouterReqDurationHistogram().With("router", "demo", "service", "test", "code", strconv.Itoa(http.StatusOK)).Observe(10000) registry.RouterReqsBytesCounter().With("router", "RouterReqsCounter", "service", "test", "code", strconv.Itoa(http.StatusNotFound), "method", http.MethodGet).Add(1) registry.RouterRespsBytesCounter().With("router", "RouterReqsCounter", "service", "test", "code", strconv.Itoa(http.StatusNotFound), "method", http.MethodGet).Add(1) - msgRouter := <-c - assertMessage(t, *msgRouter, expected) + tryAssertMessage(t, c, expectedRouters) - expected = append(expected, + expectedServices := []string{ `({"name":"traefik_service_requests_total","description":"How many HTTP requests processed on a service, partitioned by status code, protocol, and method.","unit":"1","sum":{"dataPoints":\[{"attributes":\[{"key":"code","value":{"stringValue":"(?:200|404)"}},{"key":"method","value":{"stringValue":"GET"}},{"key":"service","value":{"stringValue":"ServiceReqsCounter"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1},{"attributes":\[{"key":"code","value":{"stringValue":"(?:200|404)"}},{"key":"method","value":{"stringValue":"GET"}},{"key":"service","value":{"stringValue":"ServiceReqsCounter"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1}\],"aggregationTemporality":2,"isMonotonic":true}})`, `({"name":"traefik_service_requests_tls_total","description":"How many HTTP requests with TLS processed on a service, partitioned by TLS version and TLS cipher.","unit":"1","sum":{"dataPoints":\[{"attributes":\[{"key":"service","value":{"stringValue":"test"}},{"key":"tls_cipher","value":{"stringValue":"bar"}},{"key":"tls_version","value":{"stringValue":"foo"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1}\],"aggregationTemporality":2,"isMonotonic":true}})`, `({"name":"traefik_service_request_duration_seconds","description":"How long it took to process the request on a service, partitioned by status code, protocol, and method.","unit":"ms","histogram":{"dataPoints":\[{"attributes":\[{"key":"code","value":{"stringValue":"200"}},{"key":"service","value":{"stringValue":"test"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","count":"1","sum":10000,"bucketCounts":\["0","0","0","0","0","0","0","0","0","0","0","1"\],"explicitBounds":\[0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2.5,5,10\],"min":10000,"max":10000}\],"aggregationTemporality":2}})`, `({"name":"traefik_service_server_up","description":"service server is up, described by gauge value of 0 or 1.","unit":"1","gauge":{"dataPoints":\[{"attributes":\[{"key":"service","value":{"stringValue":"test"}},{"key":"url","value":{"stringValue":"http://127.0.0.1"}}\],"timeUnixNano":"[\d]{19}","asDouble":1}\]}})`, `({"name":"traefik_service_requests_bytes_total","description":"The total size of requests in bytes received by a service, partitioned by status code, protocol, and method.","unit":"1","sum":{"dataPoints":\[{"attributes":\[{"key":"code","value":{"stringValue":"404"}},{"key":"method","value":{"stringValue":"GET"}},{"key":"service","value":{"stringValue":"ServiceReqsCounter"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1}\],"aggregationTemporality":2,"isMonotonic":true}})`, `({"name":"traefik_service_responses_bytes_total","description":"The total size of responses in bytes returned by a service, partitioned by status code, protocol, and method.","unit":"1","sum":{"dataPoints":\[{"attributes":\[{"key":"code","value":{"stringValue":"404"}},{"key":"method","value":{"stringValue":"GET"}},{"key":"service","value":{"stringValue":"ServiceReqsCounter"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1}\],"aggregationTemporality":2,"isMonotonic":true}})`, - ) + } registry.ServiceReqsCounter().With(nil, "service", "ServiceReqsCounter", "code", strconv.Itoa(http.StatusOK), "method", http.MethodGet).Add(1) registry.ServiceReqsCounter().With(nil, "service", "ServiceReqsCounter", "code", strconv.Itoa(http.StatusNotFound), "method", http.MethodGet).Add(1) @@ -413,21 +410,19 @@ func TestOpenTelemetry(t *testing.T) { registry.ServiceServerUpGauge().With("service", "test", "url", "http://127.0.0.1").Set(1) registry.ServiceReqsBytesCounter().With("service", "ServiceReqsCounter", "code", strconv.Itoa(http.StatusNotFound), "method", http.MethodGet).Add(1) registry.ServiceRespsBytesCounter().With("service", "ServiceReqsCounter", "code", strconv.Itoa(http.StatusNotFound), "method", http.MethodGet).Add(1) - msgService := <-c - assertMessage(t, *msgService, expected) + tryAssertMessage(t, c, expectedServices) - expected = append(expected, + expectedServicesRetries := []string{ `({"attributes":\[{"key":"service","value":{"stringValue":"foobar"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":1})`, `({"attributes":\[{"key":"service","value":{"stringValue":"test"}}\],"startTimeUnixNano":"[\d]{19}","timeUnixNano":"[\d]{19}","asDouble":2})`, - ) + } registry.ServiceRetriesCounter().With("service", "test").Add(1) registry.ServiceRetriesCounter().With("service", "test").Add(1) registry.ServiceRetriesCounter().With("service", "foobar").Add(1) - msgServiceRetries := <-c - assertMessage(t, *msgServiceRetries, expected) + tryAssertMessage(t, c, expectedServicesRetries) // We cannot rely on the previous expected pattern, // because this pattern was for matching only one dataPoint in the histogram, @@ -439,15 +434,46 @@ func TestOpenTelemetry(t *testing.T) { registry.EntryPointReqDurationHistogram().With("entrypoint", "myEntrypoint").Observe(10000) registry.EntryPointReqDurationHistogram().With("entrypoint", "myEntrypoint").Observe(20000) - msgEntryPointReqDurationHistogram := <-c - assertMessage(t, *msgEntryPointReqDurationHistogram, expectedEntryPointReqDuration) - - // Stopping OpenTelemetry. - go func() { - StopOpenTelemetry() - }() - - // We need to unlock the HTTP Server for the last export call. - <-c + tryAssertMessage(t, c, expectedEntryPointReqDuration) +} + +func assertMessage(t *testing.T, msg string, expected []string) { + t.Helper() + errs := verifyMessage(msg, expected) + for _, err := range errs { + t.Error(err) + } +} + +func tryAssertMessage(t *testing.T, c chan *string, expected []string) { + t.Helper() + + var errs []error + timeout := time.After(1 * time.Second) + for { + select { + case <-timeout: + for _, err := range errs { + t.Error(err) + } + case msg := <-c: + errs = verifyMessage(*msg, expected) + if len(errs) == 0 { + return + } + } + } +} + +func verifyMessage(msg string, expected []string) []error { + var errs []error + for _, pattern := range expected { + re := regexp.MustCompile(pattern) + match := re.FindStringSubmatch(msg) + if len(match) != 2 { + errs = append(errs, fmt.Errorf("Got %q %v, want %q", msg, match, pattern)) + } + } + return errs } From f4f3dbe1f5f9c8602df5a23ac57d19df5e68b148 Mon Sep 17 00:00:00 2001 From: Matthieu W Date: Thu, 25 Jan 2024 15:12:05 +0100 Subject: [PATCH 13/14] Update version comment in quick-start.md --- docs/content/getting-started/quick-start.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/getting-started/quick-start.md b/docs/content/getting-started/quick-start.md index a33eb4b22..122ff530d 100644 --- a/docs/content/getting-started/quick-start.md +++ b/docs/content/getting-started/quick-start.md @@ -19,7 +19,7 @@ version: '3' services: reverse-proxy: - # The official v2 Traefik docker image + # The official v3 Traefik docker image image: traefik:v3.0 # Enables the web UI and tells Traefik to listen to docker command: --api.insecure=true --providers.docker From 3174c69c66fab7f6906192eeba1bb2f4ccc96c96 Mon Sep 17 00:00:00 2001 From: Julien Salleyron Date: Fri, 26 Jan 2024 01:44:05 +0100 Subject: [PATCH 14/14] Adds weight on ServersLoadBalancer --- .../dynamic-configuration/docker-labels.yml | 1 + .../reference/dynamic-configuration/file.toml | 2 + .../reference/dynamic-configuration/file.yaml | 2 + .../reference/dynamic-configuration/kv-ref.md | 2 + docs/content/routing/services/index.md | 30 +++++++++++ integration/docker_test.go | 51 +++++++++++++++++++ integration/fixtures/wrr_server.toml | 35 +++++++++++++ integration/resources/compose/docker.yml | 11 ++++ integration/simple_test.go | 43 ++++++++++++++++ pkg/config/dynamic/http_config.go | 1 + pkg/config/dynamic/zz_generated.deepcopy.go | 9 +++- pkg/server/service/service.go | 2 +- 12 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 integration/fixtures/wrr_server.toml diff --git a/docs/content/reference/dynamic-configuration/docker-labels.yml b/docs/content/reference/dynamic-configuration/docker-labels.yml index df6db2ff0..7044d13d0 100644 --- a/docs/content/reference/dynamic-configuration/docker-labels.yml +++ b/docs/content/reference/dynamic-configuration/docker-labels.yml @@ -178,6 +178,7 @@ - "traefik.http.services.service02.loadbalancer.sticky.cookie.secure=true" - "traefik.http.services.service02.loadbalancer.server.port=foobar" - "traefik.http.services.service02.loadbalancer.server.scheme=foobar" +- "traefik.http.services.service02.loadbalancer.server.weight=42" - "traefik.tcp.middlewares.tcpmiddleware01.ipallowlist.sourcerange=foobar, foobar" - "traefik.tcp.middlewares.tcpmiddleware02.ipwhitelist.sourcerange=foobar, foobar" - "traefik.tcp.middlewares.tcpmiddleware03.inflightconn.amount=42" diff --git a/docs/content/reference/dynamic-configuration/file.toml b/docs/content/reference/dynamic-configuration/file.toml index 541affb9c..7d771c6a2 100644 --- a/docs/content/reference/dynamic-configuration/file.toml +++ b/docs/content/reference/dynamic-configuration/file.toml @@ -58,9 +58,11 @@ [[http.services.Service02.loadBalancer.servers]] url = "foobar" + weight = 42 [[http.services.Service02.loadBalancer.servers]] url = "foobar" + weight = 42 [http.services.Service02.loadBalancer.healthCheck] scheme = "foobar" mode = "foobar" diff --git a/docs/content/reference/dynamic-configuration/file.yaml b/docs/content/reference/dynamic-configuration/file.yaml index 4e0019e42..24df9aada 100644 --- a/docs/content/reference/dynamic-configuration/file.yaml +++ b/docs/content/reference/dynamic-configuration/file.yaml @@ -65,7 +65,9 @@ http: maxAge: 42 servers: - url: foobar + weight: 42 - url: foobar + weight: 42 healthCheck: scheme: foobar mode: foobar diff --git a/docs/content/reference/dynamic-configuration/kv-ref.md b/docs/content/reference/dynamic-configuration/kv-ref.md index 6d80b13b8..cc0334567 100644 --- a/docs/content/reference/dynamic-configuration/kv-ref.md +++ b/docs/content/reference/dynamic-configuration/kv-ref.md @@ -240,7 +240,9 @@ THIS FILE MUST NOT BE EDITED BY HAND | `traefik/http/services/Service02/loadBalancer/passHostHeader` | `true` | | `traefik/http/services/Service02/loadBalancer/responseForwarding/flushInterval` | `42s` | | `traefik/http/services/Service02/loadBalancer/servers/0/url` | `foobar` | +| `traefik/http/services/Service02/loadBalancer/servers/0/weight` | `42` | | `traefik/http/services/Service02/loadBalancer/servers/1/url` | `foobar` | +| `traefik/http/services/Service02/loadBalancer/servers/1/weight` | `42` | | `traefik/http/services/Service02/loadBalancer/serversTransport` | `foobar` | | `traefik/http/services/Service02/loadBalancer/sticky/cookie/httpOnly` | `true` | | `traefik/http/services/Service02/loadBalancer/sticky/cookie/maxAge` | `42` | diff --git a/docs/content/routing/services/index.md b/docs/content/routing/services/index.md index 7658195e1..7ac5ce78c 100644 --- a/docs/content/routing/services/index.md +++ b/docs/content/routing/services/index.md @@ -143,6 +143,36 @@ The `url` option point to a specific instance. url = "http://private-ip-server-1/" ``` +The `weight` option allows for weighted load balancing on the servers. + +??? example "A Service with Two Servers with Weight -- Using the [File Provider](../../providers/file.md)" + + ```yaml tab="YAML" + ## Dynamic configuration + http: + services: + my-service: + loadBalancer: + servers: + - url: "http://private-ip-server-1/" + weight: 2 + - url: "http://private-ip-server-2/" + weight: 1 + + ``` + + ```toml tab="TOML" + ## Dynamic configuration + [http.services] + [http.services.my-service.loadBalancer] + [[http.services.my-service.loadBalancer.servers]] + url = "http://private-ip-server-1/" + weight = 2 + [[http.services.my-service.loadBalancer.servers]] + url = "http://private-ip-server-2/" + weight = 1 + ``` + #### Load-balancing For now, only round robin load balancing is supported: diff --git a/integration/docker_test.go b/integration/docker_test.go index 1a767ff6f..daa6fa06b 100644 --- a/integration/docker_test.go +++ b/integration/docker_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io" "net/http" + "strings" "testing" "time" @@ -55,6 +56,56 @@ func (s *DockerSuite) TestSimpleConfiguration() { require.NoError(s.T(), err) } +func (s *DockerSuite) TestWRRServer() { + tempObjects := struct { + DockerHost string + DefaultRule string + }{ + DockerHost: s.getDockerHost(), + DefaultRule: "Host(`{{ normalize .Name }}.docker.localhost`)", + } + + file := s.adaptFile("fixtures/docker/simple.toml", tempObjects) + + s.composeUp() + + s.traefikCmd(withConfigFile(file)) + + whoami1IP := s.getComposeServiceIP("wrr-server") + whoami2IP := s.getComposeServiceIP("wrr-server2") + + // Expected a 404 as we did not configure anything + err := try.GetRequest("http://127.0.0.1:8000/", 500*time.Millisecond, try.StatusCodeIs(http.StatusNotFound)) + require.NoError(s.T(), err) + + err = try.GetRequest("http://127.0.0.1:8080/api/http/services", 1000*time.Millisecond, try.BodyContains("wrr-server")) + require.NoError(s.T(), err) + + repartition := map[string]int{} + for i := 0; i < 4; i++ { + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/whoami", nil) + req.Host = "my.wrr.host" + require.NoError(s.T(), err) + + response, err := http.DefaultClient.Do(req) + require.NoError(s.T(), err) + assert.Equal(s.T(), http.StatusOK, response.StatusCode) + + body, err := io.ReadAll(response.Body) + require.NoError(s.T(), err) + + if strings.Contains(string(body), whoami1IP) { + repartition[whoami1IP]++ + } + if strings.Contains(string(body), whoami2IP) { + repartition[whoami2IP]++ + } + } + + assert.Equal(s.T(), 3, repartition[whoami1IP]) + assert.Equal(s.T(), 1, repartition[whoami2IP]) +} + func (s *DockerSuite) TestDefaultDockerContainers() { tempObjects := struct { DockerHost string diff --git a/integration/fixtures/wrr_server.toml b/integration/fixtures/wrr_server.toml new file mode 100644 index 000000000..d1735986d --- /dev/null +++ b/integration/fixtures/wrr_server.toml @@ -0,0 +1,35 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[api] + insecure = true + +[log] + level = "DEBUG" + noColor = true + +[entryPoints] + + [entryPoints.web] + address = ":8000" + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.routers] + [http.routers.router] + service = "service1" + rule = "Path(`/whoami`)" + +[http.services] + + [http.services.service1.loadBalancer] + [[http.services.service1.loadBalancer.servers]] + url = "{{ .Server1 }}" + weight = 3 + [[http.services.service1.loadBalancer.servers]] + url = "{{ .Server2 }}" + diff --git a/integration/resources/compose/docker.yml b/integration/resources/compose/docker.yml index e594ec87c..b16571a4b 100644 --- a/integration/resources/compose/docker.yml +++ b/integration/resources/compose/docker.yml @@ -36,3 +36,14 @@ services: labels: traefik.http.Routers.Super.Rule: Host(`my.super.host`) traefik.http.Services.powpow.LoadBalancer.server.Port: 2375 + + wrr-server: + image: traefik/whoami + labels: + traefik.http.Routers.wrr-server.Rule: Host(`my.wrr.host`) + traefik.http.Services.wrr-server.LoadBalancer.server.Weight: 4 + wrr-server2: + image: traefik/whoami + labels: + traefik.http.Routers.wrr-server.Rule: Host(`my.wrr.host`) + traefik.http.Services.wrr-server.LoadBalancer.server.Weight: 1 diff --git a/integration/simple_test.go b/integration/simple_test.go index 94b4ec45e..d2c3be077 100644 --- a/integration/simple_test.go +++ b/integration/simple_test.go @@ -809,6 +809,49 @@ func (s *SimpleSuite) TestUDPServiceConfigErrors() { require.NoError(s.T(), err) } +func (s *SimpleSuite) TestWRRServer() { + s.createComposeProject("base") + + s.composeUp() + defer s.composeDown() + + whoami1IP := s.getComposeServiceIP("whoami1") + whoami2IP := s.getComposeServiceIP("whoami2") + + file := s.adaptFile("fixtures/wrr_server.toml", struct { + Server1 string + Server2 string + }{Server1: "http://" + whoami1IP, Server2: "http://" + whoami2IP}) + + s.traefikCmd(withConfigFile(file)) + + err := try.GetRequest("http://127.0.0.1:8080/api/http/services", 1000*time.Millisecond, try.BodyContains("service1")) + require.NoError(s.T(), err) + + repartition := map[string]int{} + for i := 0; i < 4; i++ { + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/whoami", nil) + require.NoError(s.T(), err) + + response, err := http.DefaultClient.Do(req) + require.NoError(s.T(), err) + assert.Equal(s.T(), http.StatusOK, response.StatusCode) + + body, err := io.ReadAll(response.Body) + require.NoError(s.T(), err) + + if strings.Contains(string(body), whoami1IP) { + repartition[whoami1IP]++ + } + if strings.Contains(string(body), whoami2IP) { + repartition[whoami2IP]++ + } + } + + assert.Equal(s.T(), 3, repartition[whoami1IP]) + assert.Equal(s.T(), 1, repartition[whoami2IP]) +} + func (s *SimpleSuite) TestWRR() { s.createComposeProject("base") diff --git a/pkg/config/dynamic/http_config.go b/pkg/config/dynamic/http_config.go index 0783d5592..541f61b79 100644 --- a/pkg/config/dynamic/http_config.go +++ b/pkg/config/dynamic/http_config.go @@ -227,6 +227,7 @@ func (r *ResponseForwarding) SetDefaults() { // Server holds the server configuration. type Server struct { URL string `json:"url,omitempty" toml:"url,omitempty" yaml:"url,omitempty" label:"-"` + Weight *int `json:"weight,omitempty" toml:"weight,omitempty" yaml:"weight,omitempty" label:"weight"` Scheme string `json:"-" toml:"-" yaml:"-" file:"-"` Port string `json:"-" toml:"-" yaml:"-" file:"-"` } diff --git a/pkg/config/dynamic/zz_generated.deepcopy.go b/pkg/config/dynamic/zz_generated.deepcopy.go index 3969d144d..85b513b2a 100644 --- a/pkg/config/dynamic/zz_generated.deepcopy.go +++ b/pkg/config/dynamic/zz_generated.deepcopy.go @@ -1128,6 +1128,11 @@ func (in *RouterTLSConfig) DeepCopy() *RouterTLSConfig { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Server) DeepCopyInto(out *Server) { *out = *in + if in.Weight != nil { + in, out := &in.Weight, &out.Weight + *out = new(int) + **out = **in + } return } @@ -1180,7 +1185,9 @@ func (in *ServersLoadBalancer) DeepCopyInto(out *ServersLoadBalancer) { if in.Servers != nil { in, out := &in.Servers, &out.Servers *out = make([]Server, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.HealthCheck != nil { in, out := &in.HealthCheck, &out.HealthCheck diff --git a/pkg/server/service/service.go b/pkg/server/service/service.go index 55fcb9fa2..2d71791a8 100644 --- a/pkg/server/service/service.go +++ b/pkg/server/service/service.go @@ -319,7 +319,7 @@ func (m *Manager) getLoadBalancerServiceHandler(ctx context.Context, serviceName proxy = tracingMiddle.NewService(ctx, serviceName, proxy) - lb.Add(proxyName, proxy, nil) + lb.Add(proxyName, proxy, server.Weight) // servers are considered UP by default. info.UpdateServerStatus(target.String(), runtime.StatusUp)