├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ ├── on_push.yaml │ └── on_tag.yaml ├── .gitignore ├── .goreleaser.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── bin └── traefik-kop │ └── main.go ├── config.go ├── docker-compose.yml ├── docker.go ├── docker_helpers_test.go ├── docker_test.go ├── fixtures ├── gitea.yml ├── hello-automapped.yml ├── hello-ignore.yml ├── hello-no-cert.yml ├── hellodetect.yml ├── helloip.yml ├── helloworld.yml ├── mqtt.yml ├── network.yml ├── prefix.yml └── sample.toml ├── go.mod ├── go.sum ├── kv.go ├── kv_test.go ├── multi_provider.go ├── polling_provider.go ├── store.go ├── store_test.go ├── testing ├── docker-compose.yml ├── helloworld │ ├── Dockerfile │ ├── README.md │ ├── go.mod │ └── main.go ├── kop │ └── docker-compose.yml └── publish-random.sh ├── traefik_kop.go ├── traefik_kop_test.go └── util.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | .env 4 | 5 | .dockerignore 6 | Dockerfile 7 | 8 | Makefile 9 | LICENSE 10 | README.md 11 | docs 12 | dist/ 13 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: chetan 2 | -------------------------------------------------------------------------------- /.github/workflows/on_push.yaml: -------------------------------------------------------------------------------- 1 | name: on-push 2 | 3 | on: 4 | create: {} 5 | push: {} 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }} 13 | 14 | jobs: 15 | build_docker: 16 | name: Build Docker 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | with: 22 | # so we get proper snapshot version info 23 | fetch-depth: 0 24 | 25 | - name: Log in to the Container registry 26 | uses: docker/login-action@v3 27 | with: 28 | registry: ${{ env.REGISTRY }} 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Set up QEMU 33 | uses: docker/setup-qemu-action@v3 34 | 35 | - name: Set up Docker Buildx 36 | uses: docker/setup-buildx-action@v3 37 | 38 | - name: Set up Go 39 | uses: actions/setup-go@v5 40 | with: 41 | go-version: ~1.22 42 | cache: false 43 | 44 | - uses: actions/cache@v4 45 | with: 46 | path: | 47 | ~/.cache/go-build 48 | ~/go/pkg/mod 49 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 50 | restore-keys: | 51 | ${{ runner.os }}-go- 52 | 53 | - name: Set up GoReleaser 54 | uses: goreleaser/goreleaser-action@v4 55 | with: 56 | version: latest 57 | install-only: true 58 | 59 | - name: Run GoReleaser 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | run: | 63 | set -eo pipefail 64 | goreleaser release --clean --snapshot 65 | # Manually push docker images to :latest tag 66 | docker images --format '{{ .Repository }}:{{ .Tag }}' \ 67 | | grep kop \ 68 | | grep -v none \ 69 | | xargs -n 1 docker push 70 | -------------------------------------------------------------------------------- /.github/workflows/on_tag.yaml: -------------------------------------------------------------------------------- 1 | name: tagged-release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | tagged-release: 14 | name: Tagged Release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Log in to the Container registry 21 | uses: docker/login-action@v3 22 | with: 23 | registry: ${{ env.REGISTRY }} 24 | username: ${{ github.actor }} 25 | password: ${{ secrets.GITHUB_TOKEN }} 26 | 27 | - name: Set up QEMU 28 | uses: docker/setup-qemu-action@v3 29 | 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v3 32 | 33 | - name: Set up Go 34 | uses: actions/setup-go@v5 35 | with: 36 | go-version: ~1.22 37 | cache: false 38 | 39 | - uses: actions/cache@v4 40 | with: 41 | path: | 42 | ~/.cache/go-build 43 | ~/go/pkg/mod 44 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 45 | restore-keys: | 46 | ${{ runner.os }}-go- 47 | 48 | - name: Run GoReleaser 49 | uses: goreleaser/goreleaser-action@v4 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | with: 53 | version: latest 54 | args: release --clean 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode/ 3 | /traefik-kop 4 | /dist 5 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: traefik-kop 2 | before: 3 | hooks: 4 | - go mod tidy 5 | - go mod download 6 | builds: 7 | - main: ./bin/traefik-kop/ 8 | env: 9 | - CGO_ENABLED=0 10 | goos: 11 | - linux 12 | - darwin 13 | goarch: 14 | - amd64 15 | - arm 16 | - arm64 17 | goarm: 18 | - "6" 19 | - "7" 20 | archives: 21 | - name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' 22 | checksum: 23 | name_template: "checksums.txt" 24 | snapshot: 25 | name_template: "{{ incpatch .Version }}-next-{{ .ShortCommit }}" 26 | changelog: 27 | sort: asc 28 | filters: 29 | exclude: 30 | - "^docs:" 31 | - "^test:" 32 | dockers: 33 | - image_templates: 34 | ["ghcr.io/jittering/{{ .ProjectName }}:{{ .Version }}-amd64"] 35 | dockerfile: Dockerfile 36 | use: buildx 37 | build_flag_templates: 38 | - --platform=linux/amd64 39 | - --label=org.opencontainers.image.title={{ .ProjectName }} 40 | - --label=org.opencontainers.image.description={{ .ProjectName }} 41 | - --label=org.opencontainers.image.url=https://github.com/jittering/{{ .ProjectName }} 42 | - --label=org.opencontainers.image.source=https://github.com/jittering/{{ .ProjectName }} 43 | - --label=org.opencontainers.image.version={{ .Version }} 44 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 45 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 46 | - --label=org.opencontainers.image.licenses=MIT 47 | - image_templates: 48 | ["ghcr.io/jittering/{{ .ProjectName }}:{{ .Version }}-armv6"] 49 | goarch: arm 50 | goarm: "6" 51 | dockerfile: Dockerfile 52 | use: buildx 53 | build_flag_templates: 54 | - --platform=linux/arm/v6 55 | - --label=org.opencontainers.image.title={{ .ProjectName }} 56 | - --label=org.opencontainers.image.description={{ .ProjectName }} 57 | - --label=org.opencontainers.image.url=https://github.com/jittering/{{ .ProjectName }} 58 | - --label=org.opencontainers.image.source=https://github.com/jittering/{{ .ProjectName }} 59 | - --label=org.opencontainers.image.version={{ .Version }} 60 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 61 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 62 | - --label=org.opencontainers.image.licenses=MIT 63 | - image_templates: 64 | ["ghcr.io/jittering/{{ .ProjectName }}:{{ .Version }}-armv7"] 65 | goarch: arm 66 | goarm: "7" 67 | dockerfile: Dockerfile 68 | use: buildx 69 | build_flag_templates: 70 | - --platform=linux/arm/v7 71 | - --label=org.opencontainers.image.title={{ .ProjectName }} 72 | - --label=org.opencontainers.image.description={{ .ProjectName }} 73 | - --label=org.opencontainers.image.url=https://github.com/jittering/{{ .ProjectName }} 74 | - --label=org.opencontainers.image.source=https://github.com/jittering/{{ .ProjectName }} 75 | - --label=org.opencontainers.image.version={{ .Version }} 76 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 77 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 78 | - --label=org.opencontainers.image.licenses=MIT 79 | - image_templates: 80 | ["ghcr.io/jittering/{{ .ProjectName }}:{{ .Version }}-arm64v8"] 81 | goarch: arm64 82 | dockerfile: Dockerfile 83 | use: buildx 84 | build_flag_templates: 85 | - --platform=linux/arm64/v8 86 | - --label=org.opencontainers.image.title={{ .ProjectName }} 87 | - --label=org.opencontainers.image.description={{ .ProjectName }} 88 | - --label=org.opencontainers.image.url=https://github.com/jittering/{{ .ProjectName }} 89 | - --label=org.opencontainers.image.source=https://github.com/jittering/{{ .ProjectName }} 90 | - --label=org.opencontainers.image.version={{ .Version }} 91 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 92 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 93 | - --label=org.opencontainers.image.licenses=MIT 94 | docker_manifests: 95 | - name_template: ghcr.io/jittering/{{ .ProjectName }}:{{ .Version }} 96 | image_templates: 97 | - ghcr.io/jittering/{{ .ProjectName }}:{{ .Version }}-amd64 98 | - ghcr.io/jittering/{{ .ProjectName }}:{{ .Version }}-armv6 99 | - ghcr.io/jittering/{{ .ProjectName }}:{{ .Version }}-armv7 100 | - ghcr.io/jittering/{{ .ProjectName }}:{{ .Version }}-arm64v8 101 | - name_template: ghcr.io/jittering/{{ .ProjectName }}:latest 102 | image_templates: 103 | - ghcr.io/jittering/{{ .ProjectName }}:{{ .Version }}-amd64 104 | - ghcr.io/jittering/{{ .ProjectName }}:{{ .Version }}-armv6 105 | - ghcr.io/jittering/{{ .ProjectName }}:{{ .Version }}-armv7 106 | - ghcr.io/jittering/{{ .ProjectName }}:{{ .Version }}-arm64v8 107 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.16 4 | 5 | ### Fixes 6 | 7 | - Fix port detection when publishing to ephemeral ports [#48, thanks @Flamefork](https://github.com/jittering/traefik-kop/pull/48) 8 | 9 | ## v0.15 10 | 11 | ### Fixes 12 | 13 | - Push last config to redis in the case of a restart or failure in the cache [#46](https://github.com/jittering/traefik-kop/pull/46) 14 | 15 | ## v0.14 16 | 17 | ### New Features 18 | 19 | - Allow filtering containers processed by `traefik-kop` using [namespaces](https://github.com/jittering/traefik-kop#namespaces) 20 | 21 | ### Fixes 22 | 23 | - Use exact service name match when searching container labels (#39, thanks @damfleu) 24 | 25 | **Full Changelog**: https://github.com/jittering/traefik-kop/compare/v0.13.3...v0.14 26 | 27 | ## v0.13.3 28 | 29 | - 16beda8 build: bump go version to 1.22 30 | 31 | ## v0.13.2 32 | 33 | - 10ab916 fix: properly stringify floats when writing to redis (resolves #25) 34 | 35 | ## v0.13.1 36 | 37 | * [build: upgraded docker client dep](https://github.com/jittering/traefik-kop/commit/e7f30f3108f46cf0d174369b45f59d57398d002b) 38 | * [fix: NPE when creating error message from port map](https://github.com/jittering/traefik-kop/commit/80d40e2aa904a78d4ec7b311c9f99bc449f556f3) ([fixes #24](https://github.com/jittering/traefik-kop/issues/24)) 39 | * [fix: avoid possible NPE when resolving CNI container IP](https://github.com/jittering/traefik-kop/commit/37686b0089ccaf91d4fa13df62447e15671944dd) 40 | 41 | 42 | 43 | ## [v0.13](https://github.com/jittering/traefik-kop/tree/v0.13) (2022-10-17) 44 | 45 | [Full Changelog](https://github.com/jittering/traefik-kop/compare/v0.12.1...v0.13) 46 | 47 | ### New Features 48 | 49 | - Set bind IP per-container or service 50 | - Set traefik docker provider config (e.g., `defaultRule`) 51 | 52 | ### Fixes 53 | 54 | - Correctly set port for TCP and UDP services 55 | 56 | ### Closed issues 57 | 58 | - Go runtime error [\#20](https://github.com/jittering/traefik-kop/issues/20) 59 | - Default Rule [\#18](https://github.com/jittering/traefik-kop/issues/18) 60 | - Provide IP for each docker via label [\#17](https://github.com/jittering/traefik-kop/issues/17) 61 | - setting port for tcp service does not work [\#16](https://github.com/jittering/traefik-kop/issues/16) 62 | - Doesn't work with multiple services on one container [\#14](https://github.com/jittering/traefik-kop/issues/14) 63 | 64 | ## v0.12.1 65 | 66 | This release updates the upstream version of the traefik library to v2.8.4 and 67 | adds additional logging around port detection (both debug and info levels) to 68 | make it easier to see what's going on and troubleshoot various scenarios. 69 | 70 | [Full Changelog](https://github.com/jittering/traefik-kop/compare/v0.12...v0.12.1) 71 | 72 | - [8c5a3f0](https://github.com/jittering/traefik-kop/commit/8c5a3f0) build: bump actions/cache to v3 73 | - [dad6e90](https://github.com/jittering/traefik-kop/commit/dad6e90) build: bump go version in github actions 74 | - [f009b84](https://github.com/jittering/traefik-kop/commit/f009b84) docs: added more detail and logging around port selection 75 | - [2f18114](https://github.com/jittering/traefik-kop/commit/2f18114) test: added helloworld service for testing multiple bindings 76 | - [be636f7](https://github.com/jittering/traefik-kop/commit/be636f7) build: upgraded traefik to 2.8.4 (now supports go 1.18+) 77 | 78 | ## v0.12 79 | 80 | ### Notes 81 | 82 | By default, `traefik-kop` will listen for push events via the Docker API in 83 | order to detect configuration changes. In some circumstances, a change may not 84 | be pushed correctly. For example, when using healthchecks in certain 85 | configurations, the `start -> healthy` change may not be detected via push 86 | event. As a failsafe, there is an additional polling mechanism to detect those 87 | missed changes. 88 | 89 | The default interval of 60 seconds should be light so as not to cause any 90 | issues, however it can be adjusted as needed via the `KOP_POLL_INTERVAL` env var 91 | or set to 0 to disable it completely. 92 | 93 | [Full Changelog](https://github.com/jittering/traefik-kop/compare/v0.11...v0.12) 94 | 95 | - [347352b](https://github.com/jittering/traefik-kop/commit/347352b) build: fix goreleaser tidy 96 | - [b6447c3](https://github.com/jittering/traefik-kop/commit/b6447c3) build: go mod tidy 97 | - [12ad255](https://github.com/jittering/traefik-kop/commit/12ad255) docs: added poll interval to readme 98 | - [10f7aab](https://github.com/jittering/traefik-kop/commit/10f7aab) feat: expose providers in case anyone wants to reuse 99 | - [5b58547](https://github.com/jittering/traefik-kop/commit/5b58547) feat: add log message when explicitly disabling polling 100 | - [02802d5](https://github.com/jittering/traefik-kop/commit/02802d5) feat: configurable poll interval (default 60) 101 | - [b2ef52b](https://github.com/jittering/traefik-kop/commit/b2ef52b) feat: combine providers into single config watcher 102 | - [07fe8aa](https://github.com/jittering/traefik-kop/commit/07fe8aa) feat: added polling provider as a workaround for healthcheck issue 103 | - [cc3854b](https://github.com/jittering/traefik-kop/commit/cc3854b) feat: added config for changing docker endpoint 104 | - [c309d40](https://github.com/jittering/traefik-kop/commit/c309d40) build: upgraded traefik lib to v2.7 105 | - [32c2df6](https://github.com/jittering/traefik-kop/commit/32c2df6) test: added pihole container (with builtin healthcheck) 106 | - [e770242](https://github.com/jittering/traefik-kop/commit/e770242) docs: updated changelog 107 | 108 | 109 | 110 | ## v0.11 111 | 112 | 113 | [Full Changelog](https://github.com/jittering/traefik-kop/compare/v0.10.1...v0.11) 114 | 115 | #### Notes 116 | 117 | * If your container is configured to use a network-routable IP address via an 118 | overlay network or CNI plugin, that address will override the `bind-ip` 119 | configuration when the `traefik.docker.network` label is present. 120 | 121 | **Merged pull requests:** 122 | 123 | - Add support for `traefik.docker.network` [\#8](https://github.com/jittering/traefik-kop/pull/8) ([hcooper](https://github.com/hcooper)) 124 | 125 | 126 | ## v0.10.1 127 | 128 | * e0af6eb Merge pull request #7 from jittering/fix/port-detect 129 | 130 | 131 | 132 | ## v0.10.1 133 | 134 | * e0af6eb Merge pull request #7 from jittering/fix/port-detect 135 | 136 | 137 | 138 | ## v0.10.0 139 | 140 | * 5d029d2 feat: add support for ports published via --publish-all (closes #6) 141 | 142 | 143 | 144 | ## v0.9.2 145 | 146 | * 5871d16 feat: log the container name/id if found 147 | 148 | 149 | 150 | ## v0.9.1 151 | 152 | 153 | * fbd2d1d fix: Automatic port assignment not working for containers without a service 154 | 155 | 156 | ## v0.9 157 | 158 | 159 | * 4bd7cd1 Merge pull request #2 from jittering/feature/detect-host-port 160 | 161 | 162 | 163 | ## v0.8.1 164 | 165 | 166 | * e69bd05 fix: strip @docker when removing keys 167 | 168 | 169 | ### Docker images 170 | 171 | - `docker pull ghcr.io/jittering/traefik-kop:0.8.1` 172 | 173 | 174 | ## v0.8 175 | 176 | 177 | * dccbf22 build: fix release step 178 | 179 | 180 | ### Docker images 181 | 182 | - `docker pull ghcr.io/jittering/traefik-kop:0.8` 183 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | ENTRYPOINT ["/traefik-kop"] 3 | COPY traefik-kop / 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Chetan Sarva 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | PROJECT=ghcr.io/jittering/traefik-kop 3 | 4 | .DEFAULT_GOAL := run 5 | 6 | SHELL := bash 7 | 8 | build-docker: build-linux 9 | docker build --platform linux/amd64 -t ${PROJECT}:latest . 10 | 11 | build-linux: 12 | GOOS=linux go build ./bin/traefik-kop 13 | 14 | build: 15 | go build ./bin/traefik-kop 16 | 17 | run: 18 | go run ./bin/traefik-kop 19 | 20 | serve: run 21 | 22 | test: 23 | go test ./... 24 | 25 | cover: 26 | go test -coverprofile=c.out ./... 27 | 28 | watch: 29 | watchexec -e go,yml "make test" 30 | 31 | watch-cover: 32 | watchexec -e go,yml "make cover" 33 | 34 | clean: 35 | rm -rf dist/ 36 | rm -f traefik-kop 37 | 38 | release: clean 39 | goreleaser release --rm-dist --skip-validate 40 | 41 | update-changelog: 42 | echo -e "# Changelog\n" >> temp.md 43 | rel=$$(gh release list | head -n 1 | awk '{print $$1}'); \ 44 | echo "## $$rel" >> temp.md; \ 45 | echo "" >> temp.md; \ 46 | gh release view --json body $$rel | \ 47 | jq --raw-output '.body' | \ 48 | grep -v '^## Changelog' | \ 49 | sed -e 's/^#/##/g' >> temp.md 50 | cat CHANGELOG.md | grep -v '^# Changelog' >> temp.md 51 | mv temp.md CHANGELOG.md 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # traefik-kop 2 | 3 | A dynamic docker->redis->traefik discovery agent. 4 | 5 | Solves the problem of running a non-Swarm/Kubernetes multi-host cluster with a 6 | single public-facing traefik instance. 7 | 8 | ```text 9 | +---------------------+ +---------------------+ 10 | | | | | 11 | +---------+ :443 | +---------+ | :8088 | +------------+ | 12 | | WAN |--------------->| traefik |<-------------------->| svc-nginx | | 13 | +---------+ | +---------+ | | +------------+ | 14 | | | | | | 15 | | +---------+ | | +-------------+ | 16 | | | redis |<-------------------->| traefik-kop | | 17 | | +---------+ | | +-------------+ | 18 | | docker1 | | docker2 | 19 | +---------------------+ +---------------------+ 20 | ``` 21 | 22 | `traefik-kop` solves this problem by using the same `traefik` docker-provider 23 | logic. It reads the container labels from the local docker node and publishes 24 | them to a given `redis` instance. Simply configure your `traefik` node with a 25 | `redis` provider and point it to the same instance, as in the diagram above. 26 | 27 | ## Usage 28 | 29 | Configure `traefik` to use the redis provider, for example via `traefik.yml`: 30 | 31 | ```yaml 32 | providers: 33 | providersThrottleDuration: 2s 34 | docker: 35 | watch: true 36 | endpoint: unix:///var/run/docker.sock 37 | swarmModeRefreshSeconds: 15s 38 | exposedByDefault: false 39 | redis: 40 | endpoints: 41 | # assumes a redis link with this service name running on the same 42 | # docker host as traefik 43 | - "redis:6379" 44 | ``` 45 | 46 | Run `traefik-kop` on your other nodes via docker-compose: 47 | 48 | ```yaml 49 | services: 50 | traefik-kop: 51 | image: "ghcr.io/jittering/traefik-kop:latest" 52 | restart: unless-stopped 53 | volumes: 54 | - /var/run/docker.sock:/var/run/docker.sock 55 | environment: 56 | - "REDIS_ADDR=192.168.1.50:6379" 57 | - "BIND_IP=192.168.1.75" 58 | ``` 59 | 60 | Then add the usual labels to your target service: 61 | 62 | ```yml 63 | services: 64 | nginx: 65 | image: "nginx:alpine" 66 | restart: unless-stopped 67 | ports: 68 | # The host port binding will automatically be picked up for use as the 69 | # service endpoint. See 'service port binding' in the configuration 70 | # section for more. 71 | - 8088:80 72 | labels: 73 | - "traefik.enable=true" 74 | - "traefik.http.routers.nginx.rule=Host(`nginx-on-docker2.example.com`)" 75 | - "traefik.http.routers.nginx.tls=true" 76 | - "traefik.http.routers.nginx.tls.certresolver=default" 77 | # [opptional] explicitly set the port binding for this service. 78 | # See 'service port binding' in the configuration section for more. 79 | - "traefik.http.services.nginx.loadbalancer.server.scheme=http" 80 | - "traefik.http.services.nginx.loadbalancer.server.port=8088" 81 | ``` 82 | 83 | See also [bind-ip](#bind-ip) section below. 84 | 85 | ## Configuration 86 | 87 | traefik-kop can be configured via either CLI flags are environment variables. 88 | 89 | ```text 90 | USAGE: 91 | traefik-kop [global options] command [command options] [arguments...] 92 | 93 | GLOBAL OPTIONS: 94 | --hostname value Hostname to identify this node in redis (default: "server.local") [$KOP_HOSTNAME] 95 | --bind-ip value IP address to bind services to (default: "auto.detected.ip.addr") [$BIND_IP] 96 | --redis-addr value Redis address (default: "127.0.0.1:6379") [$REDIS_ADDR] 97 | --redis-pass value Redis password (if needed) [$REDIS_PASS] 98 | --redis-db value Redis DB number (default: 0) [$REDIS_DB] 99 | --docker-host value Docker endpoint (default: "unix:///var/run/docker.sock") [$DOCKER_HOST] 100 | --docker-config value Docker provider config (file must end in .yaml) [$DOCKER_CONFIG] 101 | --poll-interval value Poll interval for refreshing container list (default: 60) [$KOP_POLL_INTERVAL] 102 | --namespace value Namespace to process containers for [$NAMESPACE] 103 | --verbose Enable debug logging (default: false) [$VERBOSE, $DEBUG] 104 | --help, -h show help 105 | --version, -V Print the version (default: false) 106 | ``` 107 | 108 | Most important are the `bind-ip` and `redis-addr` flags. 109 | 110 | ## IP Binding 111 | 112 | There are a number of ways to set the IP published to traefik. Below is the 113 | order of precedence (highest first) and detailed descriptions of each setting. 114 | 115 | 1. `kop..bind.ip` label 116 | 2. `kop.bind.ip` label 117 | 3. Container networking IP 118 | 4. `--bind-ip` CLI flag 119 | 5. `BIND_IP` env var 120 | 6. Auto-detected host IP 121 | 122 | ### bind-ip 123 | 124 | Since your upstream docker nodes are external to your primary traefik server, 125 | traefik needs to connect to these services via the server's public IP rather 126 | than the usual method of using the internal docker-network IPs (by default 127 | 172.20.0.x or similar). 128 | 129 | When using host networking this can be auto-detected, however it is advisable in 130 | the majority of cases to manually set this to the desired IP address. This can 131 | be done using the docker image by exporting the `BIND_IP` environment variable. 132 | 133 | ### traefik-kop service labels 134 | 135 | The bind IP can be set via label for each service/container. 136 | 137 | Labels can be one of two keys: 138 | 139 | - `kop..bind.ip=2.2.2.2` 140 | - `kop.bind.ip=2.2.2.2` 141 | 142 | For a container with a single exposed service, or where all services use 143 | the same IP, the latter is sufficient. 144 | 145 | ### Container Networking 146 | 147 | If your container is configured to use a network-routable IP address via an 148 | overlay network or CNI plugin, that address will override the `bind-ip` 149 | configuration above when the `traefik.docker.network` label is present on the 150 | service. 151 | 152 | ## Service port binding 153 | 154 | By default, the service port will be picked up from the container port bindings 155 | if only a single port is bound. For example: 156 | 157 | ```yml 158 | services: 159 | nginx: 160 | image: "nginx:alpine" 161 | restart: unless-stopped 162 | ports: 163 | - 8088:80 164 | ``` 165 | 166 | `8088` would automatically be used as the service endpoint's port in traefik. If 167 | you have more than one port or are using *host networking*, you will need to 168 | explicitly set the port binding via service label, like so: 169 | 170 | ```yaml 171 | services: 172 | nginx: 173 | image: "nginx:alpine" 174 | network_mode: host 175 | ports: 176 | - 8088:80 177 | - 8888:81 178 | labels: 179 | # (note: other labels snipped for brevity) 180 | - "traefik.http.services.nginx.loadbalancer.server.port=8088" 181 | ``` 182 | 183 | __NOTE:__ unlike the standard traefik-docker usage, we need to expose the 184 | service port on the host and tell traefik to bind to *that* port (8088 in the 185 | example above) in the load balancer config, not the internal port (80). This is 186 | so that traefik can reach it over the network. 187 | 188 | ## Namespaces 189 | 190 | traefik-kop has the ability to target containers via namespaces. Simply 191 | configure `kop` with a namespace: 192 | 193 | ```yaml 194 | services: 195 | traefik-kop: 196 | image: "ghcr.io/jittering/traefik-kop:latest" 197 | restart: unless-stopped 198 | volumes: 199 | - /var/run/docker.sock:/var/run/docker.sock 200 | environment: 201 | - "REDIS_ADDR=192.168.1.50:6379" 202 | - "BIND_IP=192.168.1.75" 203 | - "NAMESPACE=staging" 204 | ``` 205 | 206 | Then add the `kop.namespace` label to your target services, along with the usual traefik labels: 207 | 208 | ```yaml 209 | services: 210 | nginx: 211 | image: "nginx:alpine" 212 | restart: unless-stopped 213 | ports: 214 | - 8088:80 215 | labels: 216 | - "kop.namespace=staging" 217 | - "traefik.enable=true" 218 | - "traefik..." 219 | ``` 220 | 221 | 222 | ## Docker API 223 | 224 | traefik-kop expects to connect to the Docker host API via a unix socket, by 225 | default at `/var/run/docker.sock`. The location can be overridden via the 226 | `DOCKER_HOST` env var or `--docker-host` flag. 227 | 228 | Other connection methods (like ssh, http/s) are not supported. 229 | 230 | By default, `traefik-kop` will listen for push events via the Docker API in 231 | order to detect configuration changes. In some circumstances, a change may not 232 | be pushed correctly. For example, when using healthchecks in certain 233 | configurations, the `start -> healthy` change may not be detected via push 234 | event. As a failsafe, there is an additional polling mechanism to detect those 235 | missed changes. 236 | 237 | The default interval of 60 seconds should be light so as not to cause any 238 | issues, however it can be adjusted as needed via the `KOP_POLL_INTERVAL` env var 239 | or set to 0 to disable it completely. 240 | 241 | ### Traefik Docker Provider Config 242 | 243 | In addition to the simple `--docker-host` setting above, all [Docker Provider 244 | configuration 245 | options](https://doc.traefik.io/traefik/providers/docker/#provider-configuration) 246 | are available via the `--docker-config ` flag which expects 247 | either a filename to read configuration from or an inline YAML document. 248 | 249 | For example: 250 | 251 | ```yaml 252 | services: 253 | traefik-kop: 254 | image: "ghcr.io/jittering/traefik-kop:latest" 255 | restart: unless-stopped 256 | volumes: 257 | - /var/run/docker.sock:/var/run/docker.sock 258 | environment: 259 | REDIS_ADDR: "172.28.183.97:6380" 260 | BIND_IP: "172.28.183.97" 261 | DOCKER_CONFIG: | 262 | --- 263 | docker: 264 | defaultRule: Host(`{{.Name}}.foo.example.com`) 265 | ``` 266 | 267 | ## Releasing 268 | 269 | To release a new version, simply push a new tag to github. 270 | 271 | ```sh 272 | git push 273 | git tag -a v0.11.0 274 | git push --tags 275 | ``` 276 | 277 | To update the changelog: 278 | 279 | ```sh 280 | make update-changelog 281 | # or (replace tag below) 282 | docker run -it --rm -v "$(pwd)":/usr/local/src/your-app \ 283 | githubchangeloggenerator/github-changelog-generator \ 284 | -u jittering -p traefik-kop --output "" \ 285 | --since-tag v0.10.1 286 | ``` 287 | 288 | ## License 289 | 290 | traefik-kop: MIT, (c) 2022, Pixelcop Research, Inc. 291 | 292 | traefik: MIT, (c) 2016-2020 Containous SAS; 2020-2022 Traefik Labs 293 | -------------------------------------------------------------------------------- /bin/traefik-kop/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "os" 7 | 8 | traefikkop "github.com/jittering/traefik-kop" 9 | "github.com/sirupsen/logrus" 10 | "github.com/traefik/traefik/v2/pkg/log" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | const defaultDockerHost = "unix:///var/run/docker.sock" 15 | 16 | var ( 17 | version string 18 | commit string 19 | date string 20 | builtBy string 21 | ) 22 | 23 | func printVersion(c *cli.Context) error { 24 | fmt.Printf("%s version %s (commit: %s, built %s)\n", c.App.Name, c.App.Version, commit, date) 25 | return nil 26 | } 27 | 28 | func flags() { 29 | if version == "" { 30 | version = "n/a" 31 | } 32 | if commit == "" { 33 | commit = "head" 34 | } 35 | if date == "" { 36 | date = "n/a" 37 | } 38 | 39 | cli.VersionFlag = &cli.BoolFlag{ 40 | Name: "version", 41 | Aliases: []string{"V"}, 42 | Usage: "Print the version", 43 | } 44 | cli.VersionPrinter = func(c *cli.Context) { 45 | printVersion(c) 46 | } 47 | 48 | app := &cli.App{ 49 | Name: "traefik-kop", 50 | Usage: "A dynamic docker->redis->traefik discovery agent", 51 | Version: version, 52 | 53 | Action: doStart, 54 | 55 | Flags: []cli.Flag{ 56 | &cli.StringFlag{ 57 | Name: "hostname", 58 | Usage: "Hostname to identify this node in redis", 59 | Value: getHostname(), 60 | EnvVars: []string{"KOP_HOSTNAME"}, 61 | }, 62 | &cli.StringFlag{ 63 | Name: "bind-ip", 64 | Usage: "IP address to bind services to", 65 | Value: getDefaultIP(), 66 | EnvVars: []string{"BIND_IP"}, 67 | }, 68 | &cli.StringFlag{ 69 | Name: "redis-addr", 70 | Usage: "Redis address", 71 | Value: "127.0.0.1:6379", 72 | EnvVars: []string{"REDIS_ADDR"}, 73 | }, 74 | &cli.StringFlag{ 75 | Name: "redis-pass", 76 | Usage: "Redis password (if needed)", 77 | EnvVars: []string{"REDIS_PASS"}, 78 | }, 79 | &cli.IntFlag{ 80 | Name: "redis-db", 81 | Usage: "Redis DB number", 82 | Value: 0, 83 | EnvVars: []string{"REDIS_DB"}, 84 | }, 85 | &cli.StringFlag{ 86 | Name: "docker-host", 87 | Usage: "Docker endpoint", 88 | Value: defaultDockerHost, 89 | EnvVars: []string{"DOCKER_HOST"}, 90 | }, 91 | &cli.StringFlag{ 92 | Name: "docker-config", 93 | Usage: "Docker provider config (file must end in .yaml)", 94 | EnvVars: []string{"DOCKER_CONFIG"}, 95 | }, 96 | &cli.Int64Flag{ 97 | Name: "poll-interval", 98 | Usage: "Poll interval for refreshing container list", 99 | Value: 60, 100 | EnvVars: []string{"KOP_POLL_INTERVAL"}, 101 | }, 102 | &cli.StringFlag{ 103 | Name: "namespace", 104 | Usage: "Namespace to process containers for", 105 | EnvVars: []string{"NAMESPACE"}, 106 | }, 107 | &cli.BoolFlag{ 108 | Name: "verbose", 109 | Usage: "Enable debug logging", 110 | Value: false, 111 | EnvVars: []string{"VERBOSE", "DEBUG"}, 112 | }, 113 | }, 114 | } 115 | 116 | err := app.Run(os.Args) 117 | if err != nil { 118 | log.Fatal(err) 119 | } 120 | } 121 | 122 | func setupLogging(debug bool) { 123 | if debug { 124 | logrus.SetLevel(logrus.DebugLevel) 125 | log.SetLevel(logrus.DebugLevel) 126 | log.WithoutContext().WriterLevel(logrus.DebugLevel) 127 | } 128 | 129 | formatter := &logrus.TextFormatter{DisableColors: true, FullTimestamp: true, DisableSorting: true} 130 | logrus.SetFormatter(formatter) 131 | log.SetFormatter(formatter) 132 | } 133 | 134 | func main() { 135 | flags() 136 | } 137 | 138 | func doStart(c *cli.Context) error { 139 | traefikkop.Version = version 140 | config := traefikkop.Config{ 141 | Hostname: c.String("hostname"), 142 | BindIP: c.String("bind-ip"), 143 | Addr: c.String("redis-addr"), 144 | Pass: c.String("redis-pass"), 145 | DB: c.Int("redis-db"), 146 | DockerHost: c.String("docker-host"), 147 | DockerConfig: c.String("docker-config"), 148 | PollInterval: c.Int64("poll-interval"), 149 | Namespace: c.String("namespace"), 150 | } 151 | 152 | setupLogging(c.Bool("verbose")) 153 | logrus.Debugf("using traefik-kop config: %s", fmt.Sprintf("%+v", config)) 154 | 155 | traefikkop.Start(config) 156 | return nil 157 | } 158 | 159 | func getHostname() string { 160 | hostname, err := os.Hostname() 161 | if err != nil { 162 | return "traefik-kop" 163 | } 164 | return hostname 165 | } 166 | 167 | func getDefaultIP() string { 168 | ip := GetOutboundIP() 169 | return ip.String() 170 | } 171 | 172 | // Get preferred outbound ip of this machine 173 | // via https://stackoverflow.com/a/37382208/102920 174 | func GetOutboundIP() net.IP { 175 | conn, err := net.Dial("udp", "8.8.8.8:80") 176 | if err != nil { 177 | log.Fatal(err) 178 | } 179 | defer conn.Close() 180 | 181 | localAddr := conn.LocalAddr().(*net.UDPAddr) 182 | 183 | return localAddr.IP 184 | } 185 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package traefikkop 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "regexp" 7 | "strings" 8 | 9 | "github.com/pkg/errors" 10 | "github.com/sirupsen/logrus" 11 | "github.com/traefik/traefik/v2/pkg/provider/docker" 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | type Config struct { 16 | DockerConfig string 17 | DockerHost string 18 | Hostname string 19 | BindIP string 20 | Addr string 21 | Pass string 22 | DB int 23 | PollInterval int64 24 | Namespace string 25 | } 26 | 27 | type ConfigFile struct { 28 | Docker docker.Provider `yaml:"docker"` 29 | } 30 | 31 | func loadDockerConfig(input string) (*docker.Provider, error) { 32 | if input == "" { 33 | return nil, nil 34 | } 35 | 36 | var r io.Reader 37 | 38 | if looksLikeFile(input) { 39 | // see if given filename 40 | _, err := os.Stat(input) 41 | if err == nil { 42 | logrus.Debugf("loading docker config from file %s", input) 43 | r, err = os.Open(input) 44 | if err != nil { 45 | return nil, errors.Wrapf(err, "failed to open docker config %s", input) 46 | } 47 | } 48 | } else { 49 | logrus.Debugf("loading docker config from yaml input") 50 | r = strings.NewReader(input) // treat as direct yaml input 51 | } 52 | 53 | // parse 54 | conf := ConfigFile{Docker: docker.Provider{}} 55 | err := yaml.NewDecoder(r).Decode(&conf) 56 | if err != nil { 57 | return nil, errors.Wrap(err, "failed to load config") 58 | } 59 | 60 | return &conf.Docker, nil 61 | } 62 | 63 | func looksLikeFile(input string) bool { 64 | if strings.Contains(input, "\n") { 65 | return false 66 | } 67 | ok, _ := regexp.MatchString(`\.ya?ml`, input) 68 | return ok 69 | } 70 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | traefik-kop: 5 | image: "ghcr.io/jittering/traefik-kop:latest" 6 | restart: unless-stopped 7 | volumes: 8 | - /var/run/docker.sock:/var/run/docker.sock 9 | environment: 10 | - "REDIS_ADDR=192.168.1.50:6379" 11 | - "BIND_IP=192.168.1.75" 12 | -------------------------------------------------------------------------------- /docker.go: -------------------------------------------------------------------------------- 1 | package traefikkop 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/docker/docker/api/types" 10 | "github.com/docker/docker/client" 11 | "github.com/docker/go-connections/nat" 12 | "github.com/pkg/errors" 13 | "github.com/sirupsen/logrus" 14 | "github.com/traefik/traefik/v2/pkg/provider/docker" 15 | ) 16 | 17 | // Copied from traefik. See docker provider package for original impl 18 | 19 | type dockerCache struct { 20 | client client.APIClient 21 | list []types.Container 22 | details map[string]types.ContainerJSON 23 | } 24 | 25 | // Must be 0 for unix socket? 26 | // Non-zero throws an error 27 | const defaultTimeout = time.Duration(0) 28 | 29 | func createDockerClient(endpoint string) (client.APIClient, error) { 30 | opts, err := getClientOpts(endpoint) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | httpHeaders := map[string]string{ 36 | "User-Agent": "traefik-kop " + Version, 37 | } 38 | opts = append(opts, client.WithHTTPHeaders(httpHeaders)) 39 | 40 | apiVersion := docker.DockerAPIVersion 41 | SwarmMode := false 42 | if SwarmMode { 43 | apiVersion = docker.SwarmAPIVersion 44 | } 45 | opts = append(opts, client.WithVersion(apiVersion)) 46 | 47 | return client.NewClientWithOpts(opts...) 48 | } 49 | 50 | func getClientOpts(endpoint string) ([]client.Opt, error) { 51 | // we currently do not support ssh, so skip helper setup 52 | opts := []client.Opt{ 53 | client.WithHost(endpoint), 54 | client.WithTimeout(time.Duration(defaultTimeout)), 55 | } 56 | return opts, nil 57 | } 58 | 59 | // looks up the docker container by finding the matching service or router traefik label 60 | func (dc *dockerCache) findContainerByServiceName(svcType string, svcName string, routerName string) (types.ContainerJSON, error) { 61 | svcName = strings.TrimSuffix(svcName, "@docker") 62 | routerName = strings.TrimSuffix(routerName, "@docker") 63 | 64 | if dc.list == nil { 65 | var err error 66 | dc.list, err = dc.client.ContainerList(context.Background(), types.ContainerListOptions{}) 67 | if err != nil { 68 | return types.ContainerJSON{}, errors.Wrap(err, "failed to list containers") 69 | } 70 | } 71 | 72 | for _, c := range dc.list { 73 | var container types.ContainerJSON 74 | var ok bool 75 | if container, ok = dc.details[c.ID]; !ok { 76 | var err error 77 | container, err = dc.client.ContainerInspect(context.Background(), c.ID) 78 | if err != nil { 79 | return types.ContainerJSON{}, errors.Wrapf(err, "failed to inspect container %s", c.ID) 80 | } 81 | dc.details[c.ID] = container 82 | } 83 | 84 | // check labels 85 | svcNeedle := fmt.Sprintf("traefik.%s.services.%s.", svcType, svcName) 86 | routerNeedle := fmt.Sprintf("traefik.%s.routers.%s.", svcType, routerName) 87 | for k := range container.Config.Labels { 88 | if strings.HasPrefix(k, svcNeedle) || (routerName != "" && strings.HasPrefix(k, routerNeedle)) { 89 | logrus.Debugf("found container '%s' (%s) for service '%s'", container.Name, container.ID, svcName) 90 | return container, nil 91 | } 92 | } 93 | } 94 | 95 | return types.ContainerJSON{}, errors.Errorf("service label not found for %s/%s", svcType, svcName) 96 | } 97 | 98 | // Check if the port is explicitly set via label 99 | func isPortSet(container types.ContainerJSON, svcType string, svcName string) string { 100 | svcName = strings.TrimSuffix(svcName, "@docker") 101 | needle := fmt.Sprintf("traefik.%s.services.%s.loadbalancer.server.port", svcType, svcName) 102 | return container.Config.Labels[needle] 103 | } 104 | 105 | // getPortBinding checks the docker container config for a port binding for the 106 | // service. Currently this will only work if a single port is mapped/exposed. 107 | // 108 | // i.e., it looks for the following from a docker-compose service: 109 | // 110 | // ports: 111 | // - 5555:5555 112 | // 113 | // If more than one port is bound (e.g., for a service like minio), then this 114 | // detection will fail. Instead, the user should explicitly set the port in the 115 | // label. 116 | func getPortBinding(container types.ContainerJSON) (string, error) { 117 | logrus.Debugln("looking for port in host config bindings") 118 | numBindings := len(container.HostConfig.PortBindings) 119 | logrus.Debugf("found %d host-port bindings", numBindings) 120 | if numBindings > 1 { 121 | return "", errors.Errorf("found more than one host-port binding for container '%s' (%s)", container.Name, portBindingString(container.HostConfig.PortBindings)) 122 | } 123 | for _, v := range container.HostConfig.PortBindings { 124 | if len(v) > 1 { 125 | return "", errors.Errorf("found more than one host-port binding for container '%s' (%s)", container.Name, portBindingString(container.HostConfig.PortBindings)) 126 | } 127 | if v[0].HostPort != "" && v[0].HostIP == "" { 128 | logrus.Debugf("found host-port binding %s", v[0].HostPort) 129 | return v[0].HostPort, nil 130 | } 131 | } 132 | 133 | // check for a randomly set port via --publish-all 134 | logrus.Debugln("looking for port in network settings") 135 | if container.NetworkSettings != nil && len(container.NetworkSettings.Ports) == 1 { 136 | for _, v := range container.NetworkSettings.Ports { 137 | if len(v) > 0 { 138 | port := v[0].HostPort 139 | if port != "" { 140 | if len(v) > 1 { 141 | logrus.Warnf("found %d port(s); trying the first one", len(v)) 142 | } 143 | return port, nil 144 | } 145 | } 146 | } 147 | } 148 | 149 | return "", errors.Errorf("no host-port binding found for container '%s'", container.Name) 150 | } 151 | 152 | // Convert host:container port binding map to a compact printable string 153 | func portBindingString(bindings nat.PortMap) string { 154 | s := []string{} 155 | for k, v := range bindings { 156 | if len(v) > 0 { 157 | containerPort := strings.TrimSuffix(string(k), "/tcp") 158 | containerPort = strings.TrimSuffix(string(containerPort), "/udp") 159 | s = append(s, fmt.Sprintf("%s:%s", v[0].HostPort, containerPort)) 160 | } 161 | } 162 | return strings.Join(s, ", ") 163 | } 164 | -------------------------------------------------------------------------------- /docker_helpers_test.go: -------------------------------------------------------------------------------- 1 | package traefikkop 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net" 9 | "os" 10 | "path" 11 | "sync" 12 | "testing" 13 | 14 | "github.com/docker/cli/cli/compose/loader" 15 | compose "github.com/docker/cli/cli/compose/types" 16 | "github.com/docker/docker/api/types" 17 | "github.com/docker/docker/api/types/container" 18 | "github.com/docker/docker/api/types/events" 19 | "github.com/docker/docker/api/types/network" 20 | "github.com/docker/docker/api/types/swarm" 21 | "github.com/docker/go-connections/nat" 22 | "github.com/gofiber/fiber/v2" 23 | "github.com/gofiber/fiber/v2/middleware/logger" 24 | "github.com/stretchr/testify/assert" 25 | "github.com/traefik/traefik/v2/pkg/config/dynamic" 26 | "github.com/traefik/traefik/v2/pkg/provider/docker" 27 | "github.com/traefik/traefik/v2/pkg/safe" 28 | "github.com/traefik/traefik/v2/pkg/server" 29 | ) 30 | 31 | type testStore struct { 32 | kv map[string]interface{} 33 | } 34 | 35 | func (s testStore) Ping() error { 36 | return nil 37 | } 38 | 39 | // Add a method to push the last configuration if needed 40 | func (s *testStore) KeepConfAlive() error { 41 | return nil 42 | } 43 | 44 | func (s *testStore) Store(conf dynamic.Configuration) error { 45 | kv, err := ConfigToKV(conf) 46 | if err != nil { 47 | return err 48 | } 49 | s.kv = kv 50 | return nil 51 | } 52 | 53 | type DockerAPIStub struct { 54 | containers []types.Container 55 | containersJSON map[string]types.ContainerJSON 56 | } 57 | 58 | func (d DockerAPIStub) ServerVersion(ctx context.Context) (types.Version, error) { 59 | // Implement your logic here 60 | return types.Version{ 61 | Version: "1.0.0", 62 | APIVersion: "1.0.0-test", 63 | }, nil 64 | } 65 | 66 | func (d DockerAPIStub) Events(ctx context.Context, options types.EventsOptions) (<-chan events.Message, <-chan error) { 67 | // Implement your logic here 68 | fmt.Println("Events") 69 | return nil, nil 70 | } 71 | 72 | func (d DockerAPIStub) ContainerList(ctx context.Context, options types.ContainerListOptions) ([]types.Container, error) { 73 | // Implement your logic here 74 | fmt.Println("ContainerList") 75 | return d.containers, nil 76 | } 77 | 78 | func (d DockerAPIStub) ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) { 79 | // Implement your logic here 80 | fmt.Println("ContainerInspect", containerID) 81 | return d.containersJSON[containerID], nil 82 | } 83 | 84 | func (d DockerAPIStub) ServiceList(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) { 85 | // Implement your logic here 86 | fmt.Println("ServiceList") 87 | return nil, nil 88 | } 89 | 90 | func (d DockerAPIStub) NetworkList(ctx context.Context, options types.NetworkListOptions) ([]types.NetworkResource, error) { 91 | // Implement your logic here 92 | fmt.Println("NetworkList") 93 | return nil, nil 94 | } 95 | 96 | func getAvailablePort() (int, error) { 97 | addr, err := net.ResolveTCPAddr("tcp", "localhost:0") 98 | if err != nil { 99 | return 0, err 100 | } 101 | 102 | l, err := net.ListenTCP("tcp", addr) 103 | if err != nil { 104 | return 0, err 105 | } 106 | defer l.Close() 107 | return l.Addr().(*net.TCPAddr).Port, nil 108 | } 109 | 110 | func createHTTPServer() (*fiber.App, string) { 111 | app := fiber.New() 112 | app.Use(logger.New()) 113 | 114 | app.Get("/v1.24/version", func(c *fiber.Ctx) error { 115 | version, err := dockerAPI.ServerVersion(c.Context()) 116 | if err != nil { 117 | return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) 118 | } 119 | return c.JSON(version) 120 | }) 121 | 122 | app.Get("/v1.24/containers/json", func(c *fiber.Ctx) error { 123 | containers, err := dockerAPI.ContainerList(c.Context(), types.ContainerListOptions{}) 124 | if err != nil { 125 | return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) 126 | } 127 | return c.JSON(containers) 128 | }) 129 | 130 | app.Get("/v1.24/containers/:id/json", func(c *fiber.Ctx) error { 131 | container, err := dockerAPI.ContainerInspect(c.Context(), c.Params("id")) 132 | if err != nil { 133 | return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) 134 | } 135 | // fmt.Printf("returning container: %+v\n", container) 136 | // print container as json 137 | // json.NewEncoder((os.Stdout)).Encode(container) 138 | return c.JSON(container) 139 | }) 140 | 141 | port, err := getAvailablePort() 142 | if err != nil { 143 | log.Fatal(err) 144 | } 145 | // log.Println("Available port:", port) 146 | 147 | go app.Listen(fmt.Sprintf(":%d", port)) 148 | 149 | dockerEndpoint := fmt.Sprintf("http://localhost:%d", port) 150 | 151 | return app, dockerEndpoint 152 | } 153 | 154 | func buildConfigDetails(source map[string]any, env map[string]string) compose.ConfigDetails { 155 | workingDir, err := os.Getwd() 156 | if err != nil { 157 | panic(err) 158 | } 159 | 160 | return compose.ConfigDetails{ 161 | WorkingDir: workingDir, 162 | ConfigFiles: []compose.ConfigFile{ 163 | {Filename: "filename.yml", Config: source}, 164 | }, 165 | Environment: env, 166 | } 167 | } 168 | 169 | func loadYAML(yaml []byte) (*compose.Config, error) { 170 | return loadYAMLWithEnv(yaml, nil) 171 | } 172 | 173 | func loadYAMLWithEnv(yaml []byte, env map[string]string) (*compose.Config, error) { 174 | dict, err := loader.ParseYAML(yaml) 175 | if err != nil { 176 | return nil, err 177 | } 178 | 179 | return loader.Load(buildConfigDetails(dict, env)) 180 | } 181 | 182 | // convert compose services to containers 183 | func createContainers(composeConfig *compose.Config) []types.Container { 184 | containers := make([]types.Container, 0) 185 | for _, service := range composeConfig.Services { 186 | container := types.Container{ 187 | ID: service.Name, 188 | Labels: service.Labels, 189 | State: "running", 190 | Status: "running", 191 | } 192 | // convert ports 193 | ports := make([]types.Port, 0) 194 | for _, port := range service.Ports { 195 | ports = append(ports, types.Port{ 196 | IP: "172.18.0.2", 197 | PrivatePort: uint16(port.Target), 198 | PublicPort: uint16(port.Published), 199 | Type: port.Protocol, 200 | }) 201 | } 202 | container.Ports = ports 203 | containers = append(containers, container) 204 | } 205 | return containers 206 | } 207 | 208 | // convert compose services to containersJSON 209 | func createContainersJSON(composeConfig *compose.Config) map[string]types.ContainerJSON { 210 | containersJSON := make(map[string]types.ContainerJSON) 211 | for _, service := range composeConfig.Services { 212 | containerJSON := types.ContainerJSON{ 213 | ContainerJSONBase: &types.ContainerJSONBase{ 214 | ID: service.Name, 215 | Name: service.Name, 216 | State: &types.ContainerState{ 217 | Status: "running", 218 | Running: true, 219 | }, 220 | HostConfig: &container.HostConfig{ 221 | NetworkMode: "testing_default", // network name 222 | PortBindings: nat.PortMap{}, 223 | }, 224 | }, 225 | Config: &container.Config{ 226 | Labels: service.Labels, 227 | }, 228 | NetworkSettings: &types.NetworkSettings{ 229 | Networks: map[string]*network.EndpointSettings{ 230 | "testing_default": { 231 | NetworkID: "testing_default", // should normally look like a random id but we can reuse the name here 232 | IPAddress: "172.18.0.2", 233 | }, 234 | "foobar": { 235 | NetworkID: "foobar", 236 | IPAddress: "10.10.10.5", 237 | }, 238 | }, 239 | NetworkSettingsBase: types.NetworkSettingsBase{}, 240 | }, 241 | } 242 | 243 | // add port bindings 244 | for _, port := range service.Ports { 245 | portID := nat.Port(fmt.Sprintf("%d/%s", port.Published, port.Protocol)) 246 | containerJSON.HostConfig.PortBindings[portID] = []nat.PortBinding{ 247 | { 248 | HostIP: "", 249 | HostPort: fmt.Sprintf("%d", port.Published), 250 | }, 251 | } 252 | } 253 | containerJSON.NetworkSettings.Ports = containerJSON.HostConfig.PortBindings 254 | for portID, mappings := range containerJSON.NetworkSettings.Ports { 255 | for i, mapping := range mappings { 256 | if mapping.HostPort == "0" { 257 | // Emulating random port assignment for testing 258 | containerJSON.NetworkSettings.Ports[portID][i].HostPort = "12345" 259 | } 260 | } 261 | } 262 | containersJSON[service.Name] = containerJSON 263 | } 264 | return containersJSON 265 | } 266 | 267 | func doTest(t *testing.T, file string, config *Config) *testStore { 268 | p := path.Join("fixtures", file) 269 | f, err := os.Open(p) 270 | assert.NoError(t, err) 271 | if err != nil { 272 | t.FailNow() 273 | } 274 | 275 | b, err := io.ReadAll(f) 276 | assert.NoError(t, err) 277 | 278 | composeConfig, err := loadYAML(b) 279 | assert.NoError(t, err) 280 | 281 | store := &testStore{} 282 | 283 | // fmt.Printf("%+v\n", composeConfig) 284 | 285 | dockerAPI.containers = createContainers(composeConfig) 286 | dockerAPI.containersJSON = createContainersJSON(composeConfig) 287 | 288 | dp := &docker.Provider{} 289 | dp.Watch = false 290 | dp.Endpoint = dockerEndpoint 291 | 292 | if config == nil { 293 | config = &Config{ 294 | BindIP: "192.168.100.100", 295 | } 296 | } else { 297 | config.BindIP = "192.168.100.100" 298 | } 299 | 300 | handleConfigChange := createConfigHandler(*config, store, dp, dc) 301 | 302 | routinesPool := safe.NewPool(context.Background()) 303 | watcher := server.NewConfigurationWatcher( 304 | routinesPool, 305 | dp, 306 | []string{}, 307 | "docker", 308 | ) 309 | watcher.AddListener(handleConfigChange) 310 | 311 | // ensure we get exactly one change 312 | wgChanges := sync.WaitGroup{} 313 | wgChanges.Add(1) 314 | watcher.AddListener(func(c dynamic.Configuration) { 315 | wgChanges.Done() 316 | }) 317 | 318 | watcher.Start() 319 | defer watcher.Stop() 320 | 321 | wgChanges.Wait() 322 | 323 | // print the kv store 324 | for k, v := range store.kv { 325 | fmt.Printf("%s: %+v\n", k, v) 326 | } 327 | 328 | return store 329 | } 330 | 331 | func assertServiceIP(t *testing.T, store *testStore, serviceName string, ip string) { 332 | assert.Equal(t, ip, store.kv[fmt.Sprintf("traefik/http/services/%s/loadBalancer/servers/0/url", serviceName)]) 333 | } 334 | 335 | type svc struct { 336 | name string 337 | proto string 338 | ip string 339 | } 340 | 341 | func assertServiceIPs(t *testing.T, store *testStore, svcs []svc) { 342 | for _, svc := range svcs { 343 | path := "url" 344 | if svc.proto != "http" { 345 | path = "address" 346 | } 347 | key := fmt.Sprintf("traefik/%s/services/%s/loadBalancer/servers/0/%s", svc.proto, svc.name, path) 348 | assert.Equal(t, 349 | svc.ip, 350 | store.kv[key], 351 | "service has wrong IP at key: %s", 352 | key, 353 | ) 354 | } 355 | } 356 | -------------------------------------------------------------------------------- /docker_test.go: -------------------------------------------------------------------------------- 1 | package traefikkop 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "testing" 8 | 9 | "github.com/docker/docker/client" 10 | "github.com/gofiber/fiber/v2" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | var app *fiber.App 15 | var dockerEndpoint string 16 | var dc client.APIClient 17 | var dockerAPI = &DockerAPIStub{} 18 | 19 | func setup() { 20 | app, dockerEndpoint = createHTTPServer() 21 | var err error 22 | dc, err = createDockerClient(dockerEndpoint) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | } 27 | 28 | func teardown() { 29 | err := app.Shutdown() 30 | if err != nil { 31 | log.Fatal(err) 32 | } 33 | } 34 | 35 | func TestMain(m *testing.M) { 36 | setup() 37 | 38 | code := m.Run() 39 | 40 | teardown() 41 | 42 | os.Exit(code) 43 | } 44 | 45 | func Test_httpServerVersion(t *testing.T) { 46 | v, err := dc.ServerVersion(context.Background()) 47 | assert.NoError(t, err) 48 | assert.Equal(t, "1.0.0", v.Version) 49 | } 50 | 51 | func Test_helloWorld(t *testing.T) { 52 | store := doTest(t, "helloworld.yml", nil) 53 | 54 | assert.NotNil(t, store) 55 | assert.NotNil(t, store.kv) 56 | 57 | assert.Equal(t, "hello1", store.kv["traefik/http/routers/hello1/service"]) 58 | assert.Equal(t, "hello2", store.kv["traefik/http/routers/hello2/service"]) 59 | assert.NotNil(t, store.kv["traefik/http/routers/hello1/tls/certResolver"]) 60 | assert.NotNil(t, store.kv["traefik/http/routers/hello2/tls/certResolver"]) 61 | 62 | assertServiceIPs(t, store, []svc{ 63 | {"hello1", "http", "http://192.168.100.100:5555"}, 64 | {"hello2", "http", "http://192.168.100.100:5566"}, 65 | }) 66 | 67 | // assertServiceIP(t, store, "hello1", "http://192.168.100.100:5555") 68 | // assert.Equal(t, "http://192.168.100.100:5555", store.kv["traefik/http/services/hello1/loadBalancer/servers/0/url"]) 69 | // assert.Equal(t, "http://192.168.100.100:5566", store.kv["traefik/http/services/hello2/loadBalancer/servers/0/url"]) 70 | } 71 | 72 | func Test_helloDetect(t *testing.T) { 73 | // both services get mapped to the same port (error case) 74 | store := doTest(t, "hellodetect.yml", nil) 75 | assertServiceIPs(t, store, []svc{ 76 | {"hello-detect", "http", "http://192.168.100.100:5577"}, 77 | {"hello-detect2", "http", "http://192.168.100.100:5577"}, 78 | }) 79 | } 80 | 81 | func Test_helloIP(t *testing.T) { 82 | // override ip via labels 83 | store := doTest(t, "helloip.yml", nil) 84 | assertServiceIPs(t, store, []svc{ 85 | {"helloip", "http", "http://4.4.4.4:5599"}, 86 | {"helloip2", "http", "http://3.3.3.3:5599"}, 87 | }) 88 | } 89 | 90 | func Test_helloNetwork(t *testing.T) { 91 | // use ip from specific docker network 92 | store := doTest(t, "network.yml", nil) 93 | assertServiceIPs(t, store, []svc{ 94 | {"hello1", "http", "http://10.10.10.5:5555"}, 95 | }) 96 | } 97 | 98 | func Test_TCP(t *testing.T) { 99 | // tcp service 100 | store := doTest(t, "gitea.yml", nil) 101 | assertServiceIPs(t, store, []svc{ 102 | {"gitea-ssh", "tcp", "192.168.100.100:20022"}, 103 | }) 104 | } 105 | 106 | func Test_TCPMQTT(t *testing.T) { 107 | // from https://github.com/jittering/traefik-kop/issues/35 108 | store := doTest(t, "mqtt.yml", nil) 109 | assertServiceIPs(t, store, []svc{ 110 | {"mqtt", "http", "http://192.168.100.100:9001"}, 111 | {"mqtt", "tcp", "192.168.100.100:1883"}, 112 | }) 113 | } 114 | 115 | func Test_helloWorldNoCert(t *testing.T) { 116 | store := doTest(t, "hello-no-cert.yml", nil) 117 | 118 | assert.Equal(t, "hello1", store.kv["traefik/http/routers/hello1/service"]) 119 | assert.Nil(t, store.kv["traefik/http/routers/hello1/tls/certResolver"]) 120 | 121 | assertServiceIPs(t, store, []svc{ 122 | {"hello1", "http", "http://192.168.100.100:5555"}, 123 | }) 124 | } 125 | 126 | func Test_helloWorldIgnore(t *testing.T) { 127 | store := doTest(t, "hello-ignore.yml", nil) 128 | assert.Nil(t, store.kv["traefik/http/routers/hello1/service"]) 129 | 130 | store = doTest(t, "hello-ignore.yml", &Config{Namespace: "foobar"}) 131 | assert.Equal(t, "hello1", store.kv["traefik/http/routers/hello1/service"]) 132 | assertServiceIPs(t, store, []svc{ 133 | {"hello1", "http", "http://192.168.100.100:5555"}, 134 | }) 135 | } 136 | 137 | func Test_helloWorldAutoMapped(t *testing.T) { 138 | store := doTest(t, "hello-automapped.yml", nil) 139 | assert.Equal(t, "hello", store.kv["traefik/http/routers/hello/service"]) 140 | assertServiceIPs(t, store, []svc{ 141 | {"hello", "http", "http://192.168.100.100:12345"}, 142 | }) 143 | } 144 | 145 | func Test_samePrefix(t *testing.T) { 146 | store := doTest(t, "prefix.yml", nil) 147 | 148 | // Two services `hello` and `hello-test`. 149 | // The former's name is a prefix of the latter. Ensure the matching does not mix them up. 150 | assertServiceIPs(t, store, []svc{ 151 | {"hello", "http", "http://192.168.100.100:5555"}, 152 | {"hello-test", "http", "http://192.168.100.100:5566"}, 153 | }) 154 | } 155 | -------------------------------------------------------------------------------- /fixtures/gitea.yml: -------------------------------------------------------------------------------- 1 | services: 2 | gitea: 3 | image: gitea/gitea 4 | labels: 5 | traefik.enable: "true" 6 | traefik.http.routers.gitea.rule: "Host(`git.domain`)" 7 | traefik.http.routers.gitea.entrypoints: webs 8 | traefik.http.routers.gitea.service: gitea@redis 9 | traefik.http.services.gitea.loadbalancer.server.port: 20080 10 | 11 | traefik.tcp.routers.gitea-ssh.rule: "HostSNI(`*`)" 12 | traefik.tcp.routers.gitea-ssh.entrypoints: ssh 13 | traefik.tcp.routers.gitea-ssh.service: gitea-ssh@redis 14 | traefik.tcp.services.gitea-ssh.loadbalancer.server.port: 20022 15 | -------------------------------------------------------------------------------- /fixtures/hello-automapped.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | hello: 4 | image: helloworld 5 | restart: unless-stopped 6 | ports: 7 | - 5555 8 | labels: 9 | - "traefik.enable=true" 10 | - "traefik.http.routers.hello.rule=Host(`hello.local`)" 11 | - "traefik.http.routers.hello.service=hello" 12 | - "traefik.http.routers.hello.tls=true" 13 | - "traefik.http.routers.hello.tls.certresolver=default" 14 | -------------------------------------------------------------------------------- /fixtures/hello-ignore.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | helloworld: 4 | image: helloworld 5 | restart: unless-stopped 6 | ports: 7 | - 5555:5555 8 | - 5566:5566 9 | labels: 10 | - "kop.namespace=foobar" 11 | - "traefik.enable=true" 12 | - "traefik.http.routers.hello1.rule=Host(`hello1.local`)" 13 | - "traefik.http.routers.hello1.service=hello1" 14 | - "traefik.http.routers.hello1.tls=true" 15 | - "traefik.http.routers.hello1.tls.certresolver=default" 16 | - "traefik.http.services.hello1.loadbalancer.server.scheme=http" 17 | - "traefik.http.services.hello1.loadbalancer.server.port=5555" 18 | -------------------------------------------------------------------------------- /fixtures/hello-no-cert.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | helloworld: 4 | image: helloworld 5 | restart: unless-stopped 6 | ports: 7 | - 5555:5555 8 | - 5566:5566 9 | labels: 10 | - "traefik.enable=true" 11 | - "traefik.http.routers.hello1.rule=Host(`hello1.local`)" 12 | - "traefik.http.routers.hello1.service=hello1" 13 | - "traefik.http.routers.hello1.tls=true" 14 | # - "traefik.http.routers.hello1.tls.certresolver=default" 15 | - "traefik.http.services.hello1.loadbalancer.server.scheme=http" 16 | - "traefik.http.services.hello1.loadbalancer.server.port=5555" 17 | -------------------------------------------------------------------------------- /fixtures/hellodetect.yml: -------------------------------------------------------------------------------- 1 | services: 2 | # This service is the same as above except that it does not have a label 3 | # which explicitly maps the port and so it fails to correctly determine which 4 | # port to tell traefik to connect to. i.e., both services connect to 5555. 5 | # 6 | # This scenario will *not* be handled correctly by traefik-kop as we have no 7 | # fallback way to determine the port. 8 | hellodetect: 9 | build: 10 | dockerfile: ./helloworld/Dockerfile 11 | context: ./ 12 | restart: unless-stopped 13 | ports: 14 | - 5577:5555 15 | - 5588:5566 16 | labels: 17 | - "traefik.enable=true" 18 | - "traefik.http.routers.hello-detect.rule=Host(`hello-detect.local`)" 19 | - "traefik.http.routers.hello-detect.service=hello-detect" 20 | - "traefik.http.routers.hello-detect.tls=true" 21 | - "traefik.http.routers.hello-detect.tls.certresolver=default" 22 | - "traefik.http.services.hello-detect.loadbalancer.server.scheme=http" 23 | - "traefik.http.routers.hello-detect2.rule=Host(`hello-detect2.local`)" 24 | - "traefik.http.routers.hello-detect2.service=hello-detect2" 25 | - "traefik.http.routers.hello-detect2.tls=true" 26 | - "traefik.http.routers.hello-detect2.tls.certresolver=default" 27 | - "traefik.http.services.hello-detect2.loadbalancer.server.scheme=http" 28 | -------------------------------------------------------------------------------- /fixtures/helloip.yml: -------------------------------------------------------------------------------- 1 | services: 2 | helloip: 3 | build: 4 | dockerfile: ./helloworld/Dockerfile 5 | context: ./ 6 | restart: unless-stopped 7 | ports: 8 | - 5599:5555 9 | labels: 10 | # override ip for a specific service (helloip) 11 | - "kop.helloip.bind.ip=4.4.4.4" 12 | - "traefik.enable=true" 13 | - "traefik.http.routers.helloip.rule=Host(`helloip.local`)" 14 | - "traefik.http.routers.helloip.service=helloip" 15 | - "traefik.http.routers.helloip.tls=true" 16 | - "traefik.http.routers.helloip.tls.certresolver=default" 17 | - "traefik.http.services.helloip.loadbalancer.server.scheme=http" 18 | - "traefik.http.services.helloip.loadbalancer.server.port=5599" 19 | 20 | helloip2: 21 | build: 22 | dockerfile: ./helloworld/Dockerfile 23 | context: ./ 24 | restart: unless-stopped 25 | ports: 26 | - 5599:5555 27 | labels: 28 | # override without service name (assumes single service or same ip for all services) 29 | - "kop.bind.ip=3.3.3.3" 30 | - "traefik.enable=true" 31 | - "traefik.http.routers.helloip2.rule=Host(`helloip2.local`)" 32 | - "traefik.http.routers.helloip2.service=helloip2" 33 | - "traefik.http.routers.helloip2.tls=true" 34 | - "traefik.http.routers.helloip2.tls.certresolver=default" 35 | - "traefik.http.services.helloip2.loadbalancer.server.scheme=http" 36 | - "traefik.http.services.helloip2.loadbalancer.server.port=5599" 37 | -------------------------------------------------------------------------------- /fixtures/helloworld.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | helloworld: 4 | image: helloworld 5 | restart: unless-stopped 6 | ports: 7 | - 5555:5555 8 | - 5566:5566 9 | labels: 10 | - "traefik.enable=true" 11 | - "traefik.http.routers.hello1.rule=Host(`hello1.local`)" 12 | - "traefik.http.routers.hello1.service=hello1" 13 | - "traefik.http.routers.hello1.tls=true" 14 | - "traefik.http.routers.hello1.tls.certresolver=default" 15 | - "traefik.http.services.hello1.loadbalancer.server.scheme=http" 16 | - "traefik.http.services.hello1.loadbalancer.server.port=5555" 17 | - "traefik.http.routers.hello2.rule=Host(`hello2.local`)" 18 | - "traefik.http.routers.hello2.service=hello2" 19 | - "traefik.http.routers.hello2.tls=true" 20 | - "traefik.http.routers.hello2.tls.certresolver=default" 21 | - "traefik.http.services.hello2.loadbalancer.server.scheme=http" 22 | - "traefik.http.services.hello2.loadbalancer.server.port=5566" 23 | -------------------------------------------------------------------------------- /fixtures/mqtt.yml: -------------------------------------------------------------------------------- 1 | services: 2 | gitea: 3 | image: gitea/gitea 4 | labels: 5 | - "traefik.enable=true" 6 | - "traefik.http.routers.mqtt.rule=Host(`mqtt.local`)" 7 | - "traefik.http.services.mqtt.loadbalancer.server.port=9001" 8 | # MQTT routing 9 | - "traefik.tcp.routers.mqtt.rule=HostSNI(`*`)" 10 | - "traefik.tcp.routers.mqtt.entrypoints=mqtt" 11 | - "traefik.tcp.routers.mqtt.service=service-broker-mqtt" 12 | - "traefik.tcp.services.mqtt.loadbalancer.server.port=1883" 13 | -------------------------------------------------------------------------------- /fixtures/network.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | helloworld: 4 | image: helloworld 5 | restart: unless-stopped 6 | ports: 7 | - 5555:5555 8 | - 5566:5566 9 | labels: 10 | - "traefik.enable=true" 11 | - "traefik.docker.network=foobar" 12 | - "traefik.http.routers.hello1.rule=Host(`hello1.local`)" 13 | - "traefik.http.routers.hello1.service=hello1" 14 | - "traefik.http.routers.hello1.tls=true" 15 | - "traefik.http.routers.hello1.tls.certresolver=default" 16 | - "traefik.http.services.hello1.loadbalancer.server.scheme=http" 17 | - "traefik.http.services.hello1.loadbalancer.server.port=5555" 18 | -------------------------------------------------------------------------------- /fixtures/prefix.yml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | hello: 4 | image: helloworld 5 | restart: unless-stopped 6 | ports: 7 | - 5555:5555 8 | labels: 9 | - "traefik.enable=true" 10 | - "traefik.http.routers.hello.rule=Host(`hello.local`)" 11 | - "traefik.http.routers.hello.service=hello" 12 | - "traefik.http.routers.hello.tls=true" 13 | - "traefik.http.routers.hello.tls.certresolver=default" 14 | - "traefik.http.services.hello.loadbalancer.server.scheme=http" 15 | - "traefik.http.services.hello.loadbalancer.server.port=5555" 16 | 17 | hello-test: 18 | image: helloworld 19 | restart: unless-stopped 20 | ports: 21 | - 5566:5566 22 | labels: 23 | - "traefik.enable=true" 24 | - "traefik.http.routers.hello-test.rule=Host(`hello-test.local`)" 25 | - "traefik.http.routers.hello-test.service=hello-test" 26 | - "traefik.http.routers.hello-test.tls=true" 27 | - "traefik.http.routers.hello-test.tls.certresolver=default" 28 | - "traefik.http.services.hello-test.loadbalancer.server.scheme=http" 29 | - "traefik.http.services.hello-test.loadbalancer.server.port=5566" 30 | -------------------------------------------------------------------------------- /fixtures/sample.toml: -------------------------------------------------------------------------------- 1 | [global] 2 | checkNewVersion = true 3 | sendAnonymousUsage = true 4 | 5 | [serversTransport] 6 | insecureSkipVerify = true 7 | rootCAs = ["foobar", "foobar"] 8 | maxIdleConnsPerHost = 42 9 | [serversTransport.forwardingTimeouts] 10 | dialTimeout = 42 11 | responseHeaderTimeout = 42 12 | idleConnTimeout = 42 13 | 14 | [entryPoints] 15 | [entryPoints.EntryPoint0] 16 | address = "foobar" 17 | [entryPoints.EntryPoint0.transport] 18 | [entryPoints.EntryPoint0.transport.lifeCycle] 19 | requestAcceptGraceTimeout = 42 20 | graceTimeOut = 42 21 | [entryPoints.EntryPoint0.transport.respondingTimeouts] 22 | readTimeout = 42 23 | writeTimeout = 42 24 | idleTimeout = 42 25 | [entryPoints.EntryPoint0.proxyProtocol] 26 | insecure = true 27 | trustedIPs = ["foobar", "foobar"] 28 | [entryPoints.EntryPoint0.forwardedHeaders] 29 | insecure = true 30 | trustedIPs = ["foobar", "foobar"] 31 | 32 | [providers] 33 | providersThrottleDuration = 42 34 | [providers.docker] 35 | constraints = "foobar" 36 | watch = true 37 | endpoint = "foobar" 38 | defaultRule = "foobar" 39 | exposedByDefault = true 40 | useBindPortIP = true 41 | swarmMode = true 42 | network = "foobar" 43 | swarmModeRefreshSeconds = 42 44 | httpClientTimeout = 42 45 | [providers.docker.tls] 46 | ca = "foobar" 47 | caOptional = true 48 | cert = "foobar" 49 | key = "foobar" 50 | insecureSkipVerify = true 51 | [providers.file] 52 | directory = "foobar" 53 | watch = true 54 | filename = "foobar" 55 | debugLogGeneratedTemplate = true 56 | [providers.marathon] 57 | constraints = "foobar" 58 | trace = true 59 | watch = true 60 | endpoint = "foobar" 61 | defaultRule = "foobar" 62 | exposedByDefault = true 63 | dcosToken = "foobar" 64 | dialerTimeout = 42 65 | responseHeaderTimeout = 42 66 | tlsHandshakeTimeout = 42 67 | keepAlive = 42 68 | forceTaskHostname = true 69 | respectReadinessChecks = true 70 | [providers.marathon.tls] 71 | ca = "foobar" 72 | caOptional = true 73 | cert = "foobar" 74 | key = "foobar" 75 | insecureSkipVerify = true 76 | [providers.marathon.basic] 77 | httpBasicAuthUser = "foobar" 78 | httpBasicPassword = "foobar" 79 | [providers.kubernetesIngress] 80 | endpoint = "foobar" 81 | token = "foobar" 82 | certAuthFilePath = "foobar" 83 | namespaces = ["foobar", "foobar"] 84 | labelSelector = "foobar" 85 | ingressClass = "foobar" 86 | [providers.kubernetesIngress.ingressEndpoint] 87 | ip = "foobar" 88 | hostname = "foobar" 89 | publishedService = "foobar" 90 | [providers.kubernetesCRD] 91 | endpoint = "foobar" 92 | token = "foobar" 93 | certAuthFilePath = "foobar" 94 | namespaces = ["foobar", "foobar"] 95 | labelSelector = "foobar" 96 | ingressClass = "foobar" 97 | [providers.rest] 98 | entryPoint = "foobar" 99 | [providers.rancher] 100 | constraints = "foobar" 101 | watch = true 102 | defaultRule = "foobar" 103 | exposedByDefault = true 104 | enableServiceHealthFilter = true 105 | refreshSeconds = 42 106 | intervalPoll = true 107 | prefix = "foobar" 108 | 109 | [api] 110 | entryPoint = "foobar" 111 | dashboard = true 112 | middlewares = ["foobar", "foobar"] 113 | [api.statistics] 114 | recentErrors = 42 115 | 116 | [metrics] 117 | [metrics.prometheus] 118 | buckets = [42.0, 42.0] 119 | entryPoint = "foobar" 120 | middlewares = ["foobar", "foobar"] 121 | [metrics.datadog] 122 | address = "foobar" 123 | pushInterval = "10s" 124 | [metrics.statsD] 125 | address = "foobar" 126 | pushInterval = "10s" 127 | [metrics.influxDB] 128 | address = "foobar" 129 | protocol = "foobar" 130 | pushInterval = "10s" 131 | database = "foobar" 132 | retentionPolicy = "foobar" 133 | username = "foobar" 134 | password = "foobar" 135 | 136 | [ping] 137 | entryPoint = "foobar" 138 | middlewares = ["foobar", "foobar"] 139 | 140 | [log] 141 | level = "foobar" 142 | filePath = "foobar" 143 | format = "foobar" 144 | 145 | [accessLog] 146 | filePath = "foobar" 147 | format = "foobar" 148 | bufferingSize = 42 149 | [accessLog.filters] 150 | statusCodes = ["foobar", "foobar"] 151 | retryAttempts = true 152 | minDuration = 42 153 | [accessLog.fields] 154 | defaultMode = "foobar" 155 | [accessLog.fields.names] 156 | name0 = "foobar" 157 | name1 = "foobar" 158 | [accessLog.fields.headers] 159 | defaultMode = "foobar" 160 | [accessLog.fields.headers.names] 161 | name0 = "foobar" 162 | name1 = "foobar" 163 | 164 | [tracing] 165 | serviceName = "foobar" 166 | spanNameLimit = 42 167 | [tracing.jaeger] 168 | samplingServerURL = "foobar" 169 | samplingType = "foobar" 170 | samplingParam = 42.0 171 | localAgentHostPort = "foobar" 172 | gen128Bit = true 173 | propagation = "foobar" 174 | traceContextHeaderName = "foobar" 175 | [tracing.zipkin] 176 | httpEndpoint = "foobar" 177 | sameSpan = true 178 | id128Bit = true 179 | debug = true 180 | sampleRate = 42.0 181 | [tracing.datadog] 182 | localAgentHostPort = "foobar" 183 | globalTag = "foobar" 184 | debug = true 185 | prioritySampling = true 186 | traceIDHeaderName = "foobar" 187 | parentIDHeaderName = "foobar" 188 | samplingPriorityHeaderName = "foobar" 189 | bagagePrefixHeaderName = "foobar" 190 | [tracing.instana] 191 | localAgentHost = "foobar" 192 | localAgentPort = 42 193 | logLevel = "foobar" 194 | [tracing.haystack] 195 | localAgentHost = "foobar" 196 | localAgentPort = 42 197 | globalTag = "foobar" 198 | traceIDHeaderName = "foobar" 199 | parentIDHeaderName = "foobar" 200 | spanIDHeaderName = "foobar" 201 | 202 | [hostResolver] 203 | cnameFlattening = true 204 | resolvConfig = "foobar" 205 | resolvDepth = 42 206 | 207 | [acme] 208 | email = "foobar" 209 | acmeLogging = true 210 | caServer = "foobar" 211 | storage = "foobar" 212 | entryPoint = "foobar" 213 | keyType = "foobar" 214 | [acme.dnsChallenge] 215 | provider = "foobar" 216 | delayBeforeCheck = 42 217 | resolvers = ["foobar", "foobar"] 218 | disablePropagationCheck = true 219 | [acme.httpChallenge] 220 | entryPoint = "foobar" 221 | [acme.tlsChallenge] 222 | 223 | [[acme.domains]] 224 | main = "foobar" 225 | sans = ["foobar", "foobar"] 226 | 227 | [[acme.domains]] 228 | main = "foobar" 229 | sans = ["foobar", "foobar"] 230 | 231 | ## Dynamic configuration 232 | 233 | [http] 234 | [http.routers] 235 | [http.routers.Router0] 236 | entryPoints = ["foobar", "foobar"] 237 | middlewares = ["foobar", "foobar"] 238 | service = "foobar" 239 | rule = "foobar" 240 | priority = 42 241 | [http.routers.Router0.tls] 242 | [http.middlewares] 243 | [http.middlewares.Middleware0] 244 | [http.middlewares.Middleware0.addPrefix] 245 | prefix = "foobar" 246 | [http.middlewares.Middleware1] 247 | [http.middlewares.Middleware1.stripPrefix] 248 | prefixes = ["foobar", "foobar"] 249 | [http.middlewares.Middleware10] 250 | [http.middlewares.Middleware10.rateLimit] 251 | average = 42 252 | period = "1s" 253 | burst = 42 254 | [http.middlewares.Middleware10.rateLimit.sourceCriterion] 255 | requestHeaderName = "foobar" 256 | requestHost = true 257 | [http.middlewares.Middleware10.rateLimit.sourceCriterion.ipStrategy] 258 | depth = 42 259 | excludedIPs = ["foobar", "foobar"] 260 | [http.middlewares.Middleware11] 261 | [http.middlewares.Middleware11.redirectRegex] 262 | regex = "foobar" 263 | replacement = "foobar" 264 | permanent = true 265 | [http.middlewares.Middleware12] 266 | [http.middlewares.Middleware12.redirectScheme] 267 | scheme = "foobar" 268 | port = "foobar" 269 | permanent = true 270 | [http.middlewares.Middleware13] 271 | [http.middlewares.Middleware13.basicAuth] 272 | users = ["foobar", "foobar"] 273 | usersFile = "foobar" 274 | realm = "foobar" 275 | removeHeader = true 276 | headerField = "foobar" 277 | [http.middlewares.Middleware14] 278 | [http.middlewares.Middleware14.digestAuth] 279 | users = ["foobar", "foobar"] 280 | usersFile = "foobar" 281 | removeHeader = true 282 | realm = "foobar" 283 | headerField = "foobar" 284 | [http.middlewares.Middleware15] 285 | [http.middlewares.Middleware15.forwardAuth] 286 | address = "foobar" 287 | trustForwardHeader = true 288 | authResponseHeaders = ["foobar", "foobar"] 289 | authResponseHeadersRegex = "foobar" 290 | authRequestHeaders = ["foobar", "foobar"] 291 | [http.middlewares.Middleware15.forwardAuth.tls] 292 | ca = "foobar" 293 | caOptional = true 294 | cert = "foobar" 295 | key = "foobar" 296 | insecureSkipVerify = true 297 | [http.middlewares.Middleware16] 298 | [http.middlewares.Middleware16.inFlightReq] 299 | amount = 42 300 | [http.middlewares.Middleware16.inFlightReq.sourceCriterion] 301 | requestHeaderName = "foobar" 302 | requestHost = true 303 | [http.middlewares.Middleware16.inFlightReq.sourceCriterion.ipStrategy] 304 | depth = 42 305 | excludedIPs = ["foobar", "foobar"] 306 | [http.middlewares.Middleware17] 307 | [http.middlewares.Middleware17.buffering] 308 | maxRequestBodyBytes = 42 309 | memRequestBodyBytes = 42 310 | maxResponseBodyBytes = 42 311 | memResponseBodyBytes = 42 312 | retryExpression = "foobar" 313 | [http.middlewares.Middleware18] 314 | [http.middlewares.Middleware18.circuitBreaker] 315 | expression = "foobar" 316 | [http.middlewares.Middleware19] 317 | [http.middlewares.Middleware19.compress] 318 | [http.middlewares.Middleware2] 319 | [http.middlewares.Middleware2.stripPrefixRegex] 320 | regex = ["foobar", "foobar"] 321 | [http.middlewares.Middleware20] 322 | [http.middlewares.Middleware20.passTLSClientCert] 323 | pem = true 324 | [http.middlewares.Middleware20.passTLSClientCert.info] 325 | notAfter = true 326 | notBefore = true 327 | sans = true 328 | [http.middlewares.Middleware20.passTLSClientCert.info.subject] 329 | country = true 330 | province = true 331 | locality = true 332 | organization = true 333 | organizationalUnit = true 334 | commonName = true 335 | serialNumber = true 336 | domainComponent = true 337 | [http.middlewares.Middleware20.passTLSClientCert.info.issuer] 338 | country = true 339 | province = true 340 | locality = true 341 | organization = true 342 | commonName = true 343 | serialNumber = true 344 | domainComponent = true 345 | [http.middlewares.Middleware21] 346 | [http.middlewares.Middleware21.retry] 347 | regex = 0 348 | [http.middlewares.Middleware3] 349 | [http.middlewares.Middleware3.replacePath] 350 | path = "foobar" 351 | [http.middlewares.Middleware4] 352 | [http.middlewares.Middleware4.replacePathRegex] 353 | regex = "foobar" 354 | replacement = "foobar" 355 | [http.middlewares.Middleware5] 356 | [http.middlewares.Middleware5.chain] 357 | middlewares = ["foobar", "foobar"] 358 | [http.middlewares.Middleware6] 359 | [http.middlewares.Middleware6.ipWhiteList] 360 | sourceRange = ["foobar", "foobar"] 361 | [http.middlewares.Middleware7] 362 | [http.middlewares.Middleware7.ipWhiteList] 363 | [http.middlewares.Middleware7.ipWhiteList.ipStrategy] 364 | depth = 42 365 | excludedIPs = ["foobar", "foobar"] 366 | [http.middlewares.Middleware8] 367 | [http.middlewares.Middleware8.headers] 368 | accessControlAllowCredentials = true 369 | accessControlAllowHeaders = ["foobar", "foobar"] 370 | accessControlAllowMethods = ["foobar", "foobar"] 371 | accessControlAllowOriginList = ["foobar", "foobar"] 372 | accessControlExposeHeaders = ["foobar", "foobar"] 373 | accessControlMaxAge = 42 374 | addVaryHeader = true 375 | allowedHosts = ["foobar", "foobar"] 376 | hostsProxyHeaders = ["foobar", "foobar"] 377 | sslRedirect = true 378 | sslTemporaryRedirect = true 379 | sslHost = "foobar" 380 | sslForceHost = true 381 | stsSeconds = 42 382 | stsIncludeSubdomains = true 383 | stsPreload = true 384 | forceSTSHeader = true 385 | frameDeny = true 386 | customFrameOptionsValue = "foobar" 387 | contentTypeNosniff = true 388 | browserXssFilter = true 389 | customBrowserXSSValue = "foobar" 390 | contentSecurityPolicy = "foobar" 391 | publicKey = "foobar" 392 | referrerPolicy = "foobar" 393 | featurePolicy = "foobar" 394 | isDevelopment = true 395 | [http.middlewares.Middleware8.headers.customRequestHeaders] 396 | name0 = "foobar" 397 | name1 = "foobar" 398 | [http.middlewares.Middleware8.headers.customResponseHeaders] 399 | name0 = "foobar" 400 | name1 = "foobar" 401 | [http.middlewares.Middleware8.headers.sslProxyHeaders] 402 | name0 = "foobar" 403 | name1 = "foobar" 404 | [http.middlewares.Middleware9] 405 | [http.middlewares.Middleware9.errors] 406 | status = ["foobar", "foobar"] 407 | service = "foobar" 408 | query = "foobar" 409 | [http.services] 410 | [http.services.Service0] 411 | [http.services.Service0.loadBalancer] 412 | passHostHeader = true 413 | [http.services.Service0.loadBalancer.sticky.cookie] 414 | name = "foobar" 415 | 416 | [[http.services.Service0.loadBalancer.servers]] 417 | url = "foobar" 418 | 419 | [[http.services.Service0.loadBalancer.servers]] 420 | url = "foobar" 421 | [http.services.Service0.loadBalancer.healthCheck] 422 | scheme = "foobar" 423 | path = "foobar" 424 | port = 42 425 | interval = "foobar" 426 | timeout = "foobar" 427 | hostname = "foobar" 428 | [http.services.Service0.loadBalancer.healthCheck.headers] 429 | name0 = "foobar" 430 | name1 = "foobar" 431 | [http.services.Service0.loadBalancer.responseForwarding] 432 | flushInterval = "foobar" 433 | 434 | [tcp] 435 | [tcp.routers] 436 | [tcp.routers.TCPRouter0] 437 | entryPoints = ["foobar", "foobar"] 438 | service = "foobar" 439 | rule = "foobar" 440 | [tcp.routers.TCPRouter0.tls] 441 | passthrough = true 442 | [tcp.services] 443 | [tcp.services.TCPService0] 444 | [tcp.services.TCPService0.loadBalancer] 445 | 446 | [[tcp.services.TCPService0.loadBalancer.servers]] 447 | address = "foobar" 448 | 449 | [[tcp.services.TCPService0.loadBalancer.servers]] 450 | address = "foobar" 451 | 452 | [tls] 453 | 454 | [[tls.Certificates]] 455 | certFile = "foobar" 456 | keyFile = "foobar" 457 | stores = ["foobar", "foobar"] 458 | 459 | [[tls.Certificates]] 460 | certFile = "foobar" 461 | keyFile = "foobar" 462 | stores = ["foobar", "foobar"] 463 | [tls.options] 464 | [tls.options.TLS0] 465 | minVersion = "foobar" 466 | cipherSuites = ["foobar", "foobar"] 467 | sniStrict = true 468 | [tls.options.TLS0.clientCA] 469 | files = ["foobar", "foobar"] 470 | optional = true 471 | [tls.options.TLS1] 472 | minVersion = "foobar" 473 | cipherSuites = ["foobar", "foobar"] 474 | sniStrict = true 475 | [tls.options.TLS1.clientCA] 476 | files = ["foobar", "foobar"] 477 | optional = true 478 | [tls.stores] 479 | [tls.stores.Store0] 480 | [tls.stores.Store0.defaultCertificate] 481 | certFile = "foobar" 482 | keyFile = "foobar" 483 | [tls.stores.Store1] 484 | [tls.stores.Store1.defaultCertificate] 485 | certFile = "foobar" 486 | keyFile = "foobar" 487 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jittering/traefik-kop 2 | 3 | go 1.22 4 | 5 | toolchain go1.22.3 6 | 7 | replace ( 8 | github.com/abbot/go-http-auth => github.com/containous/go-http-auth v0.4.1-0.20200324110947-a37a7636d23e 9 | github.com/go-check/check => github.com/containous/check v0.0.0-20170915194414-ca0bf163426a 10 | github.com/gorilla/mux => github.com/containous/mux v0.0.0-20181024131434-c33f32e26898 11 | github.com/mailgun/minheap => github.com/containous/minheap v0.0.0-20190809180810-6e71eb837595 12 | github.com/mailgun/multibuf => github.com/containous/multibuf v0.0.0-20190809014333-8b6c9a7e6bba 13 | ) 14 | 15 | require ( 16 | github.com/BurntSushi/toml v1.3.2 17 | github.com/docker/cli v24.0.9+incompatible 18 | github.com/docker/docker v24.0.9+incompatible 19 | github.com/docker/go-connections v0.4.0 20 | github.com/gofiber/fiber/v2 v2.52.4 21 | github.com/pkg/errors v0.9.1 22 | github.com/sirupsen/logrus v1.9.3 23 | github.com/stretchr/testify v1.8.4 24 | github.com/traefik/paerser v0.2.0 25 | github.com/traefik/traefik/v2 v2.11.3 26 | github.com/urfave/cli/v2 v2.27.1 27 | gopkg.in/redis.v5 v5.2.9 28 | gopkg.in/yaml.v3 v3.0.1 29 | ) 30 | 31 | require ( 32 | cloud.google.com/go/compute v1.23.0 // indirect 33 | cloud.google.com/go/compute/metadata v0.2.3 // indirect 34 | github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect 35 | github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect 36 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0 // indirect 37 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0 // indirect 38 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect 39 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.1.0 // indirect 40 | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.1.0 // indirect 41 | github.com/Azure/go-autorest v14.2.0+incompatible // indirect 42 | github.com/Azure/go-autorest/autorest v0.11.29 // indirect 43 | github.com/Azure/go-autorest/autorest/adal v0.9.23 // indirect 44 | github.com/Azure/go-autorest/autorest/azure/auth v0.5.12 // indirect 45 | github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 // indirect 46 | github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect 47 | github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect 48 | github.com/Azure/go-autorest/logger v0.2.1 // indirect 49 | github.com/Azure/go-autorest/tracing v0.6.0 // indirect 50 | github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0 // indirect 51 | github.com/DataDog/appsec-internal-go v1.0.0 // indirect 52 | github.com/DataDog/datadog-agent/pkg/obfuscate v0.48.0 // indirect 53 | github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.48.0-devel.0.20230725154044-2549ba9058df // indirect 54 | github.com/DataDog/datadog-go/v5 v5.3.0 // indirect 55 | github.com/DataDog/go-libddwaf v1.5.0 // indirect 56 | github.com/DataDog/go-tuf v1.0.2-0.5.2 // indirect 57 | github.com/DataDog/sketches-go v1.4.2 // indirect 58 | github.com/ExpediaDotCom/haystack-client-go v0.0.0-20190315171017-e7edbdf53a61 // indirect 59 | github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect 60 | github.com/Masterminds/goutils v1.1.1 // indirect 61 | github.com/Masterminds/semver/v3 v3.2.1 // indirect 62 | github.com/Masterminds/sprig/v3 v3.2.3 // indirect 63 | github.com/Microsoft/go-winio v0.6.1 // indirect 64 | github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect 65 | github.com/VividCortex/gohistogram v1.0.0 // indirect 66 | github.com/abbot/go-http-auth v0.0.0-00010101000000-000000000000 // indirect 67 | github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect 68 | github.com/aliyun/alibaba-cloud-sdk-go v1.61.1755 // indirect 69 | github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect 70 | github.com/andybalholm/brotli v1.1.0 // indirect 71 | github.com/armon/go-metrics v0.4.1 // indirect 72 | github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c // indirect 73 | github.com/aws/aws-sdk-go v1.44.327 // indirect 74 | github.com/aws/aws-sdk-go-v2 v1.24.1 // indirect 75 | github.com/aws/aws-sdk-go-v2/config v1.26.6 // indirect 76 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16 // indirect 77 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect 78 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect 79 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect 80 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 // indirect 81 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect 82 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect 83 | github.com/aws/aws-sdk-go-v2/service/lightsail v1.34.0 // indirect 84 | github.com/aws/aws-sdk-go-v2/service/route53 v1.37.0 // indirect 85 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect 86 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect 87 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect 88 | github.com/aws/smithy-go v1.19.0 // indirect 89 | github.com/beorn7/perks v1.0.1 // indirect 90 | github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect 91 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 92 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 93 | github.com/civo/civogo v0.3.11 // indirect 94 | github.com/cloudflare/cloudflare-go v0.86.0 // indirect 95 | github.com/containous/alice v0.0.0-20181107144136-d83ebdd94cbd // indirect 96 | github.com/coreos/go-semver v0.3.0 // indirect 97 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 98 | github.com/cpu/goacmedns v0.1.1 // indirect 99 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 100 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 101 | github.com/deepmap/oapi-codegen v1.9.1 // indirect 102 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 103 | github.com/dimchansky/utfbom v1.1.1 // indirect 104 | github.com/dnsimple/dnsimple-go v1.2.0 // indirect 105 | github.com/docker/distribution v2.8.2+incompatible // indirect 106 | github.com/docker/go-units v0.5.0 // indirect 107 | github.com/donovanhide/eventsource v0.0.0-20170630084216-b8f31a59085e // indirect 108 | github.com/dustin/go-humanize v1.0.1 // indirect 109 | github.com/ebitengine/purego v0.5.0-alpha.1 // indirect 110 | github.com/elastic/go-sysinfo v1.7.1 // indirect 111 | github.com/elastic/go-windows v1.0.0 // indirect 112 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 113 | github.com/exoscale/egoscale v0.102.3 // indirect 114 | github.com/fatih/color v1.15.0 // indirect 115 | github.com/fatih/structs v1.1.0 // indirect 116 | github.com/fsnotify/fsnotify v1.7.0 // indirect 117 | github.com/gambol99/go-marathon v0.0.0-20180614232016-99a156b96fb2 // indirect 118 | github.com/ghodss/yaml v1.0.0 // indirect 119 | github.com/go-acme/lego/v4 v4.16.1 // indirect 120 | github.com/go-errors/errors v1.0.1 // indirect 121 | github.com/go-jose/go-jose/v4 v4.0.1 // indirect 122 | github.com/go-kit/kit v0.10.1-0.20200915143503-439c4d2ed3ea // indirect 123 | github.com/go-logfmt/logfmt v0.5.1 // indirect 124 | github.com/go-logr/logr v1.4.1 // indirect 125 | github.com/go-openapi/jsonpointer v0.19.5 // indirect 126 | github.com/go-openapi/jsonreference v0.20.0 // indirect 127 | github.com/go-openapi/swag v0.19.14 // indirect 128 | github.com/go-resty/resty/v2 v2.11.0 // indirect 129 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 130 | github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect 131 | github.com/go-zookeeper/zk v1.0.3 // indirect 132 | github.com/goccy/go-json v0.10.2 // indirect 133 | github.com/gofrs/uuid v4.4.0+incompatible // indirect 134 | github.com/gogo/protobuf v1.3.2 // indirect 135 | github.com/golang-jwt/jwt/v4 v4.5.0 // indirect 136 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 137 | github.com/golang/protobuf v1.5.3 // indirect 138 | github.com/google/gnostic v0.5.7-v3refs // indirect 139 | github.com/google/go-cmp v0.6.0 // indirect 140 | github.com/google/go-github/v28 v28.1.1 // indirect 141 | github.com/google/go-querystring v1.1.0 // indirect 142 | github.com/google/gofuzz v1.2.0 // indirect 143 | github.com/google/pprof v0.0.0-20240402174815-29b9bb013b0f // indirect 144 | github.com/google/s2a-go v0.1.5 // indirect 145 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 146 | github.com/google/uuid v1.6.0 // indirect 147 | github.com/googleapis/enterprise-certificate-proxy v0.2.5 // indirect 148 | github.com/googleapis/gax-go/v2 v2.11.0 // indirect 149 | github.com/gophercloud/gophercloud v1.0.0 // indirect 150 | github.com/gophercloud/utils v0.0.0-20210216074907-f6de111f2eae // indirect 151 | github.com/gorilla/context v1.1.1 // indirect 152 | github.com/gorilla/mux v1.8.0 // indirect 153 | github.com/gorilla/websocket v1.5.0 // indirect 154 | github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf // indirect 155 | github.com/hashicorp/consul/api v1.26.1 // indirect 156 | github.com/hashicorp/cronexpr v1.1.2 // indirect 157 | github.com/hashicorp/errwrap v1.1.0 // indirect 158 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 159 | github.com/hashicorp/go-hclog v1.5.0 // indirect 160 | github.com/hashicorp/go-immutable-radix v1.3.1 // indirect 161 | github.com/hashicorp/go-multierror v1.1.1 // indirect 162 | github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 163 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 164 | github.com/hashicorp/go-version v1.6.0 // indirect 165 | github.com/hashicorp/golang-lru v1.0.2 // indirect 166 | github.com/hashicorp/nomad/api v0.0.0-20231213195942-64e3dca9274b // indirect 167 | github.com/hashicorp/serf v0.10.1 // indirect 168 | github.com/huandu/xstrings v1.4.0 // indirect 169 | github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect 170 | github.com/imdario/mergo v0.3.16 // indirect 171 | github.com/influxdata/influxdb-client-go/v2 v2.7.0 // indirect 172 | github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d // indirect 173 | github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect 174 | github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect 175 | github.com/instana/go-sensor v1.38.3 // indirect 176 | github.com/jmespath/go-jmespath v0.4.0 // indirect 177 | github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 // indirect 178 | github.com/jonboulle/clockwork v0.4.0 // indirect 179 | github.com/josharian/intern v1.0.0 // indirect 180 | github.com/json-iterator/go v1.1.12 // indirect 181 | github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect 182 | github.com/klauspost/compress v1.17.8 // indirect 183 | github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect 184 | github.com/kvtools/consul v1.0.2 // indirect 185 | github.com/kvtools/etcdv3 v1.0.2 // indirect 186 | github.com/kvtools/redis v1.1.0 // indirect 187 | github.com/kvtools/valkeyrie v1.0.0 // indirect 188 | github.com/kvtools/zookeeper v1.0.2 // indirect 189 | github.com/kylelemons/godebug v1.1.0 // indirect 190 | github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect 191 | github.com/labbsr0x/goh v1.0.1 // indirect 192 | github.com/linode/linodego v1.28.0 // indirect 193 | github.com/liquidweb/liquidweb-cli v0.6.9 // indirect 194 | github.com/liquidweb/liquidweb-go v1.6.4 // indirect 195 | github.com/looplab/fsm v0.1.0 // indirect 196 | github.com/mailgun/minheap v0.0.0-20170619185613-3dbe6c6bf55f // indirect 197 | github.com/mailgun/multibuf v0.1.2 // indirect 198 | github.com/mailgun/timetools v0.0.0-20141028012446-7e6055773c51 // indirect 199 | github.com/mailgun/ttlmap v0.0.0-20170619185759-c1c17f74874f // indirect 200 | github.com/mailru/easyjson v0.7.7 // indirect 201 | github.com/mattn/go-colorable v0.1.13 // indirect 202 | github.com/mattn/go-isatty v0.0.20 // indirect 203 | github.com/mattn/go-runewidth v0.0.15 // indirect 204 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 205 | github.com/miekg/dns v1.1.58 // indirect 206 | github.com/mimuret/golang-iij-dpf v0.9.1 // indirect 207 | github.com/mitchellh/copystructure v1.0.0 // indirect 208 | github.com/mitchellh/go-homedir v1.1.0 // indirect 209 | github.com/mitchellh/hashstructure v1.0.0 // indirect 210 | github.com/mitchellh/mapstructure v1.5.0 // indirect 211 | github.com/mitchellh/reflectwalk v1.0.1 // indirect 212 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 213 | github.com/modern-go/reflect2 v1.0.2 // indirect 214 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 215 | github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect 216 | github.com/nrdcg/auroradns v1.1.0 // indirect 217 | github.com/nrdcg/bunny-go v0.0.0-20230728143221-c9dda82568d9 // indirect 218 | github.com/nrdcg/desec v0.7.0 // indirect 219 | github.com/nrdcg/dnspod-go v0.4.0 // indirect 220 | github.com/nrdcg/freemyip v0.2.0 // indirect 221 | github.com/nrdcg/goinwx v0.10.0 // indirect 222 | github.com/nrdcg/mailinabox v0.2.0 // indirect 223 | github.com/nrdcg/namesilo v0.2.1 // indirect 224 | github.com/nrdcg/nodion v0.1.0 // indirect 225 | github.com/nrdcg/porkbun v0.3.0 // indirect 226 | github.com/nzdjb/go-metaname v1.0.0 // indirect 227 | github.com/onsi/ginkgo/v2 v2.17.1 // indirect 228 | github.com/opencontainers/go-digest v1.0.0 // indirect 229 | github.com/opencontainers/image-spec v1.1.0-rc5 // indirect 230 | github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492 // indirect 231 | github.com/opentracing/opentracing-go v1.2.0 // indirect 232 | github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5 // indirect 233 | github.com/openzipkin/zipkin-go v0.2.2 // indirect 234 | github.com/oracle/oci-go-sdk v24.3.0+incompatible // indirect 235 | github.com/outcaste-io/ristretto v0.2.3 // indirect 236 | github.com/ovh/go-ovh v1.4.3 // indirect 237 | github.com/patrickmn/go-cache v2.1.0+incompatible // indirect 238 | github.com/philhofer/fwd v1.1.2 // indirect 239 | github.com/pires/go-proxyproto v0.6.1 // indirect 240 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect 241 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 242 | github.com/pquerna/otp v1.4.0 // indirect 243 | github.com/prometheus/client_golang v1.14.0 // indirect 244 | github.com/prometheus/client_model v0.3.0 // indirect 245 | github.com/prometheus/common v0.42.0 // indirect 246 | github.com/prometheus/procfs v0.9.0 // indirect 247 | github.com/quic-go/qpack v0.4.0 // indirect 248 | github.com/quic-go/quic-go v0.42.0 // indirect 249 | github.com/rancher/go-rancher-metadata v0.0.0-20200311180630-7f4c936a06ac // indirect 250 | github.com/redis/go-redis/v9 v9.2.1 // indirect 251 | github.com/rivo/uniseg v0.4.7 // indirect 252 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 253 | github.com/sacloud/api-client-go v0.2.8 // indirect 254 | github.com/sacloud/go-http v0.1.6 // indirect 255 | github.com/sacloud/iaas-api-go v1.11.1 // indirect 256 | github.com/sacloud/packages-go v0.0.9 // indirect 257 | github.com/scaleway/scaleway-sdk-go v1.0.0-beta.22 // indirect 258 | github.com/secure-systems-lab/go-securesystemslib v0.7.0 // indirect 259 | github.com/segmentio/fasthash v1.0.3 // indirect 260 | github.com/shopspring/decimal v1.2.0 // indirect 261 | github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect 262 | github.com/softlayer/softlayer-go v1.1.3 // indirect 263 | github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect 264 | github.com/spf13/cast v1.5.0 // indirect 265 | github.com/spf13/pflag v1.0.5 // indirect 266 | github.com/stretchr/objx v0.5.1 // indirect 267 | github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.490 // indirect 268 | github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.490 // indirect 269 | github.com/tinylib/msgp v1.1.8 // indirect 270 | github.com/traefik/yaegi v0.16.1 // indirect 271 | github.com/transip/gotransip/v6 v6.23.0 // indirect 272 | github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect 273 | github.com/uber/jaeger-lib v2.2.0+incompatible // indirect 274 | github.com/ultradns/ultradns-go-sdk v1.6.1-20231103022937-8589b6a // indirect 275 | github.com/unrolled/render v1.0.2 // indirect 276 | github.com/unrolled/secure v1.0.9 // indirect 277 | github.com/valyala/bytebufferpool v1.0.0 // indirect 278 | github.com/valyala/fasthttp v1.54.0 // indirect 279 | github.com/valyala/tcplisten v1.0.0 // indirect 280 | github.com/vinyldns/go-vinyldns v0.9.16 // indirect 281 | github.com/vulcand/oxy/v2 v2.0.0-20230427132221-be5cf38f3c1c // indirect 282 | github.com/vulcand/predicate v1.2.0 // indirect 283 | github.com/vultr/govultr/v2 v2.17.2 // indirect 284 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect 285 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 286 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 287 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 288 | github.com/yandex-cloud/go-genproto v0.0.0-20220805142335-27b56ddae16f // indirect 289 | github.com/yandex-cloud/go-sdk v0.0.0-20220805164847-cf028e604997 // indirect 290 | go.elastic.co/apm/module/apmhttp/v2 v2.4.8 // indirect 291 | go.elastic.co/apm/module/apmot/v2 v2.4.8 // indirect 292 | go.elastic.co/apm/v2 v2.4.8 // indirect 293 | go.elastic.co/fastjson v1.1.0 // indirect 294 | go.etcd.io/etcd/api/v3 v3.5.6 // indirect 295 | go.etcd.io/etcd/client/pkg/v3 v3.5.6 // indirect 296 | go.etcd.io/etcd/client/v3 v3.5.6 // indirect 297 | go.opencensus.io v0.24.0 // indirect 298 | go.uber.org/atomic v1.11.0 // indirect 299 | go.uber.org/mock v0.4.0 // indirect 300 | go.uber.org/multierr v1.8.0 // indirect 301 | go.uber.org/ratelimit v0.2.0 // indirect 302 | go.uber.org/zap v1.21.0 // indirect 303 | go4.org/intern v0.0.0-20230525184215-6c62f75575cb // indirect 304 | go4.org/unsafe/assume-no-moving-gc v0.0.0-20230525183740-e7c30c78aeb2 // indirect 305 | golang.org/x/crypto v0.22.0 // indirect 306 | golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect 307 | golang.org/x/mod v0.17.0 // indirect 308 | golang.org/x/net v0.24.0 // indirect 309 | golang.org/x/oauth2 v0.16.0 // indirect 310 | golang.org/x/sync v0.7.0 // indirect 311 | golang.org/x/sys v0.21.0 // indirect 312 | golang.org/x/term v0.19.0 // indirect 313 | golang.org/x/text v0.14.0 // indirect 314 | golang.org/x/time v0.5.0 // indirect 315 | golang.org/x/tools v0.20.0 // indirect 316 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect 317 | google.golang.org/api v0.128.0 // indirect 318 | google.golang.org/appengine v1.6.7 // indirect 319 | google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d // indirect 320 | google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d // indirect 321 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect 322 | google.golang.org/grpc v1.59.0 // indirect 323 | google.golang.org/protobuf v1.33.0 // indirect 324 | gopkg.in/DataDog/dd-trace-go.v1 v1.56.1 // indirect 325 | gopkg.in/inf.v0 v0.9.1 // indirect 326 | gopkg.in/ini.v1 v1.67.0 // indirect 327 | gopkg.in/ns1/ns1-go.v2 v2.7.13 // indirect 328 | gopkg.in/yaml.v2 v2.4.0 // indirect 329 | howett.net/plist v0.0.0-20181124034731-591f970eefbb // indirect 330 | inet.af/netaddr v0.0.0-20230525184311-b8eac61e914a // indirect 331 | k8s.io/api v0.26.3 // indirect 332 | k8s.io/apiextensions-apiserver v0.26.3 // indirect 333 | k8s.io/apimachinery v0.26.3 // indirect 334 | k8s.io/client-go v0.26.3 // indirect 335 | k8s.io/klog/v2 v2.90.1 // indirect 336 | k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 // indirect 337 | k8s.io/utils v0.0.0-20230313181309-38a27ef9d749 // indirect 338 | mvdan.cc/xurls/v2 v2.5.0 // indirect 339 | sigs.k8s.io/gateway-api v0.4.0 // indirect 340 | sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect 341 | sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect 342 | sigs.k8s.io/yaml v1.3.0 // indirect 343 | ) 344 | -------------------------------------------------------------------------------- /kv.go: -------------------------------------------------------------------------------- 1 | package traefikkop 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/pkg/errors" 12 | "github.com/sirupsen/logrus" 13 | "github.com/traefik/traefik/v2/pkg/config/dynamic" 14 | ) 15 | 16 | type KV struct { 17 | data map[string]interface{} 18 | base string 19 | } 20 | 21 | func NewKV() *KV { 22 | return &KV{data: make(map[string]interface{})} 23 | } 24 | 25 | func (kv *KV) SetBase(b string) { 26 | kv.base = b 27 | } 28 | 29 | func (kv *KV) add(val interface{}, format string, a ...interface{}) { 30 | if val == nil { 31 | return // todo: log it? debug? 32 | } 33 | str := fmt.Sprintf("%s", val) 34 | if str == "" { 35 | return // todo: log it? debug? 36 | } 37 | if kv.base != "" { 38 | format = kv.base + "/" + format 39 | } 40 | 41 | key := fmt.Sprintf(format, a...) 42 | if strings.HasPrefix(key, "traefik/tls/") { 43 | // ignore tls options, only interested in things that can be set per-container 44 | return 45 | } 46 | 47 | kv.data[key] = val 48 | } 49 | 50 | // ConfigToKV flattens the given configuration into a format suitable for 51 | // putting into a KV store such as redis 52 | func ConfigToKV(conf dynamic.Configuration) (map[string]interface{}, error) { 53 | b, err := json.Marshal(conf) 54 | if err != nil { 55 | return nil, errors.Wrap(err, "failed to create kv") 56 | } 57 | 58 | hash := make(map[string]interface{}) 59 | err = json.Unmarshal(b, &hash) 60 | if err != nil { 61 | return nil, errors.Wrap(err, "failed to create kv") 62 | } 63 | 64 | kv := NewKV() 65 | walk(kv, "traefik", hash, "") 66 | 67 | return kv.data, nil 68 | } 69 | 70 | var reKeyName = regexp.MustCompile(`^traefik/(http|tcp|udp)/(router|service|middleware)s$`) 71 | 72 | func walk(kv *KV, path string, obj interface{}, pos string) { 73 | if obj == nil { 74 | return 75 | } 76 | 77 | val := reflect.ValueOf(obj) 78 | 79 | switch val.Kind() { 80 | case reflect.Map: 81 | iter := val.MapRange() 82 | for iter.Next() { 83 | key := iter.Key() 84 | val := iter.Value() 85 | if !val.CanInterface() { 86 | continue 87 | } 88 | strKey := key.String() 89 | if reKeyName.MatchString(path) { 90 | strKey = strings.TrimSuffix(strKey, "@docker") 91 | } 92 | walk(kv, path+"/"+strKey, val.Interface(), strKey) 93 | } 94 | 95 | case reflect.Struct: 96 | num := val.NumField() 97 | for i := 0; i < num; i++ { 98 | val := val.Field(i) 99 | if !val.CanInterface() { 100 | continue 101 | } 102 | walk(kv, path, val.Interface(), pos) 103 | } 104 | 105 | case reflect.Slice: 106 | n := val.Len() 107 | for i := 0; i < n; i++ { 108 | val := val.Index(i) 109 | if !val.CanInterface() { 110 | continue 111 | } 112 | walk(kv, fmt.Sprintf("%s/%d", path, i), val.Interface(), pos) 113 | } 114 | 115 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, 116 | reflect.String, reflect.Bool: 117 | 118 | // stringify it 119 | kv.add(stringify(val.Interface()), path) 120 | 121 | case reflect.Float32, reflect.Float64: 122 | kv.add(stringifyFloat(val.Float()), path) 123 | 124 | default: 125 | logrus.Warnf("unhandled kind %s: %#v\n", val.Kind(), obj) 126 | } 127 | 128 | } 129 | 130 | func stringify(val interface{}) string { 131 | return fmt.Sprintf("%v", val) 132 | } 133 | 134 | func stringifyFloat(val float64) string { 135 | return strconv.FormatFloat(val, 'f', -1, 64) 136 | } 137 | -------------------------------------------------------------------------------- /kv_test.go: -------------------------------------------------------------------------------- 1 | package traefikkop 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/BurntSushi/toml" 9 | "github.com/stretchr/testify/require" 10 | "github.com/traefik/traefik/v2/pkg/config/dynamic" 11 | ) 12 | 13 | func Test_configToKV(t *testing.T) { 14 | cfg := &dynamic.Configuration{} 15 | _, err := toml.DecodeFile("./fixtures/sample.toml", &cfg) 16 | require.NoError(t, err) 17 | 18 | got, err := ConfigToKV(*cfg) 19 | require.NoError(t, err) 20 | require.NotNil(t, got) 21 | 22 | require.Contains(t, got, "traefik/http/services/Service0/loadBalancer/healthCheck/port") 23 | require.Contains(t, got, "traefik/http/middlewares/Middleware15/forwardAuth/authRequestHeaders/1") 24 | require.NotContains(t, got, "traefik/tls/options/TLS0/sniStrict") 25 | 26 | // should not include @docker in names 27 | cfg = &dynamic.Configuration{} 28 | err = json.Unmarshal([]byte(NGINX_CONF_JSON), cfg) 29 | require.NoError(t, err) 30 | 31 | got, err = ConfigToKV(*cfg) 32 | require.NoError(t, err) 33 | // dumpKV(got) 34 | require.NotContains(t, got, "traefik/http/routers/nginx@docker/service") 35 | require.NotContains(t, got, "traefik/http/services/nginx@docker/loadBalancer/passHostHeader") 36 | 37 | // t.Fail() 38 | } 39 | 40 | func dumpKV(kv map[string]interface{}) { 41 | for k, v := range kv { 42 | fmt.Printf("%s = %s\n", k, v) 43 | } 44 | } 45 | 46 | func Test_stringify(t *testing.T) { 47 | v := 15552000 48 | s := stringify(v) 49 | require.Equal(t, "15552000", s, "int should match") 50 | 51 | b := false 52 | s = stringify(b) 53 | require.Equal(t, "false", s, "bool should match") 54 | 55 | b = true 56 | s = stringify(b) 57 | require.Equal(t, "true", s, "bool should match") 58 | } 59 | 60 | func Test_stringifyFloat(t *testing.T) { 61 | f := float64(15552000) 62 | s := stringifyFloat(f) 63 | require.Equal(t, "15552000", s, "float should match") 64 | 65 | f32 := float32(15552000) 66 | s = stringifyFloat(float64(f32)) 67 | require.Equal(t, "15552000", s, "float should match") 68 | } 69 | -------------------------------------------------------------------------------- /multi_provider.go: -------------------------------------------------------------------------------- 1 | package traefikkop 2 | 3 | import ( 4 | "github.com/traefik/traefik/v2/pkg/config/dynamic" 5 | "github.com/traefik/traefik/v2/pkg/provider" 6 | "github.com/traefik/traefik/v2/pkg/safe" 7 | ) 8 | 9 | // MultiProvider simply wraps an array of providers (more generic than 10 | // ProviderAggregator) 11 | type MultiProvider struct { 12 | upstreamProviders []provider.Provider 13 | } 14 | 15 | func NewMultiProvider(upstream []provider.Provider) *MultiProvider { 16 | return &MultiProvider{upstream} 17 | } 18 | 19 | func (p MultiProvider) Init() error { 20 | for _, provider := range p.upstreamProviders { 21 | err := provider.Init() 22 | if err != nil { 23 | return err 24 | } 25 | } 26 | return nil 27 | } 28 | 29 | func (p MultiProvider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { 30 | for _, provider := range p.upstreamProviders { 31 | provider.Provide(configurationChan, pool) 32 | } 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /polling_provider.go: -------------------------------------------------------------------------------- 1 | package traefikkop 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/sirupsen/logrus" 8 | "github.com/traefik/traefik/v2/pkg/config/dynamic" 9 | "github.com/traefik/traefik/v2/pkg/log" 10 | "github.com/traefik/traefik/v2/pkg/provider" 11 | "github.com/traefik/traefik/v2/pkg/safe" 12 | ) 13 | 14 | // PollingProvider simply wraps the target upstream provider with a poller. 15 | type PollingProvider struct { 16 | refreshInterval time.Duration 17 | upstreamProvider provider.Provider 18 | store TraefikStore 19 | } 20 | 21 | func NewPollingProvider(refreshInterval time.Duration, upstream provider.Provider, store TraefikStore) *PollingProvider { 22 | return &PollingProvider{refreshInterval, upstream, store} 23 | } 24 | 25 | func (p PollingProvider) Init() error { 26 | return p.upstreamProvider.Init() 27 | } 28 | 29 | func (p PollingProvider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { 30 | if p.refreshInterval == 0 { 31 | logrus.Infoln("Disabling polling provider (interval=0)") 32 | return nil 33 | } 34 | 35 | logrus.Infof("starting polling provider with %s interval", p.refreshInterval.String()) 36 | ticker := time.NewTicker(p.refreshInterval) 37 | 38 | pool.GoCtx(func(ctx context.Context) { 39 | ctx = log.With(ctx, log.Str(log.ProviderName, "docker")) 40 | 41 | for { 42 | select { 43 | case <-ticker.C: 44 | logrus.Debugln("tick") 45 | p.upstreamProvider.Provide(configurationChan, pool) 46 | 47 | // Try to push the last config if Redis restarted 48 | err := p.store.KeepConfAlive() 49 | if err != nil { 50 | logrus.Warnf("Failed to push cached config: %s", err) 51 | } 52 | 53 | case <-ctx.Done(): 54 | ticker.Stop() 55 | return 56 | } 57 | } 58 | }) 59 | 60 | return nil 61 | } 62 | -------------------------------------------------------------------------------- /store.go: -------------------------------------------------------------------------------- 1 | package traefikkop 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | "time" 8 | 9 | "github.com/pkg/errors" 10 | "github.com/sirupsen/logrus" 11 | "github.com/traefik/traefik/v2/pkg/config/dynamic" 12 | "gopkg.in/redis.v5" 13 | ) 14 | 15 | type TraefikStore interface { 16 | Store(conf dynamic.Configuration) error 17 | Ping() error 18 | KeepConfAlive() error 19 | } 20 | 21 | func collectKeys(m interface{}) []string { 22 | mk := reflect.ValueOf(m).MapKeys() 23 | // set := mapset.NewSet() 24 | set := make([]string, len(mk)) 25 | for i := 0; i < len(mk); i++ { 26 | // set.Add(mk[i].String()) 27 | set[i] = mk[i].String() 28 | } 29 | return set 30 | } 31 | 32 | type RedisStore struct { 33 | Hostname string 34 | Addr string 35 | Pass string 36 | DB int 37 | 38 | client *redis.Client 39 | lastConfig *dynamic.Configuration 40 | } 41 | 42 | func NewRedisStore(hostname string, addr string, pass string, db int) TraefikStore { 43 | logrus.Infof("creating new redis store at %s for hostname %s", addr, hostname) 44 | 45 | store := &RedisStore{ 46 | Hostname: hostname, 47 | Addr: addr, 48 | Pass: pass, 49 | DB: db, 50 | 51 | client: redis.NewClient(&redis.Options{ 52 | Addr: addr, 53 | Password: pass, 54 | DB: db, 55 | }), 56 | } 57 | return store 58 | } 59 | 60 | func (s *RedisStore) Ping() error { 61 | return s.client.Ping().Err() 62 | } 63 | 64 | // sk returns the 'set key' for keeping track of our services/routers/middlewares 65 | // e.g., traefik_http_routers@culture.local 66 | func (s RedisStore) sk(b string) string { 67 | return fmt.Sprintf("traefik_%s@%s", b, s.Hostname) 68 | } 69 | 70 | func (s *RedisStore) Store(conf dynamic.Configuration) error { 71 | s.removeOldKeys(conf.HTTP.Middlewares, "http_middlewares") 72 | s.removeOldKeys(conf.HTTP.Routers, "http_routers") 73 | s.removeOldKeys(conf.HTTP.Services, "http_services") 74 | s.removeOldKeys(conf.TCP.Middlewares, "tcp_middlewares") 75 | s.removeOldKeys(conf.TCP.Routers, "tcp_routers") 76 | s.removeOldKeys(conf.TCP.Services, "tcp_services") 77 | s.removeOldKeys(conf.UDP.Routers, "udp_routers") 78 | s.removeOldKeys(conf.UDP.Services, "udp_services") 79 | 80 | kv, err := ConfigToKV(conf) 81 | if err != nil { 82 | return err 83 | } 84 | for k, v := range kv { 85 | logrus.Debugf("writing %s = %s", k, v) 86 | s.client.Set(k, v, 0) 87 | } 88 | 89 | s.swapKeys(s.sk("http_middlewares")) 90 | s.swapKeys(s.sk("http_routers")) 91 | s.swapKeys(s.sk("http_services")) 92 | s.swapKeys(s.sk("tcp_middlewares")) 93 | s.swapKeys(s.sk("tcp_routers")) 94 | s.swapKeys(s.sk("tcp_services")) 95 | s.swapKeys(s.sk("udp_routers")) 96 | s.swapKeys(s.sk("udp_services")) 97 | 98 | // Update sentinel key with current timestamp 99 | s.client.Set(s.sk("kop_last_update"), time.Now().Unix(), 0) 100 | 101 | // Store a copy of the configuration in case redis restarts 102 | configCopy := conf 103 | s.lastConfig = &configCopy 104 | 105 | return nil 106 | } 107 | 108 | // NeedsUpdate checks if Redis needs a full configuration refresh 109 | // by checking for the sentinel key's existence 110 | func (s *RedisStore) NeedsUpdate() bool { 111 | // Check if sentinel key exists 112 | exists, err := s.client.Exists(s.sk("kop_last_update")).Result() 113 | if err != nil { 114 | logrus.Warnf("Failed to check Redis status: %s", err) 115 | } 116 | return !exists 117 | } 118 | 119 | // Push the last configuration if needed 120 | func (s *RedisStore) KeepConfAlive() error { 121 | if s.lastConfig == nil { 122 | return nil // No config to push yet 123 | } 124 | 125 | if s.NeedsUpdate() { 126 | logrus.Warnln("Redis seems to have restarted and needs to be updated. Pushing last known configuration") 127 | return s.Store(*s.lastConfig) 128 | } 129 | 130 | return nil 131 | } 132 | 133 | func (s *RedisStore) swapKeys(setkey string) error { 134 | // store router name list by renaming 135 | err := s.client.Rename(setkey+"_new", setkey).Err() 136 | if err != nil { 137 | if strings.Contains(err.Error(), "no such key") { 138 | s.client.Unlink(setkey) 139 | return nil 140 | } 141 | return errors.Wrap(err, "rename failed") 142 | } 143 | return nil 144 | } 145 | 146 | // k returns the actual config key path 147 | // e.g., traefik/http/routers/nginx@docker 148 | func (s RedisStore) k(sk, b string) string { 149 | k := strings.ReplaceAll(fmt.Sprintf("traefik_%s", sk), "_", "/") 150 | b = strings.TrimSuffix(b, "@docker") 151 | return fmt.Sprintf("%s/%s", k, b) 152 | } 153 | 154 | func (s *RedisStore) removeKeys(setkey string, keys []string) error { 155 | if len(keys) == 0 { 156 | return nil 157 | } 158 | if logrus.IsLevelEnabled(logrus.DebugLevel) { 159 | logrus.Debugf("removing keys from %s: %s", setkey, strings.Join(keys, ",")) 160 | } 161 | for _, removeKey := range keys { 162 | keyPath := s.k(setkey, removeKey) + "/*" 163 | logrus.Debugf("removing keys matching %s", keyPath) 164 | res, err := s.client.Keys(keyPath).Result() 165 | if err != nil { 166 | return errors.Wrap(err, "fetch failed") 167 | } 168 | if err := s.client.Unlink(res...).Err(); err != nil { 169 | return errors.Wrap(err, "unlink failed") 170 | } 171 | } 172 | return nil 173 | } 174 | 175 | func (s *RedisStore) removeOldKeys(m interface{}, setname string) error { 176 | setkey := s.sk(setname) 177 | // store new keys in temp set 178 | newkeys := collectKeys(m) 179 | if len(newkeys) == 0 { 180 | res, err := s.client.SMembers(setkey).Result() 181 | if err != nil { 182 | return errors.Wrap(err, "fetch failed") 183 | } 184 | return s.removeKeys(setname, res) 185 | 186 | } else { 187 | // make a diff and remove 188 | err := s.client.SAdd(setkey+"_new", mkslice(newkeys)...).Err() 189 | if err != nil { 190 | return errors.Wrap(err, "add failed") 191 | } 192 | 193 | // diff the existing keys with the new ones 194 | res, err := s.client.SDiff(setkey, setkey+"_new").Result() 195 | if err != nil { 196 | return errors.Wrap(err, "diff failed") 197 | } 198 | return s.removeKeys(setname, res) 199 | } 200 | } 201 | 202 | // mkslice converts a string slice to an interface slice 203 | func mkslice(old []string) []interface{} { 204 | new := make([]interface{}, len(old)) 205 | for i, v := range old { 206 | new[i] = v 207 | } 208 | return new 209 | } 210 | -------------------------------------------------------------------------------- /store_test.go: -------------------------------------------------------------------------------- 1 | package traefikkop 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/BurntSushi/toml" 8 | "github.com/stretchr/testify/require" 9 | "github.com/traefik/traefik/v2/pkg/config/dynamic" 10 | ) 11 | 12 | const NGINX_CONF_JSON = `{"http":{"routers":{"nginx@docker":{"service":"nginx","rule":"Host('nginx.local')"}},"services":{"nginx@docker":{"loadBalancer":{"servers":[{"url":"http://172.20.0.2:80"}],"passHostHeader":true}}}},"tcp":{},"udp":{},"tls":{"options":{"default":{"clientAuth":{},"alpnProtocols":["h2","http/1.1","acme-tls/1"]}}}}` 13 | const NGINX_CONF_JSON_DIFFRENT_SERVICE_NAME = `{"http":{"routers":{"nginx@docker":{"service":"nginx-nginx","rule":"Host('nginx.local')"}},"services":{"nginx-nginx@docker":{"loadBalancer":{"servers":[{"url":"http://172.20.0.2:80"}],"passHostHeader":true}}}},"tcp":{},"udp":{},"tls":{"options":{"default":{"clientAuth":{},"alpnProtocols":["h2","http/1.1","acme-tls/1"]}}}}` 14 | 15 | func Test_collectKeys(t *testing.T) { 16 | cfg := &dynamic.Configuration{} 17 | _, err := toml.DecodeFile("./fixtures/sample.toml", &cfg) 18 | require.NoError(t, err) 19 | 20 | keys := collectKeys(cfg.HTTP.Middlewares) 21 | require.NotEmpty(t, keys) 22 | require.Contains(t, keys, "Middleware21") 23 | 24 | require.Contains(t, collectKeys(cfg.HTTP.Services), "Service0") 25 | 26 | cfg = &dynamic.Configuration{} 27 | err = json.Unmarshal([]byte(NGINX_CONF_JSON), cfg) 28 | require.NoError(t, err) 29 | keys = collectKeys(cfg.HTTP.Routers) 30 | require.Len(t, keys, 1) 31 | } 32 | 33 | // keys := collectKeys(cfg.HTTP.Middlewares) 34 | // require.NotEmpty(t, keys) 35 | // require.True(t, keys.Contains("Middleware21")) 36 | 37 | // require.True(t, collectKeys(cfg.HTTP.Services).Contains("Service0")) 38 | -------------------------------------------------------------------------------- /testing/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | # Redis for use in testing, if we don't have one available locally 3 | redis: 4 | image: "redis:alpine" 5 | restart: unless-stopped 6 | ports: 7 | - 6380:6379 8 | 9 | # Service with two routers+services listening on different ports. 10 | helloworld: 11 | build: 12 | dockerfile: ./helloworld/Dockerfile 13 | context: ./ 14 | restart: unless-stopped 15 | ports: 16 | - 5555:5555 17 | - 5566:5566 18 | labels: 19 | - "traefik.enable=true" 20 | - "traefik.http.routers.hello1.rule=Host(`hello1.local`)" 21 | - "traefik.http.routers.hello1.service=hello1" 22 | - "traefik.http.routers.hello1.tls=true" 23 | - "traefik.http.routers.hello1.tls.certresolver=default" 24 | - "traefik.http.services.hello1.loadbalancer.server.scheme=http" 25 | - "traefik.http.services.hello1.loadbalancer.server.port=5555" 26 | - "traefik.http.routers.hello2.rule=Host(`hello2.local`)" 27 | - "traefik.http.routers.hello2.service=hello2" 28 | - "traefik.http.routers.hello2.tls=true" 29 | - "traefik.http.routers.hello2.tls.certresolver=default" 30 | - "traefik.http.services.hello2.loadbalancer.server.scheme=http" 31 | - "traefik.http.services.hello2.loadbalancer.server.port=5566" 32 | 33 | # This service is the same as above except that it does not have a label 34 | # which explicitly maps the port and so it fails to correctly determine which 35 | # port to tell traefik to connect to. i.e., both services connect to 5555. 36 | # 37 | # This scenario will *not* be handled correctly by traefik-kop as we have no 38 | # fallback way to determine the port. 39 | hellodetect: 40 | build: 41 | dockerfile: ./helloworld/Dockerfile 42 | context: ./ 43 | restart: unless-stopped 44 | ports: 45 | - 5577:5555 46 | - 5588:5566 47 | labels: 48 | - "traefik.enable=true" 49 | - "traefik.http.routers.hello-detect.rule=Host(`hello-detect.local`)" 50 | - "traefik.http.routers.hello-detect.service=hello-detect" 51 | - "traefik.http.routers.hello-detect.tls=true" 52 | - "traefik.http.routers.hello-detect.tls.certresolver=default" 53 | - "traefik.http.services.hello-detect.loadbalancer.server.scheme=http" 54 | - "traefik.http.routers.hello-detect2.rule=Host(`hello-detect2.local`)" 55 | - "traefik.http.routers.hello-detect2.service=hello-detect2" 56 | - "traefik.http.routers.hello-detect2.tls=true" 57 | - "traefik.http.routers.hello-detect2.tls.certresolver=default" 58 | - "traefik.http.services.hello-detect2.loadbalancer.server.scheme=http" 59 | 60 | # Hello service with IP bind override 61 | helloip: 62 | build: 63 | dockerfile: ./helloworld/Dockerfile 64 | context: ./ 65 | restart: unless-stopped 66 | ports: 67 | - 5599:5555 68 | labels: 69 | # - "kop.bind.ip=6.6.6.6" 70 | - "kop.helloip.bind.ip=4.4.4.4" 71 | - "traefik.enable=true" 72 | - "traefik.http.routers.helloip.rule=Host(`helloip.local`)" 73 | - "traefik.http.routers.helloip.service=helloip" 74 | - "traefik.http.routers.helloip.tls=true" 75 | - "traefik.http.routers.helloip.tls.certresolver=default" 76 | - "traefik.http.services.helloip.loadbalancer.server.scheme=http" 77 | - "traefik.http.services.helloip.loadbalancer.server.port=5599" 78 | 79 | # Basic nginx with a simple healthcheck 80 | nginx: 81 | image: "nginx:alpine" 82 | restart: unless-stopped 83 | ports: 84 | - 8088:80 85 | healthcheck: 86 | test: ["CMD", "curl", "-s", "localhost:80"] 87 | timeout: 10s 88 | interval: 2s 89 | retries: 10 90 | labels: 91 | - "traefik.enable=true" 92 | - "traefik.http.routers.nginx.rule=Host(`nginx.local`)" 93 | - "traefik.http.routers.nginx.tls=true" 94 | - "traefik.http.routers.nginx.tls.certresolver=default" 95 | - "traefik.http.services.nginx.loadbalancer.server.scheme=http" 96 | - "traefik.http.services.nginx.loadbalancer.server.port=8088" 97 | 98 | # Pihole to test a specific bug report 99 | pihole: 100 | image: "pihole/pihole:latest" 101 | restart: unless-stopped 102 | ports: 103 | - 8089:80 104 | labels: 105 | - "traefik.enable=true" 106 | - "traefik.http.routers.pihole.rule=Host(`pihole.local`)" 107 | - "traefik.http.routers.pihole.tls=true" 108 | - "traefik.http.routers.pihole.tls.certresolver=default" 109 | - "traefik.http.services.pihole.loadbalancer.server.scheme=http" 110 | - "traefik.http.services.pihole.loadbalancer.server.port=8089" 111 | 112 | gitea: 113 | image: gitea/gitea 114 | labels: 115 | traefik.enable: true 116 | traefik.http.routers.gitea.rule: "Host(`git.domain`)" 117 | traefik.http.routers.gitea.entrypoints: webs 118 | traefik.http.routers.gitea.service: gitea@redis 119 | traefik.http.services.gitea.loadbalancer.server.port: 20080 120 | 121 | traefik.tcp.routers.gitea-ssh.rule: "HostSNI(`*`)" 122 | traefik.tcp.routers.gitea-ssh.entrypoints: ssh 123 | traefik.tcp.routers.gitea-ssh.service: gitea-ssh@redis 124 | traefik.tcp.services.gitea-ssh.loadbalancer.server.port: 20022 125 | 126 | ephemeral: 127 | image: "nginx:alpine" 128 | restart: "no" 129 | ports: 130 | - 80 131 | labels: 132 | - "traefik.enable=true" 133 | - "traefik.http.routers.ephemeral.rule=Host(`ephemeral.local`)" 134 | - "traefik.http.routers.ephemeral.tls=true" 135 | - "traefik.http.routers.ephemeral.tls.certresolver=default" 136 | # not explicitly set, let kop figure it out 137 | # - "traefik.http.services.ephemeral.loadbalancer.server.scheme=http" 138 | # - "traefik.http.services.ephemeral.loadbalancer.server.port=8099" 139 | -------------------------------------------------------------------------------- /testing/helloworld/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang 2 | 3 | COPY * /go/ 4 | RUN go build -o helloworld ./ 5 | 6 | ENTRYPOINT ["./helloworld"] 7 | -------------------------------------------------------------------------------- /testing/helloworld/README.md: -------------------------------------------------------------------------------- 1 | # helloworld 2 | 3 | Simple helloworld service for testing a container which listens on multiple 4 | ports (5555, 5566). 5 | -------------------------------------------------------------------------------- /testing/helloworld/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jittering/traefik-kop/testing/helloworld 2 | 3 | go 1.22.3 4 | -------------------------------------------------------------------------------- /testing/helloworld/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | func main() { 10 | mux := http.NewServeMux() 11 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 12 | io.WriteString(w, "Hello from port 5555") 13 | }) 14 | fmt.Println("listening on port 5555") 15 | go http.ListenAndServe(":5555", mux) 16 | 17 | mux2 := http.NewServeMux() 18 | mux2.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 19 | io.WriteString(w, "Hello from port 5566") 20 | }) 21 | fmt.Println("listening on port 5566") 22 | http.ListenAndServe(":5566", mux2) 23 | } 24 | -------------------------------------------------------------------------------- /testing/kop/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Compose file for testing traefik-kop itself from within a container env 2 | version: "3" 3 | 4 | services: 5 | traefik-kop: 6 | image: "ghcr.io/jittering/traefik-kop:0.12.2-next-amd64" 7 | restart: unless-stopped 8 | volumes: 9 | - /var/run/docker.sock:/var/run/docker.sock 10 | environment: 11 | REDIS_ADDR: "172.28.183.97:6380" 12 | BIND_IP: "172.28.183.97" 13 | DEBUG: "1" 14 | DOCKER_CONFIG: | 15 | --- 16 | docker: 17 | exposedByDefault: false 18 | useBindPortIP: true 19 | -------------------------------------------------------------------------------- /testing/publish-random.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Test a docker run command 4 | 5 | docker run --rm -it \ 6 | --label "traefik.enable=true" \ 7 | --label "traefik.http.routers.nginx.rule=Host(\`nginx.local\`)" \ 8 | --label "traefik.http.routers.nginx.tls=true" \ 9 | --label "traefik.http.routers.nginx.tls.certresolver=default" \ 10 | --publish-all \ 11 | nginx:alpine 12 | -------------------------------------------------------------------------------- /traefik_kop.go: -------------------------------------------------------------------------------- 1 | package traefikkop 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | "time" 9 | 10 | "github.com/docker/docker/api/types" 11 | "github.com/docker/docker/client" 12 | "github.com/sirupsen/logrus" 13 | ptypes "github.com/traefik/paerser/types" 14 | "github.com/traefik/traefik/v2/pkg/config/dynamic" 15 | "github.com/traefik/traefik/v2/pkg/config/static" 16 | "github.com/traefik/traefik/v2/pkg/provider" 17 | "github.com/traefik/traefik/v2/pkg/provider/aggregator" 18 | "github.com/traefik/traefik/v2/pkg/provider/docker" 19 | "github.com/traefik/traefik/v2/pkg/safe" 20 | "github.com/traefik/traefik/v2/pkg/server" 21 | ) 22 | 23 | var Version = "" 24 | 25 | // const defaultThrottleDuration = 5 * time.Second 26 | 27 | // newDockerProvider creates a provider via yaml config or returns a default 28 | // which connects to docker over a unix socket 29 | func newDockerProvider(config Config) *docker.Provider { 30 | dp, err := loadDockerConfig(config.DockerConfig) 31 | if err != nil { 32 | logrus.Fatalf("failed to read docker config: %s", err) 33 | 34 | } 35 | 36 | if dp == nil { 37 | dp = &docker.Provider{} 38 | } 39 | 40 | // set defaults 41 | if dp.Endpoint == "" { 42 | dp.Endpoint = config.DockerHost 43 | } 44 | if dp.HTTPClientTimeout.String() != "0s" && strings.HasPrefix(dp.Endpoint, "unix://") { 45 | // force to 0 for unix socket 46 | dp.HTTPClientTimeout = ptypes.Duration(defaultTimeout) 47 | } 48 | if dp.SwarmModeRefreshSeconds.String() == "0s" { 49 | dp.SwarmModeRefreshSeconds = ptypes.Duration(15 * time.Second) 50 | } 51 | dp.Watch = true // always 52 | 53 | return dp 54 | } 55 | 56 | func createConfigHandler(config Config, store TraefikStore, dp *docker.Provider, dockerClient client.APIClient) func(conf dynamic.Configuration) { 57 | return func(conf dynamic.Configuration) { 58 | // logrus.Printf("got new conf..\n") 59 | // fmt.Printf("%s\n", dumpJson(conf)) 60 | logrus.Infoln("refreshing traefik-kop configuration") 61 | 62 | dc := &dockerCache{ 63 | client: dockerClient, 64 | list: nil, 65 | details: make(map[string]types.ContainerJSON), 66 | } 67 | 68 | filterServices(dc, &conf, config.Namespace) 69 | 70 | if !dp.UseBindPortIP { 71 | // if not using traefik's built in IP/Port detection, use our own 72 | replaceIPs(dc, &conf, config.BindIP) 73 | } 74 | err := store.Store(conf) 75 | if err != nil { 76 | panic(err) 77 | } 78 | } 79 | } 80 | 81 | func Start(config Config) { 82 | dp := newDockerProvider(config) 83 | store := NewRedisStore(config.Hostname, config.Addr, config.Pass, config.DB) 84 | err := store.Ping() 85 | if err != nil { 86 | if strings.Contains(err.Error(), config.Addr) { 87 | logrus.Fatalf("failed to connect to redis: %s", err) 88 | } 89 | logrus.Fatalf("failed to connect to redis at %s: %s", config.Addr, err) 90 | } 91 | 92 | providers := &static.Providers{ 93 | Docker: dp, 94 | } 95 | providerAggregator := aggregator.NewProviderAggregator(*providers) 96 | 97 | dockerClient, err := createDockerClient(config.DockerHost) 98 | if err != nil { 99 | logrus.Fatalf("failed to create docker client: %s", err) 100 | } 101 | 102 | ctx := context.Background() 103 | routinesPool := safe.NewPool(ctx) 104 | 105 | handleConfigChange := createConfigHandler(config, store, dp, dockerClient) 106 | 107 | pollingDockerProvider := newDockerProvider(config) 108 | pollingDockerProvider.Watch = false 109 | multiProvider := NewMultiProvider([]provider.Provider{ 110 | providerAggregator, 111 | NewPollingProvider( 112 | time.Second*time.Duration(config.PollInterval), 113 | pollingDockerProvider, 114 | store, 115 | ), 116 | }) 117 | 118 | // initialize all providers 119 | err = multiProvider.Init() 120 | if err != nil { 121 | panic(err) 122 | } 123 | 124 | watcher := server.NewConfigurationWatcher( 125 | routinesPool, 126 | multiProvider, 127 | []string{}, 128 | "docker", 129 | ) 130 | watcher.AddListener(handleConfigChange) 131 | watcher.Start() 132 | 133 | select {} // go forever 134 | } 135 | 136 | func keepContainer(ns string, container types.ContainerJSON) bool { 137 | containerNS := container.Config.Labels["kop.namespace"] 138 | return ns == containerNS || (ns == "" && containerNS == "") 139 | } 140 | 141 | // filter out services by namespace 142 | // ns is traefik-kop's configured namespace to match against. 143 | func filterServices(dc *dockerCache, conf *dynamic.Configuration, ns string) { 144 | if conf.HTTP != nil && conf.HTTP.Services != nil { 145 | for svcName := range conf.HTTP.Services { 146 | container, err := dc.findContainerByServiceName("http", svcName, getRouterOfService(conf, svcName, "http")) 147 | if err != nil { 148 | logrus.Warnf("failed to find container for service '%s': %s", svcName, err) 149 | continue 150 | } 151 | if !keepContainer(ns, container) { 152 | logrus.Infof("skipping service %s (not in namespace %s)", svcName, ns) 153 | delete(conf.HTTP.Services, svcName) 154 | } 155 | } 156 | } 157 | 158 | if conf.HTTP != nil && conf.HTTP.Routers != nil { 159 | for routerName, router := range conf.HTTP.Routers { 160 | svcName := router.Service 161 | container, err := dc.findContainerByServiceName("http", svcName, routerName) 162 | if err != nil { 163 | logrus.Warnf("failed to find container for service '%s': %s", svcName, err) 164 | continue 165 | } 166 | if !keepContainer(ns, container) { 167 | logrus.Infof("skipping router %s (not in namespace %s)", routerName, ns) 168 | delete(conf.HTTP.Routers, routerName) 169 | } 170 | } 171 | } 172 | 173 | if conf.TCP != nil && conf.TCP.Services != nil { 174 | for svcName := range conf.TCP.Services { 175 | container, err := dc.findContainerByServiceName("tcp", svcName, getRouterOfService(conf, svcName, "tcp")) 176 | if err != nil { 177 | logrus.Warnf("failed to find container for service '%s': %s", svcName, err) 178 | continue 179 | } 180 | if !keepContainer(ns, container) { 181 | logrus.Infof("skipping service %s (not in namespace %s)", svcName, ns) 182 | delete(conf.TCP.Services, svcName) 183 | } 184 | } 185 | } 186 | 187 | if conf.TCP != nil && conf.TCP.Routers != nil { 188 | for routerName, router := range conf.TCP.Routers { 189 | svcName := router.Service 190 | container, err := dc.findContainerByServiceName("tcp", svcName, routerName) 191 | if err != nil { 192 | logrus.Warnf("failed to find container for service '%s': %s", svcName, err) 193 | continue 194 | } 195 | if !keepContainer(ns, container) { 196 | logrus.Infof("skipping router %s (not in namespace %s)", routerName, ns) 197 | delete(conf.TCP.Routers, routerName) 198 | } 199 | } 200 | } 201 | 202 | if conf.UDP != nil && conf.UDP.Services != nil { 203 | for svcName := range conf.UDP.Services { 204 | container, err := dc.findContainerByServiceName("udp", svcName, getRouterOfService(conf, svcName, "udp")) 205 | if err != nil { 206 | logrus.Warnf("failed to find container for service '%s': %s", svcName, err) 207 | continue 208 | } 209 | if !keepContainer(ns, container) { 210 | logrus.Warnf("service %s is not running: removing from config", svcName) 211 | delete(conf.UDP.Services, svcName) 212 | } 213 | } 214 | } 215 | 216 | if conf.UDP != nil && conf.UDP.Routers != nil { 217 | for routerName, router := range conf.UDP.Routers { 218 | svcName := router.Service 219 | container, err := dc.findContainerByServiceName("udp", svcName, routerName) 220 | if err != nil { 221 | logrus.Warnf("failed to find container for service '%s': %s", svcName, err) 222 | continue 223 | } 224 | if !keepContainer(ns, container) { 225 | logrus.Infof("skipping router %s (not in namespace %s)", routerName, ns) 226 | delete(conf.UDP.Routers, routerName) 227 | } 228 | } 229 | } 230 | } 231 | 232 | // replaceIPs for all service endpoints 233 | // 234 | // By default, traefik finds the local/internal docker IP for each container. 235 | // Since we are exposing these services to an external node/server, we need 236 | // to replace any IPs with the correct IP for this server, as configured at startup. 237 | // 238 | // When using CNI, as indicated by the container label `traefik.docker.network`, 239 | // we will stick with the container IP. 240 | func replaceIPs(dc *dockerCache, conf *dynamic.Configuration, ip string) { 241 | // modify HTTP URLs 242 | if conf.HTTP != nil && conf.HTTP.Services != nil { 243 | for svcName, svc := range conf.HTTP.Services { 244 | log := logrus.WithFields(logrus.Fields{"service": svcName, "service-type": "http"}) 245 | log.Debugf("found http service: %s", svcName) 246 | for i := range svc.LoadBalancer.Servers { 247 | ip, changed := getKopOverrideBinding(dc, conf, "http", svcName, ip) 248 | if !changed { 249 | // override with container IP if we have a routable IP 250 | ip = getContainerNetworkIP(dc, conf, "http", svcName, ip) 251 | } 252 | 253 | // replace ip into URLs 254 | server := &svc.LoadBalancer.Servers[i] 255 | if server.URL != "" { 256 | // the URL IP will initially refer to the container-local IP 257 | // 258 | // the URL Port will initially be the configured port number, either 259 | // explicitly via traefik label or detected by traefik. We cannot 260 | // determine how the port was set without looking at the traefik 261 | // labels ourselves. 262 | log.Debugf("using load balancer URL for port detection: %s", server.URL) 263 | u, _ := url.Parse(server.URL) 264 | p := getContainerPort(dc, conf, "http", svcName, u.Port()) 265 | if p != "" { 266 | u.Host = ip + ":" + p 267 | } else { 268 | u.Host = ip 269 | } 270 | server.URL = u.String() 271 | } else { 272 | scheme := "http" 273 | if server.Scheme != "" { 274 | scheme = server.Scheme 275 | } 276 | server.URL = fmt.Sprintf("%s://%s", scheme, ip) 277 | port := getContainerPort(dc, conf, "http", svcName, server.Port) 278 | if port != "" { 279 | server.URL += ":" + server.Port 280 | } 281 | } 282 | log.Infof("publishing %s", server.URL) 283 | } 284 | 285 | if conf.HTTP.Routers != nil { 286 | for routerName, router := range conf.HTTP.Routers { 287 | if router.Service+"@docker" == svcName && (router.TLS == nil || strings.TrimSpace(router.TLS.CertResolver) == "") { 288 | log.Warnf("router %s has no TLS cert resolver", routerName) 289 | } 290 | } 291 | } 292 | } 293 | } 294 | 295 | // TCP 296 | if conf.TCP != nil && conf.TCP.Services != nil { 297 | for svcName, svc := range conf.TCP.Services { 298 | log := logrus.WithFields(logrus.Fields{"service": svcName, "service-type": "tcp"}) 299 | log.Debugf("found tcp service: %s", svcName) 300 | for i := range svc.LoadBalancer.Servers { 301 | // override with container IP if we have a routable IP 302 | ip = getContainerNetworkIP(dc, conf, "tcp", svcName, ip) 303 | 304 | server := &svc.LoadBalancer.Servers[i] 305 | server.Port = getContainerPort(dc, conf, "tcp", svcName, server.Port) 306 | log.Debugf("using ip '%s' and port '%s' for %s", ip, server.Port, svcName) 307 | server.Address = ip 308 | if server.Port != "" { 309 | server.Address += ":" + server.Port 310 | } 311 | log.Infof("publishing %s", server.Address) 312 | } 313 | } 314 | } 315 | 316 | // UDP 317 | if conf.UDP != nil && conf.UDP.Services != nil { 318 | for svcName, svc := range conf.UDP.Services { 319 | log := logrus.WithFields(logrus.Fields{"service": svcName, "service-type": "udp"}) 320 | log.Debugf("found udp service: %s", svcName) 321 | for i := range svc.LoadBalancer.Servers { 322 | // override with container IP if we have a routable IP 323 | ip = getContainerNetworkIP(dc, conf, "udp", svcName, ip) 324 | 325 | server := &svc.LoadBalancer.Servers[i] 326 | server.Port = getContainerPort(dc, conf, "udp", svcName, server.Port) 327 | log.Debugf("using ip '%s' and port '%s' for %s", ip, server.Port, svcName) 328 | server.Address = ip 329 | if server.Port != "" { 330 | server.Address += ":" + server.Port 331 | } 332 | log.Infof("publishing %s", server.Address) 333 | } 334 | } 335 | } 336 | } 337 | 338 | // Get the matching router name for the given service. 339 | // 340 | // It is possible that no traefik service was explicitly configured, only a 341 | // router. In this case, we need to use the router name to find the traefik 342 | // labels to identify the container. 343 | func getRouterOfService(conf *dynamic.Configuration, svcName string, svcType string) string { 344 | svcName = strings.TrimSuffix(svcName, "@docker") 345 | name := "" 346 | 347 | if svcType == "http" { 348 | for routerName, router := range conf.HTTP.Routers { 349 | if router.Service == svcName { 350 | name = routerName 351 | break 352 | } 353 | } 354 | } else if svcType == "tcp" { 355 | for routerName, router := range conf.TCP.Routers { 356 | if router.Service == svcName { 357 | name = routerName 358 | break 359 | } 360 | } 361 | } else if svcType == "udp" { 362 | for routerName, router := range conf.UDP.Routers { 363 | if router.Service == svcName { 364 | name = routerName 365 | break 366 | } 367 | } 368 | } 369 | 370 | logrus.Debugf("found router '%s' for service %s", name, svcName) 371 | return name 372 | } 373 | 374 | // Get host-port binding from container, if not explicitly set via labels 375 | // 376 | // The `port` param is the value which was either set via label or inferred by 377 | // traefik during its config parsing (possibly an container-internal port). The 378 | // purpose of this method is to see if we can find a better match, specifically 379 | // by looking at the host-port bindings in the docker config. 380 | func getContainerPort(dc *dockerCache, conf *dynamic.Configuration, svcType string, svcName string, port string) string { 381 | log := logrus.WithFields(logrus.Fields{"service": svcName, "service-type": svcType}) 382 | container, err := dc.findContainerByServiceName(svcType, svcName, getRouterOfService(conf, svcName, svcType)) 383 | if err != nil { 384 | log.Warnf("failed to find host-port: %s", err) 385 | return port 386 | } 387 | if p := isPortSet(container, svcType, svcName); p != "" { 388 | log.Debugf("using explicitly set port %s for %s", p, svcName) 389 | return p 390 | } 391 | exposedPort, err := getPortBinding(container) 392 | if err != nil { 393 | if strings.Contains(err.Error(), "no host-port binding") { 394 | log.Debug(err) 395 | } else { 396 | log.Warn(err) 397 | } 398 | log.Debugf("using existing port %s", port) 399 | return port 400 | } 401 | if exposedPort == "" { 402 | log.Warnf("failed to find host-port for service %s", svcName) 403 | return port 404 | } 405 | log.Debugf("overriding service port from container host-port: using %s (was %s) for %s", exposedPort, port, svcName) 406 | return exposedPort 407 | } 408 | 409 | // Gets the container IP when it is configured to use a network-routable address 410 | // (i.e., via CNI plugins such as calico or weave) 411 | // 412 | // If not configured, returns the globally bound hostIP 413 | func getContainerNetworkIP(dc *dockerCache, conf *dynamic.Configuration, svcType string, svcName string, hostIP string) string { 414 | container, err := dc.findContainerByServiceName(svcType, svcName, getRouterOfService(conf, svcName, svcType)) 415 | if err != nil { 416 | logrus.Debugf("failed to find container for service '%s': %s", svcName, err) 417 | return hostIP 418 | } 419 | 420 | networkName := container.Config.Labels["traefik.docker.network"] 421 | if networkName == "" { 422 | logrus.Debugf("no network label set for %s", svcName) 423 | return hostIP 424 | } 425 | 426 | if container.NetworkSettings != nil { 427 | networkEndpoint := container.NetworkSettings.Networks[networkName] 428 | if networkEndpoint != nil { 429 | networkIP := networkEndpoint.IPAddress 430 | logrus.Debugf("found network name '%s' with container IP '%s' for service %s", networkName, networkIP, svcName) 431 | return networkIP 432 | } 433 | } 434 | // fallback 435 | logrus.Debugf("container IP not found for %s", svcName) 436 | return hostIP 437 | } 438 | 439 | // Check for explicit IP binding set via label 440 | // 441 | // Label can be one of two keys: 442 | // - kop..bind.ip = 2.2.2.2 443 | // - kop.bind.ip = 2.2.2.2 444 | // 445 | // For a container with only a single exposed service, or where all services use 446 | // the same IP, the latter is sufficient. 447 | func getKopOverrideBinding(dc *dockerCache, conf *dynamic.Configuration, svcType string, svcName string, hostIP string) (string, bool) { 448 | container, err := dc.findContainerByServiceName(svcType, svcName, getRouterOfService(conf, svcName, svcType)) 449 | if err != nil { 450 | logrus.Debugf("failed to find container for service '%s': %s", svcName, err) 451 | return hostIP, false 452 | } 453 | 454 | svcName = strings.TrimSuffix(svcName, "@docker") 455 | svcNeedle := fmt.Sprintf("kop.%s.bind.ip", svcName) 456 | if ip := container.Config.Labels[svcNeedle]; ip != "" { 457 | logrus.Debugf("found label %s with IP '%s' for service %s", svcNeedle, ip, svcName) 458 | return ip, true 459 | } 460 | 461 | if ip := container.Config.Labels["kop.bind.ip"]; ip != "" { 462 | logrus.Debugf("found label %s with IP '%s' for service %s", "kop.bind.ip", ip, svcName) 463 | return ip, true 464 | } 465 | 466 | return hostIP, false 467 | } 468 | -------------------------------------------------------------------------------- /traefik_kop_test.go: -------------------------------------------------------------------------------- 1 | package traefikkop 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/BurntSushi/toml" 10 | "github.com/docker/docker/api/types" 11 | "github.com/docker/docker/api/types/container" 12 | "github.com/docker/docker/client" 13 | "github.com/docker/go-connections/nat" 14 | "github.com/sirupsen/logrus" 15 | "github.com/stretchr/testify/require" 16 | "github.com/traefik/traefik/v2/pkg/config/dynamic" 17 | "github.com/traefik/traefik/v2/pkg/log" 18 | ) 19 | 20 | func init() { 21 | logrus.SetLevel(logrus.DebugLevel) 22 | log.SetLevel(logrus.DebugLevel) 23 | log.WithoutContext().WriterLevel(logrus.DebugLevel) 24 | } 25 | 26 | type fakeDockerClient struct { 27 | client.APIClient 28 | containers []types.Container 29 | container types.ContainerJSON 30 | err error 31 | } 32 | 33 | func (c *fakeDockerClient) ContainerList(ctx context.Context, options types.ContainerListOptions) ([]types.Container, error) { 34 | return c.containers, nil 35 | } 36 | 37 | func (c *fakeDockerClient) ContainerInspect(ctx context.Context, container string) (types.ContainerJSON, error) { 38 | return c.container, c.err 39 | } 40 | 41 | func Test_replaceIPs(t *testing.T) { 42 | cfg := &dynamic.Configuration{} 43 | err := json.Unmarshal([]byte(NGINX_CONF_JSON), cfg) 44 | require.NoError(t, err) 45 | require.Contains(t, cfg.HTTP.Services["nginx@docker"].LoadBalancer.Servers[0].URL, "172.20.0.2") 46 | 47 | fc := &dockerCache{client: &fakeDockerClient{}, list: nil, details: make(map[string]types.ContainerJSON)} 48 | 49 | // replace and test check again 50 | replaceIPs(fc, cfg, "7.7.7.7") 51 | require.NotContains(t, cfg.HTTP.Services["nginx@docker"].LoadBalancer.Servers[0].URL, "172.20.0.2") 52 | 53 | // full url 54 | require.Equal(t, "http://7.7.7.7:80", cfg.HTTP.Services["nginx@docker"].LoadBalancer.Servers[0].URL) 55 | 56 | // test again with larger fixture, tcp service 57 | cfg = &dynamic.Configuration{} 58 | _, err = toml.DecodeFile("./fixtures/sample.toml", &cfg) 59 | require.NoError(t, err) 60 | require.Equal(t, "foobar", cfg.TCP.Services["TCPService0"].LoadBalancer.Servers[0].Address) 61 | replaceIPs(fc, cfg, "7.7.7.7") 62 | require.Equal(t, "7.7.7.7", cfg.TCP.Services["TCPService0"].LoadBalancer.Servers[0].Address) 63 | } 64 | 65 | func createTestClient(labels map[string]string) *fakeDockerClient { 66 | return &fakeDockerClient{ 67 | containers: []types.Container{ 68 | types.Container{ 69 | ID: "foobar_id", 70 | }, 71 | }, 72 | container: types.ContainerJSON{ 73 | ContainerJSONBase: &types.ContainerJSONBase{ 74 | ID: "foobar_id", 75 | HostConfig: &container.HostConfig{}, 76 | }, 77 | Config: &container.Config{ 78 | Labels: labels, 79 | }, 80 | }, 81 | } 82 | 83 | } 84 | 85 | func Test_replacePorts(t *testing.T) { 86 | 87 | portMap := nat.PortMap{ 88 | "80": []nat.PortBinding{ 89 | {HostIP: "172.20.0.2", HostPort: "8888"}, 90 | }, 91 | } 92 | 93 | portLabel := "traefik.http.services.nginx.loadbalancer.server.port" 94 | dc := createTestClient(map[string]string{ 95 | "traefik.http.services.nginx.loadbalancer.server.scheme": "http", 96 | portLabel: "8888", 97 | }) 98 | 99 | fc := &dockerCache{client: dc, list: nil, details: make(map[string]types.ContainerJSON)} 100 | 101 | cfg := &dynamic.Configuration{} 102 | err := json.Unmarshal([]byte(NGINX_CONF_JSON), cfg) 103 | require.NoError(t, err) 104 | 105 | require.True(t, strings.HasSuffix(cfg.HTTP.Services["nginx@docker"].LoadBalancer.Servers[0].URL, "172.20.0.2:80")) 106 | 107 | // explicit label present 108 | replaceIPs(fc, cfg, "4.4.4.4") 109 | require.True(t, strings.HasSuffix(cfg.HTTP.Services["nginx@docker"].LoadBalancer.Servers[0].URL, "4.4.4.4:8888"), "URL '%s' should end with '%s'", cfg.HTTP.Services["nginx@docker"].LoadBalancer.Servers[0].URL, "4.4.4.4:8888") 110 | 111 | // without label but no port binding 112 | delete(dc.container.Config.Labels, portLabel) 113 | json.Unmarshal([]byte(NGINX_CONF_JSON), cfg) 114 | replaceIPs(fc, cfg, "4.4.4.4") 115 | require.True(t, strings.HasSuffix(cfg.HTTP.Services["nginx@docker"].LoadBalancer.Servers[0].URL, "4.4.4.4:80")) 116 | 117 | // with port binding 118 | dc.container.HostConfig.PortBindings = portMap 119 | json.Unmarshal([]byte(NGINX_CONF_JSON), cfg) 120 | replaceIPs(fc, cfg, "4.4.4.4") 121 | require.False(t, strings.HasSuffix(cfg.HTTP.Services["nginx@docker"].LoadBalancer.Servers[0].URL, "4.4.4.4:80")) 122 | require.True(t, strings.HasSuffix(cfg.HTTP.Services["nginx@docker"].LoadBalancer.Servers[0].URL, "4.4.4.4:8888")) 123 | } 124 | 125 | func Test_replacePortsNoService(t *testing.T) { 126 | 127 | portMap := nat.PortMap{ 128 | "80": []nat.PortBinding{ 129 | {HostIP: "172.20.0.2", HostPort: "8888"}, 130 | }, 131 | } 132 | 133 | dc := createTestClient(map[string]string{ 134 | "traefik.http.routers.nginx.entrypoints": "web-secure", 135 | }) 136 | fc := &dockerCache{client: dc, list: nil, details: make(map[string]types.ContainerJSON)} 137 | 138 | cfg := &dynamic.Configuration{} 139 | err := json.Unmarshal([]byte(NGINX_CONF_JSON_DIFFRENT_SERVICE_NAME), cfg) 140 | require.NoError(t, err) 141 | 142 | require.True(t, strings.HasSuffix(cfg.HTTP.Services["nginx-nginx@docker"].LoadBalancer.Servers[0].URL, "172.20.0.2:80")) 143 | 144 | // explicit label present 145 | replaceIPs(fc, cfg, "4.4.4.4") 146 | require.True(t, strings.HasSuffix(cfg.HTTP.Services["nginx-nginx@docker"].LoadBalancer.Servers[0].URL, "4.4.4.4:80")) 147 | 148 | // without label but no port binding 149 | json.Unmarshal([]byte(NGINX_CONF_JSON_DIFFRENT_SERVICE_NAME), cfg) 150 | replaceIPs(fc, cfg, "4.4.4.4") 151 | require.True(t, strings.HasSuffix(cfg.HTTP.Services["nginx-nginx@docker"].LoadBalancer.Servers[0].URL, "4.4.4.4:80")) 152 | 153 | // with port binding 154 | dc.container.HostConfig.PortBindings = portMap 155 | json.Unmarshal([]byte(NGINX_CONF_JSON_DIFFRENT_SERVICE_NAME), cfg) 156 | replaceIPs(fc, cfg, "4.4.4.4") 157 | require.False(t, strings.HasSuffix(cfg.HTTP.Services["nginx-nginx@docker"].LoadBalancer.Servers[0].URL, "4.4.4.4:80")) 158 | require.True(t, strings.HasSuffix(cfg.HTTP.Services["nginx-nginx@docker"].LoadBalancer.Servers[0].URL, "4.4.4.4:8888")) 159 | } 160 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package traefikkop 2 | 3 | import "encoding/json" 4 | 5 | func dumpJson(o interface{}) []byte { 6 | out, err := json.Marshal(o) 7 | if err != nil { 8 | panic(err) 9 | } 10 | return out 11 | } 12 | --------------------------------------------------------------------------------