├── .dockerignore ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── claude-code-review.yml │ ├── claude.yml │ ├── helm-package-test.yaml │ ├── integration-test.yaml │ ├── release-container-image.yaml │ ├── release-helm.yaml │ └── unit-test.yaml ├── .gitignore ├── .well-known └── funding-manifest-urls ├── CLAUDE.md ├── LICENSE ├── Makefile ├── README.md ├── cmd └── cloudflare-tunnel-ingress-controller │ └── main.go ├── go.mod ├── go.sum ├── hack ├── dev │ ├── cloudflare-api.example.yaml │ ├── deployment.yaml │ ├── ingress-class.yaml │ ├── kubernetes-dashboard-ingress.yaml │ └── ns.yaml ├── install-setup-envtest.sh └── update-kubernetes-library.sh ├── helm └── cloudflare-tunnel-ingress-controller │ ├── .helmignore │ ├── Chart.yaml │ ├── templates │ ├── _helpers.tpl │ ├── clusterrole.yaml │ ├── clusterrolebinding.yaml │ ├── controlled-cloudflared-connector-headless-service.yaml │ ├── controlled-cloudflared-servicemonitor.yaml │ ├── deployment.yaml │ ├── ingressclass.yaml │ ├── role.yaml │ ├── rulebinding.yaml │ ├── secret.yaml │ └── serviceaccount.yaml │ └── values.yaml ├── image └── cloudflare-tunnel-ingress-controller │ └── Dockerfile ├── pkg ├── cloudflare-controller │ ├── bootstrap.go │ ├── dns.go │ ├── dns_test.go │ ├── domain.go │ ├── domain_test.go │ ├── transform.go │ ├── transform_test.go │ └── tunnel-client.go ├── controller │ ├── bootstrap.go │ ├── controlled-cloudflared-connector.go │ ├── controlled-cloudflared-connector_test.go │ ├── ingress-controller.go │ ├── transform.go │ ├── transform_test.go │ └── weel_known_annotations.go └── exposure │ └── exposure.go ├── skaffold.yaml ├── static └── dash.strrl.cloud.png └── test ├── fixtures └── kubernetes_namespace.go └── integration └── controller ├── controlled_cloudflared_connector_test.go ├── ingress_transform_test.go └── suite_test.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | cloudflare-tunnel-ingress-controller 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ['strrl'] 2 | buy_me_a_coffee: 'strrl' 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | on: 4 | pull_request: 5 | types: [opened, review_requested] 6 | # Optional: Only run on specific file changes 7 | # paths: 8 | # - "src/**/*.ts" 9 | # - "src/**/*.tsx" 10 | # - "src/**/*.js" 11 | # - "src/**/*.jsx" 12 | 13 | jobs: 14 | claude-review: 15 | # Optional: Filter by PR author 16 | # if: | 17 | # github.event.pull_request.user.login == 'external-contributor' || 18 | # github.event.pull_request.user.login == 'new-developer' || 19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code Review 35 | id: claude-review 36 | uses: anthropics/claude-code-action@beta 37 | with: 38 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 39 | 40 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) 41 | # model: "claude-opus-4-20250514" 42 | 43 | # Direct prompt for automated review (no @claude mention needed) 44 | direct_prompt: | 45 | Please review this pull request and provide feedback on: 46 | - Code quality and best practices 47 | - Potential bugs or issues 48 | - Performance considerations 49 | - Security concerns 50 | - Test coverage 51 | 52 | Be constructive and helpful in your feedback. 53 | 54 | # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR 55 | # use_sticky_comment: true 56 | 57 | # Optional: Customize review based on file types 58 | # direct_prompt: | 59 | # Review this PR focusing on: 60 | # - For TypeScript files: Type safety and proper interface usage 61 | # - For API endpoints: Security, input validation, and error handling 62 | # - For React components: Performance, accessibility, and best practices 63 | # - For tests: Coverage, edge cases, and test quality 64 | 65 | # Optional: Different prompts for different authors 66 | # direct_prompt: | 67 | # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && 68 | # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || 69 | # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} 70 | 71 | # Optional: Add specific tools for running tests or linting 72 | # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" 73 | 74 | # Optional: Skip review for certain conditions 75 | # if: | 76 | # !contains(github.event.pull_request.title, '[skip-review]') && 77 | # !contains(github.event.pull_request.title, '[WIP]') 78 | 79 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | actions: read # Required for Claude to read CI results on PRs 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 1 32 | 33 | - name: Run Claude Code 34 | id: claude 35 | uses: anthropics/claude-code-action@beta 36 | with: 37 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 38 | 39 | # This is an optional setting that allows Claude to read CI results on PRs 40 | additional_permissions: | 41 | actions: read 42 | 43 | # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) 44 | # model: "claude-opus-4-20250514" 45 | 46 | # Optional: Customize the trigger phrase (default: @claude) 47 | # trigger_phrase: "/claude" 48 | 49 | # Optional: Trigger when specific user is assigned to an issue 50 | # assignee_trigger: "claude-bot" 51 | 52 | # Optional: Allow Claude to run specific commands 53 | # allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)" 54 | 55 | # Optional: Add custom instructions for Claude to customize its behavior for your project 56 | # custom_instructions: | 57 | # Follow our coding standards 58 | # Ensure all new code has tests 59 | # Use TypeScript for new files 60 | 61 | # Optional: Custom environment variables for Claude 62 | # claude_env: | 63 | # NODE_ENV: test 64 | 65 | -------------------------------------------------------------------------------- /.github/workflows/helm-package-test.yaml: -------------------------------------------------------------------------------- 1 | name: Helm Package Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "master" 7 | push: 8 | branches: 9 | - "master" 10 | 11 | permissions: read-all 12 | 13 | jobs: 14 | integration-test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: azure/setup-helm@v3 19 | - name: Run helm package 20 | run: | 21 | helm package ./helm/cloudflare-tunnel-ingress-controller 22 | -------------------------------------------------------------------------------- /.github/workflows/integration-test.yaml: -------------------------------------------------------------------------------- 1 | name: Integration Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "master" 7 | push: 8 | branches: 9 | - "master" 10 | 11 | permissions: read-all 12 | 13 | jobs: 14 | integration-test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-go@v5 19 | with: 20 | go-version-file: './go.mod' 21 | - name: Run integration tests 22 | run: | 23 | make integration-test 24 | - name: Upload coverage reports to Codecov 25 | uses: codecov/codecov-action@v3 26 | with: 27 | files: ./test/integration/cover.out 28 | env: 29 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/release-container-image.yaml: -------------------------------------------------------------------------------- 1 | name: Latest Docker Image 2 | 3 | on: 4 | workflow_dispatch: {} 5 | push: 6 | tags: 7 | - v* 8 | 9 | jobs: 10 | metadata: 11 | runs-on: ubuntu-latest 12 | outputs: 13 | version: ${{ steps.meta.outputs.version }} 14 | tags: ${{ steps.meta.outputs.tags }} 15 | labels: ${{ steps.meta.outputs.labels }} 16 | owner: ${{ steps.lowercase.outputs.owner }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | - name: Convert owner to lowercase 21 | id: lowercase 22 | run: echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_OUTPUT 23 | - name: Docker meta 24 | id: meta 25 | uses: docker/metadata-action@v5 26 | with: 27 | images: | 28 | ghcr.io/${{ steps.lowercase.outputs.owner }}/cloudflare-tunnel-ingress-controller 29 | tags: | 30 | type=ref,event=branch,enable=${{ github.ref_name != 'main' }},suffix=-{{sha}},format=lowercase 31 | type=ref,event=pr,prefix=pr-,suffix=-{{sha}},format=lowercase 32 | type=semver,pattern={{version}},format=lowercase 33 | type=semver,pattern={{major}}.{{minor}},format=lowercase 34 | type=semver,pattern={{major}},format=lowercase 35 | type=sha 36 | 37 | build-amd64: 38 | needs: metadata 39 | permissions: 40 | packages: write 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | - name: Set up Docker Buildx 45 | uses: docker/setup-buildx-action@v3 46 | - name: Login to GitHub Container Registry 47 | uses: docker/login-action@v3 48 | with: 49 | registry: ghcr.io 50 | username: ${{ needs.metadata.outputs.owner }} 51 | password: ${{ secrets.GITHUB_TOKEN }} 52 | - name: Build and push AMD64 53 | uses: docker/build-push-action@v5 54 | with: 55 | file: image/cloudflare-tunnel-ingress-controller/Dockerfile 56 | platforms: linux/amd64 57 | push: ${{ github.event_name != 'pull_request' }} 58 | tags: ghcr.io/${{ needs.metadata.outputs.owner }}/cloudflare-tunnel-ingress-controller:${{ needs.metadata.outputs.version }}-amd64 59 | labels: ${{ needs.metadata.outputs.labels }} 60 | cache-from: type=gha 61 | cache-to: type=gha,mode=max 62 | 63 | build-arm64: 64 | needs: metadata 65 | permissions: 66 | packages: write 67 | runs-on: [ 68 | "self-hosted", 69 | "linux", 70 | "arm64", 71 | ] 72 | steps: 73 | - uses: actions/checkout@v4 74 | - name: Set up Docker Buildx 75 | uses: docker/setup-buildx-action@v3 76 | - name: Login to GitHub Container Registry 77 | uses: docker/login-action@v3 78 | with: 79 | registry: ghcr.io 80 | username: ${{ needs.metadata.outputs.owner }} 81 | password: ${{ secrets.GITHUB_TOKEN }} 82 | - name: Build and push ARM64 83 | uses: docker/build-push-action@v5 84 | with: 85 | file: image/cloudflare-tunnel-ingress-controller/Dockerfile 86 | platforms: linux/arm64 87 | push: ${{ github.event_name != 'pull_request' }} 88 | tags: ghcr.io/${{ needs.metadata.outputs.owner }}/cloudflare-tunnel-ingress-controller:${{ needs.metadata.outputs.version }}-arm64 89 | labels: ${{ needs.metadata.outputs.labels }} 90 | cache-from: type=gha 91 | cache-to: type=gha,mode=max 92 | 93 | create-manifest: 94 | needs: [metadata, build-amd64, build-arm64] 95 | permissions: 96 | packages: write 97 | runs-on: ubuntu-latest 98 | steps: 99 | - name: Login to GitHub Container Registry 100 | uses: docker/login-action@v3 101 | with: 102 | registry: ghcr.io 103 | username: ${{ needs.metadata.outputs.owner }} 104 | password: ${{ secrets.GITHUB_TOKEN }} 105 | - name: Create and push manifest 106 | run: | 107 | docker buildx imagetools create -t ghcr.io/${{ needs.metadata.outputs.owner }}/cloudflare-tunnel-ingress-controller:${{ needs.metadata.outputs.version }} \ 108 | ghcr.io/${{ needs.metadata.outputs.owner }}/cloudflare-tunnel-ingress-controller:${{ needs.metadata.outputs.version }}-amd64 \ 109 | ghcr.io/${{ needs.metadata.outputs.owner }}/cloudflare-tunnel-ingress-controller:${{ needs.metadata.outputs.version }}-arm64 110 | 111 | # Create additional tags from metadata 112 | for tag in $(echo "${{ needs.metadata.outputs.tags }}" | tr '\n' ' '); do 113 | if [[ $tag != *":${{ needs.metadata.outputs.version }}" ]]; then 114 | docker buildx imagetools create -t $tag \ 115 | ghcr.io/${{ needs.metadata.outputs.owner }}/cloudflare-tunnel-ingress-controller:${{ needs.metadata.outputs.version }}-amd64 \ 116 | ghcr.io/${{ needs.metadata.outputs.owner }}/cloudflare-tunnel-ingress-controller:${{ needs.metadata.outputs.version }}-arm64 117 | fi 118 | done 119 | -------------------------------------------------------------------------------- /.github/workflows/release-helm.yaml: -------------------------------------------------------------------------------- 1 | name: Release helm chart 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: read-all 9 | 10 | jobs: 11 | release-chart: 12 | runs-on: ubuntu-20.04 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: "Extract Version" 16 | id: extract_version 17 | run: | 18 | GIT_TAG=${GITHUB_REF##*/} 19 | VERSION=${GIT_TAG##v} 20 | echo "::set-output name=version::$(echo $VERSION)" 21 | - name: Publish Helm chart 22 | uses: stefanprodan/helm-gh-pages@master 23 | with: 24 | token: ${{ secrets.HELM_TOKEN }} 25 | charts_dir: helm 26 | charts_url: https://helm.strrl.dev 27 | owner: strrl 28 | repository: helm.strrl.dev 29 | branch: gh-pages 30 | app_version: ${{ steps.extract_version.outputs.version }} 31 | chart_version: ${{ steps.extract_version.outputs.version }} 32 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yaml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - "master" 7 | push: 8 | branches: 9 | - "master" 10 | 11 | permissions: read-all 12 | 13 | jobs: 14 | unit-test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-go@v5 19 | with: 20 | go-version-file: './go.mod' 21 | - name: Run unit tests 22 | run: | 23 | make unit-test 24 | - name: Upload coverage reports to Codecov 25 | uses: codecov/codecov-action@v3 26 | with: 27 | files: ./cover.out 28 | env: 29 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | /cloudflare-tunnel-ingress-controller 24 | hack/dev/cloudflare-api.yaml 25 | 26 | # go test coverage report 27 | cover.out 28 | 29 | .idea/ 30 | -------------------------------------------------------------------------------- /.well-known/funding-manifest-urls: -------------------------------------------------------------------------------- 1 | https://strrl.dev/funding.json 2 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is a Kubernetes Ingress Controller that integrates with Cloudflare Tunnel to expose Kubernetes services to the internet securely without requiring port forwarding or firewall configuration. It watches Kubernetes Ingress resources and automatically configures Cloudflare Tunnels to route traffic to the corresponding services. 8 | 9 | ## Key Architecture Components 10 | 11 | ### Core Controllers 12 | - **IngressController** (`pkg/controller/ingress-controller.go`): Main reconciler that watches Ingress resources and manages tunnel configurations 13 | - **TunnelClient** (`pkg/cloudflare-controller/tunnel-client.go`): Handles Cloudflare API interactions for tunnel configuration and DNS management 14 | - **ControlledCloudflaredConnector** (`pkg/controller/controlled-cloudflared-connector.go`): Manages cloudflared daemon deployment in Kubernetes 15 | 16 | ### Data Flow 17 | 1. Ingress resources are created with `ingressClassName: cloudflare-tunnel` or the annotation `kubernetes.io/ingress.class: cloudflare-tunnel` 18 | 2. IngressController reconciles changes and transforms Ingress specs into Exposure objects (`pkg/exposure/exposure.go`) 19 | 3. TunnelClient updates Cloudflare tunnel ingress rules and creates/updates DNS CNAME records pointing to the tunnel domain 20 | 4. ControlledCloudflaredConnector runs every 10 seconds to ensure cloudflared pods are deployed and up-to-date 21 | 5. Cloudflared connects to Cloudflare and maintains the tunnel, routing traffic based on the configured ingress rules 22 | 23 | ### Key Packages 24 | - `pkg/controller/`: Kubernetes controllers and reconciliation logic 25 | - `pkg/cloudflare-controller/`: Cloudflare API client and tunnel management 26 | - `pkg/exposure/`: Data structures for representing service exposures 27 | 28 | ## Development Commands 29 | 30 | ### Building and Testing 31 | ```bash 32 | # Run unit tests 33 | make unit-test 34 | 35 | # Run integration tests (requires setup-envtest) 36 | make integration-test 37 | 38 | # Build Docker image 39 | make image 40 | 41 | # Development with live reload 42 | make dev 43 | ``` 44 | 45 | ### Go Commands 46 | ```bash 47 | # Standard Go operations 48 | go mod tidy 49 | go fmt ./... 50 | go vet ./... 51 | 52 | # Run unit tests with coverage (same as make unit-test) 53 | CGO_ENABLED=1 go test -race ./pkg/... -coverprofile ./cover.out 54 | 55 | # Run integration tests with coverage (requires setup-envtest) 56 | KUBEBUILDER_ASSETS="$(shell setup-envtest use $(ENVTEST_K8S_VERSION) -p path)" CGO_ENABLED=1 go test -race -v -coverpkg=./... -coverprofile ./test/integration/cover.out ./test/integration/... 57 | ``` 58 | 59 | ### Development Environment 60 | ```bash 61 | # Start development environment with Skaffold 62 | skaffold dev --namespace cloudflare-tunnel-ingress-controller-dev 63 | ``` 64 | 65 | ## Configuration 66 | 67 | ### Required Environment Variables/Flags 68 | - `--cloudflare-api-token`: Cloudflare API token with Zone:Zone:Read, Zone:DNS:Edit and Account:Cloudflare Tunnel:Edit permissions 69 | - `--cloudflare-account-id`: Cloudflare account ID 70 | - `--cloudflare-tunnel-name`: Name of the Cloudflare tunnel to manage 71 | - `--ingress-class`: Ingress class name (default: "cloudflare-tunnel") 72 | - `--controller-class`: Controller class name (default: "strrl.dev/cloudflare-tunnel-ingress-controller") 73 | - `--namespace`: Namespace to execute cloudflared connector (default: "default") 74 | - `--cloudflared-protocol`: Cloudflared protocol (default: "auto") 75 | - `--cloudflared-extra-args`: Extra arguments to pass to cloudflared 76 | 77 | ### Optional Environment Variables for Cloudflared 78 | - `CLOUDFLARED_IMAGE`: Docker image for cloudflared (default: "cloudflare/cloudflared:latest") 79 | - `CLOUDFLARED_IMAGE_PULL_POLICY`: Image pull policy (default: "IfNotPresent") 80 | - `CLOUDFLARED_REPLICA_COUNT`: Number of cloudflared replicas (default: 1) 81 | - `ENVTEST_K8S_VERSION`: Kubernetes version for integration tests 82 | 83 | ### Supported Annotations 84 | The controller recognizes standard Kubernetes ingress annotations and the following custom annotations: 85 | - `cloudflare-tunnel-ingress-controller.strrl.dev/proxy-ssl-verify`: Enable/disable SSL verification ("on" or "off", default: "off") 86 | - `cloudflare-tunnel-ingress-controller.strrl.dev/backend-protocol`: Backend protocol (default: "http") 87 | - `cloudflare-tunnel-ingress-controller.strrl.dev/http-host-header`: Set HTTP Host header for the local webserver 88 | - `cloudflare-tunnel-ingress-controller.strrl.dev/origin-server-name`: Hostname on the origin server certificate 89 | 90 | ## Testing Strategy 91 | 92 | ### Unit Tests 93 | Located in `pkg/` directories alongside source files (e.g., `dns_test.go`, `transform_test.go`) 94 | 95 | ### Integration Tests 96 | Located in `test/integration/` using Ginkgo/Gomega framework with envtest for Kubernetes API simulation 97 | 98 | ### Test Environment Setup 99 | Integration tests use `setup-envtest` to download and configure a local Kubernetes API server for testing. The `hack/install-setup-envtest.sh` script automatically installs `setup-envtest` if not present. Tests use Ginkgo/Gomega with controller-runtime's envtest framework. 100 | 101 | ## Deployment 102 | 103 | ### Helm Chart 104 | The project includes a Helm chart in `helm/cloudflare-tunnel-ingress-controller/` for easy deployment to Kubernetes clusters. 105 | 106 | ### Development Files 107 | Example configurations are available in `hack/dev/` for local development and testing. 108 | 109 | ## Dependencies 110 | 111 | - **Kubernetes**: Uses controller-runtime framework for Kubernetes integration 112 | - **Cloudflare Go SDK**: Official Cloudflare API client for tunnel and DNS management 113 | - **Cobra**: CLI framework for the main controller binary 114 | - **Ginkgo/Gomega**: Testing framework for BDD-style tests -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Zhou Zhiqiang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: dev 2 | dev: 3 | skaffold dev --namespace cloudflare-tunnel-ingress-controller-dev 4 | 5 | .PHONY: image 6 | image: 7 | DOCKER_BUILDKIT=1 TARGETARCH=amd64 docker build -t ghcr.io/strrl/cloudflare-tunnel-ingress-controller -f ./image/cloudflare-tunnel-ingress-controller/Dockerfile . 8 | 9 | .PHONY: unit-test 10 | unit-test: 11 | CGO_ENABLED=1 go test -race ./pkg/... -coverprofile ./cover.out 12 | 13 | .PHONY: integration-test 14 | integration-test: setup-envtest 15 | KUBEBUILDER_ASSETS="$(shell setup-envtest use $(ENVTEST_K8S_VERSION) -p path)" CGO_ENABLED=1 go test -race -v -coverpkg=./... -coverprofile ./test/integration/cover.out ./test/integration/... 16 | 17 | .PHONY: setup-envtest 18 | setup-envtest: 19 | bash ./hack/install-setup-envtest.sh 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloudflare Tunnel Ingress Controller 2 | 3 | TLDR; This project simplifies exposing Kubernetes services to the internet easily and securely using Cloudflare Tunnel. 4 | 5 | ## Prerequisites 6 | 7 | To use the Cloudflare Tunnel Ingress Controller, you need to have a Cloudflare account and a domain configured on Cloudflare. You also need to create a Cloudflare API token with the following permissions: `Zone:Zone:Read`, `Zone:DNS:Edit`, and `Account:Cloudflare Tunnel:Edit`. 8 | 9 | Additionally, you need to fetch the Account ID from the Cloudflare dashboard. 10 | 11 | Finally, you need to have a Kubernetes cluster with public Internet access. 12 | 13 | ## Get Started 14 | 15 | Take a look on this video to see how smoothly and easily it works: 16 | 17 | [![Less than 4 minutes! Bootstrap a Kubernetes Cluster and Expose Kubernetes Dashboard to the Internet.](https://markdown-videos.vercel.app/youtube/e-ARlEnS4zQ)](http://www.youtube.com/watch?v=e-ARlEnS4zQ "Less than 4 minutes! Bootstrap a Kubernetes Cluster and Expose Kubernetes Dashboard to the Internet.") 18 | 19 | Want to DIY? The following instructions would help your bootstrap a minikube Kubernetes Cluster, then expose the Kubernetes Dashboard to the internet via Cloudflare Tunnel Ingress Controller. 20 | 21 | - You should have a Cloudflare account and a domain configured on Cloudflare. 22 | - Create a Cloudflare API token with the following: 23 | - `Zone:Zone:Read` 24 | - `Zone:DNS:Edit` 25 | - `Account:Cloudflare Tunnel:Edit` 26 | - Fetch the Account ID from the Cloudflare dashboard, follow the instructions [here](https://developers.cloudflare.com/fundamentals/get-started/basic-tasks/find-account-and-zone-ids/). 27 | - Bootstrap a minikube cluster 28 | 29 | ```bash 30 | minikube start 31 | ``` 32 | 33 | - Add Helm Repository; 34 | 35 | ```bash 36 | helm repo add strrl.dev https://helm.strrl.dev 37 | helm repo update 38 | ``` 39 | 40 | - Install with Helm: 41 | 42 | ```bash 43 | helm upgrade --install --wait \ 44 | -n cloudflare-tunnel-ingress-controller --create-namespace \ 45 | cloudflare-tunnel-ingress-controller \ 46 | strrl.dev/cloudflare-tunnel-ingress-controller \ 47 | --set=cloudflare.apiToken="",cloudflare.accountId="",cloudflare.tunnelName="" 48 | ``` 49 | 50 | > if the tunnel does not exist, controller will create it for you. 51 | 52 | - Then enable some awesome features in minikube, like kubernetes-dashboard: 53 | 54 | ```bash 55 | minikube addons enable dashboard 56 | minikube addons enable metrics-server 57 | ``` 58 | 59 | - Then expose the dashboard to the internet by creating an `Ingress`: 60 | 61 | ```bash 62 | kubectl -n kubernetes-dashboard \ 63 | create ingress dashboard-via-cf-tunnel \ 64 | --rule="/*=kubernetes-dashboard:80"\ 65 | --class cloudflare-tunnel 66 | ``` 67 | 68 | > for example, I would use `dash.strrl.cloud` as my favorite domain here. 69 | 70 | - At last, access the dashboard via the domain you just created: 71 | 72 | ![dash.strrl.cloud](./static/dash.strrl.cloud.png) 73 | 74 | - Done! Enjoy! 🎉 75 | 76 | ## Alternative 77 | 78 | There is also an awesome project which could integrate with Cloudflare Tunnel as CRD, check it out [adyanth/cloudflare-operator](https://github.com/adyanth/cloudflare-operator)! 79 | 80 | ## Contributing 81 | 82 | Contributions are welcome! If you find a bug or have a feature request, please open an issue or submit a pull request. 83 | 84 | ## License 85 | 86 | This project is licensed under the MIT License. See the LICENSE file for details. 87 | -------------------------------------------------------------------------------- /cmd/cloudflare-tunnel-ingress-controller/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | cloudflarecontroller "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/cloudflare-controller" 10 | "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/controller" 11 | "github.com/cloudflare/cloudflare-go" 12 | "github.com/go-logr/logr" 13 | "github.com/go-logr/stdr" 14 | "github.com/spf13/cobra" 15 | "sigs.k8s.io/controller-runtime/pkg/client/config" 16 | crlog "sigs.k8s.io/controller-runtime/pkg/log" 17 | "sigs.k8s.io/controller-runtime/pkg/manager" 18 | ) 19 | 20 | type rootCmdFlags struct { 21 | logger logr.Logger 22 | // for annotation on Ingress 23 | ingressClass string 24 | // for IngressClass.spec.controller 25 | controllerClass string 26 | logLevel int 27 | cloudflareAPIToken string 28 | cloudflareAccountId string 29 | cloudflareTunnelName string 30 | namespace string 31 | cloudflaredProtocol string 32 | cloudflaredExtraArgs []string 33 | } 34 | 35 | func main() { 36 | var rootLogger = stdr.NewWithOptions(log.New(os.Stderr, "", log.LstdFlags), stdr.Options{LogCaller: stdr.All}) 37 | 38 | options := rootCmdFlags{ 39 | logger: rootLogger.WithName("main"), 40 | ingressClass: "cloudflare-tunnel", 41 | controllerClass: "strrl.dev/cloudflare-tunnel-ingress-controller", 42 | logLevel: 0, 43 | namespace: "default", 44 | cloudflaredProtocol: "auto", 45 | } 46 | 47 | crlog.SetLogger(rootLogger.WithName("controller-runtime")) 48 | 49 | rootCommand := cobra.Command{ 50 | Use: "tunnel-controller", 51 | RunE: func(cmd *cobra.Command, args []string) error { 52 | ctx := context.Background() 53 | stdr.SetVerbosity(options.logLevel) 54 | logger := options.logger 55 | logger.Info("logging verbosity", "level", options.logLevel) 56 | 57 | logger.V(3).Info("build cloudflare client with API Token", "api-token", options.cloudflareAPIToken) 58 | cloudflareClient, err := cloudflare.NewWithAPIToken(options.cloudflareAPIToken) 59 | if err != nil { 60 | logger.Error(err, "create cloudflare client") 61 | os.Exit(1) 62 | } 63 | 64 | var tunnelClient *cloudflarecontroller.TunnelClient 65 | 66 | logger.V(3).Info("bootstrap tunnel client with tunnel name", "account-id", options.cloudflareAccountId, "tunnel-name", options.cloudflareTunnelName) 67 | tunnelClient, err = cloudflarecontroller.BootstrapTunnelClientWithTunnelName(ctx, logger.WithName("tunnel-client"), cloudflareClient, options.cloudflareAccountId, options.cloudflareTunnelName) 68 | if err != nil { 69 | logger.Error(err, "bootstrap tunnel client with tunnel name") 70 | os.Exit(1) 71 | } 72 | 73 | cfg, err := config.GetConfig() 74 | if err != nil { 75 | logger.Error(err, "unable to get kubeconfig") 76 | os.Exit(1) 77 | } 78 | 79 | mgr, err := manager.New(cfg, manager.Options{}) 80 | if err != nil { 81 | logger.Error(err, "unable to set up manager") 82 | os.Exit(1) 83 | } 84 | 85 | logger.Info("cloudflare-tunnel-ingress-controller start serving") 86 | err = controller.RegisterIngressController(logger, mgr, 87 | controller.IngressControllerOptions{ 88 | IngressClassName: options.ingressClass, 89 | ControllerClassName: options.controllerClass, 90 | CFTunnelClient: tunnelClient, 91 | }) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | ticker := time.NewTicker(10 * time.Second) 97 | done := make(chan struct{}) 98 | defer close(done) 99 | 100 | go func() { 101 | for { 102 | select { 103 | case <-done: 104 | return 105 | case _ = <-ticker.C: 106 | err := controller.CreateOrUpdateControlledCloudflared(ctx, mgr.GetClient(), tunnelClient, options.namespace, options.cloudflaredProtocol, options.cloudflaredExtraArgs) 107 | if err != nil { 108 | logger.WithName("controlled-cloudflared").Error(err, "create controlled cloudflared") 109 | } 110 | } 111 | } 112 | }() 113 | 114 | // controller-runtime manager would graceful shutdown with signal by itself, no need to provide context 115 | return mgr.Start(context.Background()) 116 | }, 117 | } 118 | 119 | rootCommand.PersistentFlags().StringVar(&options.ingressClass, "ingress-class", options.ingressClass, "ingress class name") 120 | rootCommand.PersistentFlags().StringVar(&options.controllerClass, "controller-class", options.controllerClass, "controller class name") 121 | rootCommand.PersistentFlags().IntVarP(&options.logLevel, "log-level", "v", options.logLevel, "numeric log level") 122 | rootCommand.PersistentFlags().StringVar(&options.cloudflareAPIToken, "cloudflare-api-token", options.cloudflareAPIToken, "cloudflare api token") 123 | rootCommand.PersistentFlags().StringVar(&options.cloudflareAccountId, "cloudflare-account-id", options.cloudflareAccountId, "cloudflare account id") 124 | rootCommand.PersistentFlags().StringVar(&options.cloudflareTunnelName, "cloudflare-tunnel-name", options.cloudflareTunnelName, "cloudflare tunnel name") 125 | rootCommand.PersistentFlags().StringVar(&options.namespace, "namespace", options.namespace, "namespace to execute cloudflared connector") 126 | rootCommand.PersistentFlags().StringVar(&options.cloudflaredProtocol, "cloudflared-protocol", options.cloudflaredProtocol, "cloudflared protocol") 127 | rootCommand.PersistentFlags().StringSliceVar(&options.cloudflaredExtraArgs, "cloudflared-extra-args", options.cloudflaredExtraArgs, "extra arguments to pass to cloudflared") 128 | 129 | err := rootCommand.Execute() 130 | if err != nil { 131 | panic(err) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/STRRL/cloudflare-tunnel-ingress-controller 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.4 6 | 7 | require ( 8 | github.com/cloudflare/cloudflare-go v0.115.0 9 | github.com/go-logr/logr v1.4.2 10 | github.com/go-logr/stdr v1.2.2 11 | github.com/onsi/ginkgo/v2 v2.22.2 12 | github.com/onsi/gomega v1.36.2 13 | github.com/pkg/errors v0.9.1 14 | github.com/spf13/cobra v1.9.1 15 | github.com/stretchr/testify v1.10.0 16 | k8s.io/api v0.33.2 17 | k8s.io/apimachinery v0.33.2 18 | k8s.io/client-go v0.33.2 19 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 20 | sigs.k8s.io/controller-runtime v0.21.0 21 | ) 22 | 23 | require ( 24 | github.com/beorn7/perks v1.0.1 // indirect 25 | github.com/blang/semver/v4 v4.0.0 // indirect 26 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 27 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 28 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 29 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 30 | github.com/fsnotify/fsnotify v1.7.0 // indirect 31 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 32 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 33 | github.com/go-openapi/jsonreference v0.20.2 // indirect 34 | github.com/go-openapi/swag v0.23.0 // indirect 35 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 36 | github.com/goccy/go-json v0.10.5 // indirect 37 | github.com/gogo/protobuf v1.3.2 // indirect 38 | github.com/google/btree v1.1.3 // indirect 39 | github.com/google/gnostic-models v0.6.9 // indirect 40 | github.com/google/go-cmp v0.7.0 // indirect 41 | github.com/google/go-querystring v1.1.0 // indirect 42 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect 43 | github.com/google/uuid v1.6.0 // indirect 44 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 45 | github.com/josharian/intern v1.0.0 // indirect 46 | github.com/json-iterator/go v1.1.12 // indirect 47 | github.com/mailru/easyjson v0.7.7 // indirect 48 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 49 | github.com/modern-go/reflect2 v1.0.2 // indirect 50 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 51 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 52 | github.com/prometheus/client_golang v1.22.0 // indirect 53 | github.com/prometheus/client_model v0.6.1 // indirect 54 | github.com/prometheus/common v0.62.0 // indirect 55 | github.com/prometheus/procfs v0.15.1 // indirect 56 | github.com/spf13/pflag v1.0.6 // indirect 57 | github.com/x448/float16 v0.8.4 // indirect 58 | golang.org/x/net v0.38.0 // indirect 59 | golang.org/x/oauth2 v0.27.0 // indirect 60 | golang.org/x/sync v0.12.0 // indirect 61 | golang.org/x/sys v0.31.0 // indirect 62 | golang.org/x/term v0.30.0 // indirect 63 | golang.org/x/text v0.23.0 // indirect 64 | golang.org/x/time v0.9.0 // indirect 65 | golang.org/x/tools v0.28.0 // indirect 66 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 67 | google.golang.org/protobuf v1.36.5 // indirect 68 | gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect 69 | gopkg.in/inf.v0 v0.9.1 // indirect 70 | gopkg.in/yaml.v3 v3.0.1 // indirect 71 | k8s.io/apiextensions-apiserver v0.33.0 // indirect 72 | k8s.io/klog/v2 v2.130.1 // indirect 73 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect 74 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect 75 | sigs.k8s.io/randfill v1.0.0 // indirect 76 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect 77 | sigs.k8s.io/yaml v1.4.0 // indirect 78 | ) 79 | 80 | replace k8s.io/api => k8s.io/api v0.33.2 81 | 82 | replace k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.33.2 83 | 84 | replace k8s.io/apimachinery => k8s.io/apimachinery v0.33.2 85 | 86 | replace k8s.io/apiserver => k8s.io/apiserver v0.33.2 87 | 88 | replace k8s.io/cli-runtime => k8s.io/cli-runtime v0.33.2 89 | 90 | replace k8s.io/client-go => k8s.io/client-go v0.33.2 91 | 92 | replace k8s.io/cloud-provider => k8s.io/cloud-provider v0.33.2 93 | 94 | replace k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.33.2 95 | 96 | replace k8s.io/code-generator => k8s.io/code-generator v0.33.2 97 | 98 | replace k8s.io/component-base => k8s.io/component-base v0.33.2 99 | 100 | replace k8s.io/component-helpers => k8s.io/component-helpers v0.33.2 101 | 102 | replace k8s.io/controller-manager => k8s.io/controller-manager v0.33.2 103 | 104 | replace k8s.io/cri-api => k8s.io/cri-api v0.33.2 105 | 106 | replace k8s.io/cri-client => k8s.io/cri-client v0.33.2 107 | 108 | replace k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.33.2 109 | 110 | replace k8s.io/dynamic-resource-allocation => k8s.io/dynamic-resource-allocation v0.33.2 111 | 112 | replace k8s.io/endpointslice => k8s.io/endpointslice v0.33.2 113 | 114 | replace k8s.io/externaljwt => k8s.io/externaljwt v0.33.2 115 | 116 | replace k8s.io/kms => k8s.io/kms v0.33.2 117 | 118 | replace k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.33.2 119 | 120 | replace k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.33.2 121 | 122 | replace k8s.io/kube-proxy => k8s.io/kube-proxy v0.33.2 123 | 124 | replace k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.33.2 125 | 126 | replace k8s.io/kubectl => k8s.io/kubectl v0.33.2 127 | 128 | replace k8s.io/kubelet => k8s.io/kubelet v0.33.2 129 | 130 | replace k8s.io/metrics => k8s.io/metrics v0.33.2 131 | 132 | replace k8s.io/mount-utils => k8s.io/mount-utils v0.33.2 133 | 134 | replace k8s.io/pod-security-admission => k8s.io/pod-security-admission v0.33.2 135 | 136 | replace k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.33.2 137 | 138 | replace k8s.io/sample-cli-plugin => k8s.io/sample-cli-plugin v0.33.2 139 | 140 | replace k8s.io/sample-controller => k8s.io/sample-controller v0.33.2 141 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= 4 | github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= 5 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 6 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= 8 | github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= 9 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 10 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 14 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= 16 | github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 17 | github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= 18 | github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= 19 | github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= 20 | github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= 21 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 22 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 23 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 24 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 25 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 26 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 27 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 28 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 29 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 30 | github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= 31 | github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= 32 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 33 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 34 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 35 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 36 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 37 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 38 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 39 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 40 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 41 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 42 | github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 43 | github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 44 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 45 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 46 | github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= 47 | github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 48 | github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= 49 | github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= 50 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 51 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 52 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 53 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 54 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 55 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 56 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 57 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 58 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 59 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= 60 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 61 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 62 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 63 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 64 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 65 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 66 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 67 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 68 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 69 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 70 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 71 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 72 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 73 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 74 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 75 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 76 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 77 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 78 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 79 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 80 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 81 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 82 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 83 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 84 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 85 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 86 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 87 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 88 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 89 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 90 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 91 | github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= 92 | github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= 93 | github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= 94 | github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= 95 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 96 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 97 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 98 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 99 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 100 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 101 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 102 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 103 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 104 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= 105 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 106 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 107 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 108 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 109 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 110 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 111 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 112 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 113 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 114 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 115 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 116 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 117 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 118 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 119 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 120 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 121 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 122 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 123 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 124 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 125 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 126 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 127 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 128 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 129 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 130 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 131 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 132 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 133 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 134 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 135 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 136 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 137 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 138 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 139 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 140 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 141 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 142 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 143 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 144 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 145 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 146 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 147 | golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 148 | golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 149 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 150 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 151 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 152 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 153 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 154 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 155 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 156 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 157 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 158 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 159 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 160 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 161 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 162 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 163 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 164 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 165 | golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 166 | golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 167 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 168 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 169 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 170 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 171 | golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= 172 | golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= 173 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 174 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 175 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 176 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 177 | gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= 178 | gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= 179 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 180 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 181 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 182 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 183 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 184 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 185 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 186 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 187 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 188 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 189 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 190 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 191 | k8s.io/api v0.33.2 h1:YgwIS5jKfA+BZg//OQhkJNIfie/kmRsO0BmNaVSimvY= 192 | k8s.io/api v0.33.2/go.mod h1:fhrbphQJSM2cXzCWgqU29xLDuks4mu7ti9vveEnpSXs= 193 | k8s.io/apiextensions-apiserver v0.33.2 h1:6gnkIbngnaUflR3XwE1mCefN3YS8yTD631JXQhsU6M8= 194 | k8s.io/apiextensions-apiserver v0.33.2/go.mod h1:IvVanieYsEHJImTKXGP6XCOjTwv2LUMos0YWc9O+QP8= 195 | k8s.io/apimachinery v0.33.2 h1:IHFVhqg59mb8PJWTLi8m1mAoepkUNYmptHsV+Z1m5jY= 196 | k8s.io/apimachinery v0.33.2/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= 197 | k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E= 198 | k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo= 199 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 200 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 201 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= 202 | k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= 203 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= 204 | k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 205 | sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= 206 | sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= 207 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= 208 | sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= 209 | sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 210 | sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= 211 | sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 212 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= 213 | sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= 214 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 215 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 216 | -------------------------------------------------------------------------------- /hack/dev/cloudflare-api.example.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: cloudflare-api 6 | stringData: 7 | api-token: "" 8 | cloudflare-account-id: "" 9 | cloudflare-tunnel-name: "" 10 | -------------------------------------------------------------------------------- /hack/dev/deployment.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: cloudflare-tunnel-ingress-controller 6 | namespace: cloudflare-tunnel-ingress-controller-dev 7 | labels: 8 | app: cloudflare-tunnel-ingress-controller 9 | spec: 10 | clusterIP: None 11 | selector: 12 | app: cloudflare-tunnel-ingress-controller 13 | --- 14 | apiVersion: rbac.authorization.k8s.io/v1 15 | kind: ClusterRole 16 | metadata: 17 | name: cloudflare-tunnel-ingress-controller 18 | labels: 19 | app: cloudflare-tunnel-ingress-controller 20 | rules: 21 | - apiGroups: 22 | - "" 23 | resources: 24 | - services 25 | - endpoints 26 | - secrets 27 | verbs: 28 | - get 29 | - list 30 | - watch 31 | - apiGroups: 32 | - networking.k8s.io 33 | resources: 34 | - ingresses 35 | - ingressclasses 36 | verbs: 37 | - get 38 | - list 39 | - watch 40 | - update 41 | - apiGroups: 42 | - networking.k8s.io 43 | resources: 44 | - ingresses/status 45 | verbs: 46 | - update 47 | - apiGroups: 48 | - apps 49 | resources: 50 | - deployments 51 | verbs: 52 | - get 53 | - list 54 | - watch 55 | - update 56 | - create 57 | --- 58 | apiVersion: v1 59 | kind: ServiceAccount 60 | metadata: 61 | name: cloudflare-tunnel-ingress-controller 62 | namespace: cloudflare-tunnel-ingress-controller-dev 63 | labels: 64 | app: cloudflare-tunnel-ingress-controller 65 | --- 66 | apiVersion: rbac.authorization.k8s.io/v1 67 | kind: ClusterRoleBinding 68 | metadata: 69 | name: cloudflare-tunnel-ingress-controller 70 | labels: 71 | app: cloudflare-tunnel-ingress-controller 72 | roleRef: 73 | apiGroup: rbac.authorization.k8s.io 74 | kind: ClusterRole 75 | name: cloudflare-tunnel-ingress-controller 76 | subjects: 77 | - name: cloudflare-tunnel-ingress-controller 78 | kind: ServiceAccount 79 | # hardcoded namespace for dev 80 | namespace: cloudflare-tunnel-ingress-controller-dev 81 | --- 82 | apiVersion: apps/v1 83 | kind: Deployment 84 | metadata: 85 | name: cloudflare-tunnel-ingress-controller 86 | namespace: cloudflare-tunnel-ingress-controller-dev 87 | labels: 88 | app: cloudflare-tunnel-ingress-controller 89 | spec: 90 | replicas: 1 91 | selector: 92 | matchLabels: 93 | app: cloudflare-tunnel-ingress-controller 94 | template: 95 | metadata: 96 | labels: 97 | app: cloudflare-tunnel-ingress-controller 98 | spec: 99 | volumes: 100 | - name: cloudflare-api-token 101 | secret: 102 | secretName: cloudflare-api 103 | containers: 104 | - name: cloudflare-tunnel-ingress-controller 105 | image: cloudflare-tunnel-ingress-controller 106 | command: 107 | - cloudflare-tunnel-ingress-controller 108 | - -v=10 109 | - --ingress-class=cloudflare-tunnel 110 | - --controller-class=strrl.dev/cloudflare-tunnel-ingress-controller 111 | - --cloudflare-api-token=$(CLOUDFLARE_API_TOKEN) 112 | - --cloudflare-account-id=$(CLOUDFLARE_ACCOUNT_ID) 113 | - --cloudflare-tunnel-name=$(CLOUDFLARE_TUNNEL_NAME) 114 | - --namespace=$(NAMESPACE) 115 | env: 116 | - name: CLOUDFLARED_REPLICA_COUNT 117 | value: "1" 118 | - name: CLOUDFLARED_IMAGE 119 | value: "cloudflare/cloudflared:latest" 120 | - name: CLOUDFLARED_IMAGE_PULL_POLICY 121 | value: "IfNotPresent" 122 | - name: CLOUDFLARE_API_TOKEN 123 | valueFrom: 124 | secretKeyRef: 125 | name: cloudflare-api 126 | key: api-token 127 | - name: CLOUDFLARE_ACCOUNT_ID 128 | valueFrom: 129 | secretKeyRef: 130 | name: cloudflare-api 131 | key: cloudflare-account-id 132 | - name: CLOUDFLARE_TUNNEL_NAME 133 | valueFrom: 134 | secretKeyRef: 135 | name: cloudflare-api 136 | key: cloudflare-tunnel-name 137 | - name: NAMESPACE 138 | valueFrom: 139 | fieldRef: 140 | fieldPath: metadata.namespace 141 | serviceAccountName: cloudflare-tunnel-ingress-controller 142 | -------------------------------------------------------------------------------- /hack/dev/ingress-class.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: networking.k8s.io/v1 3 | kind: IngressClass 4 | metadata: 5 | name: cloudflare-tunnel 6 | annotations: 7 | ingressclass.kubernetes.io/is-default-class: "true" 8 | spec: 9 | controller: strrl.dev/cloudflare-tunnel-ingress-controller 10 | -------------------------------------------------------------------------------- /hack/dev/kubernetes-dashboard-ingress.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: networking.k8s.io/v1 3 | kind: Ingress 4 | metadata: 5 | name: kubernetes-dashboard 6 | namespace: kubernetes-dashboard 7 | spec: 8 | ingressClassName: cloudflare-tunnel 9 | rules: 10 | - http: 11 | paths: 12 | - path: / 13 | pathType: Prefix 14 | backend: 15 | service: 16 | name: kubernetes-dashboard 17 | port: 18 | number: 80 19 | host: dashboard.strrl.cloud -------------------------------------------------------------------------------- /hack/dev/ns.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Namespace 4 | metadata: 5 | name: cloudflare-tunnel-ingress-controller-dev -------------------------------------------------------------------------------- /hack/install-setup-envtest.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # if setup-envtest is not installed, install it 4 | 5 | if ! command -v 'setup-envtest' &> /dev/null 6 | then 7 | echo 'setup-envtest could not be found' 8 | echo 'installing setup-envtest' 9 | go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest 10 | else 11 | echo 'setup-envtest is already installed' 12 | fi -------------------------------------------------------------------------------- /hack/update-kubernetes-library.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This script is adapted from chaos-mesh/chaos-mesh 4 | # Original: https://github.com/chaos-mesh/chaos-mesh/blob/master/hack/update-kubernetes-library.sh 5 | 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | 18 | set -euo pipefail 19 | 20 | if [ $# -ne 1 ]; then 21 | echo "Usage: $0 " 22 | echo "Example: $0 v1.32.1" 23 | exit 1 24 | fi 25 | 26 | VERSION=${1#"v"} 27 | if [ -z "$VERSION" ]; then 28 | echo "Must specify version!" 29 | exit 1 30 | fi 31 | MODS=($( 32 | curl -sS https://raw.githubusercontent.com/kubernetes/kubernetes/v${VERSION}/go.mod | 33 | sed -n 's|.*k8s.io/\(.*\) => ./staging/src/k8s.io/.*|k8s.io/\1|p' 34 | )) 35 | for MOD in "${MODS[@]}"; do 36 | V=$( 37 | go mod download -json "${MOD}@kubernetes-${VERSION}" | 38 | sed -n 's|.*"Version": "\(.*\)".*|\1|p' 39 | ) 40 | echo "bump ${MOD}@${V}" 41 | go mod edit "-replace=${MOD}=${MOD}@${V}" 42 | done 43 | 44 | go get "k8s.io/kubernetes@v${VERSION}" 45 | go mod tidy -------------------------------------------------------------------------------- /helm/cloudflare-tunnel-ingress-controller/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /helm/cloudflare-tunnel-ingress-controller/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: cloudflare-tunnel-ingress-controller 3 | description: Ingress Controller based on Cloudflare Tunnel 4 | 5 | # A chart can be either an 'application' or a 'library' chart. 6 | # 7 | # Application charts are a collection of templates that can be packaged into versioned archives 8 | # to be deployed. 9 | # 10 | # Library charts provide useful utilities or functions for the chart developer. They're included as 11 | # a dependency of application charts to inject those utilities and functions into the rendering 12 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 13 | type: application 14 | 15 | # This is the chart version. This version number should be incremented each time you make changes 16 | # to the chart and its templates, including the app version. 17 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 18 | version: 0.1.0 19 | 20 | # This is the version number of the application being deployed. This version number should be 21 | # incremented each time you make changes to the application. Versions are not expected to 22 | # follow Semantic Versioning. They should reflect the version the application is using. 23 | # It is recommended to use it with quotes. 24 | appVersion: "0.0.1" 25 | home: https://github.com/STRRL/cloudflare-tunnel-ingress-controller 26 | sources: 27 | - https://github.com/STRRL/cloudflare-tunnel-ingress-controller 28 | keywords: 29 | - cloudflare 30 | - cloudflare-tunnel 31 | - ingress 32 | - ingress-controller 33 | maintainers: 34 | - name: STRRL 35 | email: im@strrl.dev 36 | annotations: 37 | # Use this annotation to indicate that this chart version contains security updates, boolean string. 38 | artifacthub.io/containsSecurityUpdates: "false" 39 | # Use this annotation to indicate that your chart represents an operator, boolean string. 40 | artifacthub.io/operator: "true" 41 | # Use this annotation to indicate the capabilities of the operator your chart provides. It must be one of the following options: basic install, seamless upgrades, full lifecycle, deep insights or auto pilot. 42 | artifacthub.io/operatorCapabilities: "Seamless Upgrades" 43 | # Use this annotation to indicate that this chart version is a pre-release. 44 | artifacthub.io/prerelease: "true" 45 | # Use this annotation to indicate the chart’s license, string. 46 | artifacthub.io/license: "Apache-2.0" -------------------------------------------------------------------------------- /helm/cloudflare-tunnel-ingress-controller/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "cloudflare-tunnel-ingress-controller.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "cloudflare-tunnel-ingress-controller.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "cloudflare-tunnel-ingress-controller.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "cloudflare-tunnel-ingress-controller.labels" -}} 37 | helm.sh/chart: {{ include "cloudflare-tunnel-ingress-controller.chart" . }} 38 | {{ include "cloudflare-tunnel-ingress-controller.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "cloudflare-tunnel-ingress-controller.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "cloudflare-tunnel-ingress-controller.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "cloudflare-tunnel-ingress-controller.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "cloudflare-tunnel-ingress-controller.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /helm/cloudflare-tunnel-ingress-controller/templates/clusterrole.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: {{ include "cloudflare-tunnel-ingress-controller.fullname" . }}-watch-ingress 5 | labels: 6 | {{- include "cloudflare-tunnel-ingress-controller.labels" . | nindent 4 }} 7 | rules: 8 | - apiGroups: 9 | - "" 10 | resources: 11 | - services 12 | verbs: 13 | - get 14 | - list 15 | - watch 16 | - apiGroups: 17 | - networking.k8s.io 18 | resources: 19 | - ingresses 20 | - ingressclasses 21 | verbs: 22 | - get 23 | - list 24 | - watch 25 | - update 26 | - apiGroups: 27 | - networking.k8s.io 28 | resources: 29 | - ingresses/status 30 | verbs: 31 | - update 32 | - apiGroups: 33 | - apps 34 | resources: 35 | - deployments 36 | verbs: 37 | - get 38 | - list 39 | - watch 40 | - update 41 | - create -------------------------------------------------------------------------------- /helm/cloudflare-tunnel-ingress-controller/templates/clusterrolebinding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: {{ include "cloudflare-tunnel-ingress-controller.fullname" . }}-watch-ingress 5 | labels: 6 | {{- include "cloudflare-tunnel-ingress-controller.labels" . | nindent 4 }} 7 | roleRef: 8 | apiGroup: rbac.authorization.k8s.io 9 | kind: ClusterRole 10 | name: {{ include "cloudflare-tunnel-ingress-controller.fullname" . }}-watch-ingress 11 | subjects: 12 | - name: {{ include "cloudflare-tunnel-ingress-controller.serviceAccountName" . }} 13 | kind: ServiceAccount 14 | namespace: {{ .Release.Namespace | quote }} -------------------------------------------------------------------------------- /helm/cloudflare-tunnel-ingress-controller/templates/controlled-cloudflared-connector-headless-service.yaml: -------------------------------------------------------------------------------- 1 | # a headless service 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: controlled-cloudflared-connector-headless 6 | labels: 7 | app.kubernetes.io/component: controlled-cloudflared 8 | {{- include "cloudflare-tunnel-ingress-controller.labels" . | nindent 4 }} 9 | annotations: 10 | {{- if not .Values.cloudflaredServiceMonitor.create }} 11 | prometheus.io/scrape: "true" 12 | prometheus.io/port: "44483" 13 | {{- end }} 14 | spec: 15 | ports: 16 | - name: metrics 17 | port: 44483 18 | protocol: TCP 19 | clusterIP: None 20 | selector: 21 | app: controlled-cloudflared-connector 22 | strrl.dev/cloudflare-tunnel-ingress-controller: controlled-cloudflared-connector 23 | -------------------------------------------------------------------------------- /helm/cloudflare-tunnel-ingress-controller/templates/controlled-cloudflared-servicemonitor.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.cloudflaredServiceMonitor.create }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: ServiceMonitor 4 | metadata: 5 | name: {{ include "cloudflare-tunnel-ingress-controller.fullname" . }}-controlled-cloudflared 6 | labels: 7 | {{- include "cloudflare-tunnel-ingress-controller.labels" . | nindent 4 }} 8 | spec: 9 | {{- if .Values.cloudflaredServiceMonitor.jobLabel }} 10 | jobLabel: {{ .Values.cloudflaredServiceMonitor.jobLabel }} 11 | {{- end }} 12 | selector: 13 | matchLabels: 14 | app.kubernetes.io/component: controlled-cloudflared 15 | {{- include "cloudflare-tunnel-ingress-controller.selectorLabels" . | nindent 6 }} 16 | endpoints: 17 | - port: metrics 18 | path: /metrics 19 | scheme: {{ .Values.cloudflaredServiceMonitor.scheme }} 20 | {{- if .Values.cloudflaredServiceMonitor.interval }} 21 | interval: {{ .Values.cloudflaredServiceMonitor.interval }} 22 | {{- end }} 23 | {{- if .Values.cloudflaredServiceMonitor.scrapeTimeout }} 24 | scrapeTimeout: {{ .Values.cloudflaredServiceMonitor.scrapeTimeout }} 25 | {{- end }} 26 | {{- if .Values.cloudflaredServiceMonitor.honorLabels }} 27 | honorLabels: {{ .Values.cloudflaredServiceMonitor.honorLabels }} 28 | {{- end }} 29 | {{- if .Values.cloudflaredServiceMonitor.metricRelabelings }} 30 | metricRelabelings: {{ .Values.cloudflaredServiceMonitor.metricRelabelings }} 31 | {{- end }} 32 | {{- if .Values.cloudflaredServiceMonitor.relabelings }} 33 | relabelings: {{ .Values.cloudflaredServiceMonitor.relabelings }} 34 | {{- end }} 35 | namespaceSelector: 36 | matchNames: 37 | - {{ .Release.Namespace }} 38 | {{- end }} 39 | -------------------------------------------------------------------------------- /helm/cloudflare-tunnel-ingress-controller/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "cloudflare-tunnel-ingress-controller.fullname" . }} 5 | labels: 6 | {{- include "cloudflare-tunnel-ingress-controller.labels" . | nindent 4 }} 7 | spec: 8 | replicas: {{ .Values.replicaCount }} 9 | selector: 10 | matchLabels: 11 | {{- include "cloudflare-tunnel-ingress-controller.selectorLabels" . | nindent 6 }} 12 | template: 13 | metadata: 14 | {{- with .Values.podAnnotations }} 15 | annotations: 16 | {{- toYaml . | nindent 8 }} 17 | {{- end }} 18 | labels: 19 | {{- include "cloudflare-tunnel-ingress-controller.selectorLabels" . | nindent 8 }} 20 | spec: 21 | {{- with .Values.imagePullSecrets }} 22 | imagePullSecrets: 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | serviceAccountName: {{ include "cloudflare-tunnel-ingress-controller.serviceAccountName" . }} 26 | securityContext: 27 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 28 | containers: 29 | - name: {{ .Chart.Name }} 30 | securityContext: 31 | {{- toYaml .Values.securityContext | nindent 12 }} 32 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 33 | imagePullPolicy: {{ .Values.image.pullPolicy }} 34 | command: 35 | - cloudflare-tunnel-ingress-controller 36 | - --ingress-class={{ .Values.ingressClass.name }} 37 | - --controller-class={{ .Values.ingressClass.controllerValue }} 38 | - --cloudflare-api-token=$(CLOUDFLARE_API_TOKEN) 39 | - --cloudflare-account-id=$(CLOUDFLARE_ACCOUNT_ID) 40 | - --cloudflare-tunnel-name=$(CLOUDFLARE_TUNNEL_NAME) 41 | - --namespace=$(NAMESPACE) 42 | - --cloudflared-protocol={{ .Values.cloudflared.protocol }} 43 | {{- range .Values.cloudflared.extraArgs }} 44 | - --cloudflared-extra-args={{ . }} 45 | {{- end }} 46 | env: 47 | - name: CLOUDFLARE_API_TOKEN 48 | valueFrom: 49 | secretKeyRef: 50 | {{- if hasKey .Values.cloudflare "secretRef" }} 51 | name: {{ .Values.cloudflare.secretRef.name }} 52 | key: {{ .Values.cloudflare.secretRef.apiTokenKey }} 53 | {{- else }} 54 | name: cloudflare-api 55 | key: api-token 56 | {{- end }} 57 | - name: CLOUDFLARE_ACCOUNT_ID 58 | valueFrom: 59 | secretKeyRef: 60 | {{- if hasKey .Values.cloudflare "secretRef" }} 61 | name: {{ .Values.cloudflare.secretRef.name }} 62 | key: {{ .Values.cloudflare.secretRef.accountIDKey }} 63 | {{- else }} 64 | name: cloudflare-api 65 | key: cloudflare-account-id 66 | {{- end }} 67 | - name: CLOUDFLARE_TUNNEL_NAME 68 | valueFrom: 69 | secretKeyRef: 70 | {{- if hasKey .Values.cloudflare "secretRef" }} 71 | name: {{ .Values.cloudflare.secretRef.name }} 72 | key: {{ .Values.cloudflare.secretRef.tunnelNameKey }} 73 | {{- else }} 74 | name: cloudflare-api 75 | key: cloudflare-tunnel-name 76 | {{- end }} 77 | - name: NAMESPACE 78 | valueFrom: 79 | fieldRef: 80 | fieldPath: metadata.namespace 81 | - name: CLOUDFLARED_IMAGE 82 | value: "{{ .Values.cloudflared.image.repository }}:{{ .Values.cloudflared.image.tag }}" 83 | - name: CLOUDFLARED_IMAGE_PULL_POLICY 84 | value: {{ .Values.cloudflared.image.pullPolicy | quote }} 85 | - name: CLOUDFLARED_REPLICA_COUNT 86 | value: {{ .Values.cloudflared.replicaCount | quote }} 87 | resources: 88 | {{- toYaml .Values.resources | nindent 12 }} 89 | {{- with .Values.nodeSelector }} 90 | nodeSelector: 91 | {{- toYaml . | nindent 8 }} 92 | {{- end }} 93 | {{- with .Values.affinity }} 94 | affinity: 95 | {{- toYaml . | nindent 8 }} 96 | {{- end }} 97 | {{- with .Values.tolerations }} 98 | tolerations: 99 | {{- toYaml . | nindent 8 }} 100 | {{- end }} 101 | -------------------------------------------------------------------------------- /helm/cloudflare-tunnel-ingress-controller/templates/ingressclass.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: IngressClass 3 | metadata: 4 | annotations: 5 | ingressclass.kubernetes.io/is-default-class: {{ .Values.ingressClass.isDefaultClass | quote }} 6 | name: {{ .Values.ingressClass.name }} 7 | spec: 8 | controller: {{ .Values.ingressClass.controllerValue }} 9 | -------------------------------------------------------------------------------- /helm/cloudflare-tunnel-ingress-controller/templates/role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: Role 3 | metadata: 4 | name: {{ include "cloudflare-tunnel-ingress-controller.fullname" . }}-controlled-cloudflared-connector 5 | labels: 6 | {{- include "cloudflare-tunnel-ingress-controller.labels" . | nindent 4 }} 7 | rules: 8 | - apiGroups: 9 | - apps 10 | resources: 11 | - deployments 12 | verbs: 13 | - get 14 | - list 15 | - watch 16 | - update 17 | - create -------------------------------------------------------------------------------- /helm/cloudflare-tunnel-ingress-controller/templates/rulebinding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | name: {{ include "cloudflare-tunnel-ingress-controller.fullname" . }}-controlled-cloudflared-connector 5 | labels: 6 | {{- include "cloudflare-tunnel-ingress-controller.labels" . | nindent 4 }} 7 | roleRef: 8 | apiGroup: rbac.authorization.k8s.io 9 | kind: ClusterRole 10 | name: {{ include "cloudflare-tunnel-ingress-controller.fullname" . }}-controlled-cloudflared-connector 11 | subjects: 12 | - name: {{ include "cloudflare-tunnel-ingress-controller.serviceAccountName" . }} 13 | kind: ServiceAccount 14 | namespace: {{ .Release.Namespace | quote }} -------------------------------------------------------------------------------- /helm/cloudflare-tunnel-ingress-controller/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | {{- if not (hasKey .Values.cloudflare "secretRef") }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: cloudflare-api 6 | stringData: 7 | api-token: "{{ .Values.cloudflare.apiToken }}" 8 | cloudflare-account-id: "{{ .Values.cloudflare.accountId }}" 9 | cloudflare-tunnel-name: "{{ .Values.cloudflare.tunnelName }}" 10 | {{- end }} 11 | -------------------------------------------------------------------------------- /helm/cloudflare-tunnel-ingress-controller/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "cloudflare-tunnel-ingress-controller.serviceAccountName" . }} 6 | labels: 7 | {{- include "cloudflare-tunnel-ingress-controller.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /helm/cloudflare-tunnel-ingress-controller/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for cloudflare-tunnel-ingress-controller. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | cloudflare: 6 | accountId: "" 7 | tunnelName: "" 8 | apiToken: "" 9 | 10 | # Uncomment if you would like to use an existing secret instead of the creating a new one. 11 | # secretRef: 12 | # name: cloudflare-external-secret 13 | # accountIDKey: account_id 14 | # tunnelNameKey: tunnel_name 15 | # apiTokenKey: api_token 16 | 17 | ingressClass: 18 | name: cloudflare-tunnel 19 | controllerValue: strrl.dev/cloudflare-tunnel-ingress-controller 20 | isDefaultClass: false 21 | 22 | replicaCount: 1 23 | 24 | image: 25 | repository: cr.strrl.dev/strrl/cloudflare-tunnel-ingress-controller 26 | pullPolicy: IfNotPresent 27 | # Overrides the image tag whose default is the chart appVersion. 28 | tag: "" 29 | 30 | imagePullSecrets: [] 31 | nameOverride: "" 32 | fullnameOverride: "" 33 | 34 | serviceAccount: 35 | # Specifies whether a service account should be created 36 | create: true 37 | # Annotations to add to the service account 38 | annotations: {} 39 | # The name of the service account to use. 40 | # If not set and create is true, a name is generated using the fullname template 41 | name: "" 42 | 43 | podAnnotations: {} 44 | 45 | podSecurityContext: 46 | {} 47 | # fsGroup: 2000 48 | 49 | securityContext: 50 | {} 51 | # capabilities: 52 | # drop: 53 | # - ALL 54 | # readOnlyRootFilesystem: true 55 | # runAsNonRoot: true 56 | # runAsUser: 1000 57 | 58 | resources: 59 | # We usually recommend not to specify default resources and to leave this as a conscious 60 | # choice for the user. This also increases chances charts run on environments with little 61 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 62 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 63 | limits: 64 | cpu: 100m 65 | memory: 128Mi 66 | requests: 67 | cpu: 100m 68 | memory: 128Mi 69 | 70 | nodeSelector: {} 71 | 72 | tolerations: [] 73 | 74 | affinity: {} 75 | 76 | cloudflared: 77 | image: 78 | repository: cloudflare/cloudflared 79 | pullPolicy: IfNotPresent 80 | tag: latest 81 | replicaCount: 1 82 | protocol: auto 83 | # Extra arguments to pass to cloudflared command 84 | # Example: ["--post-quantum", "--edge-ip-version", "4"] 85 | extraArgs: [] 86 | 87 | cloudflaredServiceMonitor: 88 | create: false 89 | jobLabel: "" 90 | interval: "" 91 | scrapeTimeout: "" 92 | honorLabels: false 93 | metricRelabelings: [] 94 | relabelings: [] 95 | labels: {} 96 | scheme: http 97 | -------------------------------------------------------------------------------- /image/cloudflare-tunnel-ingress-controller/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM golang:1.23 AS builder 3 | 4 | WORKDIR /workspace 5 | 6 | # pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change 7 | COPY go.mod go.sum ./ 8 | RUN go mod download && go mod verify 9 | 10 | # Build 11 | COPY . . 12 | RUN --mount=type=cache,target=/go \ 13 | CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH GO111MODULE=on \ 14 | go build -ldflags="-s -w" -o cloudflare-tunnel-ingress-controller ./cmd/cloudflare-tunnel-ingress-controller 15 | 16 | # Use distroless as minimal base image to package the manager binary 17 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 18 | FROM gcr.io/distroless/static:nonroot 19 | LABEL org.opencontainers.image.source=https://github.com/STRRL/cloudflare-tunnel-ingress-controller 20 | WORKDIR / 21 | COPY --from=builder /workspace/cloudflare-tunnel-ingress-controller /usr/bin/cloudflare-tunnel-ingress-controller 22 | USER nonroot:nonroot 23 | 24 | ENTRYPOINT ["cloudflare-tunnel-ingress-controller"] 25 | -------------------------------------------------------------------------------- /pkg/cloudflare-controller/bootstrap.go: -------------------------------------------------------------------------------- 1 | package cloudflarecontroller 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "fmt" 7 | 8 | "github.com/cloudflare/cloudflare-go" 9 | "github.com/go-logr/logr" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | func BootstrapTunnelClientWithTunnelName(ctx context.Context, logger logr.Logger, cfClient *cloudflare.API, accountId string, tunnelName string) (*TunnelClient, error) { 14 | logger.V(3).Info("fetch tunnel id with tunnel name", "account-id", accountId, "tunnel-name", tunnelName) 15 | tunnelId, err := GetTunnelIdFromTunnelName(ctx, logger, cfClient, tunnelName, accountId) 16 | if err != nil { 17 | return nil, errors.Wrapf(err, "get tunnel id from tunnel name %s", tunnelName) 18 | } 19 | logger.V(3).Info("tunnel id fetched", "tunnel-id", tunnelId, "tunnel-name", tunnelName, "account-id", accountId) 20 | return NewTunnelClient(logger, cfClient, accountId, tunnelId, tunnelName), nil 21 | } 22 | 23 | func GetTunnelIdFromTunnelName(ctx context.Context, logger logr.Logger, cfClient *cloudflare.API, tunnelName string, accountId string) (string, error) { 24 | logger.V(3).Info("list cloudflare tunnels", "account-id", accountId) 25 | tunnels, _, err := cfClient.ListTunnels(ctx, cloudflare.ResourceIdentifier(accountId), cloudflare.TunnelListParams{ 26 | IsDeleted: boolPointer(false), 27 | // FIXME: that's a workaround for https://github.com/cloudflare/cloudflare-go/issues/1247 28 | ResultInfo: cloudflare.ResultInfo{ 29 | Page: 1, 30 | PerPage: 1000, 31 | }, 32 | }) 33 | logger.V(3).Info("list cloudflare tunnels complete", "account-id", accountId, "tunnels", tunnels) 34 | 35 | if err != nil { 36 | return "", errors.Wrap(err, "list cloudflare tunnels") 37 | } 38 | for _, tunnel := range tunnels { 39 | if tunnel.Name == tunnelName { 40 | return tunnel.ID, nil 41 | } 42 | } 43 | 44 | // create tunnel if not found 45 | logger.V(3).Info("tunnel not found, create tunnel", "account-id", accountId, "tunnel-name", tunnelName) 46 | randomSecret := make([]byte, 64) 47 | _, err = rand.Read(randomSecret) 48 | if err != nil { 49 | return "", errors.Wrap(err, "generate random secret") 50 | } 51 | 52 | hexSecret := fmt.Sprintf("%x", randomSecret) 53 | newTunnel, err := cfClient.CreateTunnel(ctx, cloudflare.ResourceIdentifier(accountId), cloudflare.TunnelCreateParams{ 54 | Name: tunnelName, 55 | Secret: hexSecret, 56 | ConfigSrc: "cloudflare", 57 | }) 58 | if err != nil { 59 | return "", errors.Wrapf(err, "create tunnel %s", tunnelName) 60 | } 61 | 62 | return newTunnel.ID, nil 63 | } 64 | 65 | func boolPointer(b bool) *bool { 66 | return &b 67 | } 68 | -------------------------------------------------------------------------------- /pkg/cloudflare-controller/dns.go: -------------------------------------------------------------------------------- 1 | package cloudflarecontroller 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/exposure" 8 | "github.com/cloudflare/cloudflare-go" 9 | ) 10 | 11 | const ManagedCNAMERecordCommentMarkFormat = "managed by strrl.dev/cloudflare-tunnel-ingress-controller, tunnel [%s]" 12 | 13 | type DNSOperationCreate struct { 14 | Hostname string 15 | Type string 16 | Content string 17 | Comment string 18 | } 19 | 20 | type DNSOperationUpdate struct { 21 | OldRecord cloudflare.DNSRecord 22 | Type string 23 | Content string 24 | Comment string 25 | } 26 | 27 | type DNSOperationDelete struct { 28 | OldRecord cloudflare.DNSRecord 29 | } 30 | 31 | func syncDNSRecord(exposures []exposure.Exposure, existedCNAMERecords []cloudflare.DNSRecord, tunnelId string, tunnelName string) ([]DNSOperationCreate, []DNSOperationUpdate, []DNSOperationDelete, error) { 32 | var effectiveExposures []exposure.Exposure 33 | for _, item := range exposures { 34 | if !item.IsDeleted { 35 | effectiveExposures = append(effectiveExposures, item) 36 | } 37 | } 38 | 39 | var toCreate []DNSOperationCreate 40 | var toUpdate []DNSOperationUpdate 41 | 42 | for _, item := range effectiveExposures { 43 | contains, old := dnsRecordsContainsHostname(existedCNAMERecords, item.Hostname) 44 | 45 | if contains { 46 | toUpdate = append(toUpdate, DNSOperationUpdate{ 47 | OldRecord: old, 48 | Type: "CNAME", 49 | Content: tunnelDomain(tunnelId), 50 | Comment: renderDNSRecordComment(tunnelName), 51 | }) 52 | } else { 53 | toCreate = append(toCreate, DNSOperationCreate{ 54 | Hostname: item.Hostname, 55 | Type: "CNAME", 56 | Content: tunnelDomain(tunnelId), 57 | Comment: renderDNSRecordComment(tunnelName), 58 | }) 59 | } 60 | } 61 | 62 | var toDelete []DNSOperationDelete 63 | for _, item := range existedCNAMERecords { 64 | contains, _ := exposureContainsHostname(effectiveExposures, item.Name) 65 | if !contains { 66 | if item.Comment == renderDNSRecordComment(tunnelName) { 67 | toDelete = append(toDelete, DNSOperationDelete{ 68 | OldRecord: item, 69 | }) 70 | } 71 | } 72 | } 73 | 74 | return toCreate, toUpdate, toDelete, nil 75 | } 76 | 77 | func dnsRecordsContainsHostname(records []cloudflare.DNSRecord, hostname string) (bool, cloudflare.DNSRecord) { 78 | for _, item := range records { 79 | if item.Name == hostname { 80 | return true, item 81 | } 82 | } 83 | return false, cloudflare.DNSRecord{} 84 | } 85 | 86 | func exposureContainsHostname(exposures []exposure.Exposure, hostname string) (bool, exposure.Exposure) { 87 | for _, item := range exposures { 88 | if item.Hostname == hostname { 89 | return true, item 90 | } 91 | } 92 | return false, exposure.Exposure{} 93 | } 94 | 95 | const WellKnownTunnelDomainFormat = "%s.cfargotunnel.com" 96 | 97 | func tunnelDomain(tunnelId string) string { 98 | return strings.ToLower(fmt.Sprintf(WellKnownTunnelDomainFormat, tunnelId)) 99 | } 100 | 101 | func renderDNSRecordComment(tunnelName string) string { 102 | // TODO: comment has a limitation with max 100 char, maybe use TXT record in the future? 103 | return fmt.Sprintf(ManagedCNAMERecordCommentMarkFormat, tunnelName) 104 | } 105 | -------------------------------------------------------------------------------- /pkg/cloudflare-controller/dns_test.go: -------------------------------------------------------------------------------- 1 | package cloudflarecontroller 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/exposure" 8 | "github.com/cloudflare/cloudflare-go" 9 | ) 10 | 11 | const WhateverTunnelId = "whatever" 12 | const WhateverTunnelDomain = "whatever.cfargotunnel.com" 13 | 14 | func Test_syncDNSRecord(t *testing.T) { 15 | type args struct { 16 | exposures []exposure.Exposure 17 | existedRecords []cloudflare.DNSRecord 18 | tunnelId string 19 | tunnelName string 20 | } 21 | var tests = []struct { 22 | name string 23 | args args 24 | wantCreate []DNSOperationCreate 25 | wantUpdate []DNSOperationUpdate 26 | wantDelete []DNSOperationDelete 27 | wantErr bool 28 | }{ 29 | { 30 | name: "noop", 31 | args: args{ 32 | exposures: nil, 33 | existedRecords: nil, 34 | tunnelId: WhateverTunnelId, 35 | }, 36 | wantCreate: nil, 37 | wantUpdate: nil, 38 | wantDelete: nil, 39 | wantErr: false, 40 | }, 41 | { 42 | name: "create new exposure", 43 | args: args{ 44 | exposures: []exposure.Exposure{ 45 | { 46 | Hostname: "test.example.com", 47 | ServiceTarget: "http://10.0.0.1:233", 48 | PathPrefix: "/", 49 | IsDeleted: false, 50 | }, 51 | }, 52 | existedRecords: nil, 53 | tunnelId: WhateverTunnelId, 54 | tunnelName: "tunnel-in-test", 55 | }, 56 | wantCreate: []DNSOperationCreate{ 57 | { 58 | Hostname: "test.example.com", 59 | Type: "CNAME", 60 | Content: WhateverTunnelDomain, 61 | Comment: "managed by strrl.dev/cloudflare-tunnel-ingress-controller, tunnel [tunnel-in-test]", 62 | }, 63 | }, 64 | wantUpdate: nil, 65 | wantDelete: nil, 66 | wantErr: false, 67 | }, 68 | { 69 | name: "ignore deleted exposure", 70 | args: args{ 71 | exposures: []exposure.Exposure{ 72 | { 73 | Hostname: "test.example.com", 74 | ServiceTarget: "http://10.0.0.1:233", 75 | PathPrefix: "/", 76 | IsDeleted: true, 77 | }, 78 | { 79 | Hostname: "test2.example.com", 80 | ServiceTarget: "http://10.0.0.1:233", 81 | PathPrefix: "/", 82 | IsDeleted: false, 83 | }, 84 | }, 85 | existedRecords: nil, 86 | tunnelId: WhateverTunnelId, 87 | tunnelName: "tunnel-in-test", 88 | }, 89 | wantCreate: []DNSOperationCreate{ 90 | { 91 | Hostname: "test2.example.com", 92 | Type: "CNAME", 93 | Content: WhateverTunnelDomain, 94 | Comment: "managed by strrl.dev/cloudflare-tunnel-ingress-controller, tunnel [tunnel-in-test]", 95 | }, 96 | }, 97 | wantUpdate: nil, 98 | wantDelete: nil, 99 | wantErr: false, 100 | }, 101 | { 102 | name: "only delete managed record", 103 | args: args{ 104 | exposures: nil, 105 | existedRecords: []cloudflare.DNSRecord{ 106 | { 107 | Name: "test.example.com", 108 | Type: "CNAME", 109 | Content: "another.example.com", 110 | Comment: "not a managed record", 111 | }, 112 | { 113 | Name: "test2.example.com", 114 | Type: "A", 115 | Content: "1.2.3.4", 116 | Comment: "", 117 | }, 118 | }, 119 | tunnelId: "", 120 | tunnelName: "", 121 | }, 122 | wantCreate: nil, 123 | wantUpdate: nil, 124 | wantDelete: nil, 125 | wantErr: false, 126 | }, 127 | { 128 | name: "update existed exposure", 129 | args: args{ 130 | exposures: []exposure.Exposure{ 131 | { 132 | Hostname: "test.example.com", 133 | ServiceTarget: "http://10.0.0.1:233", 134 | PathPrefix: "/", 135 | IsDeleted: false, 136 | }, 137 | }, 138 | existedRecords: []cloudflare.DNSRecord{ 139 | { 140 | Name: "test.example.com", 141 | Type: "A", 142 | Content: "1.2.3.4", 143 | Comment: "", 144 | }, 145 | }, 146 | tunnelId: WhateverTunnelId, 147 | tunnelName: "tunnel-in-test", 148 | }, 149 | wantCreate: nil, 150 | wantUpdate: []DNSOperationUpdate{ 151 | { 152 | OldRecord: cloudflare.DNSRecord{ 153 | Name: "test.example.com", 154 | Type: "A", 155 | Content: "1.2.3.4", 156 | Comment: "", 157 | }, 158 | Type: "CNAME", 159 | Content: WhateverTunnelDomain, 160 | Comment: "managed by strrl.dev/cloudflare-tunnel-ingress-controller, tunnel [tunnel-in-test]", 161 | }, 162 | }, 163 | wantDelete: nil, 164 | wantErr: false, 165 | }, 166 | { 167 | name: "delete unused exposure", 168 | args: args{ 169 | exposures: []exposure.Exposure{ 170 | { 171 | Hostname: "test.example.com", 172 | ServiceTarget: "http://10.0.0.1:233", 173 | PathPrefix: "/", 174 | IsDeleted: true, 175 | }, 176 | }, 177 | existedRecords: []cloudflare.DNSRecord{ 178 | { 179 | Name: "test.example.com", 180 | Type: "A", 181 | Content: "1.2.3.4", 182 | Comment: "managed by strrl.dev/cloudflare-tunnel-ingress-controller, tunnel [tunnel-in-test]", 183 | }, 184 | }, 185 | tunnelId: WhateverTunnelId, 186 | tunnelName: "tunnel-in-test", 187 | }, 188 | wantCreate: nil, 189 | wantUpdate: nil, 190 | wantDelete: []DNSOperationDelete{ 191 | { 192 | OldRecord: cloudflare.DNSRecord{ 193 | Name: "test.example.com", 194 | Type: "A", 195 | Content: "1.2.3.4", 196 | Comment: "managed by strrl.dev/cloudflare-tunnel-ingress-controller, tunnel [tunnel-in-test]", 197 | }, 198 | }, 199 | }, 200 | wantErr: false, 201 | }, 202 | { 203 | name: "always update existed record", 204 | args: args{ 205 | exposures: []exposure.Exposure{ 206 | { 207 | Hostname: "test.example.com", 208 | ServiceTarget: "http://10.0.0.1:233", 209 | PathPrefix: "/", 210 | IsDeleted: false, 211 | }, 212 | }, 213 | existedRecords: []cloudflare.DNSRecord{ 214 | { 215 | Name: "test.example.com", 216 | Type: "CNAME", 217 | Content: WhateverTunnelDomain, 218 | Comment: "managed by strrl.dev/cloudflare-tunnel-ingress-controller, tunnel [tunnel-in-test]", 219 | }, 220 | }, 221 | tunnelId: WhateverTunnelId, 222 | tunnelName: "tunnel-in-test", 223 | }, 224 | wantCreate: nil, 225 | wantUpdate: []DNSOperationUpdate{ 226 | { 227 | OldRecord: cloudflare.DNSRecord{ 228 | Name: "test.example.com", 229 | Type: "CNAME", 230 | Content: WhateverTunnelDomain, 231 | Comment: "managed by strrl.dev/cloudflare-tunnel-ingress-controller, tunnel [tunnel-in-test]", 232 | }, 233 | Type: "CNAME", 234 | Content: WhateverTunnelDomain, 235 | Comment: "managed by strrl.dev/cloudflare-tunnel-ingress-controller, tunnel [tunnel-in-test]", 236 | }, 237 | }, 238 | wantDelete: nil, 239 | wantErr: false, 240 | }, 241 | } 242 | for _, tt := range tests { 243 | t.Run(tt.name, func(t *testing.T) { 244 | gotCreate, gotUpdate, gotDelete, err := syncDNSRecord(tt.args.exposures, tt.args.existedRecords, tt.args.tunnelId, tt.args.tunnelName) 245 | if (err != nil) != tt.wantErr { 246 | t.Errorf("syncDNSRecord() error = %v, wantErr %v", err, tt.wantErr) 247 | return 248 | } 249 | if !reflect.DeepEqual(gotCreate, tt.wantCreate) { 250 | t.Errorf("syncDNSRecord() gotCreate = %v, want %v", gotCreate, tt.wantCreate) 251 | } 252 | if !reflect.DeepEqual(gotUpdate, tt.wantUpdate) { 253 | t.Errorf("syncDNSRecord() gotUpdate = %v, want %v", gotUpdate, tt.wantUpdate) 254 | } 255 | if !reflect.DeepEqual(gotDelete, tt.wantDelete) { 256 | t.Errorf("syncDNSRecord() gotDelete = %v, want %v", gotDelete, tt.wantDelete) 257 | } 258 | }) 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /pkg/cloudflare-controller/domain.go: -------------------------------------------------------------------------------- 1 | package cloudflarecontroller 2 | 3 | import "strings" 4 | 5 | type Domain struct { 6 | Name string 7 | } 8 | 9 | func (d Domain) IsSubDomainOf(target Domain) bool { 10 | currentLabels := strings.Split(strings.ToLower(d.Name), ".") 11 | targetLabels := strings.Split(strings.ToLower(target.Name), ".") 12 | if len(currentLabels) <= len(targetLabels) { 13 | return false 14 | } 15 | for i := 1; i <= len(targetLabels); i++ { 16 | if currentLabels[len(currentLabels)-i] != targetLabels[len(targetLabels)-i] { 17 | return false 18 | } 19 | } 20 | return true 21 | } 22 | -------------------------------------------------------------------------------- /pkg/cloudflare-controller/domain_test.go: -------------------------------------------------------------------------------- 1 | package cloudflarecontroller 2 | 3 | import "testing" 4 | 5 | func TestDomain_IsSubDomainOf(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | domain Domain 9 | target Domain 10 | expected bool 11 | }{ 12 | { 13 | name: "Valid subdomain", 14 | domain: Domain{Name: "sub.example.com"}, 15 | target: Domain{Name: "example.com"}, 16 | expected: true, 17 | }, 18 | { 19 | name: "Same domain", 20 | domain: Domain{Name: "example.com"}, 21 | target: Domain{Name: "example.com"}, 22 | expected: false, 23 | }, 24 | { 25 | name: "Different TLD", 26 | domain: Domain{Name: "example.com"}, 27 | target: Domain{Name: "example.org"}, 28 | expected: false, 29 | }, 30 | { 31 | name: "Subdomain with multiple levels", 32 | domain: Domain{Name: "a.b.c.example.com"}, 33 | target: Domain{Name: "example.com"}, 34 | expected: true, 35 | }, 36 | { 37 | name: "Case insensitive", 38 | domain: Domain{Name: "Sub.Example.Com"}, 39 | target: Domain{Name: "example.COM"}, 40 | expected: true, 41 | }, 42 | { 43 | name: "Similar but not subdomain", 44 | domain: Domain{Name: "site1.example.com"}, 45 | target: Domain{Name: "myexample.com"}, 46 | expected: false, 47 | }, 48 | { 49 | name: "Subdomain with different prefix", 50 | domain: Domain{Name: "blog.example.com"}, 51 | target: Domain{Name: "shop.example.com"}, 52 | expected: false, 53 | }, 54 | { 55 | name: "Empty domain names", 56 | domain: Domain{Name: ""}, 57 | target: Domain{Name: ""}, 58 | expected: false, 59 | }, 60 | { 61 | name: "Domain with only TLD", 62 | domain: Domain{Name: "com"}, 63 | target: Domain{Name: ""}, 64 | expected: false, 65 | }, 66 | } 67 | 68 | for _, tt := range tests { 69 | t.Run(tt.name, func(t *testing.T) { 70 | if got := tt.domain.IsSubDomainOf(tt.target); got != tt.expected { 71 | t.Errorf("Domain.IsSubDomainOf() = %v, want %v", got, tt.expected) 72 | } 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /pkg/cloudflare-controller/transform.go: -------------------------------------------------------------------------------- 1 | package cloudflarecontroller 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/exposure" 8 | "github.com/cloudflare/cloudflare-go" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | func fromExposureToCloudflareIngress(ctx context.Context, exposure exposure.Exposure) (*cloudflare.UnvalidatedIngressRule, error) { 13 | if exposure.IsDeleted { 14 | return nil, errors.Errorf("exposure %s is deleted, should not generate cloudflare ingress for it", exposure.Hostname) 15 | } 16 | 17 | result := cloudflare.UnvalidatedIngressRule{ 18 | Hostname: exposure.Hostname, 19 | Path: exposure.PathPrefix, 20 | Service: exposure.ServiceTarget, 21 | } 22 | 23 | if exposure.HTTPHostHeader != nil { 24 | if result.OriginRequest == nil { 25 | result.OriginRequest = &cloudflare.OriginRequestConfig{} 26 | } 27 | result.OriginRequest.HTTPHostHeader = exposure.HTTPHostHeader 28 | } 29 | 30 | if strings.HasPrefix(exposure.ServiceTarget, "https://") { 31 | if result.OriginRequest == nil { 32 | result.OriginRequest = &cloudflare.OriginRequestConfig{} 33 | } 34 | result.OriginRequest.OriginServerName = exposure.OriginServerName 35 | if exposure.ProxySSLVerifyEnabled == nil { 36 | result.OriginRequest.NoTLSVerify = boolPointer(true) 37 | } else { 38 | result.OriginRequest.NoTLSVerify = boolPointer(!*exposure.ProxySSLVerifyEnabled) 39 | } 40 | } 41 | 42 | return &result, nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/cloudflare-controller/transform_test.go: -------------------------------------------------------------------------------- 1 | package cloudflarecontroller 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/exposure" 9 | "github.com/cloudflare/cloudflare-go" 10 | "k8s.io/utils/ptr" 11 | ) 12 | 13 | func Test_fromExposureToCloudflareIngress(t *testing.T) { 14 | type args struct { 15 | ctx context.Context 16 | exposure exposure.Exposure 17 | } 18 | tests := []struct { 19 | name string 20 | args args 21 | want *cloudflare.UnvalidatedIngressRule 22 | wantErr bool 23 | }{ 24 | { 25 | name: "deleted exposure", 26 | args: args{ 27 | ctx: context.Background(), 28 | exposure: exposure.Exposure{ 29 | IsDeleted: true, 30 | }, 31 | }, 32 | want: nil, 33 | wantErr: true, 34 | }, 35 | { 36 | name: "valid exposure", 37 | args: args{ 38 | ctx: context.Background(), 39 | exposure: exposure.Exposure{ 40 | Hostname: "ingress.example.com", 41 | ServiceTarget: "http://10.0.0.1:80", 42 | PathPrefix: "/", 43 | IsDeleted: false, 44 | }, 45 | }, 46 | want: &cloudflare.UnvalidatedIngressRule{ 47 | Hostname: "ingress.example.com", 48 | Path: "/", 49 | Service: "http://10.0.0.1:80", 50 | OriginRequest: nil, 51 | }, 52 | wantErr: false, 53 | }, 54 | { 55 | name: "contains path", 56 | args: args{ 57 | ctx: context.Background(), 58 | exposure: exposure.Exposure{ 59 | Hostname: "ingress.example.com", 60 | ServiceTarget: "http://10.0.0.1:80", 61 | PathPrefix: "/prefix", 62 | IsDeleted: false, 63 | }, 64 | }, 65 | want: &cloudflare.UnvalidatedIngressRule{ 66 | Hostname: "ingress.example.com", 67 | Path: "/prefix", 68 | Service: "http://10.0.0.1:80", 69 | OriginRequest: nil, 70 | }, 71 | wantErr: false, 72 | }, 73 | { 74 | name: "contains http-host-header", 75 | args: args{ 76 | ctx: context.Background(), 77 | exposure: exposure.Exposure{ 78 | Hostname: "ingress.example.com", 79 | ServiceTarget: "http://10.0.0.1:80", 80 | PathPrefix: "/prefix", 81 | IsDeleted: false, 82 | HTTPHostHeader: ptr.To("foo.internal"), 83 | }, 84 | }, 85 | want: &cloudflare.UnvalidatedIngressRule{ 86 | Hostname: "ingress.example.com", 87 | Path: "/prefix", 88 | Service: "http://10.0.0.1:80", 89 | OriginRequest: &cloudflare.OriginRequestConfig{ 90 | HTTPHostHeader: ptr.To("foo.internal"), 91 | }, 92 | }, 93 | wantErr: false, 94 | }, 95 | { 96 | name: "https with origin-server-name", 97 | args: args{ 98 | ctx: context.Background(), 99 | exposure: exposure.Exposure{ 100 | Hostname: "ingress.example.com", 101 | ServiceTarget: "https://10.0.0.1:443", 102 | PathPrefix: "/", 103 | IsDeleted: false, 104 | OriginServerName: ptr.To("bar.internal"), 105 | }, 106 | }, 107 | want: &cloudflare.UnvalidatedIngressRule{ 108 | Hostname: "ingress.example.com", 109 | Path: "/", 110 | Service: "https://10.0.0.1:443", 111 | OriginRequest: &cloudflare.OriginRequestConfig{ 112 | NoTLSVerify: boolPointer(true), 113 | OriginServerName: ptr.To("bar.internal"), 114 | }, 115 | }, 116 | }, 117 | { 118 | name: "https with different http-host-header and origin-server-name", 119 | args: args{ 120 | ctx: context.Background(), 121 | exposure: exposure.Exposure{ 122 | Hostname: "ingress.example.com", 123 | ServiceTarget: "https://10.0.0.1:443", 124 | PathPrefix: "/", 125 | IsDeleted: false, 126 | HTTPHostHeader: ptr.To("foo.internal"), 127 | OriginServerName: ptr.To("bar.internal"), 128 | }, 129 | }, 130 | want: &cloudflare.UnvalidatedIngressRule{ 131 | Hostname: "ingress.example.com", 132 | Path: "/", 133 | Service: "https://10.0.0.1:443", 134 | OriginRequest: &cloudflare.OriginRequestConfig{ 135 | NoTLSVerify: boolPointer(true), 136 | HTTPHostHeader: ptr.To("foo.internal"), 137 | OriginServerName: ptr.To("bar.internal"), 138 | }, 139 | }, 140 | }, { 141 | name: "https should enable no-tls-verify by default", 142 | args: args{ 143 | ctx: context.Background(), 144 | exposure: exposure.Exposure{ 145 | Hostname: "ingress.example.com", 146 | ServiceTarget: "https://10.0.0.1:443", 147 | PathPrefix: "/", 148 | IsDeleted: false, 149 | }, 150 | }, 151 | want: &cloudflare.UnvalidatedIngressRule{ 152 | Hostname: "ingress.example.com", 153 | Path: "/", 154 | Service: "https://10.0.0.1:443", 155 | OriginRequest: &cloudflare.OriginRequestConfig{ 156 | NoTLSVerify: boolPointer(true), 157 | }, 158 | }, 159 | }, { 160 | name: "https with no-tls-verify enabled", 161 | args: args{ 162 | ctx: context.Background(), 163 | exposure: exposure.Exposure{ 164 | Hostname: "ingress.example.com", 165 | ServiceTarget: "https://10.0.0.1:443", 166 | PathPrefix: "/", 167 | IsDeleted: false, 168 | ProxySSLVerifyEnabled: boolPointer(false), 169 | }, 170 | }, 171 | want: &cloudflare.UnvalidatedIngressRule{ 172 | Hostname: "ingress.example.com", 173 | Path: "/", 174 | Service: "https://10.0.0.1:443", 175 | OriginRequest: &cloudflare.OriginRequestConfig{ 176 | NoTLSVerify: boolPointer(true), 177 | }, 178 | }, 179 | }, { 180 | name: "https with no-tls-verify disabled", 181 | args: args{ 182 | ctx: context.Background(), 183 | exposure: exposure.Exposure{ 184 | Hostname: "ingress.example.com", 185 | ServiceTarget: "https://10.0.0.1:443", 186 | PathPrefix: "/", 187 | IsDeleted: false, 188 | ProxySSLVerifyEnabled: boolPointer(true), 189 | }, 190 | }, 191 | want: &cloudflare.UnvalidatedIngressRule{ 192 | Hostname: "ingress.example.com", 193 | Path: "/", 194 | Service: "https://10.0.0.1:443", 195 | OriginRequest: &cloudflare.OriginRequestConfig{ 196 | NoTLSVerify: boolPointer(false), 197 | }, 198 | }, 199 | }, 200 | } 201 | for _, tt := range tests { 202 | t.Run(tt.name, func(t *testing.T) { 203 | got, err := fromExposureToCloudflareIngress(tt.args.ctx, tt.args.exposure) 204 | if (err != nil) != tt.wantErr { 205 | t.Errorf("fromExposureToCloudflareIngress() error = %v, wantErr %v", err, tt.wantErr) 206 | return 207 | } 208 | if !reflect.DeepEqual(got, tt.want) { 209 | t.Errorf("fromExposureToCloudflareIngress() got = %v, want %v", got, tt.want) 210 | } 211 | }) 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /pkg/cloudflare-controller/tunnel-client.go: -------------------------------------------------------------------------------- 1 | package cloudflarecontroller 2 | 3 | import ( 4 | "context" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/exposure" 9 | "github.com/cloudflare/cloudflare-go" 10 | "github.com/go-logr/logr" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | type TunnelClientInterface interface { 15 | PutExposures(ctx context.Context, exposures []exposure.Exposure) error 16 | TunnelDomain() string 17 | FetchTunnelToken(ctx context.Context) (string, error) 18 | } 19 | 20 | var _ TunnelClientInterface = &TunnelClient{} 21 | 22 | type TunnelClient struct { 23 | logger logr.Logger 24 | cfClient *cloudflare.API 25 | accountId string 26 | tunnelId string 27 | tunnelName string 28 | } 29 | 30 | func NewTunnelClient(logger logr.Logger, cfClient *cloudflare.API, accountId string, tunnelId string, tunnelName string) *TunnelClient { 31 | return &TunnelClient{logger: logger, cfClient: cfClient, accountId: accountId, tunnelId: tunnelId, tunnelName: tunnelName} 32 | } 33 | 34 | func (t *TunnelClient) PutExposures(ctx context.Context, exposures []exposure.Exposure) error { 35 | err := t.updateTunnelIngressRules(ctx, exposures) 36 | if err != nil { 37 | return errors.Wrap(err, "update tunnel ingress rules") 38 | } 39 | 40 | err = t.updateDNSCNAMERecord(ctx, exposures) 41 | if err != nil { 42 | return errors.Wrap(err, "update DNS CNAME record") 43 | } 44 | return nil 45 | } 46 | 47 | func (t *TunnelClient) TunnelDomain() string { 48 | return tunnelDomain(t.tunnelId) 49 | } 50 | 51 | func (t *TunnelClient) updateTunnelIngressRules(ctx context.Context, exposures []exposure.Exposure) error { 52 | var ingressRules []cloudflare.UnvalidatedIngressRule 53 | 54 | var effectiveExposures []exposure.Exposure 55 | for _, item := range exposures { 56 | if !item.IsDeleted { 57 | effectiveExposures = append(effectiveExposures, item) 58 | } 59 | } 60 | 61 | for _, item := range effectiveExposures { 62 | ingress, err := fromExposureToCloudflareIngress(ctx, item) 63 | if err != nil { 64 | return errors.Wrapf(err, "transform to cloudflare ingress") 65 | } 66 | ingressRules = append(ingressRules, *ingress) 67 | } 68 | 69 | // sort the rules by hostnames first for prettiness, then by path length in descending order 70 | // to ensure "precedence will be given first to the longest matching path". 71 | slices.SortFunc(ingressRules, func(a, b cloudflare.UnvalidatedIngressRule) int { 72 | if v := strings.Compare(strings.ToLower(a.Hostname), strings.ToLower(b.Hostname)); v != 0 { 73 | return v 74 | } 75 | return len(b.Path) - len(a.Path) 76 | }) 77 | 78 | // at last, append a default 404 service as default route 79 | ingressRules = append(ingressRules, cloudflare.UnvalidatedIngressRule{ 80 | Service: "http_status:404", 81 | }) 82 | 83 | t.logger.V(3).Info("update cloudflare tunnel config", "ingress-rules", ingressRules) 84 | 85 | _, err := t.cfClient.UpdateTunnelConfiguration(ctx, 86 | cloudflare.ResourceIdentifier(t.accountId), 87 | cloudflare.TunnelConfigurationParams{ 88 | TunnelID: t.tunnelId, 89 | Config: cloudflare.TunnelConfiguration{ 90 | Ingress: ingressRules, 91 | }, 92 | }, 93 | ) 94 | 95 | if err != nil { 96 | return errors.Wrap(err, "update cloudflare tunnel config") 97 | } 98 | return nil 99 | } 100 | 101 | func (t *TunnelClient) updateDNSCNAMERecord(ctx context.Context, exposures []exposure.Exposure) error { 102 | t.logger.V(3).Info("list zones") 103 | zones, err := t.cfClient.ListZones(ctx) 104 | if err != nil { 105 | return errors.Wrap(err, "list cloudflare zones") 106 | } 107 | 108 | var zoneNames []string 109 | for _, zone := range zones { 110 | zoneNames = append(zoneNames, zone.Name) 111 | } 112 | t.logger.V(3).Info("zones", "zones", zoneNames) 113 | 114 | var exposuresByZone = make(map[string][]exposure.Exposure) 115 | for _, item := range exposures { 116 | ok, zone := zoneBelongedByExposure(item, zoneNames) 117 | if ok { 118 | exposuresByZone[zone] = append(exposuresByZone[zone], item) 119 | } else { 120 | return errors.Errorf("hostname %s not belong to any zone", item.Hostname) 121 | } 122 | } 123 | 124 | for zoneName, items := range exposuresByZone { 125 | ok, zone := findZoneByName(zoneName, zones) 126 | if !ok { 127 | return errors.Errorf("zone %s not found", zoneName) 128 | } 129 | err := t.updateDNSCNAMERecordForZone(ctx, items, zone) 130 | if err != nil { 131 | return errors.Wrapf(err, "update DNS CNAME record for zone %s", zoneNames) 132 | } 133 | } 134 | return nil 135 | } 136 | 137 | func (t *TunnelClient) updateDNSCNAMERecordForZone(ctx context.Context, exposures []exposure.Exposure, zone cloudflare.Zone) error { 138 | cnameDnsRecords, _, err := t.cfClient.ListDNSRecords(ctx, cloudflare.ResourceIdentifier(zone.ID), cloudflare.ListDNSRecordsParams{ 139 | Type: "CNAME", 140 | }) 141 | if err != nil { 142 | return errors.Wrapf(err, "list DNS records for zone %s", zone.Name) 143 | } 144 | toCreate, toUpdate, toDelete, err := syncDNSRecord(exposures, cnameDnsRecords, t.tunnelId, t.tunnelName) 145 | if err != nil { 146 | return errors.Wrap(err, "sync DNS records") 147 | } 148 | t.logger.V(3).Info("sync DNS records", "to-create", toCreate, "to-update", toUpdate, "to-delete", toDelete) 149 | 150 | for _, item := range toCreate { 151 | t.logger.Info("create DNS record", "type", item.Type, "hostname", item.Hostname, "content", item.Content) 152 | _, err := t.cfClient.CreateDNSRecord(ctx, cloudflare.ResourceIdentifier(zone.ID), cloudflare.CreateDNSRecordParams{ 153 | Type: item.Type, 154 | Name: item.Hostname, 155 | Content: item.Content, 156 | Proxied: cloudflare.BoolPtr(true), 157 | Comment: item.Comment, 158 | TTL: 1, 159 | }) 160 | if err != nil { 161 | return errors.Wrapf(err, "create DNS record for zone %s, hostname %s", zone.Name, item.Hostname) 162 | } 163 | } 164 | 165 | for _, item := range toUpdate { 166 | 167 | if item.OldRecord.Comment != renderDNSRecordComment(t.tunnelName) { 168 | t.logger.Info("WARNING, the origin DNS record is not managed by this controller, it would be changed to managed record", 169 | "origin-record", item.OldRecord, 170 | ) 171 | } 172 | 173 | t.logger.Info("update DNS record", "id", item.OldRecord.ID, "type", item.Type, "hostname", item.OldRecord.Name, "content", item.Content) 174 | 175 | _, err := t.cfClient.UpdateDNSRecord(ctx, cloudflare.ResourceIdentifier(zone.ID), cloudflare.UpdateDNSRecordParams{ 176 | ID: item.OldRecord.ID, 177 | Type: item.Type, 178 | Name: item.OldRecord.Name, 179 | Content: item.Content, 180 | Proxied: cloudflare.BoolPtr(true), 181 | Comment: &item.Comment, 182 | TTL: 1, 183 | }) 184 | if err != nil { 185 | return errors.Wrapf(err, "update DNS record for zone %s, hostname %s", zone.Name, item.OldRecord.Name) 186 | } 187 | } 188 | 189 | for _, item := range toDelete { 190 | t.logger.Info("delete DNS record", "id", item.OldRecord.ID, "type", item.OldRecord.Type, "hostname", item.OldRecord.Name, "content", item.OldRecord.Content) 191 | err := t.cfClient.DeleteDNSRecord(ctx, cloudflare.ResourceIdentifier(zone.ID), item.OldRecord.ID) 192 | if err != nil { 193 | return errors.Wrapf(err, "delete DNS record for zone %s, hostname %s", zone.Name, item.OldRecord.Name) 194 | } 195 | } 196 | 197 | return nil 198 | } 199 | 200 | func zoneBelongedByExposure(exposure exposure.Exposure, zones []string) (bool, string) { 201 | hostnameDomain := Domain{Name: exposure.Hostname} 202 | 203 | for _, zone := range zones { 204 | zoneDomain := Domain{Name: zone} 205 | if hostnameDomain.IsSubDomainOf(zoneDomain) || hostnameDomain.Name == zoneDomain.Name { 206 | return true, zone 207 | } 208 | } 209 | return false, "" 210 | } 211 | 212 | func findZoneByName(zoneName string, zones []cloudflare.Zone) (bool, cloudflare.Zone) { 213 | for _, zone := range zones { 214 | if zone.Name == zoneName { 215 | return true, zone 216 | } 217 | } 218 | return false, cloudflare.Zone{} 219 | } 220 | 221 | func (t *TunnelClient) FetchTunnelToken(ctx context.Context) (string, error) { 222 | return t.cfClient.GetTunnelToken(ctx, cloudflare.ResourceIdentifier(t.accountId), t.tunnelId) 223 | } 224 | -------------------------------------------------------------------------------- /pkg/controller/bootstrap.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | cloudflarecontroller "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/cloudflare-controller" 5 | "github.com/go-logr/logr" 6 | networkingv1 "k8s.io/api/networking/v1" 7 | "sigs.k8s.io/controller-runtime/pkg/builder" 8 | "sigs.k8s.io/controller-runtime/pkg/manager" 9 | ) 10 | 11 | type IngressControllerOptions struct { 12 | IngressClassName string 13 | ControllerClassName string 14 | CFTunnelClient *cloudflarecontroller.TunnelClient 15 | } 16 | 17 | func RegisterIngressController(logger logr.Logger, mgr manager.Manager, options IngressControllerOptions) error { 18 | controller := NewIngressController(logger.WithName("ingress-controller"), mgr.GetClient(), options.IngressClassName, options.ControllerClassName, options.CFTunnelClient) 19 | err := builder. 20 | ControllerManagedBy(mgr). 21 | For(&networkingv1.Ingress{}). 22 | Complete(controller) 23 | 24 | if err != nil { 25 | logger.WithName("register-controller").Error(err, "could not register ingress controller") 26 | return err 27 | } 28 | 29 | if err != nil { 30 | logger.WithName("register-controller").Error(err, "could not register ingress class controller") 31 | return err 32 | } 33 | 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/controller/controlled-cloudflared-connector.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "strconv" 7 | 8 | cloudflarecontroller "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/cloudflare-controller" 9 | "github.com/pkg/errors" 10 | appsv1 "k8s.io/api/apps/v1" 11 | v1 "k8s.io/api/core/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | "k8s.io/apimachinery/pkg/labels" 14 | "sigs.k8s.io/controller-runtime/pkg/client" 15 | "sigs.k8s.io/controller-runtime/pkg/log" 16 | ) 17 | 18 | func CreateOrUpdateControlledCloudflared( 19 | ctx context.Context, 20 | kubeClient client.Client, 21 | tunnelClient cloudflarecontroller.TunnelClientInterface, 22 | namespace string, 23 | protocol string, 24 | extraArgs []string, 25 | ) error { 26 | logger := log.FromContext(ctx) 27 | list := appsv1.DeploymentList{} 28 | err := kubeClient.List(ctx, &list, &client.ListOptions{ 29 | Namespace: namespace, 30 | LabelSelector: labels.SelectorFromSet(labels.Set{ 31 | "strrl.dev/cloudflare-tunnel-ingress-controller": "controlled-cloudflared-connector", 32 | }), 33 | }) 34 | if err != nil { 35 | return errors.Wrapf(err, "list controlled-cloudflared-connector in namespace %s", namespace) 36 | } 37 | 38 | if len(list.Items) > 0 { 39 | // Check if the existing deployment needs to be updated 40 | existingDeployment := &list.Items[0] 41 | desiredReplicas, err := getDesiredReplicas() 42 | if err != nil { 43 | return errors.Wrap(err, "get desired replicas") 44 | } 45 | 46 | needsUpdate := false 47 | if *existingDeployment.Spec.Replicas != desiredReplicas { 48 | needsUpdate = true 49 | } 50 | 51 | // Get token once for all checks 52 | token, err := tunnelClient.FetchTunnelToken(ctx) 53 | if err != nil { 54 | return errors.Wrap(err, "fetch tunnel token") 55 | } 56 | 57 | if len(existingDeployment.Spec.Template.Spec.Containers) > 0 { 58 | container := &existingDeployment.Spec.Template.Spec.Containers[0] 59 | if container.Image != os.Getenv("CLOUDFLARED_IMAGE") { 60 | needsUpdate = true 61 | } 62 | if string(container.ImagePullPolicy) != os.Getenv("CLOUDFLARED_IMAGE_PULL_POLICY") { 63 | needsUpdate = true 64 | } 65 | 66 | // Check if command arguments have changed 67 | desiredCommand := buildCloudflaredCommand(protocol, token, extraArgs) 68 | if !slicesEqual(container.Command, desiredCommand) { 69 | needsUpdate = true 70 | } 71 | } 72 | 73 | if needsUpdate { 74 | 75 | updatedDeployment := cloudflaredConnectDeploymentTemplating(protocol, token, namespace, desiredReplicas, extraArgs) 76 | existingDeployment.Spec = updatedDeployment.Spec 77 | err = kubeClient.Update(ctx, existingDeployment) 78 | if err != nil { 79 | return errors.Wrap(err, "update controlled-cloudflared-connector deployment") 80 | } 81 | logger.Info("Updated controlled-cloudflared-connector deployment", "namespace", namespace) 82 | } 83 | 84 | return nil 85 | } 86 | 87 | token, err := tunnelClient.FetchTunnelToken(ctx) 88 | if err != nil { 89 | return errors.Wrap(err, "fetch tunnel token") 90 | } 91 | 92 | replicas, err := getDesiredReplicas() 93 | if err != nil { 94 | return errors.Wrap(err, "get desired replicas") 95 | } 96 | 97 | deployment := cloudflaredConnectDeploymentTemplating(protocol, token, namespace, replicas, extraArgs) 98 | err = kubeClient.Create(ctx, deployment) 99 | if err != nil { 100 | return errors.Wrap(err, "create controlled-cloudflared-connector deployment") 101 | } 102 | logger.Info("Created controlled-cloudflared-connector deployment", "namespace", namespace) 103 | return nil 104 | } 105 | 106 | func cloudflaredConnectDeploymentTemplating(protocol string, token string, namespace string, replicas int32, extraArgs []string) *appsv1.Deployment { 107 | appName := "controlled-cloudflared-connector" 108 | 109 | // Use default values if environment variables are empty 110 | image := os.Getenv("CLOUDFLARED_IMAGE") 111 | if image == "" { 112 | image = "cloudflare/cloudflared:latest" 113 | } 114 | 115 | pullPolicy := os.Getenv("CLOUDFLARED_IMAGE_PULL_POLICY") 116 | if pullPolicy == "" { 117 | pullPolicy = "IfNotPresent" 118 | } 119 | 120 | return &appsv1.Deployment{ 121 | ObjectMeta: metav1.ObjectMeta{ 122 | Name: appName, 123 | Namespace: namespace, 124 | Labels: map[string]string{ 125 | "app": appName, 126 | "strrl.dev/cloudflare-tunnel-ingress-controller": "controlled-cloudflared-connector", 127 | }, 128 | }, 129 | Spec: appsv1.DeploymentSpec{ 130 | Replicas: &replicas, 131 | Selector: &metav1.LabelSelector{ 132 | MatchLabels: map[string]string{ 133 | "app": appName, 134 | "strrl.dev/cloudflare-tunnel-ingress-controller": "controlled-cloudflared-connector", 135 | }, 136 | }, 137 | Template: v1.PodTemplateSpec{ 138 | ObjectMeta: metav1.ObjectMeta{ 139 | Name: appName, 140 | Labels: map[string]string{ 141 | "app": appName, 142 | "strrl.dev/cloudflare-tunnel-ingress-controller": "controlled-cloudflared-connector", 143 | }, 144 | }, 145 | Spec: v1.PodSpec{ 146 | Containers: []v1.Container{ 147 | { 148 | Name: appName, 149 | Image: image, 150 | ImagePullPolicy: v1.PullPolicy(pullPolicy), 151 | Command: buildCloudflaredCommand(protocol, token, extraArgs), 152 | }, 153 | }, 154 | RestartPolicy: v1.RestartPolicyAlways, 155 | }, 156 | }, 157 | }, 158 | } 159 | } 160 | 161 | func getDesiredReplicas() (int32, error) { 162 | replicaCount := os.Getenv("CLOUDFLARED_REPLICA_COUNT") 163 | if replicaCount == "" { 164 | return 1, nil 165 | } 166 | replicas, err := strconv.ParseInt(replicaCount, 10, 32) 167 | if err != nil { 168 | return 0, errors.Wrap(err, "invalid replica count") 169 | } 170 | return int32(replicas), nil 171 | } 172 | 173 | func buildCloudflaredCommand(protocol string, token string, extraArgs []string) []string { 174 | command := []string{ 175 | "cloudflared", 176 | "--protocol", 177 | protocol, 178 | "--no-autoupdate", 179 | "tunnel", 180 | } 181 | 182 | // Add all extra arguments between "tunnel" and "run" 183 | if len(extraArgs) > 0 { 184 | command = append(command, extraArgs...) 185 | } 186 | 187 | // Add metrics, run subcommand and token 188 | command = append(command, "--metrics", "0.0.0.0:44483", "run", "--token", token) 189 | 190 | return command 191 | } 192 | 193 | func slicesEqual(a, b []string) bool { 194 | if len(a) != len(b) { 195 | return false 196 | } 197 | for i, v := range a { 198 | if v != b[i] { 199 | return false 200 | } 201 | } 202 | return true 203 | } 204 | -------------------------------------------------------------------------------- /pkg/controller/controlled-cloudflared-connector_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestBuildCloudflaredCommand(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | protocol string 13 | token string 14 | extraArgs []string 15 | expected []string 16 | }{ 17 | { 18 | name: "basic command without extra args", 19 | protocol: "auto", 20 | token: "test-token", 21 | extraArgs: []string{}, 22 | expected: []string{ 23 | "cloudflared", 24 | "--protocol", 25 | "auto", 26 | "--no-autoupdate", 27 | "tunnel", 28 | "--metrics", 29 | "0.0.0.0:44483", 30 | "run", 31 | "--token", 32 | "test-token", 33 | }, 34 | }, 35 | { 36 | name: "command with post-quantum extra arg", 37 | protocol: "quic", 38 | token: "test-token", 39 | extraArgs: []string{"--post-quantum"}, 40 | expected: []string{ 41 | "cloudflared", 42 | "--protocol", 43 | "quic", 44 | "--no-autoupdate", 45 | "tunnel", 46 | "--post-quantum", 47 | "--metrics", 48 | "0.0.0.0:44483", 49 | "run", 50 | "--token", 51 | "test-token", 52 | }, 53 | }, 54 | { 55 | name: "command with multiple extra args", 56 | protocol: "http2", 57 | token: "test-token", 58 | extraArgs: []string{"--post-quantum", "--edge-ip-version", "4"}, 59 | expected: []string{ 60 | "cloudflared", 61 | "--protocol", 62 | "http2", 63 | "--no-autoupdate", 64 | "tunnel", 65 | "--post-quantum", 66 | "--edge-ip-version", 67 | "4", 68 | "--metrics", 69 | "0.0.0.0:44483", 70 | "run", 71 | "--token", 72 | "test-token", 73 | }, 74 | }, 75 | { 76 | name: "command with nil extra args", 77 | protocol: "auto", 78 | token: "test-token", 79 | extraArgs: nil, 80 | expected: []string{ 81 | "cloudflared", 82 | "--protocol", 83 | "auto", 84 | "--no-autoupdate", 85 | "tunnel", 86 | "--metrics", 87 | "0.0.0.0:44483", 88 | "run", 89 | "--token", 90 | "test-token", 91 | }, 92 | }, 93 | } 94 | 95 | for _, tt := range tests { 96 | t.Run(tt.name, func(t *testing.T) { 97 | result := buildCloudflaredCommand(tt.protocol, tt.token, tt.extraArgs) 98 | assert.Equal(t, tt.expected, result) 99 | }) 100 | } 101 | } 102 | 103 | func TestSlicesEqual(t *testing.T) { 104 | tests := []struct { 105 | name string 106 | a []string 107 | b []string 108 | expected bool 109 | }{ 110 | { 111 | name: "equal slices", 112 | a: []string{"a", "b", "c"}, 113 | b: []string{"a", "b", "c"}, 114 | expected: true, 115 | }, 116 | { 117 | name: "different length", 118 | a: []string{"a", "b"}, 119 | b: []string{"a", "b", "c"}, 120 | expected: false, 121 | }, 122 | { 123 | name: "different content", 124 | a: []string{"a", "b", "c"}, 125 | b: []string{"a", "x", "c"}, 126 | expected: false, 127 | }, 128 | { 129 | name: "empty slices", 130 | a: []string{}, 131 | b: []string{}, 132 | expected: true, 133 | }, 134 | { 135 | name: "nil slices", 136 | a: nil, 137 | b: nil, 138 | expected: true, 139 | }, 140 | { 141 | name: "nil vs empty", 142 | a: nil, 143 | b: []string{}, 144 | expected: true, 145 | }, 146 | } 147 | 148 | for _, tt := range tests { 149 | t.Run(tt.name, func(t *testing.T) { 150 | result := slicesEqual(tt.a, tt.b) 151 | assert.Equal(t, tt.expected, result) 152 | }) 153 | } 154 | } -------------------------------------------------------------------------------- /pkg/controller/ingress-controller.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "slices" 7 | 8 | cloudflarecontroller "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/cloudflare-controller" 9 | "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/exposure" 10 | "github.com/go-logr/logr" 11 | "github.com/pkg/errors" 12 | v1 "k8s.io/api/core/v1" 13 | networkingv1 "k8s.io/api/networking/v1" 14 | apierrors "k8s.io/apimachinery/pkg/api/errors" 15 | "sigs.k8s.io/controller-runtime/pkg/client" 16 | "sigs.k8s.io/controller-runtime/pkg/reconcile" 17 | ) 18 | 19 | // IngressController should implement the Reconciler interface 20 | var _ reconcile.Reconciler = &IngressController{} 21 | 22 | const WellKnownIngressAnnotation = "kubernetes.io/ingress.class" 23 | const IngressControllerFinalizer = "strrl.dev/cloudflare-tunnel-ingress-controller-controlled" 24 | 25 | type IngressController struct { 26 | logger logr.Logger 27 | kubeClient client.Client 28 | ingressClassName string 29 | controllerClassName string 30 | tunnelClient *cloudflarecontroller.TunnelClient 31 | } 32 | 33 | func NewIngressController(logger logr.Logger, kubeClient client.Client, ingressClassName string, controllerClassName string, tunnelClient *cloudflarecontroller.TunnelClient) *IngressController { 34 | return &IngressController{logger: logger, kubeClient: kubeClient, ingressClassName: ingressClassName, controllerClassName: controllerClassName, tunnelClient: tunnelClient} 35 | } 36 | 37 | func (i *IngressController) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) { 38 | origin := networkingv1.Ingress{} 39 | err := i.kubeClient.Get(ctx, request.NamespacedName, &origin) 40 | if err != nil { 41 | if apierrors.IsNotFound(err) { 42 | return reconcile.Result{}, nil 43 | } 44 | return reconcile.Result{}, errors.Wrapf(err, "fetch ingress %s", request.NamespacedName) 45 | } 46 | 47 | controlled, err := i.isControlledByThisController(ctx, origin) 48 | if err != nil && !apierrors.IsNotFound(err) { 49 | return reconcile.Result{}, errors.Wrapf(err, "check if ingress %s is controlled by this controller", request.NamespacedName) 50 | } 51 | 52 | if !controlled { 53 | i.logger.V(1).Info("ingress is NOT controlled by this controller", 54 | "ingress", request.NamespacedName, 55 | "controlled-ingress-class", i.ingressClassName, 56 | "controlled-controller-class", i.controllerClassName, 57 | ) 58 | return reconcile.Result{ 59 | Requeue: false, 60 | }, nil 61 | } 62 | 63 | i.logger.V(1).Info("ingress is controlled by this controller", 64 | "ingress", request.NamespacedName, 65 | "controlled-ingress-class", i.ingressClassName, 66 | "controlled-controller-class", i.controllerClassName, 67 | ) 68 | 69 | i.logger.Info("update cloudflare tunnel config", "triggered-by", request.NamespacedName) 70 | 71 | err = i.attachFinalizer(ctx, *(origin.DeepCopy())) 72 | if err != nil { 73 | return reconcile.Result{}, errors.Wrapf(err, "attach finalizer to ingress %s", request.NamespacedName) 74 | } 75 | 76 | ingresses, err := i.listControlledIngresses(ctx) 77 | if err != nil { 78 | return reconcile.Result{}, errors.Wrap(err, "list controlled ingresses") 79 | } 80 | 81 | var allExposures []exposure.Exposure 82 | for _, ingress := range ingresses { 83 | // best effort to extract exposures from all ingresses 84 | exposures, err := FromIngressToExposure(ctx, i.logger, i.kubeClient, ingress) 85 | if err != nil { 86 | i.logger.Info("extract exposures from ingress, skipped", "triggered-by", request.NamespacedName, "ingress", fmt.Sprintf("%s/%s", ingress.Namespace, ingress.Name), "error", err) 87 | } 88 | allExposures = append(allExposures, exposures...) 89 | } 90 | i.logger.V(3).Info("all exposures", "exposures", allExposures) 91 | 92 | err = i.tunnelClient.PutExposures(ctx, allExposures) 93 | if err != nil { 94 | return reconcile.Result{}, errors.Wrap(err, "put exposures") 95 | } 96 | 97 | if origin.DeletionTimestamp != nil { 98 | err = i.cleanFinalizer(ctx, origin) 99 | if err != nil { 100 | return reconcile.Result{}, errors.Wrapf(err, "clean finalizer from ingress %s", request.NamespacedName) 101 | } 102 | } 103 | 104 | hostname := i.tunnelClient.TunnelDomain() 105 | newOrigin := origin.DeepCopy() 106 | matchesHostname := func(ingress networkingv1.IngressLoadBalancerIngress) bool { 107 | return ingress.Hostname == hostname 108 | } 109 | if !slices.ContainsFunc(origin.Status.LoadBalancer.Ingress, matchesHostname) { 110 | newOrigin.Status.LoadBalancer.Ingress = []networkingv1.IngressLoadBalancerIngress{{ 111 | Hostname: hostname, 112 | Ports: []networkingv1.IngressPortStatus{{ 113 | Protocol: v1.ProtocolTCP, 114 | Port: 443, 115 | }}, 116 | }} 117 | } 118 | if err = i.kubeClient.Status().Update(ctx, newOrigin); err != nil { 119 | return reconcile.Result{}, errors.Wrapf(err, "failed to update ingress status") 120 | } 121 | 122 | i.logger.V(3).Info("reconcile completed", "triggered-by", request.NamespacedName) 123 | return reconcile.Result{}, nil 124 | } 125 | 126 | func (i *IngressController) isControlledByThisController(ctx context.Context, target networkingv1.Ingress) (bool, error) { 127 | if i.ingressClassName == target.GetAnnotations()[WellKnownIngressAnnotation] { 128 | return true, nil 129 | } 130 | 131 | if target.Spec.IngressClassName == nil { 132 | return false, nil 133 | } 134 | 135 | controlledIngressClasses, err := i.listControlledIngressClasses(ctx) 136 | if err != nil { 137 | return false, errors.Wrapf(err, "fetch controlled ingress classes with controller name %s", i.controllerClassName) 138 | } 139 | 140 | var controlledIngressClassNames []string 141 | for _, controlledIngressClass := range controlledIngressClasses { 142 | controlledIngressClassNames = append(controlledIngressClassNames, controlledIngressClass.Name) 143 | } 144 | 145 | if stringSliceContains(controlledIngressClassNames, *target.Spec.IngressClassName) { 146 | return true, nil 147 | } 148 | 149 | return false, nil 150 | } 151 | 152 | func (i *IngressController) listControlledIngressClasses(ctx context.Context) ([]networkingv1.IngressClass, error) { 153 | list := networkingv1.IngressClassList{} 154 | err := i.kubeClient.List(ctx, &list) 155 | if err != nil { 156 | return nil, errors.Wrap(err, "list ingress classes") 157 | } 158 | 159 | filteredList := make([]networkingv1.IngressClass, 0, len(list.Items)) 160 | for _, ingress := range list.Items { 161 | if ingress.Spec.Controller != i.controllerClassName { 162 | continue 163 | } 164 | filteredList = append(filteredList, ingress) 165 | } 166 | 167 | return filteredList, nil 168 | } 169 | 170 | func (i *IngressController) listControlledIngresses(ctx context.Context) ([]networkingv1.Ingress, error) { 171 | controlledIngressClasses, err := i.listControlledIngressClasses(ctx) 172 | if err != nil { 173 | return nil, errors.Wrapf(err, "fetch controlled ingress classes with controller name %s", i.controllerClassName) 174 | } 175 | 176 | var controlledIngressClassNames []string 177 | for _, controlledIngressClass := range controlledIngressClasses { 178 | controlledIngressClassNames = append(controlledIngressClassNames, controlledIngressClass.Name) 179 | } 180 | 181 | var result []networkingv1.Ingress 182 | list := networkingv1.IngressList{} 183 | err = i.kubeClient.List(ctx, &list) 184 | if err != nil { 185 | return nil, errors.Wrap(err, "list ingresses") 186 | } 187 | 188 | for _, ingress := range list.Items { 189 | func() { 190 | if i.ingressClassName == ingress.GetAnnotations()[WellKnownIngressAnnotation] { 191 | result = append(result, ingress) 192 | return 193 | } 194 | 195 | if ingress.Spec.IngressClassName == nil { 196 | return 197 | } 198 | 199 | if stringSliceContains(controlledIngressClassNames, *ingress.Spec.IngressClassName) { 200 | result = append(result, ingress) 201 | return 202 | } 203 | }() 204 | } 205 | 206 | return result, nil 207 | } 208 | 209 | func (i *IngressController) attachFinalizer(ctx context.Context, ingress networkingv1.Ingress) error { 210 | if stringSliceContains(ingress.Finalizers, IngressControllerFinalizer) { 211 | return nil 212 | } 213 | ingress.Finalizers = append(ingress.Finalizers, IngressControllerFinalizer) 214 | err := i.kubeClient.Update(ctx, &ingress) 215 | if err != nil { 216 | return errors.Wrapf(err, "attach finalizer for %s/%s", ingress.Namespace, ingress.Name) 217 | } 218 | return nil 219 | } 220 | 221 | func (i *IngressController) cleanFinalizer(ctx context.Context, ingress networkingv1.Ingress) error { 222 | if !stringSliceContains(ingress.Finalizers, IngressControllerFinalizer) { 223 | return nil 224 | } 225 | ingress.Finalizers = removeStringFromSlice(ingress.Finalizers, IngressControllerFinalizer) 226 | err := i.kubeClient.Update(ctx, &ingress) 227 | if err != nil { 228 | return errors.Wrapf(err, "clean finalizer for %s/%s", ingress.Namespace, ingress.Name) 229 | } 230 | return nil 231 | } 232 | 233 | func removeStringFromSlice(finalizers []string, finalizer string) []string { 234 | var result []string 235 | for _, f := range finalizers { 236 | if f != finalizer { 237 | result = append(result, f) 238 | } 239 | } 240 | return result 241 | } 242 | 243 | func stringSliceContains(slice []string, element string) bool { 244 | for _, sliceElement := range slice { 245 | if sliceElement == element { 246 | return true 247 | } 248 | } 249 | return false 250 | } 251 | -------------------------------------------------------------------------------- /pkg/controller/transform.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "sigs.k8s.io/controller-runtime/pkg/client" 8 | 9 | "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/exposure" 10 | "github.com/go-logr/logr" 11 | "github.com/pkg/errors" 12 | v1 "k8s.io/api/core/v1" 13 | networkingv1 "k8s.io/api/networking/v1" 14 | "k8s.io/apimachinery/pkg/types" 15 | "k8s.io/utils/ptr" 16 | ) 17 | 18 | func FromIngressToExposure(ctx context.Context, logger logr.Logger, kubeClient client.Client, ingress networkingv1.Ingress) ([]exposure.Exposure, error) { 19 | isDeleted := false 20 | 21 | if ingress.DeletionTimestamp != nil { 22 | isDeleted = true 23 | } 24 | 25 | if len(ingress.Spec.TLS) > 0 { 26 | logger.Info("ingress has tls specified, SSL Passthrough is not supported, it will be ignored.") 27 | } 28 | 29 | var result []exposure.Exposure 30 | for _, rule := range ingress.Spec.Rules { 31 | if rule.Host == "" { 32 | return nil, errors.Errorf("host in ingress %s/%s is empty", ingress.GetNamespace(), ingress.GetName()) 33 | } 34 | 35 | hostname := rule.Host 36 | scheme := "http" 37 | 38 | if backendProtocol, ok := getAnnotation(ingress.Annotations, AnnotationBackendProtocol); ok { 39 | scheme = backendProtocol 40 | } 41 | 42 | var httpHostHeader *string 43 | 44 | if header, ok := getAnnotation(ingress.Annotations, AnnotationHTTPHostHeader); ok { 45 | httpHostHeader = ptr.To(header) 46 | } 47 | 48 | var originServerName *string 49 | 50 | if name, ok := getAnnotation(ingress.Annotations, AnnotationOriginServerName); ok { 51 | originServerName = ptr.To(name) 52 | } 53 | 54 | var proxySSLVerifyEnabled *bool 55 | 56 | if proxySSLVerify, ok := getAnnotation(ingress.Annotations, AnnotationProxySSLVerify); ok { 57 | if proxySSLVerify == AnnotationProxySSLVerifyOn { 58 | proxySSLVerifyEnabled = boolPointer(true) 59 | } else if proxySSLVerify == AnnotationProxySSLVerifyOff { 60 | proxySSLVerifyEnabled = boolPointer(false) 61 | } else { 62 | return nil, errors.Errorf( 63 | "invalid value for annotation %s, available values: \"%s\" or \"%s\"", 64 | AnnotationProxySSLVerify, 65 | AnnotationProxySSLVerifyOn, 66 | AnnotationProxySSLVerifyOff, 67 | ) 68 | } 69 | } 70 | 71 | for _, path := range rule.HTTP.Paths { 72 | namespacedName := types.NamespacedName{ 73 | Namespace: ingress.GetNamespace(), 74 | Name: path.Backend.Service.Name, 75 | } 76 | service := v1.Service{} 77 | err := kubeClient.Get(ctx, namespacedName, &service) 78 | if err != nil { 79 | return nil, errors.Wrapf(err, "fetch service %s", namespacedName) 80 | } 81 | 82 | host, err := getHostFromService(&service) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | var port int32 88 | if path.Backend.Service.Port.Name != "" { 89 | ok, extractedPort := getPortWithName(service.Spec.Ports, path.Backend.Service.Port.Name) 90 | if !ok { 91 | return nil, errors.Errorf("service %s has no port named %s", namespacedName, path.Backend.Service.Port.Name) 92 | } 93 | port = extractedPort 94 | } else { 95 | port = path.Backend.Service.Port.Number 96 | } 97 | 98 | var supportedPathTypes = map[networkingv1.PathType]struct{}{ 99 | networkingv1.PathTypePrefix: {}, 100 | networkingv1.PathTypeImplementationSpecific: {}, 101 | } 102 | 103 | if path.PathType == nil { 104 | return nil, errors.Errorf("path type in ingress %s/%s is nil", ingress.GetNamespace(), ingress.GetName()) 105 | } 106 | 107 | if _, ok := supportedPathTypes[*path.PathType]; !ok { 108 | return nil, errors.Errorf("path type in ingress %s/%s is %s, which is not supported", ingress.GetNamespace(), ingress.GetName(), *path.PathType) 109 | } 110 | 111 | result = append(result, exposure.Exposure{ 112 | Hostname: hostname, 113 | ServiceTarget: fmt.Sprintf("%s://%s:%d", scheme, host, port), 114 | PathPrefix: path.Path, 115 | IsDeleted: isDeleted, 116 | ProxySSLVerifyEnabled: proxySSLVerifyEnabled, 117 | HTTPHostHeader: httpHostHeader, 118 | OriginServerName: originServerName, 119 | }) 120 | } 121 | } 122 | 123 | return result, nil 124 | } 125 | 126 | func getHostFromService(service *v1.Service) (string, error) { 127 | if service.Spec.ClusterIP == "None" { 128 | return "", errors.Errorf("service %s has None for cluster ip, headless service is not supported", client.ObjectKeyFromObject(service)) 129 | } 130 | 131 | if service.Spec.ClusterIP != "" { 132 | return service.Spec.ClusterIP, nil 133 | } 134 | 135 | if service.Spec.ExternalName != "" { 136 | return service.Spec.ExternalName, nil 137 | } 138 | 139 | return "", errors.Errorf("service %s has no cluster ip nor external name", client.ObjectKeyFromObject(service)) 140 | } 141 | 142 | func getPortWithName(ports []v1.ServicePort, portName string) (bool, int32) { 143 | for _, port := range ports { 144 | if port.Name == portName { 145 | return true, port.Port 146 | } 147 | } 148 | return false, 0 149 | } 150 | 151 | func getAnnotation(annotations map[string]string, key string) (string, bool) { 152 | value, ok := annotations[key] 153 | return value, ok 154 | } 155 | 156 | func boolPointer(b bool) *bool { 157 | return &b 158 | } 159 | -------------------------------------------------------------------------------- /pkg/controller/transform_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "testing" 5 | 6 | "k8s.io/api/core/v1" 7 | ) 8 | 9 | func TestGetHostFromService(t *testing.T) { 10 | for _, tc := range []struct { 11 | name string 12 | service *v1.Service 13 | want string 14 | wantErr bool 15 | }{ 16 | { 17 | name: "cluster_ip", 18 | service: &v1.Service{ 19 | Spec: v1.ServiceSpec{ 20 | ClusterIP: "1.1.1.1", 21 | }, 22 | }, 23 | want: "1.1.1.1", 24 | }, 25 | { 26 | name: "headless", 27 | service: &v1.Service{ 28 | Spec: v1.ServiceSpec{ 29 | ClusterIP: "None", 30 | }, 31 | }, 32 | wantErr: true, 33 | }, 34 | { 35 | name: "external_name", 36 | service: &v1.Service{ 37 | Spec: v1.ServiceSpec{ 38 | ExternalName: "example.default.svc.cluster.local", 39 | }, 40 | }, 41 | want: "example.default.svc.cluster.local", 42 | }, 43 | { 44 | name: "empty", 45 | service: &v1.Service{ 46 | Spec: v1.ServiceSpec{}, 47 | }, 48 | wantErr: true, 49 | }, 50 | } { 51 | t.Run(tc.name, func(t *testing.T) { 52 | got, err := getHostFromService(tc.service) 53 | 54 | if got != tc.want { 55 | t.Errorf("getHostFromService() = %q, want %q", got, tc.want) 56 | } 57 | 58 | if (err != nil) != tc.wantErr { 59 | t.Errorf("getHostFromService() returns unexpected error: %v", err) 60 | } 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pkg/controller/weel_known_annotations.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | // AnnotationProxySSLVerify is the annotation key for proxy-ssl-verify, available values: "on" or "off", default "off". 4 | const AnnotationProxySSLVerify = "cloudflare-tunnel-ingress-controller.strrl.dev/proxy-ssl-verify" 5 | const AnnotationProxySSLVerifyOn = "on" 6 | const AnnotationProxySSLVerifyOff = "off" 7 | 8 | // AnnotationBackendProtocol is the annotation key for proxy-backend-protocol, default "http". 9 | const AnnotationBackendProtocol = "cloudflare-tunnel-ingress-controller.strrl.dev/backend-protocol" 10 | 11 | // AnnotationHTTPHostHeader is to set the HTTP Host header for the local webserver. 12 | const AnnotationHTTPHostHeader = "cloudflare-tunnel-ingress-controller.strrl.dev/http-host-header" 13 | 14 | // AnnotationOriginServerName is the hostname on the origin server certificate. 15 | const AnnotationOriginServerName = "cloudflare-tunnel-ingress-controller.strrl.dev/origin-server-name" 16 | -------------------------------------------------------------------------------- /pkg/exposure/exposure.go: -------------------------------------------------------------------------------- 1 | package exposure 2 | 3 | // Exposure is the minimal information for exposing a service. 4 | type Exposure struct { 5 | // Hostname is the domain name to expose the service, eg. hello.strrl.dev 6 | Hostname string 7 | // ServiceTarget is the url of the service to expose, eg. http://10.109.94.106:9117 8 | ServiceTarget string 9 | // PathPrefix is the path prefix to expose the service, eg. /hello 10 | PathPrefix string 11 | // IsDeleted is the flag to indicate if the exposure is deleted. 12 | IsDeleted bool 13 | // ProxySSLVerifyEnabled is the flag to indicate if the exposure should skip TLS verification. 14 | ProxySSLVerifyEnabled *bool 15 | // HTTPHostHeader is to set the HTTP Host header for the local webserver. 16 | HTTPHostHeader *string 17 | // OriginServerName is the hostname on the origin server certificate. 18 | OriginServerName *string 19 | } 20 | -------------------------------------------------------------------------------- /skaffold.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: skaffold/v4beta5 2 | kind: Config 3 | metadata: 4 | name: cloudflare-tunnel-ingress-controller 5 | build: 6 | artifacts: 7 | - image: cloudflare-tunnel-ingress-controller 8 | docker: 9 | dockerfile: image/cloudflare-tunnel-ingress-controller/Dockerfile 10 | noCache: false 11 | pullParent: false 12 | squash: false 13 | local: 14 | useBuildkit: true 15 | manifests: 16 | rawYaml: 17 | - hack/dev/ns.yaml 18 | - hack/dev/cloudflare-api.yaml 19 | - hack/dev/deployment.yaml 20 | - hack/dev/ingress-class.yaml 21 | -------------------------------------------------------------------------------- /static/dash.strrl.cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/STRRL/cloudflare-tunnel-ingress-controller/033e57d0c5a1b95811508d7906d9bfb13e3d3904/static/dash.strrl.cloud.png -------------------------------------------------------------------------------- /test/fixtures/kubernetes_namespace.go: -------------------------------------------------------------------------------- 1 | package fixtures 2 | 3 | import ( 4 | "context" 5 | "github.com/pkg/errors" 6 | v1 "k8s.io/api/core/v1" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "sigs.k8s.io/controller-runtime/pkg/client" 9 | "sync" 10 | ) 11 | 12 | type KubernetesNamespaceFixtures struct { 13 | namespacePrefix string 14 | kubeClient client.Client 15 | namespace string 16 | lock sync.Mutex 17 | } 18 | 19 | func NewKubernetesNamespaceFixtures(namespacePrefix string, kubeClient client.Client) *KubernetesNamespaceFixtures { 20 | return &KubernetesNamespaceFixtures{namespacePrefix: namespacePrefix, kubeClient: kubeClient, namespace: "", lock: sync.Mutex{}} 21 | } 22 | 23 | func (f *KubernetesNamespaceFixtures) Start(ctx context.Context) (string, error) { 24 | f.lock.Lock() 25 | defer f.lock.Unlock() 26 | 27 | if f.namespace != "" { 28 | return f.namespace, nil 29 | } 30 | 31 | ns := v1.Namespace{ 32 | ObjectMeta: metav1.ObjectMeta{ 33 | GenerateName: f.namespacePrefix + "-", 34 | }, 35 | } 36 | err := f.kubeClient.Create(ctx, &ns) 37 | if err != nil { 38 | return "", errors.Wrapf(err, "create namespace with generated name %s", f.namespacePrefix) 39 | } 40 | f.namespace = ns.Name 41 | return f.namespace, nil 42 | } 43 | 44 | func (f *KubernetesNamespaceFixtures) Stop(ctx context.Context) error { 45 | f.lock.Lock() 46 | defer f.lock.Unlock() 47 | 48 | err := f.kubeClient.Delete(ctx, &v1.Namespace{ 49 | ObjectMeta: metav1.ObjectMeta{ 50 | Name: f.namespace, 51 | }, 52 | }) 53 | if err != nil { 54 | return errors.Wrapf(err, "delete namespace %s", f.namespace) 55 | } 56 | return nil 57 | } 58 | -------------------------------------------------------------------------------- /test/integration/controller/controlled_cloudflared_connector_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | cloudflarecontroller "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/cloudflare-controller" 8 | "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/controller" 9 | "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/exposure" 10 | "github.com/STRRL/cloudflare-tunnel-ingress-controller/test/fixtures" 11 | . "github.com/onsi/ginkgo/v2" 12 | . "github.com/onsi/gomega" 13 | appsv1 "k8s.io/api/apps/v1" 14 | v1 "k8s.io/api/core/v1" 15 | "k8s.io/apimachinery/pkg/types" 16 | ) 17 | 18 | var _ cloudflarecontroller.TunnelClientInterface = &MockTunnelClient{} 19 | 20 | type MockTunnelClient struct { 21 | FetchTunnelTokenFunc func(ctx context.Context) (string, error) 22 | } 23 | 24 | func (m *MockTunnelClient) PutExposures(ctx context.Context, exposures []exposure.Exposure) error { 25 | return nil 26 | } 27 | 28 | func (m *MockTunnelClient) TunnelDomain() string { 29 | return "mock.tunnel.com" 30 | } 31 | 32 | func (m *MockTunnelClient) FetchTunnelToken(ctx context.Context) (string, error) { 33 | return m.FetchTunnelTokenFunc(ctx) 34 | } 35 | 36 | var _ = Describe("CreateOrUpdateControlledCloudflared", func() { 37 | const testNamespace = "cloudflared-test" 38 | 39 | BeforeEach(func() { 40 | // Set required environment variables 41 | os.Setenv("CLOUDFLARED_REPLICA_COUNT", "2") 42 | os.Setenv("CLOUDFLARED_IMAGE", "cloudflare/cloudflared:latest") 43 | os.Setenv("CLOUDFLARED_IMAGE_PULL_POLICY", "IfNotPresent") 44 | }) 45 | 46 | AfterEach(func() { 47 | // Clean up environment variables 48 | os.Unsetenv("CLOUDFLARED_REPLICA_COUNT") 49 | os.Unsetenv("CLOUDFLARED_IMAGE") 50 | os.Unsetenv("CLOUDFLARED_IMAGE_PULL_POLICY") 51 | }) 52 | 53 | It("should create a new cloudflared deployment", func() { 54 | // Prepare 55 | namespaceFixtures := fixtures.NewKubernetesNamespaceFixtures(testNamespace, kubeClient) 56 | ns, err := namespaceFixtures.Start(ctx) 57 | Expect(err).NotTo(HaveOccurred()) 58 | 59 | defer func() { 60 | err := namespaceFixtures.Stop(ctx) 61 | Expect(err).NotTo(HaveOccurred()) 62 | }() 63 | 64 | mockTunnelClient := &MockTunnelClient{ 65 | FetchTunnelTokenFunc: func(ctx context.Context) (string, error) { 66 | return "mock-token", nil 67 | }, 68 | } 69 | 70 | protocol := "quic" 71 | 72 | // Act 73 | err = controller.CreateOrUpdateControlledCloudflared(ctx, kubeClient, mockTunnelClient, ns, protocol, []string{}) 74 | Expect(err).NotTo(HaveOccurred()) 75 | 76 | // Assert 77 | deployment := &appsv1.Deployment{} 78 | err = kubeClient.Get(ctx, types.NamespacedName{ 79 | Namespace: ns, 80 | Name: "controlled-cloudflared-connector", 81 | }, deployment) 82 | Expect(err).NotTo(HaveOccurred()) 83 | 84 | Expect(*deployment.Spec.Replicas).To(Equal(int32(2))) 85 | Expect(deployment.Spec.Template.Spec.Containers[0].Image).To(Equal("cloudflare/cloudflared:latest")) 86 | Expect(deployment.Spec.Template.Spec.Containers[0].ImagePullPolicy).To(Equal(v1.PullPolicy("IfNotPresent"))) 87 | }) 88 | 89 | It("should update an existing cloudflared deployment", func() { 90 | // Prepare 91 | namespaceFixtures := fixtures.NewKubernetesNamespaceFixtures(testNamespace, kubeClient) 92 | ns, err := namespaceFixtures.Start(ctx) 93 | Expect(err).NotTo(HaveOccurred()) 94 | 95 | defer func() { 96 | err := namespaceFixtures.Stop(ctx) 97 | Expect(err).NotTo(HaveOccurred()) 98 | }() 99 | 100 | mockTunnelClient := &MockTunnelClient{ 101 | FetchTunnelTokenFunc: func(ctx context.Context) (string, error) { 102 | return "mock-token", nil 103 | }, 104 | } 105 | 106 | protocol := "quic" 107 | 108 | // Create initial deployment 109 | err = controller.CreateOrUpdateControlledCloudflared(ctx, kubeClient, mockTunnelClient, ns, protocol, []string{}) 110 | Expect(err).NotTo(HaveOccurred()) 111 | 112 | // Change environment variables 113 | os.Setenv("CLOUDFLARED_REPLICA_COUNT", "3") 114 | os.Setenv("CLOUDFLARED_IMAGE", "cloudflare/cloudflared:2022.3.0") 115 | 116 | // Act 117 | err = controller.CreateOrUpdateControlledCloudflared(ctx, kubeClient, mockTunnelClient, ns, protocol, []string{}) 118 | Expect(err).NotTo(HaveOccurred()) 119 | 120 | // Assert 121 | deployment := &appsv1.Deployment{} 122 | err = kubeClient.Get(ctx, types.NamespacedName{ 123 | Namespace: ns, 124 | Name: "controlled-cloudflared-connector", 125 | }, deployment) 126 | Expect(err).NotTo(HaveOccurred()) 127 | 128 | Expect(*deployment.Spec.Replicas).To(Equal(int32(3))) 129 | Expect(deployment.Spec.Template.Spec.Containers[0].Image).To(Equal("cloudflare/cloudflared:2022.3.0")) 130 | }) 131 | 132 | It("should include extra args in cloudflared command", func() { 133 | // Prepare 134 | namespaceFixtures := fixtures.NewKubernetesNamespaceFixtures(testNamespace, kubeClient) 135 | ns, err := namespaceFixtures.Start(ctx) 136 | Expect(err).NotTo(HaveOccurred()) 137 | 138 | defer func() { 139 | err := namespaceFixtures.Stop(ctx) 140 | Expect(err).NotTo(HaveOccurred()) 141 | }() 142 | 143 | mockTunnelClient := &MockTunnelClient{ 144 | FetchTunnelTokenFunc: func(ctx context.Context) (string, error) { 145 | return "mock-token", nil 146 | }, 147 | } 148 | 149 | protocol := "quic" 150 | extraArgs := []string{"--post-quantum", "--edge-ip-version", "4"} 151 | 152 | // Act 153 | err = controller.CreateOrUpdateControlledCloudflared(ctx, kubeClient, mockTunnelClient, ns, protocol, extraArgs) 154 | Expect(err).NotTo(HaveOccurred()) 155 | 156 | // Assert 157 | deployment := &appsv1.Deployment{} 158 | err = kubeClient.Get(ctx, types.NamespacedName{ 159 | Namespace: ns, 160 | Name: "controlled-cloudflared-connector", 161 | }, deployment) 162 | Expect(err).NotTo(HaveOccurred()) 163 | 164 | command := deployment.Spec.Template.Spec.Containers[0].Command 165 | Expect(command).To(ContainElement("--post-quantum")) 166 | Expect(command).To(ContainElement("--edge-ip-version")) 167 | Expect(command).To(ContainElement("4")) 168 | }) 169 | }) 170 | -------------------------------------------------------------------------------- /test/integration/controller/ingress_transform_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/STRRL/cloudflare-tunnel-ingress-controller/pkg/controller" 8 | "github.com/STRRL/cloudflare-tunnel-ingress-controller/test/fixtures" 9 | "github.com/go-logr/stdr" 10 | . "github.com/onsi/ginkgo/v2" 11 | . "github.com/onsi/gomega" 12 | v1 "k8s.io/api/core/v1" 13 | networkingv1 "k8s.io/api/networking/v1" 14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 15 | "k8s.io/apimachinery/pkg/util/intstr" 16 | ) 17 | 18 | const IntegrationTestNamespace = "cf-tunnel-ingress-controller-test" 19 | 20 | var pathTypePrefix = networkingv1.PathTypePrefix 21 | var pathTypeExact = networkingv1.PathTypeExact 22 | var pathTypeImplementationSpecific = networkingv1.PathTypeImplementationSpecific 23 | 24 | var _ = Describe("transform ingress to exposure", func() { 25 | logger := stdr.NewWithOptions(log.New(os.Stderr, "", log.LstdFlags), stdr.Options{LogCaller: stdr.All}) 26 | 27 | It("should resolve ingress with PathType Prefix", func() { 28 | // prepare 29 | By("preparing namespace") 30 | namespaceFixtures := fixtures.NewKubernetesNamespaceFixtures(IntegrationTestNamespace, kubeClient) 31 | ns, err := namespaceFixtures.Start(ctx) 32 | Expect(err).ShouldNot(HaveOccurred()) 33 | 34 | defer func() { 35 | By("cleaning up namespace") 36 | err := namespaceFixtures.Stop(ctx) 37 | Expect(err).ShouldNot(HaveOccurred()) 38 | }() 39 | 40 | By("preparing service") 41 | service := v1.Service{ 42 | ObjectMeta: metav1.ObjectMeta{ 43 | Namespace: ns, 44 | GenerateName: "test-service-", 45 | }, 46 | Spec: v1.ServiceSpec{ 47 | ClusterIP: "10.0.0.23", 48 | Ports: []v1.ServicePort{ 49 | { 50 | Name: "http", 51 | Protocol: v1.ProtocolTCP, 52 | Port: 2333, 53 | TargetPort: intstr.IntOrString{ 54 | Type: intstr.Int, 55 | IntVal: 8080, 56 | }, 57 | }, 58 | }, 59 | }, 60 | } 61 | err = kubeClient.Create(ctx, &service) 62 | Expect(err).ShouldNot(HaveOccurred()) 63 | 64 | By("preparing ingress") 65 | ingress := networkingv1.Ingress{ 66 | ObjectMeta: metav1.ObjectMeta{ 67 | Namespace: ns, 68 | GenerateName: "test-ingress-", 69 | }, 70 | Spec: networkingv1.IngressSpec{ 71 | Rules: []networkingv1.IngressRule{ 72 | { 73 | Host: "test.example.com", 74 | IngressRuleValue: networkingv1.IngressRuleValue{ 75 | HTTP: &networkingv1.HTTPIngressRuleValue{ 76 | Paths: []networkingv1.HTTPIngressPath{ 77 | { 78 | Path: "/", 79 | PathType: &pathTypePrefix, 80 | Backend: networkingv1.IngressBackend{ 81 | Service: &networkingv1.IngressServiceBackend{ 82 | Name: service.Name, 83 | Port: networkingv1.ServiceBackendPort{ 84 | Number: 2333, 85 | }, 86 | }, 87 | }, 88 | }, 89 | }, 90 | }, 91 | }, 92 | }, 93 | }, 94 | }, 95 | } 96 | err = kubeClient.Create(ctx, &ingress) 97 | Expect(err).ShouldNot(HaveOccurred()) 98 | 99 | By("transforming ingress to exposure") 100 | exposure, err := controller.FromIngressToExposure(ctx, logger, kubeClient, ingress) 101 | Expect(err).ShouldNot(HaveOccurred()) 102 | Expect(exposure).ShouldNot(BeNil()) 103 | Expect(exposure).Should(HaveLen(1)) 104 | Expect(exposure[0].Hostname).Should(Equal("test.example.com")) 105 | Expect(exposure[0].ServiceTarget).Should(Equal("http://10.0.0.23:2333")) 106 | Expect(exposure[0].PathPrefix).Should(Equal("/")) 107 | Expect(exposure[0].IsDeleted).Should(BeFalse()) 108 | }) 109 | 110 | It("should fail fast with PathType Exact", func() { 111 | // prepare 112 | By("preparing namespace") 113 | namespaceFixtures := fixtures.NewKubernetesNamespaceFixtures(IntegrationTestNamespace, kubeClient) 114 | ns, err := namespaceFixtures.Start(ctx) 115 | Expect(err).ShouldNot(HaveOccurred()) 116 | 117 | defer func() { 118 | By("cleaning up namespace") 119 | err := namespaceFixtures.Stop(ctx) 120 | Expect(err).ShouldNot(HaveOccurred()) 121 | }() 122 | 123 | By("preparing service") 124 | service := v1.Service{ 125 | ObjectMeta: metav1.ObjectMeta{ 126 | Namespace: ns, 127 | GenerateName: "test-service-", 128 | }, 129 | Spec: v1.ServiceSpec{ 130 | ClusterIP: "10.0.0.24", 131 | Ports: []v1.ServicePort{ 132 | { 133 | Name: "http", 134 | Protocol: v1.ProtocolTCP, 135 | Port: 2333, 136 | TargetPort: intstr.IntOrString{ 137 | Type: intstr.Int, 138 | IntVal: 8080, 139 | }, 140 | }, 141 | }, 142 | }, 143 | } 144 | err = kubeClient.Create(ctx, &service) 145 | Expect(err).ShouldNot(HaveOccurred()) 146 | 147 | By("preparing ingress") 148 | ingress := networkingv1.Ingress{ 149 | ObjectMeta: metav1.ObjectMeta{ 150 | Namespace: ns, 151 | GenerateName: "test-ingress-", 152 | }, 153 | Spec: networkingv1.IngressSpec{ 154 | Rules: []networkingv1.IngressRule{ 155 | { 156 | Host: "test.example.com", 157 | IngressRuleValue: networkingv1.IngressRuleValue{ 158 | HTTP: &networkingv1.HTTPIngressRuleValue{ 159 | Paths: []networkingv1.HTTPIngressPath{ 160 | { 161 | Path: "/", 162 | PathType: &pathTypeExact, 163 | Backend: networkingv1.IngressBackend{ 164 | Service: &networkingv1.IngressServiceBackend{ 165 | Name: service.Name, 166 | Port: networkingv1.ServiceBackendPort{ 167 | Number: 2333, 168 | }, 169 | }, 170 | }, 171 | }, 172 | }, 173 | }, 174 | }, 175 | }, 176 | }, 177 | }, 178 | } 179 | err = kubeClient.Create(ctx, &ingress) 180 | Expect(err).ShouldNot(HaveOccurred()) 181 | 182 | By("transforming ingress to exposure") 183 | exposure, err := controller.FromIngressToExposure(ctx, logger, kubeClient, ingress) 184 | Expect(err).Should(HaveOccurred()) 185 | Expect(exposure).Should(BeNil()) 186 | }) 187 | 188 | It("should fail fast with PathType ImplementationSpecific", func() { 189 | // prepare 190 | By("preparing namespace") 191 | namespaceFixtures := fixtures.NewKubernetesNamespaceFixtures(IntegrationTestNamespace, kubeClient) 192 | ns, err := namespaceFixtures.Start(ctx) 193 | Expect(err).ShouldNot(HaveOccurred()) 194 | 195 | defer func() { 196 | By("cleaning up namespace") 197 | err := namespaceFixtures.Stop(ctx) 198 | Expect(err).ShouldNot(HaveOccurred()) 199 | }() 200 | 201 | By("preparing service") 202 | service := v1.Service{ 203 | ObjectMeta: metav1.ObjectMeta{ 204 | Namespace: ns, 205 | GenerateName: "test-service-", 206 | }, 207 | Spec: v1.ServiceSpec{ 208 | ClusterIP: "10.0.0.25", 209 | Ports: []v1.ServicePort{ 210 | { 211 | Name: "http", 212 | Protocol: v1.ProtocolTCP, 213 | Port: 2333, 214 | TargetPort: intstr.IntOrString{ 215 | Type: intstr.Int, 216 | IntVal: 8080, 217 | }, 218 | }, 219 | }, 220 | }, 221 | } 222 | err = kubeClient.Create(ctx, &service) 223 | Expect(err).ShouldNot(HaveOccurred()) 224 | 225 | By("preparing ingress") 226 | ingress := networkingv1.Ingress{ 227 | ObjectMeta: metav1.ObjectMeta{ 228 | Namespace: ns, 229 | GenerateName: "test-ingress-", 230 | }, 231 | Spec: networkingv1.IngressSpec{ 232 | Rules: []networkingv1.IngressRule{ 233 | { 234 | Host: "test.example.com", 235 | IngressRuleValue: networkingv1.IngressRuleValue{ 236 | HTTP: &networkingv1.HTTPIngressRuleValue{ 237 | Paths: []networkingv1.HTTPIngressPath{ 238 | { 239 | Path: "/", 240 | PathType: &pathTypeImplementationSpecific, 241 | Backend: networkingv1.IngressBackend{ 242 | Service: &networkingv1.IngressServiceBackend{ 243 | Name: service.Name, 244 | Port: networkingv1.ServiceBackendPort{ 245 | Number: 2333, 246 | }, 247 | }, 248 | }, 249 | }, 250 | }, 251 | }, 252 | }, 253 | }, 254 | }, 255 | }, 256 | } 257 | err = kubeClient.Create(ctx, &ingress) 258 | Expect(err).ShouldNot(HaveOccurred()) 259 | 260 | By("transforming ingress to exposure") 261 | exposure, err := controller.FromIngressToExposure(ctx, logger, kubeClient, ingress) 262 | Expect(err).ShouldNot(HaveOccurred()) 263 | Expect(exposure).Should(HaveLen(1)) 264 | }) 265 | 266 | It("should resolve ingress with port name", func() { 267 | // prepare 268 | By("preparing namespace") 269 | namespaceFixtures := fixtures.NewKubernetesNamespaceFixtures(IntegrationTestNamespace, kubeClient) 270 | ns, err := namespaceFixtures.Start(ctx) 271 | Expect(err).ShouldNot(HaveOccurred()) 272 | 273 | defer func() { 274 | By("cleaning up namespace") 275 | err := namespaceFixtures.Stop(ctx) 276 | Expect(err).ShouldNot(HaveOccurred()) 277 | }() 278 | 279 | By("preparing service") 280 | service := v1.Service{ 281 | ObjectMeta: metav1.ObjectMeta{ 282 | Namespace: ns, 283 | GenerateName: "test-service-", 284 | }, 285 | Spec: v1.ServiceSpec{ 286 | ClusterIP: "10.0.0.26", 287 | Ports: []v1.ServicePort{ 288 | { 289 | Name: "http", 290 | Protocol: v1.ProtocolTCP, 291 | Port: 2333, 292 | TargetPort: intstr.IntOrString{ 293 | Type: intstr.Int, 294 | IntVal: 8080, 295 | }, 296 | }, 297 | }, 298 | }, 299 | } 300 | err = kubeClient.Create(ctx, &service) 301 | Expect(err).ShouldNot(HaveOccurred()) 302 | 303 | By("preparing ingress") 304 | ingress := networkingv1.Ingress{ 305 | ObjectMeta: metav1.ObjectMeta{ 306 | Namespace: ns, 307 | GenerateName: "test-ingress-", 308 | }, 309 | Spec: networkingv1.IngressSpec{ 310 | Rules: []networkingv1.IngressRule{ 311 | { 312 | Host: "test.example.com", 313 | IngressRuleValue: networkingv1.IngressRuleValue{ 314 | HTTP: &networkingv1.HTTPIngressRuleValue{ 315 | Paths: []networkingv1.HTTPIngressPath{ 316 | { 317 | Path: "/", 318 | PathType: &pathTypePrefix, 319 | Backend: networkingv1.IngressBackend{ 320 | Service: &networkingv1.IngressServiceBackend{ 321 | Name: service.Name, 322 | Port: networkingv1.ServiceBackendPort{ 323 | Name: "http", 324 | }, 325 | }, 326 | }, 327 | }, 328 | }, 329 | }, 330 | }, 331 | }, 332 | }, 333 | }, 334 | } 335 | err = kubeClient.Create(ctx, &ingress) 336 | Expect(err).ShouldNot(HaveOccurred()) 337 | 338 | By("transforming ingress to exposure") 339 | exposure, err := controller.FromIngressToExposure(ctx, logger, kubeClient, ingress) 340 | Expect(err).ShouldNot(HaveOccurred()) 341 | Expect(exposure).ShouldNot(BeNil()) 342 | Expect(exposure).Should(HaveLen(1)) 343 | Expect(exposure[0].Hostname).Should(Equal("test.example.com")) 344 | Expect(exposure[0].ServiceTarget).Should(Equal("http://10.0.0.26:2333")) 345 | Expect(exposure[0].PathPrefix).Should(Equal("/")) 346 | Expect(exposure[0].IsDeleted).Should(BeFalse()) 347 | }) 348 | 349 | It("fail fast if no port found by port name", func() { 350 | // prepare 351 | By("preparing namespace") 352 | namespaceFixtures := fixtures.NewKubernetesNamespaceFixtures(IntegrationTestNamespace, kubeClient) 353 | ns, err := namespaceFixtures.Start(ctx) 354 | Expect(err).ShouldNot(HaveOccurred()) 355 | 356 | defer func() { 357 | By("cleaning up namespace") 358 | err := namespaceFixtures.Stop(ctx) 359 | Expect(err).ShouldNot(HaveOccurred()) 360 | }() 361 | 362 | By("preparing service") 363 | service := v1.Service{ 364 | ObjectMeta: metav1.ObjectMeta{ 365 | Namespace: ns, 366 | GenerateName: "test-service-", 367 | }, 368 | Spec: v1.ServiceSpec{ 369 | ClusterIP: "10.0.0.254", 370 | Ports: []v1.ServicePort{ 371 | { 372 | Name: "http", 373 | Protocol: v1.ProtocolTCP, 374 | Port: 2333, 375 | TargetPort: intstr.IntOrString{ 376 | Type: intstr.Int, 377 | IntVal: 8080, 378 | }, 379 | }, 380 | }, 381 | }, 382 | } 383 | err = kubeClient.Create(ctx, &service) 384 | Expect(err).ShouldNot(HaveOccurred()) 385 | 386 | By("preparing ingress") 387 | ingress := networkingv1.Ingress{ 388 | ObjectMeta: metav1.ObjectMeta{ 389 | Namespace: ns, 390 | GenerateName: "test-ingress-", 391 | }, 392 | Spec: networkingv1.IngressSpec{ 393 | Rules: []networkingv1.IngressRule{ 394 | { 395 | Host: "test.example.com", 396 | IngressRuleValue: networkingv1.IngressRuleValue{ 397 | HTTP: &networkingv1.HTTPIngressRuleValue{ 398 | Paths: []networkingv1.HTTPIngressPath{ 399 | { 400 | Path: "/", 401 | PathType: &pathTypePrefix, 402 | Backend: networkingv1.IngressBackend{ 403 | Service: &networkingv1.IngressServiceBackend{ 404 | Name: service.Name, 405 | Port: networkingv1.ServiceBackendPort{ 406 | Name: "whatever-name", 407 | }, 408 | }, 409 | }, 410 | }, 411 | }, 412 | }, 413 | }, 414 | }, 415 | }, 416 | }, 417 | } 418 | err = kubeClient.Create(ctx, &ingress) 419 | Expect(err).ShouldNot(HaveOccurred()) 420 | 421 | By("transforming ingress to exposure") 422 | exposure, err := controller.FromIngressToExposure(ctx, logger, kubeClient, ingress) 423 | Expect(err).Should(HaveOccurred()) 424 | Expect(exposure).Should(BeNil()) 425 | }) 426 | 427 | It("should fail fast with headless service", func() { 428 | // prepare 429 | By("preparing namespace") 430 | namespaceFixtures := fixtures.NewKubernetesNamespaceFixtures(IntegrationTestNamespace, kubeClient) 431 | ns, err := namespaceFixtures.Start(ctx) 432 | Expect(err).ShouldNot(HaveOccurred()) 433 | 434 | defer func() { 435 | By("cleaning up namespace") 436 | err := namespaceFixtures.Stop(ctx) 437 | Expect(err).ShouldNot(HaveOccurred()) 438 | }() 439 | 440 | By("preparing service") 441 | service := v1.Service{ 442 | ObjectMeta: metav1.ObjectMeta{ 443 | Namespace: ns, 444 | GenerateName: "test-service-", 445 | }, 446 | Spec: v1.ServiceSpec{ 447 | ClusterIP: "None", 448 | Ports: []v1.ServicePort{ 449 | { 450 | Name: "http", 451 | Protocol: v1.ProtocolTCP, 452 | Port: 2333, 453 | TargetPort: intstr.IntOrString{ 454 | Type: intstr.Int, 455 | IntVal: 8080, 456 | }, 457 | }, 458 | }, 459 | }, 460 | } 461 | err = kubeClient.Create(ctx, &service) 462 | Expect(err).ShouldNot(HaveOccurred()) 463 | 464 | By("preparing ingress") 465 | ingress := networkingv1.Ingress{ 466 | ObjectMeta: metav1.ObjectMeta{ 467 | Namespace: ns, 468 | GenerateName: "test-ingress-", 469 | }, 470 | Spec: networkingv1.IngressSpec{ 471 | Rules: []networkingv1.IngressRule{ 472 | { 473 | Host: "test.example.com", 474 | IngressRuleValue: networkingv1.IngressRuleValue{ 475 | HTTP: &networkingv1.HTTPIngressRuleValue{ 476 | Paths: []networkingv1.HTTPIngressPath{ 477 | { 478 | Path: "/", 479 | PathType: &pathTypeExact, 480 | Backend: networkingv1.IngressBackend{ 481 | Service: &networkingv1.IngressServiceBackend{ 482 | Name: service.Name, 483 | Port: networkingv1.ServiceBackendPort{ 484 | Number: 2333, 485 | }, 486 | }, 487 | }, 488 | }, 489 | }, 490 | }, 491 | }, 492 | }, 493 | }, 494 | }, 495 | } 496 | err = kubeClient.Create(ctx, &ingress) 497 | Expect(err).ShouldNot(HaveOccurred()) 498 | 499 | By("transforming ingress to exposure") 500 | exposure, err := controller.FromIngressToExposure(ctx, logger, kubeClient, ingress) 501 | Expect(err).Should(HaveOccurred()) 502 | Expect(exposure).Should(BeNil()) 503 | }) 504 | 505 | It("should resolve https", func() { 506 | // prepare 507 | By("preparing namespace") 508 | namespaceFixtures := fixtures.NewKubernetesNamespaceFixtures(IntegrationTestNamespace, kubeClient) 509 | ns, err := namespaceFixtures.Start(ctx) 510 | Expect(err).ShouldNot(HaveOccurred()) 511 | 512 | defer func() { 513 | By("cleaning up namespace") 514 | err := namespaceFixtures.Stop(ctx) 515 | Expect(err).ShouldNot(HaveOccurred()) 516 | }() 517 | 518 | By("preparing service") 519 | service := v1.Service{ 520 | ObjectMeta: metav1.ObjectMeta{ 521 | Namespace: ns, 522 | GenerateName: "test-service-", 523 | }, 524 | Spec: v1.ServiceSpec{ 525 | ClusterIP: "10.0.0.27", 526 | Ports: []v1.ServicePort{ 527 | { 528 | Name: "https", 529 | Protocol: v1.ProtocolTCP, 530 | Port: 2333, 531 | TargetPort: intstr.IntOrString{ 532 | Type: intstr.Int, 533 | IntVal: 443, 534 | }, 535 | }, 536 | }, 537 | }, 538 | } 539 | err = kubeClient.Create(ctx, &service) 540 | Expect(err).ShouldNot(HaveOccurred()) 541 | 542 | By("preparing ingress") 543 | ingress := networkingv1.Ingress{ 544 | ObjectMeta: metav1.ObjectMeta{ 545 | Namespace: ns, 546 | GenerateName: "test-ingress-", 547 | Annotations: map[string]string{ 548 | "cloudflare-tunnel-ingress-controller.strrl.dev/backend-protocol": "https", 549 | }, 550 | }, 551 | Spec: networkingv1.IngressSpec{ 552 | Rules: []networkingv1.IngressRule{ 553 | { 554 | Host: "test.example.com", 555 | IngressRuleValue: networkingv1.IngressRuleValue{ 556 | HTTP: &networkingv1.HTTPIngressRuleValue{ 557 | Paths: []networkingv1.HTTPIngressPath{ 558 | { 559 | Path: "/", 560 | PathType: &pathTypePrefix, 561 | Backend: networkingv1.IngressBackend{ 562 | Service: &networkingv1.IngressServiceBackend{ 563 | Name: service.Name, 564 | Port: networkingv1.ServiceBackendPort{ 565 | Name: "https", 566 | }, 567 | }, 568 | }, 569 | }, 570 | }, 571 | }, 572 | }, 573 | }, 574 | }, 575 | }, 576 | } 577 | err = kubeClient.Create(ctx, &ingress) 578 | Expect(err).ShouldNot(HaveOccurred()) 579 | 580 | By("transforming ingress to exposure") 581 | exposure, err := controller.FromIngressToExposure(ctx, logger, kubeClient, ingress) 582 | Expect(err).ShouldNot(HaveOccurred()) 583 | Expect(exposure).ShouldNot(BeNil()) 584 | Expect(exposure).Should(HaveLen(1)) 585 | Expect(exposure[0].Hostname).Should(Equal("test.example.com")) 586 | Expect(exposure[0].ServiceTarget).Should(Equal("https://10.0.0.27:2333")) 587 | Expect(exposure[0].PathPrefix).Should(Equal("/")) 588 | Expect(exposure[0].IsDeleted).Should(BeFalse()) 589 | Expect(exposure[0].ProxySSLVerifyEnabled).Should(BeNil()) 590 | }) 591 | 592 | It("should resolve https with proxy-ssl-verify disabled", func() { 593 | // prepare 594 | By("preparing namespace") 595 | namespaceFixtures := fixtures.NewKubernetesNamespaceFixtures(IntegrationTestNamespace, kubeClient) 596 | ns, err := namespaceFixtures.Start(ctx) 597 | Expect(err).ShouldNot(HaveOccurred()) 598 | 599 | defer func() { 600 | By("cleaning up namespace") 601 | err := namespaceFixtures.Stop(ctx) 602 | Expect(err).ShouldNot(HaveOccurred()) 603 | }() 604 | 605 | By("preparing service") 606 | service := v1.Service{ 607 | ObjectMeta: metav1.ObjectMeta{ 608 | Namespace: ns, 609 | GenerateName: "test-service-", 610 | }, 611 | Spec: v1.ServiceSpec{ 612 | ClusterIP: "10.0.0.28", 613 | Ports: []v1.ServicePort{ 614 | { 615 | Name: "https", 616 | Protocol: v1.ProtocolTCP, 617 | Port: 2333, 618 | TargetPort: intstr.IntOrString{ 619 | Type: intstr.Int, 620 | IntVal: 443, 621 | }, 622 | }, 623 | }, 624 | }, 625 | } 626 | err = kubeClient.Create(ctx, &service) 627 | Expect(err).ShouldNot(HaveOccurred()) 628 | 629 | By("preparing ingress") 630 | ingress := networkingv1.Ingress{ 631 | ObjectMeta: metav1.ObjectMeta{ 632 | Namespace: ns, 633 | GenerateName: "test-ingress-", 634 | Annotations: map[string]string{ 635 | "cloudflare-tunnel-ingress-controller.strrl.dev/backend-protocol": "https", 636 | "cloudflare-tunnel-ingress-controller.strrl.dev/proxy-ssl-verify": "off", 637 | }, 638 | }, 639 | Spec: networkingv1.IngressSpec{ 640 | Rules: []networkingv1.IngressRule{ 641 | { 642 | Host: "test.example.com", 643 | IngressRuleValue: networkingv1.IngressRuleValue{ 644 | HTTP: &networkingv1.HTTPIngressRuleValue{ 645 | Paths: []networkingv1.HTTPIngressPath{ 646 | { 647 | Path: "/", 648 | PathType: &pathTypePrefix, 649 | Backend: networkingv1.IngressBackend{ 650 | Service: &networkingv1.IngressServiceBackend{ 651 | Name: service.Name, 652 | Port: networkingv1.ServiceBackendPort{ 653 | Name: "https", 654 | }, 655 | }, 656 | }, 657 | }, 658 | }, 659 | }, 660 | }, 661 | }, 662 | }, 663 | }, 664 | } 665 | err = kubeClient.Create(ctx, &ingress) 666 | Expect(err).ShouldNot(HaveOccurred()) 667 | 668 | By("transforming ingress to exposure") 669 | exposure, err := controller.FromIngressToExposure(ctx, logger, kubeClient, ingress) 670 | Expect(err).ShouldNot(HaveOccurred()) 671 | Expect(exposure).ShouldNot(BeNil()) 672 | Expect(exposure).Should(HaveLen(1)) 673 | Expect(exposure[0].Hostname).Should(Equal("test.example.com")) 674 | Expect(exposure[0].ServiceTarget).Should(Equal("https://10.0.0.28:2333")) 675 | Expect(exposure[0].PathPrefix).Should(Equal("/")) 676 | Expect(exposure[0].IsDeleted).Should(BeFalse()) 677 | Expect(exposure[0].ProxySSLVerifyEnabled).ShouldNot(BeNil()) 678 | Expect(*exposure[0].ProxySSLVerifyEnabled).Should(BeFalse()) 679 | 680 | }) 681 | It("should resolve https with proxy-ssl-verify enabled", func() { 682 | // prepare 683 | By("preparing namespace") 684 | namespaceFixtures := fixtures.NewKubernetesNamespaceFixtures(IntegrationTestNamespace, kubeClient) 685 | ns, err := namespaceFixtures.Start(ctx) 686 | Expect(err).ShouldNot(HaveOccurred()) 687 | 688 | defer func() { 689 | By("cleaning up namespace") 690 | err := namespaceFixtures.Stop(ctx) 691 | Expect(err).ShouldNot(HaveOccurred()) 692 | }() 693 | 694 | By("preparing service") 695 | service := v1.Service{ 696 | ObjectMeta: metav1.ObjectMeta{ 697 | Namespace: ns, 698 | GenerateName: "test-service-", 699 | }, 700 | Spec: v1.ServiceSpec{ 701 | ClusterIP: "10.0.0.29", 702 | Ports: []v1.ServicePort{ 703 | { 704 | Name: "https", 705 | Protocol: v1.ProtocolTCP, 706 | Port: 2333, 707 | TargetPort: intstr.IntOrString{ 708 | Type: intstr.Int, 709 | IntVal: 443, 710 | }, 711 | }, 712 | }, 713 | }, 714 | } 715 | err = kubeClient.Create(ctx, &service) 716 | Expect(err).ShouldNot(HaveOccurred()) 717 | 718 | By("preparing ingress") 719 | ingress := networkingv1.Ingress{ 720 | ObjectMeta: metav1.ObjectMeta{ 721 | Namespace: ns, 722 | GenerateName: "test-ingress-", 723 | Annotations: map[string]string{ 724 | "cloudflare-tunnel-ingress-controller.strrl.dev/backend-protocol": "https", 725 | "cloudflare-tunnel-ingress-controller.strrl.dev/proxy-ssl-verify": "on", 726 | }, 727 | }, 728 | Spec: networkingv1.IngressSpec{ 729 | Rules: []networkingv1.IngressRule{ 730 | { 731 | Host: "test.example.com", 732 | IngressRuleValue: networkingv1.IngressRuleValue{ 733 | HTTP: &networkingv1.HTTPIngressRuleValue{ 734 | Paths: []networkingv1.HTTPIngressPath{ 735 | { 736 | Path: "/", 737 | PathType: &pathTypePrefix, 738 | Backend: networkingv1.IngressBackend{ 739 | Service: &networkingv1.IngressServiceBackend{ 740 | Name: service.Name, 741 | Port: networkingv1.ServiceBackendPort{ 742 | Name: "https", 743 | }, 744 | }, 745 | }, 746 | }, 747 | }, 748 | }, 749 | }, 750 | }, 751 | }, 752 | }, 753 | } 754 | err = kubeClient.Create(ctx, &ingress) 755 | Expect(err).ShouldNot(HaveOccurred()) 756 | 757 | By("transforming ingress to exposure") 758 | exposure, err := controller.FromIngressToExposure(ctx, logger, kubeClient, ingress) 759 | Expect(err).ShouldNot(HaveOccurred()) 760 | Expect(exposure).ShouldNot(BeNil()) 761 | Expect(exposure).Should(HaveLen(1)) 762 | Expect(exposure[0].Hostname).Should(Equal("test.example.com")) 763 | Expect(exposure[0].ServiceTarget).Should(Equal("https://10.0.0.29:2333")) 764 | Expect(exposure[0].PathPrefix).Should(Equal("/")) 765 | Expect(exposure[0].IsDeleted).Should(BeFalse()) 766 | Expect(exposure[0].ProxySSLVerifyEnabled).ShouldNot(BeNil()) 767 | Expect(*exposure[0].ProxySSLVerifyEnabled).Should(BeTrue()) 768 | }) 769 | }) 770 | -------------------------------------------------------------------------------- /test/integration/controller/suite_test.go: -------------------------------------------------------------------------------- 1 | package controller 2 | 3 | import ( 4 | "context" 5 | "github.com/go-logr/stdr" 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | "k8s.io/apimachinery/pkg/runtime" 9 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 10 | "k8s.io/client-go/rest" 11 | "log" 12 | "os" 13 | "sigs.k8s.io/controller-runtime/pkg/client" 14 | "sigs.k8s.io/controller-runtime/pkg/envtest" 15 | logf "sigs.k8s.io/controller-runtime/pkg/log" 16 | "testing" 17 | ) 18 | 19 | var ( 20 | cfg *rest.Config 21 | kubeClient client.Client // You'll be using this client in your tests. 22 | testEnv *envtest.Environment 23 | ctx context.Context 24 | cancel context.CancelFunc 25 | ) 26 | 27 | func TestControllers(t *testing.T) { 28 | RegisterFailHandler(Fail) 29 | 30 | RunSpecs(t, "Cloudflare Tunnel Ingress Controller Suite") 31 | } 32 | 33 | var _ = BeforeSuite(func() { 34 | rootLogger := stdr.NewWithOptions(log.New(os.Stderr, "", log.LstdFlags), stdr.Options{LogCaller: stdr.All}) 35 | 36 | logf.SetLogger(rootLogger) 37 | ctx, cancel = context.WithCancel(context.TODO()) 38 | 39 | By("bootstrapping test environment") 40 | testEnv = &envtest.Environment{} 41 | var err error 42 | // cfg is defined in this file globally. 43 | cfg, err = testEnv.Start() 44 | Expect(err).NotTo(HaveOccurred()) 45 | Expect(cfg).NotTo(BeNil()) 46 | 47 | scheme := runtime.NewScheme() 48 | err = clientgoscheme.AddToScheme(scheme) 49 | Expect(err).NotTo(HaveOccurred()) 50 | 51 | kubeClient, err = client.New(cfg, client.Options{Scheme: scheme}) 52 | Expect(err).NotTo(HaveOccurred()) 53 | Expect(kubeClient).NotTo(BeNil()) 54 | }) 55 | 56 | var _ = AfterSuite(func() { 57 | cancel() 58 | By("tearing down the test environment") 59 | err := testEnv.Stop() 60 | Expect(err).NotTo(HaveOccurred()) 61 | }) 62 | --------------------------------------------------------------------------------