diff --git a/.all-contributorsrc b/.all-contributorsrc new file mode 100644 index 0000000..d33c99c --- /dev/null +++ b/.all-contributorsrc @@ -0,0 +1,29 @@ +{ + "projectName": "external-dns-provider-adguard", + "projectOwner": "muhlba91", + "repoType": "github", + "repoHost": "https://github.com", + "files": [ + "README.md" + ], + "imageSize": 100, + "commit": false, + "contributors": [ + { + "login": "muhlba91", + "name": "Daniel Mühlbachler-Pietrzykowski", + "avatar_url": "https://avatars.githubusercontent.com/u/653739?v=4", + "profile": "https://muehlbachler.io/", + "contributions": [ + "maintenance", + "code", + "doc" + ] + } + ], + "contributorsPerLine": 7, + "linkToUsage": false, + "skipCi": true, + "commitConvention": "angular", + "commitType": "docs" +} \ No newline at end of file diff --git a/.conform.yaml b/.conform.yaml new file mode 100644 index 0000000..32c3059 --- /dev/null +++ b/.conform.yaml @@ -0,0 +1,29 @@ +--- +policies: + - type: commit + spec: + header: + length: 200 + imperative: true + case: lower + invalidLastCharacters: . + body: + required: false + dco: false + gpg: false + spellcheck: + locale: US + maximumOfOneCommit: false + conventional: + types: + - refactor + - perf + - chore + - test + - docs + - no_type + scopes: + - release + - deps + - ci + descriptionLength: 100 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..ad52c2a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @muhlba91 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0005b8f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +--- +name: Release + +on: + push: + branches: + - main + +permissions: + contents: read + pull-requests: read + packages: write + +env: + GO111MODULE: "on" + +jobs: + release: + runs-on: ubuntu-latest + name: Release + outputs: + release_created: ${{ steps.release.outputs.releases_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + permissions: + contents: write + pull-requests: write + + steps: + - uses: google-github-actions/release-please-action@v3 + id: release + with: + command: manifest + + provider: + if: needs.release.outputs.release_created + runs-on: ubuntu-latest + name: Publish Provider + permissions: + id-token: write + contents: write + needs: + - release + strategy: + max-parallel: 4 + matrix: + go-version: [1.21.x] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version: "${{ matrix.go-version }}" + + - name: Install cosign + uses: sigstore/cosign-installer@v3.1.2 + - name: Download Syft + uses: anchore/sbom-action/download-syft@v0.14.3 + + - name: Setup QEMU + uses: docker/setup-qemu-action@v3 + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Release via GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + args: -p 3 release --clean --timeout 60m0s + version: latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CI_COMMIT_TIMESTAMP: ${{ github.event.repository.updated_at }} + CI_COMMIT_SHA: ${{ github.sha }} + CI_COMMIT_TAG: ${{ needs.release.outputs.tag_name }} diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..03dd5e1 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,117 @@ +--- +name: Verify + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + packages: read + +env: + GO111MODULE: "on" + +jobs: + conform: + runs-on: ubuntu-latest + name: Conform + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: siderolabs/conform@v0.1.0-alpha.27 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + lint_provider: + runs-on: ubuntu-latest + name: Lint Provider + strategy: + max-parallel: 4 + matrix: + go-version: [1.21.x] + golangci-lint-version: [v1.54.2] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version: "${{ matrix.go-version }}" + + - name: Lint Provider + uses: golangci/golangci-lint-action@v3 + with: + version: "${{ matrix.golangci-lint-version }}" + args: -c .golangci.yml + skip-pkg-cache: true + skip-build-cache: true + + dockerfile_lint: + runs-on: ubuntu-latest + name: Lint Dockerfile + + steps: + - name: Checkout repository + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4 + with: + fetch-depth: 0 + + - name: Lint Dockerfile + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: Dockerfile + + build: + runs-on: ubuntu-latest + name: Build Provider and Container + needs: + - lint_provider + - dockerfile_lint + strategy: + max-parallel: 4 + matrix: + go-version: [1.21.x] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@v4 + with: + go-version: "${{ matrix.go-version }}" + + - name: Install cosign + uses: sigstore/cosign-installer@v3.1.2 + - name: Download Syft + uses: anchore/sbom-action/download-syft@v0.14.3 + + - name: Setup QEMU + uses: docker/setup-qemu-action@v3 + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Release via GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + args: -p 3 release --snapshot --clean --skip-publish --timeout 60m0s + version: latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CI_COMMIT_TIMESTAMP: ${{ github.event.pull_request.updated_at }} + CI_COMMIT_SHA: ${{ github.sha }} + CI_COMMIT_TAG: ${{ github.sha }}-dev diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75d7e03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,go +# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,go + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,go + +local.env diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..0b15bc1 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,23 @@ +linters: + enable: + - errcheck + - goconst + - revive + - gosec + - govet + - ineffassign + - lll + - megacheck + - misspell + - nakedret + - unconvert + - unused + enable-all: false +linters-settings: + lll: + line-length: 250 +run: + skip-files: + - schema.go + - pulumiManifest.go + timeout: 10m diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..1099853 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,100 @@ +--- +archives: + - id: archive + name_template: '{{ .Binary }}-{{ .Tag }}-{{ .Os }}-{{ .Arch }}' +builds: + - binary: external-dns-provider-adguard + env: + - CGO_ENABLED=0 + - GO111MODULE=on + goarch: + - amd64 + - arm64 + goos: + - darwin + - windows + - linux + ldflags: + - -X 'main.Version={{ .Tag }}' + - -X 'main.Gitsha={{ .ShortCommit }}' + main: ./cmd/webhook +source: + enabled: true +signs: + - cmd: cosign + env: + - COSIGN_EXPERIMENTAL=1 + certificate: '${artifact}.pem' + args: + - sign-blob + - --yes + - '--output-certificate=${certificate}' + - '--bundle=${signature}' + - '${artifact}' + artifacts: all + output: true +sboms: + - artifacts: archive + - id: source + artifacts: source +dockers: + - use: buildx + goos: linux + goarch: amd64 + image_templates: + - ghcr.io/muhlba91/external-dns-provider-adguard:latest-amd64 + - ghcr.io/muhlba91/external-dns-provider-adguard:{{ .Env.CI_COMMIT_SHA }}-amd64 + - ghcr.io/muhlba91/external-dns-provider-adguard:{{ .Env.CI_COMMIT_TAG }}-amd64 + build_flag_templates: + - --pull + - --platform=linux/amd64 + - --build-arg=CI_COMMIT_TIMESTAMP="{{ .Env.CI_COMMIT_TIMESTAMP }}" + - --build-arg=CI_COMMIT_SHA="{{ .Env.CI_COMMIT_SHA }}" + - --build-arg=CI_COMMIT_TAG="{{ .Env.CI_COMMIT_TAG }}" + - use: buildx + goos: linux + goarch: arm64 + image_templates: + - ghcr.io/muhlba91/external-dns-provider-adguard:latest-arm64 + - ghcr.io/muhlba91/external-dns-provider-adguard:{{ .Env.CI_COMMIT_SHA }}-arm64 + - ghcr.io/muhlba91/external-dns-provider-adguard:{{ .Env.CI_COMMIT_TAG }}-arm64 + build_flag_templates: + - --pull + - --platform=linux/arm64 + - --build-arg=CI_COMMIT_TIMESTAMP="{{ .Env.CI_COMMIT_TIMESTAMP }}" + - --build-arg=CI_COMMIT_SHA="{{ .Env.CI_COMMIT_SHA }}" + - --build-arg=CI_COMMIT_TAG="{{ .Env.CI_COMMIT_TAG }}" +docker_manifests: + - name_template: ghcr.io/muhlba91/external-dns-provider-adguard:latest + image_templates: + - ghcr.io/muhlba91/external-dns-provider-adguard:latest-amd64 + - ghcr.io/muhlba91/external-dns-provider-adguard:latest-arm64 + - name_template: ghcr.io/muhlba91/external-dns-provider-adguard:{{ .Env.CI_COMMIT_SHA }} + image_templates: + - ghcr.io/muhlba91/external-dns-provider-adguard:{{ .Env.CI_COMMIT_SHA }}-amd64 + - ghcr.io/muhlba91/external-dns-provider-adguard:{{ .Env.CI_COMMIT_SHA }}-arm64 + - name_template: ghcr.io/muhlba91/external-dns-provider-adguard:{{ .Env.CI_COMMIT_TAG }} + image_templates: + - ghcr.io/muhlba91/external-dns-provider-adguard:{{ .Env.CI_COMMIT_TAG }}-amd64 + - ghcr.io/muhlba91/external-dns-provider-adguard:{{ .Env.CI_COMMIT_TAG }}-arm64 +changelog: + skip: true + use: github + filters: + exclude: + - '^docs' + - '^chore' + groups: + - title: 'New Features' + regexp: "^.*feat[(\\w)]*:+.*$" + order: 0 + - title: 'Bugfixes' + regexp: "^.*fix[(\\w)]*:+.*$" + order: 10 + - title: Other Work + order: 999 +release: + disable: false + prerelease: auto +snapshot: + name_template: '{{ .Tag }}-SNAPSHOT' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c5d7fcb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,31 @@ +--- +repos: + - repo: https://github.com/talos-systems/conform + rev: v0.1.0-alpha.27 + hooks: + - id: conform + stages: + - commit-msg + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-json + exclude: .eslintrc.json|node_modules/* + - id: check-merge-conflict + - id: check-symlinks + - id: check-toml + - id: check-xml + - id: check-yaml + - id: detect-aws-credentials + args: [ + "--allow-missing-credentials" + ] + - id: detect-private-key + exclude: node_modules/* +# - id: no-commit-to-branch +# args: [ +# "--branch", +# "main", +# "--branch", +# "next" +# ] diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..88d1d60 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..81347ea --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM gcr.io/distroless/static-debian12:nonroot + +ARG CI_COMMIT_TIMESTAMP +ARG CI_COMMIT_SHA +ARG CI_COMMIT_TAG + +LABEL org.opencontainers.image.authors="Daniel Muehlbachler-Pietrzykowski " +LABEL org.opencontainers.image.vendor="Daniel Muehlbachler-Pietrzykowski" +LABEL org.opencontainers.image.source="https://github.com/muhlba91/external-dns-provider-adguard" +LABEL org.opencontainers.image.created="${CI_COMMIT_TIMESTAMP}" +LABEL org.opencontainers.image.title="external-dns-provider-adguard" +LABEL org.opencontainers.image.description="An Adguard webhook provider for external-dns" +LABEL org.opencontainers.image.revision="${CI_COMMIT_SHA}" +LABEL org.opencontainers.image.version="${CI_COMMIT_TAG}" + +USER 20000:20000 +ADD --chmod=555 external-dns-provider-adguard /opt/external-dns-provider-adguard/webhook + +EXPOSE 8888/tcp + +ENTRYPOINT ["/opt/external-dns-provider-adguard/webhook"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..d0af96c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,194 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c3f2e1d --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +ARTIFACT_NAME := external-dns-provider-adguard + +TESTPARALLELISM := 4 + +WORKING_DIR := $(shell pwd) + +.PHONY: lint +lint:: + golangci-lint run -c .golangci.yml + go vet ./... + +.PHONY: clean +clean:: + rm -r $(WORKING_DIR)/bin + +.PHONY: build +build:: + go build -o $(WORKING_DIR)/bin/${ARTIFACT_NAME} ./cmd/webhook + chmod +x $(WORKING_DIR)/bin/${ARTIFACT_NAME} + +.PHONY: test +test:: + go test -v -tags=all -parallel ${TESTPARALLELISM} -timeout 2h ./... diff --git a/README.md b/README.md index e69de29..6fcb4c9 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,102 @@ +# External DNS Adguard Home Provider (Webhook) + +[![](https://img.shields.io/github/license/muhlba91/external-dns-provider-adguard?style=for-the-badge)](LICENSE.md) +[![](https://img.shields.io/github/actions/workflow/status/muhlba91/external-dns-provider-adguard/verify.yml?style=for-the-badge)](https://github.com/muhlba91/external-dns-provider-adguard/actions/workflows/verify.yml) +[![](https://img.shields.io/github/release-date/muhlba91/external-dns-provider-adguard?style=for-the-badge)](https://github.com/muhlba91/external-dns-provider-adguard/releases) +[![](https://img.shields.io/github/all-contributors/muhlba91/external-dns-provider-adguard?color=ee8449&style=for-the-badge)](#contributors) +Buy Me A Coffee + +The Adguard Home Provider Webhook for [External DNS](https://github.com/kubernetes-sigs/external-dns) provides support for [Adguard Home filtering rules](https://github.com/AdguardTeam/AdGuardHome/wiki/Hosts-Blocklists#adblock-style). + +The provider is based on . + +## Kubernetes Deployment + +The Adguard webhook is provided as an OCI image in [ghcr.io/muhlba91/external-dns-provider-adguard](https://ghcr.io/muhlba91/external-dns-provider-adguard). + +The following example shows the deployment as a [sidecar container](https://kubernetes.io/docs/concepts/workloads/pods/#workload-resources-for-managing-pods) in the ExternalDNS pod using the [Bitnami Helm charts for ExternalDNS](https://github.com/bitnami/charts/tree/main/bitnami/external-dns). + +```shell +helm repo add bitnami https://charts.bitnami.com/bitnami + +# create the adguard configuration +kubectl create secret generic adguard-configuration --from-literal=url='' --from-literal=user='' --from-literal=password='' + +# create the helm values file +cat < external-dns-adguard-values.yaml +provider: webhook + +extraArgs: + webhook-provider-url: http://localhost:8888 + +sidecars: + - name: ionos-webhook + image: ghcr.io/muhlba91/external-dns-provider-adguard:$RELEASE_VERSION + ports: + - containerPort: 8888 + name: http + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + timeoutSeconds: 5 + env: + - name: LOG_LEVEL + value: debug + - name: ADGUARD_HOME + valueFrom: + secretKeyRef: + name: adguard-configuration + key: url + - name: ADGUARD_USER + valueFrom: + secretKeyRef: + name: adguard-configuration + key: user + - name: ADGUARD_PASSWORD + valueFrom: + secretKeyRef: + name: adguard-configuration + key: password + - name: SERVER_HOST + value: "0.0.0.0" + - name: DRY_RUN + value: "false" +EOF + +# install external-dns with helm +helm install external-dns-adguard bitnami/external-dns -f external-dns-adguard-values.yaml +``` + +## Configuration + +See [cmd/webhook/init/configuration/configuration.go](./cmd/webhook/init/configuration/configuration.go) for all available configuration options of the webhook sidecar, and [internal/adguard/configuration.go](./internal/adguard/configuration.go) for all available configuration options of the Adguard provider. + +## Contributors + +Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): + + + + + + + + + + +
Daniel Mühlbachler-Pietrzykowski
Daniel Mühlbachler-Pietrzykowski

🚧 💻 📖
+ + + + + + +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! diff --git a/bin/external-dns-provider-adguard b/bin/external-dns-provider-adguard new file mode 100755 index 0000000..5f42783 Binary files /dev/null and b/bin/external-dns-provider-adguard differ diff --git a/cmd/webhook/init/configuration/configuration.go b/cmd/webhook/init/configuration/configuration.go new file mode 100644 index 0000000..c109634 --- /dev/null +++ b/cmd/webhook/init/configuration/configuration.go @@ -0,0 +1,29 @@ +package configuration + +import ( + "time" + + "github.com/caarlos0/env/v8" + log "github.com/sirupsen/logrus" +) + +// Config struct for configuration environmental variables +type Config struct { + ServerHost string `env:"SERVER_HOST" envDefault:"localhost"` + ServerPort int `env:"SERVER_PORT" envDefault:"8888"` + ServerReadTimeout time.Duration `env:"SERVER_READ_TIMEOUT"` + ServerWriteTimeout time.Duration `env:"SERVER_WRITE_TIMEOUT"` + DomainFilter []string `env:"DOMAIN_FILTER" envDefault:""` + ExcludeDomains []string `env:"EXCLUDE_DOMAIN_FILTER" envDefault:""` + RegexDomainFilter string `env:"REGEXP_DOMAIN_FILTER" envDefault:""` + RegexDomainExclusion string `env:"REGEXP_DOMAIN_FILTER_EXCLUSION" envDefault:""` +} + +// Init sets up configuration by reading set environmental variables +func Init() Config { + cfg := Config{} + if err := env.Parse(&cfg); err != nil { + log.Fatalf("error reading configuration from environment: %v", err) + } + return cfg +} diff --git a/cmd/webhook/init/dnsprovider/dnsprovider.go b/cmd/webhook/init/dnsprovider/dnsprovider.go new file mode 100644 index 0000000..3bd2ffc --- /dev/null +++ b/cmd/webhook/init/dnsprovider/dnsprovider.go @@ -0,0 +1,54 @@ +package dnsprovider + +import ( + "fmt" + "regexp" + "strings" + + "github.com/caarlos0/env/v8" + "github.com/muhlba91/external-dns-provider-adguard/cmd/webhook/init/configuration" + "github.com/muhlba91/external-dns-provider-adguard/internal/adguard" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/provider" + + log "github.com/sirupsen/logrus" +) + +type AdguardProviderFactory func(baseProvider *provider.BaseProvider, adguardConfig *adguard.Configuration) provider.Provider + +func Init(config configuration.Config) (provider.Provider, error) { + var domainFilter endpoint.DomainFilter + createMsg := "creating adguard provider with " + + if config.RegexDomainFilter != "" { + createMsg += fmt.Sprintf("regexp domain filter: '%s', ", config.RegexDomainFilter) + if config.RegexDomainExclusion != "" { + createMsg += fmt.Sprintf("with exclusion: '%s', ", config.RegexDomainExclusion) + } + domainFilter = endpoint.NewRegexDomainFilter( + regexp.MustCompile(config.RegexDomainFilter), + regexp.MustCompile(config.RegexDomainExclusion), + ) + } else { + if config.DomainFilter != nil && len(config.DomainFilter) > 0 { + createMsg += fmt.Sprintf("domain filter: '%s', ", strings.Join(config.DomainFilter, ",")) + } + if config.ExcludeDomains != nil && len(config.ExcludeDomains) > 0 { + createMsg += fmt.Sprintf("exclude domain filter: '%s', ", strings.Join(config.ExcludeDomains, ",")) + } + domainFilter = endpoint.NewDomainFilterWithExclusions(config.DomainFilter, config.ExcludeDomains) + } + + createMsg = strings.TrimSuffix(createMsg, ", ") + if strings.HasSuffix(createMsg, "with ") { + createMsg += "no kind of domain filters" + } + log.Info(createMsg) + + adguardConfig := adguard.Configuration{} + if err := env.Parse(&adguardConfig); err != nil { + return nil, fmt.Errorf("reading adguard configuration failed: %v", err) + } + + return adguard.NewAdguardProvider(domainFilter, &adguardConfig) +} diff --git a/cmd/webhook/init/dnsprovider/dnsprovider_test.go b/cmd/webhook/init/dnsprovider/dnsprovider_test.go new file mode 100644 index 0000000..386bb82 --- /dev/null +++ b/cmd/webhook/init/dnsprovider/dnsprovider_test.go @@ -0,0 +1,52 @@ +package dnsprovider + +import ( + "testing" + + "github.com/muhlba91/external-dns-provider-adguard/cmd/webhook/init/configuration" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestInit(t *testing.T) { + log.SetLevel(log.DebugLevel) + + cases := []struct { + name string + config configuration.Config + env map[string]string + expectedError string + }{ + { + name: "minimal config for adguard provider", + config: configuration.Config{}, + env: map[string]string{ + "ADGUARD_URL": "https://domain.com", + "DRY_RUN": "true", + }, + }, + { + name: "empty configuration", + config: configuration.Config{}, + expectedError: "reading adguard configuration failed: env: environment variable \"ADGUARD_URL\" should not be empty", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.env { + t.Setenv(k, v) + } + + dnsProvider, err := Init(tc.config) + + if tc.expectedError != "" { + assert.EqualError(t, err, tc.expectedError, "expecting error") + return + } + + assert.NoErrorf(t, err, "error creating provider") + assert.NotNil(t, dnsProvider) + }) + } +} diff --git a/cmd/webhook/init/logging/log.go b/cmd/webhook/init/logging/log.go new file mode 100644 index 0000000..9b72e1b --- /dev/null +++ b/cmd/webhook/init/logging/log.go @@ -0,0 +1,37 @@ +package logging + +import ( + "os" + + log "github.com/sirupsen/logrus" +) + +func Init() { + setLogLevel() + setLogFormat() +} + +func setLogFormat() { + format := os.Getenv("LOG_FORMAT") + if format == "test" { + log.SetFormatter(&log.TextFormatter{}) + } else { + log.SetFormatter(&log.JSONFormatter{}) + } +} + +func setLogLevel() { + level := os.Getenv("LOG_LEVEL") + switch level { + case "debug": + log.SetLevel(log.DebugLevel) + case "info": + log.SetLevel(log.InfoLevel) + case "warn": + log.SetLevel(log.WarnLevel) + case "error": + log.SetLevel(log.ErrorLevel) + default: + log.SetLevel(log.InfoLevel) + } +} diff --git a/cmd/webhook/init/server/server.go b/cmd/webhook/init/server/server.go new file mode 100644 index 0000000..941d372 --- /dev/null +++ b/cmd/webhook/init/server/server.go @@ -0,0 +1,66 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-chi/chi/v5" + "github.com/muhlba91/external-dns-provider-adguard/cmd/webhook/init/configuration" + "github.com/muhlba91/external-dns-provider-adguard/pkg/webhook" + + log "github.com/sirupsen/logrus" +) + +// Init server initialization function +// The server will respond to the following endpoints: +// - / (GET): initialization, negotiates headers and returns the domain filter +// - /records (GET): returns the current records +// - /records (POST): applies the changes +// - /adjustendpoints (POST): executes the AdjustEndpoints method +func Init(config configuration.Config, p *webhook.Webhook) *http.Server { + r := chi.NewRouter() + + r.Use(webhook.Health) + r.Get("/", p.Negotiate) + r.Get("/records", p.Records) + r.Post("/records", p.ApplyChanges) + r.Post("/adjustendpoints", p.AdjustEndpoints) + + srv := createHTTPServer(fmt.Sprintf("%s:%d", config.ServerHost, config.ServerPort), r, config.ServerReadTimeout, config.ServerWriteTimeout) + go func() { + log.Infof("starting server on addr: '%s' ", srv.Addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Errorf("can't serve on addr: '%s', error: %v", srv.Addr, err) + } + }() + return srv +} + +func createHTTPServer(addr string, hand http.Handler, readTimeout, writeTimeout time.Duration) *http.Server { + return &http.Server{ + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + Addr: addr, + Handler: hand, + } +} + +// ShutdownGracefully gracefully shutdown the http server +func ShutdownGracefully(srv *http.Server) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) + sig := <-sigCh + + log.Infof("shutting down server due to received signal: %v", sig) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + if err := srv.Shutdown(ctx); err != nil { + log.Errorf("error shutting down server: %v", err) + } + cancel() +} diff --git a/cmd/webhook/init/server/server_test.go b/cmd/webhook/init/server/server_test.go new file mode 100644 index 0000000..f667d01 --- /dev/null +++ b/cmd/webhook/init/server/server_test.go @@ -0,0 +1,548 @@ +package server + +import ( + "context" + "fmt" + "io" + "net/http" + "reflect" + "strings" + "testing" + "time" + + "github.com/muhlba91/external-dns-provider-adguard/cmd/webhook/init/configuration" + "github.com/muhlba91/external-dns-provider-adguard/pkg/webhook" + log "github.com/sirupsen/logrus" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" +) + +type testCase struct { + name string + returnRecords []*endpoint.Endpoint + returnAdjustedEndpoints []*endpoint.Endpoint + returnDomainFilter endpoint.DomainFilter + hasError error + method string + path string + headers map[string]string + body string + expectedStatusCode int + expectedResponseHeaders map[string]string + expectedBody string + expectedChanges *plan.Changes + expectedEndpointsToAdjust []*endpoint.Endpoint + log.Ext1FieldLogger +} + +var mockProvider *MockProvider + +func TestMain(m *testing.M) { + mockProvider = &MockProvider{} + + srv := Init(configuration.Init(), webhook.New(mockProvider)) + go ShutdownGracefully(srv) + + time.Sleep(300 * time.Millisecond) + + m.Run() + if err := srv.Shutdown(context.TODO()); err != nil { + panic(err) + } +} + +func TestRecords(t *testing.T) { + testCases := []testCase{ + { + name: "valid case", + returnRecords: []*endpoint.Endpoint{ + { + DNSName: "test.example.com", + Targets: []string{""}, + RecordType: "A", + RecordTTL: 3600, + Labels: map[string]string{ + "label1": "value1", + }, + }, + }, + method: http.MethodGet, + headers: map[string]string{"Accept": "application/external.dns.webhook+json;version=1"}, + path: "/records", + body: "", + expectedStatusCode: http.StatusOK, + expectedResponseHeaders: map[string]string{ + "Content-Type": "application/external.dns.webhook+json;version=1", + }, + expectedBody: "[{\"dnsName\":\"test.example.com\",\"targets\":[\"\"],\"recordType\":\"A\",\"recordTTL\":3600,\"labels\":{\"label1\":\"value1\"}}]", + }, + { + name: "no accept header", + method: http.MethodGet, + headers: map[string]string{}, + path: "/records", + body: "", + expectedStatusCode: http.StatusNotAcceptable, + expectedResponseHeaders: map[string]string{ + "Content-Type": "text/plain", + }, + expectedBody: "client must provide an accept header", + }, + { + name: "wrong accept header", + method: http.MethodGet, + headers: map[string]string{"Accept": "invalid"}, + path: "/records", + body: "", + expectedStatusCode: http.StatusUnsupportedMediaType, + expectedResponseHeaders: map[string]string{ + "Content-Type": "text/plain", + }, + expectedBody: "client must provide a valid versioned media type in the accept header: unsupported media type version: 'invalid'. supported media types are: 'application/external.dns.webhook+json;version=1'", + }, + { + name: "backend error", + hasError: fmt.Errorf("backend error"), + method: http.MethodGet, + headers: map[string]string{"Accept": "application/external.dns.webhook+json;version=1"}, + path: "/records", + body: "", + expectedStatusCode: http.StatusInternalServerError, + }, + } + + executeTestCases(t, testCases) +} + +func TestApplyChanges(t *testing.T) { + testCases := []testCase{ + { + name: "valid case", + method: http.MethodPost, + headers: map[string]string{ + "Content-Type": "application/external.dns.webhook+json;version=1", + }, + path: "/records", + body: ` +{ + "Create": [ + { + "dnsName": "test.example.com", + "targets": ["11.11.11.11"], + "recordType": "A", + "recordTTL": 3600, + "labels": { + "label1": "value1", + "label2": "value2" + } + } + ] +}`, + expectedStatusCode: http.StatusNoContent, + expectedResponseHeaders: map[string]string{}, + expectedBody: "", + expectedChanges: &plan.Changes{ + Create: []*endpoint.Endpoint{ + { + DNSName: "test.example.com", + Targets: []string{"11.11.11.11"}, + RecordType: "A", + RecordTTL: 3600, + Labels: map[string]string{ + "label1": "value1", + "label2": "value2", + }, + }, + }, + }, + }, + { + name: "valid case with updates", + method: http.MethodPost, + headers: map[string]string{ + "Content-Type": "application/external.dns.webhook+json;version=1", + }, + path: "/records", + body: ` +{ + "UpdateOld": [ + { + "dnsName": "test.example.com", + "targets": ["11.11.11.11"], + "recordType": "A", + "recordTTL": 3600, + "labels": { + "label1": "value1", + "label2": "value2" + } + } + ], + "UpdateNew": [ + { + "dnsName": "test.example.com", + "targets": ["22.22.22.22"], + "recordType": "A", + "recordTTL": 3600, + "labels": { + "label1": "value1", + "label2": "value2" + } + } + ] +}`, + expectedStatusCode: http.StatusNoContent, + expectedResponseHeaders: map[string]string{}, + expectedBody: "", + expectedChanges: &plan.Changes{ + UpdateOld: []*endpoint.Endpoint{ + { + DNSName: "test.example.com", + Targets: []string{"11.11.11.11"}, + RecordType: "A", + RecordTTL: 3600, + Labels: map[string]string{ + "label1": "value1", + "label2": "value2", + }, + }, + }, + UpdateNew: []*endpoint.Endpoint{ + { + DNSName: "test.example.com", + Targets: []string{"22.22.22.22"}, + RecordType: "A", + RecordTTL: 3600, + Labels: map[string]string{ + "label1": "value1", + "label2": "value2", + }, + }, + }, + }, + }, + { + name: "no content type header", + method: http.MethodPost, + path: "/records", + body: "", + expectedStatusCode: http.StatusNotAcceptable, + expectedResponseHeaders: map[string]string{ + "Content-Type": "text/plain", + }, + expectedBody: "client must provide a content type", + }, + { + name: "wrong content type header", + method: http.MethodPost, + headers: map[string]string{ + "Content-Type": "invalid", + }, + path: "/records", + body: "", + expectedStatusCode: http.StatusUnsupportedMediaType, + expectedResponseHeaders: map[string]string{ + "Content-Type": "text/plain", + }, + expectedBody: "client must provide a valid versioned media type in the content type: unsupported media type version: 'invalid'. supported media types are: 'application/external.dns.webhook+json;version=1'", + }, + { + name: "invalid json", + method: http.MethodPost, + headers: map[string]string{ + "Content-Type": "application/external.dns.webhook+json;version=1", + "Accept": "application/external.dns.webhook+json;version=1", + }, + path: "/records", + body: "invalid", + expectedStatusCode: http.StatusBadRequest, + expectedResponseHeaders: map[string]string{ + "Content-Type": "text/plain", + }, + expectedBody: "error decoding changes: invalid character 'i' looking for beginning of value", + }, + { + name: "backend error", + hasError: fmt.Errorf("backend error"), + method: http.MethodPost, + headers: map[string]string{ + "Content-Type": "application/external.dns.webhook+json;version=1", + "Accept": "application/external.dns.webhook+json;version=1", + }, + path: "/records", + body: ` +{ + "Create": [ + { + "dnsName": "test.example.com", + "targets": ["11.11.11.11"], + "recordType": "A", + "recordTTL": 3600, + "labels": { + "label1": "value1", + "label2": "value2" + } + } + ] +}`, + expectedStatusCode: http.StatusInternalServerError, + }, + } + + executeTestCases(t, testCases) +} + +func TestAdjustEndpoints(t *testing.T) { + testCases := []testCase{ + { + name: "happy case", + returnAdjustedEndpoints: []*endpoint.Endpoint{ + { + DNSName: "adjusted.example.com", + Targets: []string{""}, + RecordType: "A", + RecordTTL: 3600, + Labels: map[string]string{ + "label1": "value1", + }, + }, + }, + method: http.MethodPost, + headers: map[string]string{ + "Content-Type": "application/external.dns.webhook+json;version=1", + "Accept": "application/external.dns.webhook+json;version=1", + }, + path: "/adjustendpoints", + body: ` +[ + { + "dnsName": "toadjust.example.com", + "targets": [], + "recordType": "A", + "recordTTL": 3600, + "labels": { + "label1": "value1", + "label2": "value2" + } + } +]`, + expectedStatusCode: http.StatusOK, + expectedResponseHeaders: map[string]string{ + "Content-Type": "application/external.dns.webhook+json;version=1", + }, + expectedBody: "[{\"dnsName\":\"adjusted.example.com\",\"targets\":[\"\"],\"recordType\":\"A\",\"recordTTL\":3600,\"labels\":{\"label1\":\"value1\"}}]", + expectedEndpointsToAdjust: []*endpoint.Endpoint{ + { + DNSName: "toadjust.example.com", + Targets: []string{}, + RecordType: "A", + RecordTTL: 3600, + Labels: map[string]string{ + "label1": "value1", + "label2": "value2", + }, + }, + }, + }, + { + name: "no content type header", + method: http.MethodPost, + headers: map[string]string{ + "Accept": "application/external.dns.webhook+json;version=1", + }, + path: "/adjustendpoints", + body: "", + expectedStatusCode: http.StatusNotAcceptable, + expectedResponseHeaders: map[string]string{ + "Content-Type": "text/plain", + }, + expectedBody: "client must provide a content type", + }, + { + name: "wrong content type header", + method: http.MethodPost, + headers: map[string]string{ + "Content-Type": "invalid", + "Accept": "application/external.dns.webhook+json;version=1", + }, + path: "/adjustendpoints", + body: "", + expectedStatusCode: http.StatusUnsupportedMediaType, + expectedResponseHeaders: map[string]string{ + "Content-Type": "text/plain", + }, + expectedBody: "client must provide a valid versioned media type in the content type: unsupported media type version: 'invalid'. supported media types are: 'application/external.dns.webhook+json;version=1'", + }, + { + name: "no accept header", + method: http.MethodPost, + headers: map[string]string{ + "Content-Type": "application/external.dns.webhook+json;version=1", + }, + path: "/adjustendpoints", + body: "", + expectedStatusCode: http.StatusNotAcceptable, + expectedResponseHeaders: map[string]string{ + "Content-Type": "text/plain", + }, + expectedBody: "client must provide an accept header", + }, + { + name: "wrong accept header", + method: http.MethodPost, + headers: map[string]string{ + "Content-Type": "application/external.dns.webhook+json;version=1", + "Accept": "invalid", + }, + path: "/adjustendpoints", + body: "", + expectedStatusCode: http.StatusUnsupportedMediaType, + expectedResponseHeaders: map[string]string{ + "Content-Type": "text/plain", + }, + expectedBody: "client must provide a valid versioned media type in the accept header: unsupported media type version: 'invalid'. supported media types are: 'application/external.dns.webhook+json;version=1'", + }, + { + name: "invalid json", + method: http.MethodPost, + headers: map[string]string{ + "Content-Type": "application/external.dns.webhook+json;version=1", + "Accept": "application/external.dns.webhook+json;version=1", + }, + path: "/adjustendpoints", + body: "invalid", + expectedStatusCode: http.StatusBadRequest, + expectedResponseHeaders: map[string]string{ + "Content-Type": "text/plain", + }, + expectedBody: "failed to decode request body: invalid character 'i' looking for beginning of value", + }, + } + + executeTestCases(t, testCases) +} + +func TestNegotiate(t *testing.T) { + testCases := []testCase{ + { + name: "happy case", + returnDomainFilter: endpoint.NewDomainFilter([]string{"a.de"}), + method: http.MethodGet, + headers: map[string]string{"Accept": "application/external.dns.webhook+json;version=1"}, + path: "/", + body: "", + expectedStatusCode: http.StatusOK, + expectedResponseHeaders: map[string]string{ + "Content-Type": "application/external.dns.webhook+json;version=1", + }, + expectedBody: `{"include":["a.de"]}`, + }, + { + name: "no accept header", + method: http.MethodGet, + headers: map[string]string{}, + path: "/", + body: "", + expectedStatusCode: http.StatusNotAcceptable, + expectedResponseHeaders: map[string]string{ + "Content-Type": "text/plain", + }, + expectedBody: "client must provide an accept header", + }, + { + name: "wrong accept header", + method: http.MethodGet, + headers: map[string]string{"Accept": "invalid"}, + path: "/", + body: "", + expectedStatusCode: http.StatusUnsupportedMediaType, + expectedResponseHeaders: map[string]string{ + "Content-Type": "text/plain", + }, + expectedBody: "client must provide a valid versioned media type in the accept header: unsupported media type version: 'invalid'. supported media types are: 'application/external.dns.webhook+json;version=1'", + }, + } + + executeTestCases(t, testCases) +} + +func executeTestCases(t *testing.T, testCases []testCase) { + log.SetLevel(log.DebugLevel) + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d. %s", i+1, tc.name), func(t *testing.T) { + mockProvider.testCase = tc + mockProvider.t = t + + var bodyReader io.Reader + if tc.body != "" { + bodyReader = strings.NewReader(tc.body) + } + + request, err := http.NewRequest(tc.method, "http://localhost:8888"+tc.path, bodyReader) + if err != nil { + t.Error(err) + } + + for k, v := range tc.headers { + request.Header.Set(k, v) + } + + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Error(err) + } + + if response.StatusCode != tc.expectedStatusCode { + t.Errorf("expected status code %d, got %d", tc.expectedStatusCode, response.StatusCode) + } + + for k, v := range tc.expectedResponseHeaders { + if response.Header.Get(k) != v { + t.Errorf("expected header '%s' with value '%s', got '%s'", k, v, response.Header.Get(k)) + } + } + + if tc.expectedBody != "" { + body, err := io.ReadAll(response.Body) + if err != nil { + t.Error(err) + } + _ = response.Body.Close() + actualTrimmedBody := strings.TrimSpace(string(body)) + if actualTrimmedBody != tc.expectedBody { + t.Errorf("expected body '%s', got '%s'", tc.expectedBody, actualTrimmedBody) + } + } + }) + } +} + +type MockProvider struct { + t *testing.T + testCase testCase +} + +func (d *MockProvider) Records(_ context.Context) ([]*endpoint.Endpoint, error) { + return d.testCase.returnRecords, d.testCase.hasError +} + +func (d *MockProvider) ApplyChanges(_ context.Context, changes *plan.Changes) error { + if d.testCase.hasError != nil { + return d.testCase.hasError + } + if !reflect.DeepEqual(changes, d.testCase.expectedChanges) { + d.t.Errorf("expected changes '%v', got '%v'", d.testCase.expectedChanges, changes) + } + return nil +} + +func (d *MockProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) { + if !reflect.DeepEqual(endpoints, d.testCase.expectedEndpointsToAdjust) { + d.t.Errorf("expected endpoints to adjust '%v', got '%v'", d.testCase.expectedEndpointsToAdjust, endpoints) + } + return d.testCase.returnAdjustedEndpoints, nil +} + +func (d *MockProvider) GetDomainFilter() endpoint.DomainFilter { + return d.testCase.returnDomainFilter +} diff --git a/cmd/webhook/main.go b/cmd/webhook/main.go new file mode 100644 index 0000000..d1a0b3b --- /dev/null +++ b/cmd/webhook/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + + "github.com/muhlba91/external-dns-provider-adguard/cmd/webhook/init/configuration" + "github.com/muhlba91/external-dns-provider-adguard/cmd/webhook/init/dnsprovider" + "github.com/muhlba91/external-dns-provider-adguard/cmd/webhook/init/logging" + "github.com/muhlba91/external-dns-provider-adguard/cmd/webhook/init/server" + "github.com/muhlba91/external-dns-provider-adguard/pkg/webhook" + log "github.com/sirupsen/logrus" +) + +const banner = ` +external-dns-provider-adguard +version: %s (%s) + +` + +var ( + Version = "local" + Gitsha = "?" +) + +func main() { + fmt.Printf(banner, Version, Gitsha) + + logging.Init() + + config := configuration.Init() + provider, err := dnsprovider.Init(config) + if err != nil { + log.Fatalf("failed to initialize provider: %v", err) + } + + srv := server.Init(config, webhook.New(provider)) + server.ShutdownGracefully(srv) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5442031 --- /dev/null +++ b/go.mod @@ -0,0 +1,50 @@ +module github.com/muhlba91/external-dns-provider-adguard + +go 1.21 + +toolchain go1.21.3 + +require ( + github.com/caarlos0/env/v8 v8.0.0 + github.com/go-chi/chi/v5 v5.0.8 + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.8.4 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d + sigs.k8s.io/external-dns v0.13.7-0.20231027112202-d8f408b8a51a +) + +require ( + github.com/aws/aws-sdk-go v1.44.311 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.2.4 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-cmp v0.5.9 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.16.0 // indirect + github.com/prometheus/client_model v0.4.0 // indirect + github.com/prometheus/common v0.43.0 // indirect + github.com/prometheus/procfs v0.10.1 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apimachinery v0.27.4 // indirect + k8s.io/klog/v2 v2.100.1 // indirect + k8s.io/utils v0.0.0-20230505201702-9f6742963106 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b9dee91 --- /dev/null +++ b/go.sum @@ -0,0 +1,158 @@ +github.com/aws/aws-sdk-go v1.44.311 h1:60i8hyVMOXqabKJQPCq4qKRBQ6hRafI/WOcDxGM+J7Q= +github.com/aws/aws-sdk-go v1.44.311/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/caarlos0/env/v8 v8.0.0 h1:POhxHhSpuxrLMIdvTGARuZqR4Jjm8AYmoi/JKlcScs0= +github.com/caarlos0/env/v8 v8.0.0/go.mod h1:7K4wMY9bH0esiXSSHlfHLX5xKGQMnkH5Fk4TDSSSzfo= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= +github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.43.0 h1:iq+BVjvYLei5f27wiuNiB1DN6DYQkp1c8Bx0Vykh5us= +github.com/prometheus/common v0.43.0/go.mod h1:NCvr5cQIh3Y/gy73/RdVtC9r8xxrxwJnB+2lB3BxrFc= +github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= +github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.27.4 h1:CdxflD4AF61yewuid0fLl6bM4a3q04jWel0IlP+aYjs= +k8s.io/apimachinery v0.27.4/go.mod h1:XNfZ6xklnMCOGGFNqXG7bUrQCoR04dh/E7FprV6pb+E= +k8s.io/klog/v2 v2.100.1 h1:7WCHKK6K8fNhTqfBhISHQ97KrnJNFZMcQvKp7gP/tmg= +k8s.io/klog/v2 v2.100.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/utils v0.0.0-20230505201702-9f6742963106 h1:EObNQ3TW2D+WptiYXlApGNLVy0zm/JIBVY9i+M4wpAU= +k8s.io/utils v0.0.0-20230505201702-9f6742963106/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/external-dns v0.13.7-0.20231027112202-d8f408b8a51a h1:YBgvZEyohKaXvyEVZcan+cgY9qjQcjy/G/vazZkyNoY= +sigs.k8s.io/external-dns v0.13.7-0.20231027112202-d8f408b8a51a/go.mod h1:d4Knr/BFz8U1Lc6yLhCzTRP6nJOz6fqR/MnqqJPcIlU= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/internal/adguard/client.go b/internal/adguard/client.go new file mode 100644 index 0000000..bf09721 --- /dev/null +++ b/internal/adguard/client.go @@ -0,0 +1,140 @@ +package adguard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + log "github.com/sirupsen/logrus" +) + +// Client interface for interacting with Adguard +// See the OpenAPI spec: https://raw.githubusercontent.com/AdguardTeam/AdGuardHome/master/openapi/openapi.yaml +type Client interface { + GetFilteringRules(ctx context.Context) ([]string, error) + SetFilteringRules(ctx context.Context, rules []string) error +} + +// hhtpClient type used to implement Client with an HTTP client +type httpClient struct { + hc *http.Client + config *Configuration +} + +// getFilteringRules is the response of retrieving filtering rules +type getFilteringRules struct { + UserRules []string `json:"user_rules"` +} + +// setFilteringRules is the request sent to Adguard for setting new rules +type setFilteringRules struct { + Rules []string `json:"rules"` +} + +// newAdguardClient initializes a new HTTP client +func newAdguardClient(config *Configuration) (*httpClient, error) { + hc := http.Client{} + c := &httpClient{ + hc: &hc, + config: config, + } + + // check validity of the configuration + err := c.status(context.Background()) + if err != nil { + return nil, err + } + + return c, nil +} + +func (c *httpClient) doRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { + log.Debugf("making %s request to /%s", method, path) + + req, err := http.NewRequestWithContext(ctx, method, c.config.URL+path, body) + if err != nil { + return nil, err + } + + req.SetBasicAuth(c.config.User, c.config.Password) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.hc.Do(req) + if err != nil { + return nil, err + } + + log.Debugf("response code from %s request to %s: %d", method, path, resp.StatusCode) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s request to %s was not successful: %d", method, path, resp.StatusCode) + } + + return resp, nil +} + +func (c *httpClient) status(ctx context.Context) error { + if c.config.DryRun { + log.Info("would check adguard configuration") + return nil + } + + r, err := c.doRequest(ctx, http.MethodGet, "status", nil) + if err != nil { + return err + } + _ = r.Body.Close() + + return nil +} + +// Retrieves all existing filtering rules from Adguard +func (c *httpClient) GetFilteringRules(ctx context.Context) ([]string, error) { + if c.config.DryRun { + log.Info("would retrieve rules") + return []string{}, nil + } + + r, err := c.doRequest(ctx, http.MethodGet, "filtering/status", nil) + if err != nil { + return nil, err + } + defer r.Body.Close() + + var resp getFilteringRules + err = json.NewDecoder(r.Body).Decode(&resp) + if err != nil { + return nil, err + } + log.Debugf("retrieved filtering rules: %+v", resp) + + return resp.UserRules, nil +} + +// Sets new filtering rules in Adguard +func (c *httpClient) SetFilteringRules(ctx context.Context, rules []string) error { + if c.config.DryRun { + log.Infof("would set rules: %+v", rules) + return nil + } + + body := setFilteringRules{Rules: rules} + log.Debugf("sending filtering rules: %s", body) + + b := bytes.NewBuffer(nil) + err := json.NewEncoder(b).Encode(body) + if err != nil { + return err + } + + r, err := c.doRequest(ctx, http.MethodPost, "filtering/set_rules", b) + if err != nil { + return err + } + _ = r.Body.Close() + + return nil +} diff --git a/internal/adguard/configuration.go b/internal/adguard/configuration.go new file mode 100644 index 0000000..c319a07 --- /dev/null +++ b/internal/adguard/configuration.go @@ -0,0 +1,9 @@ +package adguard + +// Configuration holds configuration from environmental variables +type Configuration struct { + URL string `env:"ADGUARD_URL,notEmpty"` + User string `env:"ADGUARD_USER"` + Password string `env:"ADGUARD_PASSWORD"` + DryRun bool `env:"DRY_RUN" envDefault:"false"` +} diff --git a/internal/adguard/provider.go b/internal/adguard/provider.go new file mode 100644 index 0000000..5dc4d95 --- /dev/null +++ b/internal/adguard/provider.go @@ -0,0 +1,214 @@ +package adguard + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + + log "github.com/sirupsen/logrus" + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider" +) + +// Provider type for interfacing with Adguard +type Provider struct { + provider.BaseProvider + + client Client + domainFilter endpoint.DomainFilter +} + +var ( + errNotManaged = fmt.Errorf("not managed by external-dns") +) + +const ( + managedBy = "$managed-by-external-dns" +) + +// NewAdguardProvider initializes a new provider +func NewAdguardProvider(domainFilter endpoint.DomainFilter, config *Configuration) (provider.Provider, error) { + log.Debugf("using adguard at %s", config.URL) + + // URL adjustment according to the specification + if !strings.HasSuffix(config.URL, "/") { + config.URL = config.URL + "/" + } + if !strings.HasSuffix(config.URL, "control/") { + config.URL = config.URL + "control/" + } + + c, err := newAdguardClient(config) + if err != nil { + return nil, fmt.Errorf("failed to create the adguard client: %w", err) + } + + p := &Provider{ + client: c, + domainFilter: domainFilter, + } + + return p, nil +} + +// ApplyChanges syncs the desired state with Adguard +func (p *Provider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + log.Debugf("received changes: %+v", changes) + + or, err := p.client.GetFilteringRules(ctx) + if err != nil { + return err + } + + // resulting rules + rr := make([]string, 0) + // list of endpoints to create - endpoints are referenced by dns name and record type + eps := make(map[string]*endpoint.Endpoint) + + // collect all non-managed rules and existing endpoints managed by external-dns + for _, r := range or { + ep, err := deserializeToEndpoint(r) + if err != nil { + // rules not managed by external-dns are kept + if errors.Is(err, errNotManaged) { + rr = append(rr, r) + continue + } + return fmt.Errorf("failed to parse rule %s: %w", r, err) + } + + epk := ep.DNSName + ep.RecordType + if eps[epk] != nil { + eps[epk].Targets = append(eps[epk].Targets, ep.Targets...) + } else { + eps[epk] = ep + } + } + + // delete all records to be updated or deleted + for _, dep := range append(changes.UpdateOld, changes.Delete...) { + epk := dep.DNSName + dep.RecordType + if ep, ok := eps[epk]; ok { + for _, t := range dep.Targets { + if slices.Contains(ep.Targets, t) { + ti := slices.Index(ep.Targets, t) + ep.Targets = append(ep.Targets[:ti], ep.Targets[ti+1:]...) + log.Debugf("deleting target %s for %s %s", t, dep.DNSName, dep.RecordType) + } + if len(ep.Targets) == 0 { + delete(eps, epk) + log.Debugf("deleting rule %s %s", dep.DNSName, dep.RecordType) + break + } + } + } + } + + // add all endpoints and targets to be created + for _, cep := range append(changes.Create, changes.UpdateNew...) { + if !endpointSupported(cep) { + log.Warnf("requested unsupported endpoint creation: %s", cep) + continue + } + + epk := cep.DNSName + cep.RecordType + if ep, ok := eps[epk]; ok { + ep.Targets = append(ep.Targets, cep.Targets...) + log.Debugf("adding target %s to existing rule for %s %s", ep.Targets, ep.DNSName, ep.RecordType) + } else { + ep = &endpoint.Endpoint{ + DNSName: cep.DNSName, + RecordType: cep.RecordType, + Targets: cep.Targets, + } + eps[epk] = ep + log.Debugf("adding rule %s", cep) + } + } + + // convert endpoints to rules + for _, e := range eps { + s := serializeToString(e) + rr = append(rr, s...) + } + + return p.client.SetFilteringRules(ctx, rr) +} + +// Records reads all endpoints from Adguard +func (p *Provider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { + resp, err := p.client.GetFilteringRules(ctx) + if err != nil { + return nil, err + } + + // deserialize all endpoints managed by external-dns + // endpoints are referenced by dns name and record type + eps := make(map[string]*endpoint.Endpoint) + for _, rule := range resp { + ep, err := deserializeToEndpoint(rule) + if err != nil { + // unmanaged rules are ignored + if err == errNotManaged { + continue + } + return nil, err + } + + if !p.domainFilter.Match(ep.DNSName) { + continue + } + + epk := ep.DNSName + ep.RecordType + if eep, ok := eps[epk]; ok { + eep.Targets = append(eep.Targets, ep.Targets...) + log.Debugf("found target %s for existing rule for %s %s", ep.Targets, ep.DNSName, ep.RecordType) + } else { + eps[epk] = ep + log.Debugf("found rule %s", ep) + } + } + + return maps.Values(eps), nil +} + +func endpointSupported(e *endpoint.Endpoint) bool { + return e.RecordType == endpoint.RecordTypeA || e.RecordType == endpoint.RecordTypeTXT || e.RecordType == endpoint.RecordTypeAAAA || e.RecordType == endpoint.RecordTypeCNAME +} + +func deserializeToEndpoint(rule string) (*endpoint.Endpoint, error) { + // unmanaged rules must not be deserialized + if !strings.Contains(rule, managedBy) { + return nil, errNotManaged + } + + // format: "||DNS.NAME^dnsrewrite=NOERROR;RECORD_TYPE;TARGET #MANAGED_BY_TEXT" + re := regexp.MustCompile(`[(||)(\^$);( #)]+`) + p := re.Split(rule, -1) + if len(p) != 6 { + return nil, fmt.Errorf("invalid rule: %s", rule) + } + + // see serializeToString for the format + r := &endpoint.Endpoint{ + RecordType: p[3], + DNSName: p[1], + Targets: endpoint.Targets{p[4]}, + } + + return r, nil +} + +func serializeToString(e *endpoint.Endpoint) []string { + r := []string{} + for _, t := range e.Targets { + // format: "||DNS.NAME^dnsrewrite=NOERROR;RECORD_TYPE;TARGET #MANAGED_BY_TEXT" + r = append(r, fmt.Sprintf("||%s^$dnsrewrite=NOERROR;%s;%s #%s", e.DNSName, e.RecordType, t, managedBy)) + } + return r +} diff --git a/internal/adguard/provider_test.go b/internal/adguard/provider_test.go new file mode 100644 index 0000000..4f9e7f5 --- /dev/null +++ b/internal/adguard/provider_test.go @@ -0,0 +1,527 @@ +package adguard + +import ( + "context" + "fmt" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" + + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" +) + +type testCase struct { + name string + filteringRules getFilteringRules + hasError bool + endpoints []*endpoint.Endpoint + changes *plan.Changes + rules []string + domainFilter endpoint.DomainFilter + log.Ext1FieldLogger +} + +var mockHTTPClient *MockHTTPClient +var testProvider *Provider + +func TestEndpointSupported(t *testing.T) { + log.SetLevel(log.DebugLevel) + + testCases := []struct { + name string + endpoint *endpoint.Endpoint + valid bool + }{ + { + name: "A record", + endpoint: endpoint.NewEndpoint("domain.com", endpoint.RecordTypeA, "1.1.1.1"), + valid: true, + }, + { + name: "AAAA record", + endpoint: endpoint.NewEndpoint("domain.com", endpoint.RecordTypeAAAA, "1111:1111::1"), + valid: true, + }, + { + name: "TXT record", + endpoint: endpoint.NewEndpoint("domain.com", endpoint.RecordTypeTXT, "text"), + valid: true, + }, + { + name: "CNAME record", + endpoint: endpoint.NewEndpoint("domain.com", endpoint.RecordTypeCNAME, "other.org"), + valid: true, + }, + { + name: "SRV record", + endpoint: endpoint.NewEndpoint("domain.com", endpoint.RecordTypeSRV, "rsv"), + valid: false, + }, + { + name: "NS record", + endpoint: endpoint.NewEndpoint("domain.com", endpoint.RecordTypeNS, "1.1.1.1"), + valid: false, + }, + { + name: "PTR record", + endpoint: endpoint.NewEndpoint("1.1.1.1", endpoint.RecordTypePTR, "domain.com"), + valid: false, + }, + { + name: "MX record", + endpoint: endpoint.NewEndpoint("1.1.1.1", endpoint.RecordTypeMX, "10 mail.domain.com."), + valid: false, + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d. %s", i+1, tc.name), func(t *testing.T) { + require.Equal(t, tc.valid, endpointSupported(tc.endpoint)) + }) + } +} + +func TestDeserializeToEndpoint(t *testing.T) { + log.SetLevel(log.DebugLevel) + + testCases := []struct { + name string + text string + endpoint *endpoint.Endpoint + expectedErr bool + }{ + { + name: "A record", + text: fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;1.1.1.1 #%s", managedBy), + endpoint: &endpoint.Endpoint{DNSName: "domain.com", RecordType: endpoint.RecordTypeA, Targets: []string{"1.1.1.1"}}, + }, + { + name: "AAAA record", + text: fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;AAAA;1111:1111::1 #%s", managedBy), + endpoint: &endpoint.Endpoint{DNSName: "domain.com", RecordType: endpoint.RecordTypeAAAA, Targets: []string{"1111:1111::1"}}, + }, + { + name: "TXT record", + text: fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;TXT;external-dns-txt #%s", managedBy), + endpoint: &endpoint.Endpoint{DNSName: "domain.com", RecordType: endpoint.RecordTypeTXT, Targets: []string{"external-dns-txt"}}, + }, + { + name: "CNAME record", + text: fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;CNAME;other.org #%s", managedBy), + endpoint: &endpoint.Endpoint{DNSName: "domain.com", RecordType: endpoint.RecordTypeCNAME, Targets: []string{"other.org"}}, + }, + { + name: "invalid record", + text: fmt.Sprintf("@@||abc.com #%s", managedBy), + expectedErr: true, + }, + { + name: "unmanaged record", + text: "@@||abc.com", + expectedErr: true, + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d. %s", i+1, tc.name), func(t *testing.T) { + ep, err := deserializeToEndpoint(tc.text) + if tc.expectedErr { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.endpoint, ep) + } + }) + } +} + +func TestSerializeToString(t *testing.T) { + log.SetLevel(log.DebugLevel) + + testCases := []struct { + name string + text []string + endpoint *endpoint.Endpoint + }{ + { + name: "A record", + text: []string{fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;1.1.1.1 #%s", managedBy)}, + endpoint: &endpoint.Endpoint{DNSName: "domain.com", RecordType: endpoint.RecordTypeA, Targets: []string{"1.1.1.1"}}, + }, + { + name: "AAAA record", + text: []string{fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;AAAA;1111:1111::1 #%s", managedBy)}, + endpoint: &endpoint.Endpoint{DNSName: "domain.com", RecordType: endpoint.RecordTypeAAAA, Targets: []string{"1111:1111::1"}}, + }, + { + name: "TXT record", + text: []string{fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;TXT;external-dns-txt #%s", managedBy)}, + endpoint: &endpoint.Endpoint{DNSName: "domain.com", RecordType: endpoint.RecordTypeTXT, Targets: []string{"external-dns-txt"}}, + }, + { + name: "CNAME record", + text: []string{fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;CNAME;other.org #%s", managedBy)}, + endpoint: &endpoint.Endpoint{DNSName: "domain.com", RecordType: endpoint.RecordTypeCNAME, Targets: []string{"other.org"}}, + }, + { + name: "multiple records", + text: []string{ + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;1.1.1.1 #%s", managedBy), + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;2.2.2.2 #%s", managedBy), + }, + endpoint: &endpoint.Endpoint{DNSName: "domain.com", RecordType: endpoint.RecordTypeA, Targets: []string{"1.1.1.1", "2.2.2.2"}}, + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d. %s", i+1, tc.name), func(t *testing.T) { + rr := serializeToString(tc.endpoint) + require.Equal(t, tc.text, rr) + }) + } +} + +func TestRecords(t *testing.T) { + log.SetLevel(log.DebugLevel) + + testCases := []*testCase{ + { + name: "valid case", + hasError: false, + domainFilter: endpoint.DomainFilter{}, + filteringRules: getFilteringRules{ + UserRules: []string{ + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;1.1.1.1 #%s", managedBy), + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;2.2.2.2 #%s", managedBy), + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;AAAA;1111:1111::1 #%s", managedBy), + fmt.Sprintf("||other.org^$dnsrewrite=NOERROR;A;3.3.3.3 #%s", managedBy), + }, + }, + endpoints: []*endpoint.Endpoint{ + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeA, + Targets: []string{ + "1.1.1.1", + "2.2.2.2", + }, + }, + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeAAAA, + Targets: []string{ + "1111:1111::1", + }, + }, + { + DNSName: "other.org", + RecordType: endpoint.RecordTypeA, + Targets: []string{ + "3.3.3.3", + }, + }, + }, + }, + { + name: "unmanaged filters", + hasError: false, + domainFilter: endpoint.DomainFilter{}, + filteringRules: getFilteringRules{ + UserRules: []string{ + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;1.1.1.1 #%s", managedBy), + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;2.2.2.2 #%s", managedBy), + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;AAAA;1111:1111::1 #%s", managedBy), + "||other.org^$dnsrewrite=NOERROR;A;3.3.3.3 #unmanaged", + "@@||other.org", + }, + }, + endpoints: []*endpoint.Endpoint{ + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeA, + Targets: []string{ + "1.1.1.1", + "2.2.2.2", + }, + }, + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeAAAA, + Targets: []string{ + "1111:1111::1", + }, + }, + }, + }, + { + name: "valid case with domain filter", + hasError: false, + domainFilter: endpoint.NewDomainFilter([]string{"domain.com"}), + filteringRules: getFilteringRules{ + UserRules: []string{ + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;1.1.1.1 #%s", managedBy), + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;2.2.2.2 #%s", managedBy), + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;AAAA;1111:1111::1 #%s", managedBy), + fmt.Sprintf("||other.org^$dnsrewrite=NOERROR;A;3.3.3.3 #%s", managedBy), + }, + }, + endpoints: []*endpoint.Endpoint{ + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeA, + Targets: []string{ + "1.1.1.1", + "2.2.2.2", + }, + }, + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeAAAA, + Targets: []string{ + "1111:1111::1", + }, + }, + }, + }, + { + name: "invalid filters", + hasError: true, + domainFilter: endpoint.DomainFilter{}, + filteringRules: getFilteringRules{ + UserRules: []string{ + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;1.1.1.1 whatever #%s", managedBy), + }, + }, + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d. %s", i+1, tc.name), func(t *testing.T) { + mockHTTPClient = &MockHTTPClient{ + testCase: tc, + t: t, + } + testProvider = &Provider{ + client: mockHTTPClient, + domainFilter: tc.domainFilter, + } + + records, err := testProvider.Records(context.TODO()) + if tc.hasError { + require.Error(t, err) + } else { + require.ElementsMatch(t, tc.endpoints, records) + } + }) + } +} + +func TestApplyChanges(t *testing.T) { + log.SetLevel(log.DebugLevel) + + testCases := []*testCase{ + { + name: "valid create", + hasError: false, + domainFilter: endpoint.DomainFilter{}, + rules: []string{ + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;1.1.1.1 #%s", managedBy), + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;2.2.2.2 #%s", managedBy), + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;AAAA;1111:1111::1 #%s", managedBy), + fmt.Sprintf("||other.org^$dnsrewrite=NOERROR;A;3.3.3.3 #%s", managedBy), + }, + changes: &plan.Changes{ + Create: []*endpoint.Endpoint{ + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeA, + Targets: []string{ + "1.1.1.1", + "2.2.2.2", + }, + }, + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeAAAA, + Targets: []string{ + "1111:1111::1", + }, + }, + { + DNSName: "other.org", + RecordType: endpoint.RecordTypeA, + Targets: []string{ + "3.3.3.3", + }, + }, + }, + }, + }, + { + name: "valid update", + hasError: false, + domainFilter: endpoint.DomainFilter{}, + rules: []string{ + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;1.1.1.1 #%s", managedBy), + }, + changes: &plan.Changes{ + UpdateOld: []*endpoint.Endpoint{ + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeA, + Targets: []string{ + "2.2.2.2", + }, + }, + }, + UpdateNew: []*endpoint.Endpoint{ + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeA, + Targets: []string{ + "1.1.1.1", + }, + }, + }, + }, + }, + { + name: "valid delete", + hasError: false, + domainFilter: endpoint.DomainFilter{}, + filteringRules: getFilteringRules{ + UserRules: []string{ + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;2.2.2.2 #%s", managedBy), + }, + }, + rules: []string{}, + changes: &plan.Changes{ + Delete: []*endpoint.Endpoint{ + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeA, + Targets: []string{ + "2.2.2.2", + }, + }, + }, + }, + }, + { + name: "valid partial delete", + hasError: false, + domainFilter: endpoint.DomainFilter{}, + filteringRules: getFilteringRules{ + UserRules: []string{ + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;1.1.1.1 #%s", managedBy), + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;2.2.2.2 #%s", managedBy), + }, + }, + rules: []string{ + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;1.1.1.1 #%s", managedBy), + }, + changes: &plan.Changes{ + Delete: []*endpoint.Endpoint{ + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeA, + Targets: []string{ + "2.2.2.2", + }, + }, + }, + }, + }, + { + name: "valid delete and create", + hasError: false, + domainFilter: endpoint.DomainFilter{}, + filteringRules: getFilteringRules{ + UserRules: []string{ + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;A;2.2.2.2 #%s", managedBy), + }, + }, + rules: []string{ + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;AAAA;1111:1111::1 #%s", managedBy), + }, + changes: &plan.Changes{ + Create: []*endpoint.Endpoint{ + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeAAAA, + Targets: []string{ + "1111:1111::1", + }, + }, + }, + Delete: []*endpoint.Endpoint{ + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeA, + Targets: []string{ + "2.2.2.2", + }, + }, + }, + }, + }, + { + name: "invalid type", + hasError: false, + domainFilter: endpoint.DomainFilter{}, + rules: []string{ + fmt.Sprintf("||domain.com^$dnsrewrite=NOERROR;AAAA;1111:1111::1 #%s", managedBy), + }, + changes: &plan.Changes{ + Create: []*endpoint.Endpoint{ + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeSRV, + Targets: []string{ + "srv", + }, + }, + { + DNSName: "domain.com", + RecordType: endpoint.RecordTypeAAAA, + Targets: []string{ + "1111:1111::1", + }, + }, + }, + }, + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d. %s", i+1, tc.name), func(t *testing.T) { + mockHTTPClient = &MockHTTPClient{ + testCase: tc, + t: t, + } + testProvider = &Provider{ + client: mockHTTPClient, + domainFilter: tc.domainFilter, + } + + err := testProvider.ApplyChanges(context.TODO(), tc.changes) + require.NoError(t, err) + }) + } +} + +type MockHTTPClient struct { + t *testing.T + testCase *testCase +} + +func (d *MockHTTPClient) GetFilteringRules(_ context.Context) ([]string, error) { + return d.testCase.filteringRules.UserRules, nil +} + +func (d *MockHTTPClient) SetFilteringRules(_ context.Context, rules []string) error { + require.ElementsMatch(d.t, d.testCase.rules, rules) + return nil +} diff --git a/pkg/webhook/mediatype.go b/pkg/webhook/mediatype.go new file mode 100644 index 0000000..39f2ba8 --- /dev/null +++ b/pkg/webhook/mediatype.go @@ -0,0 +1,41 @@ +package webhook + +import ( + "fmt" + "strings" +) + +const ( + mediaTypeFormat = "application/external.dns.webhook+json;" + supportedMediaVersions = "1" +) + +var mediaTypeVersion1 = mediaTypeVersion("1") + +type mediaType string + +func mediaTypeVersion(v string) mediaType { + return mediaType(mediaTypeFormat + "version=" + v) +} + +func (m mediaType) Is(headerValue string) bool { + return string(m) == headerValue +} + +func checkAndGetMediaTypeHeaderValue(value string) (string, error) { + for _, v := range strings.Split(supportedMediaVersions, ",") { + if mediaTypeVersion(v).Is(value) { + return v, nil + } + } + + supportedMediaTypesString := "" + for i, v := range strings.Split(supportedMediaVersions, ",") { + sep := "" + if i < len(supportedMediaVersions)-1 { + sep = ", " + } + supportedMediaTypesString += string(mediaTypeVersion(v)) + sep + } + return "", fmt.Errorf("unsupported media type version: '%s'. supported media types are: '%s'", value, supportedMediaTypesString) +} diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go new file mode 100644 index 0000000..ef22b77 --- /dev/null +++ b/pkg/webhook/webhook.go @@ -0,0 +1,228 @@ +package webhook + +import ( + "encoding/json" + "fmt" + "net/http" + + log "github.com/sirupsen/logrus" + + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider" +) + +const ( + contentTypeHeader = "Content-Type" + contentTypePlaintext = "text/plain" + acceptHeader = "Accept" + varyHeader = "Vary" + healthPath = "/health" + logFieldRequestPath = "requestPath" + logFieldRequestMethod = "requestMethod" + logFieldError = "error" +) + +// Webhook for external dns provider +type Webhook struct { + provider provider.Provider +} + +// New creates a new instance of the Webhook +func New(provider provider.Provider) *Webhook { + p := Webhook{provider: provider} + return &p +} + +// Health handles the health request +func Health(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == healthPath { + w.WriteHeader(http.StatusOK) + return + } + next.ServeHTTP(w, r) + }) +} + +func (p *Webhook) contentTypeHeaderCheck(w http.ResponseWriter, r *http.Request) error { + return p.headerCheck(true, w, r) +} + +func (p *Webhook) acceptHeaderCheck(w http.ResponseWriter, r *http.Request) error { + return p.headerCheck(false, w, r) +} + +func (p *Webhook) headerCheck(isContentType bool, w http.ResponseWriter, r *http.Request) error { + var header string + if isContentType { + header = r.Header.Get(contentTypeHeader) + } else { + header = r.Header.Get(acceptHeader) + } + + if len(header) == 0 { + w.Header().Set(contentTypeHeader, contentTypePlaintext) + w.WriteHeader(http.StatusNotAcceptable) + + msg := "client must provide " + if isContentType { + msg += "a content type" + } else { + msg += "an accept header" + } + err := fmt.Errorf(msg) + + _, writeErr := fmt.Fprint(w, err.Error()) + if writeErr != nil { + requestLog(r).WithField(logFieldError, writeErr).Fatalf("error writing error message to response writer") + } + return err + } + + // as we support only one media type version, we can ignore the returned value + if _, err := checkAndGetMediaTypeHeaderValue(header); err != nil { + w.Header().Set(contentTypeHeader, contentTypePlaintext) + w.WriteHeader(http.StatusUnsupportedMediaType) + + msg := "client must provide a valid versioned media type in the " + if isContentType { + msg += "content type" + } else { + msg += "accept header" + } + + err := fmt.Errorf(msg+": %s", err.Error()) + _, writeErr := fmt.Fprint(w, err.Error()) + if writeErr != nil { + requestLog(r).WithField(logFieldError, writeErr).Fatalf("error writing error message to response writer") + } + return err + } + + return nil +} + +// Records handles the get request for records +func (p *Webhook) Records(w http.ResponseWriter, r *http.Request) { + if err := p.acceptHeaderCheck(w, r); err != nil { + requestLog(r).WithField(logFieldError, err).Error("accept header check failed") + return + } + + requestLog(r).Debug("requesting records") + ctx := r.Context() + records, err := p.provider.Records(ctx) + if err != nil { + requestLog(r).WithField(logFieldError, err).Error("error getting records") + w.WriteHeader(http.StatusInternalServerError) + return + } + + requestLog(r).Debugf("returning records count: %d", len(records)) + w.Header().Set(contentTypeHeader, string(mediaTypeVersion1)) + w.Header().Set(varyHeader, contentTypeHeader) + err = json.NewEncoder(w).Encode(records) + if err != nil { + requestLog(r).WithField(logFieldError, err).Error("error encoding records") + w.WriteHeader(http.StatusInternalServerError) + return + } +} + +// ApplyChanges handles the post request for record changes +func (p *Webhook) ApplyChanges(w http.ResponseWriter, r *http.Request) { + if err := p.contentTypeHeaderCheck(w, r); err != nil { + requestLog(r).WithField(logFieldError, err).Error("content type header check failed") + return + } + + var changes plan.Changes + ctx := r.Context() + if err := json.NewDecoder(r.Body).Decode(&changes); err != nil { + w.Header().Set(contentTypeHeader, contentTypePlaintext) + w.WriteHeader(http.StatusBadRequest) + + errMsg := fmt.Sprintf("error decoding changes: %s", err.Error()) + if _, writeError := fmt.Fprint(w, errMsg); writeError != nil { + requestLog(r).WithField(logFieldError, writeError).Fatalf("error writing error message to response writer") + } + requestLog(r).WithField(logFieldError, err).Info(errMsg) + return + } + + requestLog(r).Debugf("requesting apply changes, create: %d , updateOld: %d, updateNew: %d, delete: %d", + len(changes.Create), len(changes.UpdateOld), len(changes.UpdateNew), len(changes.Delete)) + if err := p.provider.ApplyChanges(ctx, &changes); err != nil { + w.Header().Set(contentTypeHeader, contentTypePlaintext) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// AdjustEndpoints handles the post request for adjusting endpoints +func (p *Webhook) AdjustEndpoints(w http.ResponseWriter, r *http.Request) { + if err := p.contentTypeHeaderCheck(w, r); err != nil { + log.Errorf("content type header check failed, request method: %s, request path: %s", r.Method, r.URL.Path) + return + } + if err := p.acceptHeaderCheck(w, r); err != nil { + log.Errorf("accept header check failed, request method: %s, request path: %s", r.Method, r.URL.Path) + return + } + + var pve []*endpoint.Endpoint + if err := json.NewDecoder(r.Body).Decode(&pve); err != nil { + w.Header().Set(contentTypeHeader, contentTypePlaintext) + w.WriteHeader(http.StatusBadRequest) + + errMessage := fmt.Sprintf("failed to decode request body: %v", err) + log.Infof(errMessage+" , request method: %s, request path: %s", r.Method, r.URL.Path) + if _, writeError := fmt.Fprint(w, errMessage); writeError != nil { + requestLog(r).WithField(logFieldError, writeError).Fatalf("error writing error message to response writer") + } + return + } + + log.Debugf("requesting adjust endpoints count: %d", len(pve)) + pve, err := p.provider.AdjustEndpoints(pve) + if err != nil { + w.Header().Set(contentTypeHeader, contentTypePlaintext) + w.WriteHeader(http.StatusInternalServerError) + return + } + out, _ := json.Marshal(&pve) + + log.Debugf("return adjust endpoints response, resultEndpointCount: %d", len(pve)) + w.Header().Set(contentTypeHeader, string(mediaTypeVersion1)) + w.Header().Set(varyHeader, contentTypeHeader) + if _, writeError := fmt.Fprint(w, string(out)); writeError != nil { + requestLog(r).WithField(logFieldError, writeError).Fatalf("error writing response") + } +} + +func (p *Webhook) Negotiate(w http.ResponseWriter, r *http.Request) { + if err := p.acceptHeaderCheck(w, r); err != nil { + requestLog(r).WithField(logFieldError, err).Error("accept header check failed") + return + } + + b, err := p.provider.GetDomainFilter().MarshalJSON() + if err != nil { + log.Errorf("failed to marshal domain filter, request method: %s, request path: %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set(contentTypeHeader, string(mediaTypeVersion1)) + if _, writeError := w.Write(b); writeError != nil { + requestLog(r).WithField(logFieldError, writeError).Error("error writing response") + w.WriteHeader(http.StatusInternalServerError) + return + } +} + +func requestLog(r *http.Request) *log.Entry { + return log.WithFields(log.Fields{logFieldRequestMethod: r.Method, logFieldRequestPath: r.URL.Path}) +} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..8185a51 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "go", + "package-name": "main", + "include-component-in-tag": false, + "changelog-path": "CHANGELOG.md", + "extra-files": [] + } + }, + "include-v-in-tag": true, + "skip-github-release": false, + "pull-request-title-pattern": "chore(release): release ${version}", + "pull-request-header": ":robot: I have created a release", + "label": "release", + "changelog-type": "default", + "changelog-sections": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "revert", + "section": "Reverts" + }, + { + "type": "chore", + "section": "Miscellaneous Chores" + }, + { + "type": "docs", + "section": "Documentation", + "hidden": true + }, + { + "type": "refactor", + "section": "Code Refactoring", + "hidden": true + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "build", + "section": "Build System", + "hidden": true + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": true + } + ] +} \ No newline at end of file diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..fdc161f --- /dev/null +++ b/renovate.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:base", + ":dependencyDashboard", + ":semanticCommitTypeAll(chore)" + ], + "prHourlyLimit": 0, + "prConcurrentLimit": 0, + "baseBranches": [ + "main" + ], + "ignorePaths": [ + "**/node_modules/**", + "**/bower_components/**", + "sdk/nodejs/**", + "sdk/go/**", + "sdk/dotnet/**", + "sdk/python/**", + "sdk/java/**" + ], + "enabledManagers": [ + "github-actions", + "gomod", + "npm" + ], + "github-actions": { + "fileMatch": [ + "^(workflow-templates|\\.github/workflows)/[^/]+\\.ya?ml$", + "(^|/)action\\.ya?ml$" + ] + }, + "gomod": { + "fileMatch": [ + "(^|/)go\\.mod$" + ] + }, + "npm": { + "fileMatch": [ + "(^|/)package\\.json$" + ], + "rollbackPrs": true, + "versioning": "npm", + "digest": { + "prBodyDefinitions": { + "Change": "{{#if displayFrom}}`{{{displayFrom}}}` -> {{else}}{{#if currentValue}}`{{{currentValue}}}` -> {{/if}}{{/if}}{{#if displayTo}}`{{{displayTo}}}`{{else}}`{{{newValue}}}`{{/if}}" + } + }, + "prBodyDefinitions": { + "Change": "[{{#if displayFrom}}`{{{displayFrom}}}` -> {{else}}{{#if currentValue}}`{{{currentValue}}}` -> {{/if}}{{/if}}{{#if displayTo}}`{{{displayTo}}}`{{else}}`{{{newValue}}}`{{/if}}]({{#if depName}}https://renovatebot.com/diffs/npm/{{replace '/' '%2f' depName}}/{{{currentVersion}}}/{{{newVersion}}}{{/if}})" + } + } +} \ No newline at end of file