├── .github ├── release-drafter.yml ├── release.py └── workflows │ ├── build-binary-package.yml │ ├── lint.yml │ ├── publish-docker-doc.yaml │ ├── release-drafter.yml │ ├── release_publish_docker-image.yml │ ├── tests.yml │ └── tests_deb.yml ├── .gitignore ├── .golangci.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── root.go ├── config ├── crowdsec-cloudflare-bouncer-docker.yaml ├── crowdsec-cloudflare-bouncer.service └── crowdsec-cloudflare-bouncer.yaml ├── debian ├── changelog ├── compat ├── control ├── postinst ├── postrm ├── prerm └── rules ├── docker └── README.md ├── docs └── assets │ ├── crowdsec_cloudfare.png │ └── token_permissions.png ├── go.mod ├── go.sum ├── main.go ├── pkg ├── cf │ ├── cloudflare.go │ └── cloudflare_test.go └── cfg │ ├── config.go │ ├── config_test.go │ ├── logging.go │ └── testdata │ ├── invalid_config_remedy.yaml │ ├── invalid_config_time.yaml │ └── valid_config.yaml ├── rpm └── SPECS │ └── crowdsec-cloudflare-bouncer.spec ├── scripts ├── _bouncer.sh ├── install.sh ├── uninstall.sh └── upgrade.sh └── test ├── .python-version ├── default.env ├── pyproject.toml ├── pytest.ini ├── tests ├── __init__.py ├── bouncer │ ├── __init__.py │ ├── test_cloudflare_bouncer.py │ ├── test_tls.py │ └── test_yaml_local.py ├── conftest.py ├── install │ ├── __init__.py │ ├── no_crowdsec │ │ ├── __init__.py │ │ ├── test_no_crowdsec_deb.py │ │ └── test_no_crowdsec_scripts.py │ └── with_crowdsec │ │ ├── __init__.py │ │ ├── test_crowdsec_deb.py │ │ └── test_crowdsec_scripts.py └── pkg │ ├── __init__.py │ ├── test_build_deb.py │ ├── test_build_rpm.py │ └── test_scripts_nonroot.py └── uv.lock /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What’s Changed 3 | 4 | $CHANGES 5 | -------------------------------------------------------------------------------- /.github/release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import argparse 4 | import json 5 | import os 6 | import shutil 7 | import subprocess 8 | import sys 9 | 10 | 11 | def _goos(): 12 | yield 'linux' 13 | yield 'freebsd' 14 | 15 | 16 | def _goarch(goos): 17 | yield '386' 18 | yield 'amd64' 19 | yield 'arm' 20 | yield 'arm64' 21 | if goos == 'linux': 22 | yield 'ppc64le' 23 | yield 's390x' 24 | yield 'riscv64' 25 | 26 | 27 | def _goarm(goarch): 28 | if goarch != 'arm': 29 | yield '' 30 | return 31 | yield '6' 32 | yield '7' 33 | 34 | 35 | def _build_tarball(os): 36 | if os == 'linux': 37 | yield True 38 | else: 39 | yield False 40 | 41 | 42 | def filename_for_entry(prog_name, entry): 43 | arch = entry['goarch'] 44 | if entry['goarch'] == 'arm': 45 | arch += 'v' + entry['goarm'] 46 | ret = f'{prog_name}-{entry["goos"]}-{arch}' 47 | if entry['build_tarball']: 48 | ret += '.tgz' 49 | return ret 50 | 51 | 52 | def matrix(prog_name): 53 | for goos in _goos(): 54 | for goarch in _goarch(goos): 55 | for goarm in _goarm(goarch): 56 | for build_tarball in _build_tarball(goos): 57 | yield { 58 | 'goos': goos, 59 | 'goarch': goarch, 60 | 'goarm': goarm, 61 | 'build_tarball': build_tarball, 62 | } 63 | 64 | 65 | def print_matrix(prog_name): 66 | j = {'include': list(matrix(prog_name))} 67 | 68 | if os.isatty(sys.stdout.fileno()): 69 | print(json.dumps(j, indent=2)) 70 | else: 71 | print(json.dumps(j)) 72 | 73 | 74 | default_tarball = { 75 | 'goos': 'linux', 76 | 'goarch': 'amd64', 77 | 'goarm': '', 78 | 'build_tarball': True, 79 | } 80 | 81 | default_binary = { 82 | 'goos': 'linux', 83 | 'goarch': 'amd64', 84 | 'goarm': '', 85 | 'build_tarball': False, 86 | } 87 | 88 | 89 | def run_build(prog_name): 90 | # call the makefile for each matrix entry 91 | 92 | default_tarball_filename = None 93 | default_binary_filename = None 94 | 95 | for entry in matrix(prog_name): 96 | env = {'GOOS': entry['goos'], 'GOARCH': entry['goarch']} 97 | 98 | if entry['goarm']: 99 | env['GOARM'] = entry['goarm'] 100 | 101 | if entry['build_tarball']: 102 | target = 'tarball' 103 | else: 104 | target = 'binary' 105 | 106 | print(f"Running make {target} for {env}") 107 | 108 | subprocess.run(['make', target], env=os.environ | env, check=True) 109 | 110 | want_filename = filename_for_entry(prog_name, entry) 111 | 112 | if entry['build_tarball']: 113 | os.rename(f'{prog_name}.tgz', want_filename) 114 | else: 115 | os.rename(f'{prog_name}', want_filename) 116 | 117 | # if this is the default tarball or binary, save the filename 118 | # we'll use it later to publish a "default" package 119 | 120 | if entry == default_tarball: 121 | default_tarball_filename = want_filename 122 | 123 | if entry == default_binary: 124 | default_binary_filename = want_filename 125 | 126 | # Remove the directory to reuse it 127 | subprocess.run(['make', 'clean-release-dir'], env=os.environ | env, check=True) 128 | 129 | # publish the default tarball and binary 130 | if default_tarball_filename: 131 | shutil.copy(default_tarball_filename, f'{prog_name}.tgz') 132 | 133 | if default_binary_filename: 134 | shutil.copy(default_binary_filename, f'{prog_name}') 135 | 136 | 137 | def main(): 138 | parser = argparse.ArgumentParser( 139 | description='Build release binaries and tarballs for all supported platforms') 140 | parser.add_argument('action', help='Action to perform (ex. run-build, print-matrix)') 141 | parser.add_argument('prog_name', help='Name of the program (ex. crowdsec-firewall-bouncer)') 142 | 143 | args = parser.parse_args() 144 | 145 | if args.action == 'print-matrix': 146 | print_matrix(args.prog_name) 147 | 148 | if args.action == 'run-build': 149 | run_build(args.prog_name) 150 | 151 | 152 | if __name__ == '__main__': 153 | main() 154 | -------------------------------------------------------------------------------- /.github/workflows/build-binary-package.yml: -------------------------------------------------------------------------------- 1 | name: build-binary-package 2 | 3 | on: 4 | release: 5 | types: 6 | - prereleased 7 | 8 | permissions: 9 | # Use write for: hub release edit 10 | contents: write 11 | 12 | env: 13 | PROGRAM_NAME: crowdsec-cloudflare-bouncer 14 | 15 | jobs: 16 | build: 17 | name: Build and upload all platforms 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | 22 | - name: Check out repository 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: Set up Go 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version: "1.22" 31 | 32 | - name: Build all platforms 33 | run: | 34 | # build platform-all first so the .xz vendor file is not removed 35 | make platform-all vendor 36 | 37 | - name: Upload to release 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | run: | 41 | tag_name="${GITHUB_REF##*/}" 42 | # this will upload the $PROGRAM_NAME-vendor.tar.xz file as well 43 | gh release upload "$tag_name" $PROGRAM_NAME* vendor.tgz 44 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Static Analysis 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | 10 | jobs: 11 | build: 12 | name: "golangci-lint + codeql" 13 | runs-on: ubuntu-latest 14 | 15 | permissions: 16 | security-events: write 17 | 18 | steps: 19 | 20 | - name: Check out code into the Go module directory 21 | uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Set up Go 26 | uses: actions/setup-go@v5 27 | with: 28 | go-version: "1.22" 29 | 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v3 32 | with: 33 | languages: go, python 34 | 35 | - name: Build 36 | run: | 37 | make build 38 | 39 | - name: golangci-lint 40 | uses: golangci/golangci-lint-action@v7 41 | with: 42 | version: v2.0 43 | args: --issues-exit-code=1 --timeout 10m 44 | only-new-issues: false 45 | 46 | - name: Perform CodeQL Analysis 47 | uses: github/codeql-action/analyze@v3 48 | -------------------------------------------------------------------------------- /.github/workflows/publish-docker-doc.yaml: -------------------------------------------------------------------------------- 1 | name: Update Docker Hub README 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'docker/README.md' 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | update-docker-hub-readme: 15 | name: Update the README on Docker Hub 16 | runs-on: ubuntu-latest 17 | steps: 18 | - 19 | name: Check out the repo 20 | uses: actions/checkout@v4 21 | - 22 | name: Update docker hub README 23 | uses: ms-jpq/sync-dockerhub-readme@v1 24 | with: 25 | username: ${{ secrets.DOCKER_USERNAME }} 26 | password: ${{ secrets.DOCKER_PASSWORD }} 27 | repository: crowdsecurity/cloudflare-bouncer 28 | readme: "./docker/README.md" 29 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | # branches to consider in the event; optional, defaults to all 6 | branches: 7 | - main 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | update_release_draft: 14 | permissions: 15 | # write permission is required to create a github release 16 | contents: write 17 | # write permission is required for autolabeler 18 | # otherwise, read permission is required at least 19 | pull-requests: read 20 | runs-on: ubuntu-latest 21 | name: Update the release draft 22 | steps: 23 | # Drafts your next Release notes as Pull Requests are merged into "main" 24 | - uses: release-drafter/release-drafter@v6 25 | with: 26 | config-name: release-drafter.yml 27 | # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml 28 | # config-name: my-config.yml 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/release_publish_docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | release: 5 | types: 6 | - released 7 | - prereleased 8 | 9 | permissions: 10 | contents: read 11 | packages: write 12 | 13 | jobs: 14 | push_to_registry: 15 | name: Push Docker image to Docker Hub 16 | runs-on: ubuntu-latest 17 | steps: 18 | - 19 | name: Check out the repo 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - 24 | name: Prepare 25 | id: prep 26 | run: | 27 | DOCKER_IMAGE=crowdsecurity/cloudflare-bouncer 28 | GHCR_IMAGE=ghcr.io/${{ github.repository_owner }}/cloudflare-bouncer 29 | VERSION=edge 30 | if [[ $GITHUB_REF == refs/tags/* ]]; then 31 | VERSION=${GITHUB_REF#refs/tags/} 32 | elif [[ $GITHUB_REF == refs/heads/* ]]; then 33 | VERSION=$(echo ${GITHUB_REF#refs/heads/} | sed -E 's#/+#-#g') 34 | elif [[ $GITHUB_REF == refs/pull/* ]]; then 35 | VERSION=pr-${{ github.event.number }} 36 | fi 37 | TAGS="${DOCKER_IMAGE}:${VERSION},${GHCR_IMAGE}:${VERSION}" 38 | if [[ "${{ github.event_name }}" == "release" && "${{ github.event.release.prerelease }}" == "false" ]]; then 39 | TAGS=$TAGS,${DOCKER_IMAGE}:latest,${GHCR_IMAGE}:latest 40 | fi 41 | echo "version=${VERSION}" >> $GITHUB_OUTPUT 42 | echo "tags=${TAGS}" >> $GITHUB_OUTPUT 43 | echo "created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT 44 | - 45 | name: Set up QEMU 46 | uses: docker/setup-qemu-action@v3 47 | - 48 | name: Set up Docker Buildx 49 | uses: docker/setup-buildx-action@v3 50 | - 51 | name: Login to DockerHub 52 | if: github.event_name == 'release' 53 | uses: docker/login-action@v3 54 | with: 55 | username: ${{ secrets.DOCKER_USERNAME }} 56 | password: ${{ secrets.DOCKER_PASSWORD }} 57 | 58 | - name: Login to GitHub Container Registry 59 | uses: docker/login-action@v3 60 | with: 61 | registry: ghcr.io 62 | username: ${{ github.repository_owner }} 63 | password: ${{ secrets.GITHUB_TOKEN }} 64 | - 65 | name: Build and push 66 | uses: docker/build-push-action@v5 67 | with: 68 | context: . 69 | file: ./Dockerfile 70 | push: ${{ github.event_name == 'release' }} 71 | tags: ${{ steps.prep.outputs.tags }} 72 | # Supported by golang:1.18-alpine: linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x 73 | # Supported by alpine: same 74 | platforms: linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x 75 | labels: | 76 | org.opencontainers.image.source=${{ github.event.repository.html_url }} 77 | org.opencontainers.image.created=${{ steps.prep.outputs.created }} 78 | org.opencontainers.image.revision=${{ github.sha }} 79 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Build + tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | name: "Build + tests" 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | 19 | - name: Check out code into the Go module directory 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version-file: go.mod 28 | 29 | - name: Build 30 | run: | 31 | make build 32 | 33 | - name: Run unit tests 34 | run: | 35 | go install github.com/kyoh86/richgo@v0.3.12 36 | set -o pipefail 37 | make test | richgo testfilter 38 | env: 39 | RICHGO_FORCE_COLOR: 1 40 | 41 | - name: Install uv 42 | uses: astral-sh/setup-uv@v5 43 | with: 44 | version: 0.5.24 45 | enable-cache: true 46 | cache-dependency-glob: "test/uv.lock" 47 | 48 | - name: "Set up Python" 49 | uses: actions/setup-python@v5 50 | with: 51 | python-version-file: "test/.python-version" 52 | 53 | - name: Install the project 54 | working-directory: ./test 55 | run: uv sync --all-extras --dev 56 | 57 | - name: Install functional test dependencies 58 | run: | 59 | docker network create net-test 60 | 61 | - name: Run functional tests 62 | env: 63 | CROWDSEC_TEST_VERSION: dev 64 | CROWDSEC_TEST_FLAVORS: full 65 | CROWDSEC_TEST_NETWORK: net-test 66 | CROWDSEC_TEST_TIMEOUT: 60 67 | PYTEST_ADDOPTS: --durations=0 -vv --color=yes -m "not (deb or rpm)" 68 | working-directory: ./test 69 | run: | 70 | # everything except for 71 | # - install (requires root, ignored by default) 72 | # - deb/rpm (on their own workflows) 73 | uv run pytest 74 | # these need root 75 | sudo -E $(which uv) run pytest ./tests/install/no_crowdsec 76 | # these need a running crowdsec 77 | docker run -d --name crowdsec -e CI_TESTING=true -e DISABLE_ONLINE_API=true -p 8080:8080 -ti crowdsecurity/crowdsec 78 | install -m 0755 /dev/stdin /usr/local/bin/cscli <<'EOT' 79 | #!/bin/sh 80 | docker exec crowdsec cscli "$@" 81 | EOT 82 | sleep 5 83 | sudo -E $(which uv) run pytest ./tests/install/with_crowdsec 84 | 85 | - name: Lint 86 | working-directory: ./test 87 | run: | 88 | uv run ruff check 89 | uv run basedpyright 90 | 91 | -------------------------------------------------------------------------------- /.github/workflows/tests_deb.yml: -------------------------------------------------------------------------------- 1 | name: Test .deb packaging 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | name: "Test .deb packages" 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | 19 | - name: Check out code into the Go module directory 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version-file: go.mod 28 | 29 | - name: Install uv 30 | uses: astral-sh/setup-uv@v5 31 | with: 32 | version: 0.5.24 33 | enable-cache: true 34 | cache-dependency-glob: "test/uv.lock" 35 | 36 | - name: "Set up Python" 37 | uses: actions/setup-python@v5 38 | with: 39 | python-version-file: "test/.python-version" 40 | 41 | - name: Install the project 42 | run: uv sync --all-extras --dev 43 | working-directory: ./test 44 | 45 | - name: Install functional test dependencies 46 | run: | 47 | sudo apt update 48 | sudo apt install -y build-essential debhelper devscripts fakeroot lintian 49 | docker network create net-test 50 | 51 | - name: Run functional tests 52 | env: 53 | CROWDSEC_TEST_VERSION: dev 54 | CROWDSEC_TEST_FLAVORS: full 55 | CROWDSEC_TEST_NETWORK: net-test 56 | CROWDSEC_TEST_TIMEOUT: 60 57 | PYTEST_ADDOPTS: --durations=0 -vv --color=yes 58 | working-directory: ./test 59 | run: | 60 | uv run pytest ./tests/pkg/test_build_deb.py 61 | sudo -E $(which uv) run pytest -m deb ./tests/install/no_crowdsec 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Test binary, built with `go test -c` 2 | *.test 3 | 4 | # Output of the go coverage tool, specifically when used with LiteIDE 5 | *.out 6 | 7 | # Dependencies are not vendored by default, but a tarball is created by "make vendor" 8 | # and provided in the release. Used by freebsd, gentoo, etc. 9 | vendor/ 10 | vendor.tgz 11 | 12 | # Python 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | venv/ 17 | 18 | # built by make 19 | /crowdsec-cloudflare-bouncer 20 | /crowdsec-cloudflare-bouncer-* 21 | /crowdsec-cloudflare-bouncer.tgz 22 | 23 | # built by dpkg-buildpackage 24 | /debian/crowdsec-cloudflare-bouncer 25 | /debian/files 26 | /debian/*.substvars 27 | /debian/*.debhelper 28 | /debian/*-stamp 29 | 30 | # built by rpmbuild 31 | /rpm/BUILD 32 | /rpm/BUILDROOT 33 | /rpm/RPMS 34 | /rpm/SOURCES/*.tar.gz 35 | /rpm/SRPMS 36 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | linters: 4 | default: all 5 | disable: 6 | - depguard 7 | - recvcheck 8 | - dupl 9 | - misspell 10 | - nolintlint 11 | - forbidigo 12 | - interfacebloat 13 | - perfsprint 14 | - unparam 15 | - maintidx 16 | - containedctx 17 | - promlinter 18 | - predeclared 19 | - nestif 20 | - ireturn 21 | - cyclop # revive 22 | - funlen # revive 23 | - gocognit # revive 24 | - gocyclo # revive 25 | - lll # revive 26 | - godot # Check if comments end in a period 27 | - gosec # (gas): Inspects source code for security problems 28 | - wrapcheck # Checks that errors returned from external packages are wrapped 29 | - mnd # An analyzer to detect magic numbers. 30 | - nlreturn # nlreturn checks for a new line before return and branch statements to increase code clarity 31 | - whitespace # Whitespace is a linter that checks for unnecessary newlines at the start and end of functions, if, for, etc. 32 | - wsl # add or remove empty lines 33 | - godox # Tool for detection of FIXME, TODO and other comment keywords 34 | - err113 # Go linter to check the errors handling expressions 35 | - paralleltest # Detects missing usage of t.Parallel() method in your Go test 36 | - testpackage # linter that makes you use a separate _test package 37 | - exhaustruct # Checks if all structure fields are initialized 38 | - gochecknoglobals # Check that no global variables exist. 39 | - goconst # Finds repeated strings that could be replaced by a constant 40 | - tagliatelle # Checks the struct tags. 41 | - varnamelen # checks that the length of a variable's name matches its scope 42 | 43 | settings: 44 | 45 | errcheck: 46 | check-type-assertions: false 47 | 48 | gocritic: 49 | enable-all: true 50 | disabled-checks: 51 | - builtinShadow 52 | - captLocal 53 | - commentedOutCode 54 | - deferInLoop # 55 | - emptyStringTest 56 | - hugeParam 57 | - ifElseChain 58 | - octalLiteral 59 | - paramTypeCombine 60 | - rangeValCopy 61 | - sprintfQuotedString 62 | - typeUnparen 63 | - unnamedResult 64 | - whyNoLint 65 | 66 | govet: 67 | disable: 68 | - fieldalignment 69 | enable-all: true 70 | 71 | maintidx: 72 | # raise this after refactoring 73 | under: 17 74 | 75 | misspell: 76 | locale: US 77 | 78 | nlreturn: 79 | block-size: 5 80 | 81 | nolintlint: 82 | require-explanation: false # don't require an explanation for nolint directives 83 | require-specific: false # don't require nolint directives to be specific about which linter is being skipped 84 | allow-unused: false # report any unused nolint directives 85 | 86 | revive: 87 | severity: error 88 | enable-all-rules: true 89 | rules: 90 | - name: add-constant 91 | disabled: true 92 | - name: cognitive-complexity 93 | arguments: 94 | # lower this after refactoring 95 | - 84 96 | - name: defer 97 | disabled: true 98 | - name: confusing-results 99 | disabled: true 100 | - name: cyclomatic 101 | arguments: 102 | # lower this after refactoring 103 | - 40 104 | - name: empty-lines 105 | disabled: true 106 | - name: flag-parameter 107 | disabled: true 108 | - name: function-length 109 | arguments: 110 | # lower this after refactoring 111 | - 88 112 | - 211 113 | - name: indent-error-flow 114 | disabled: true 115 | - name: line-length-limit 116 | arguments: 117 | # lower this after refactoring 118 | - 213 119 | - name: max-public-structs 120 | disabled: true 121 | - name: redefines-builtin-id 122 | disabled: true 123 | - name: superfluous-else 124 | disabled: true 125 | - name: unexported-naming 126 | disabled: true 127 | - name: unexported-return 128 | disabled: true 129 | - name: var-naming 130 | disabled: true 131 | - name: unused-parameter 132 | disabled: true 133 | - name: unused-receiver 134 | disabled: true 135 | - name: use-errors-new 136 | disabled: true 137 | - name: var-declaration 138 | disabled: true 139 | 140 | staticcheck: 141 | checks: 142 | - all 143 | - -ST1003 144 | 145 | wsl: 146 | # Allow blocks to end with comments 147 | allow-trailing-comment: true 148 | 149 | exclusions: 150 | presets: 151 | - comments 152 | - common-false-positives 153 | - legacy 154 | - std-error-handling 155 | rules: 156 | 157 | # `err` is often shadowed, we may continue to do it 158 | - linters: 159 | - govet 160 | text: 'shadow: declaration of "(err|ctx)" shadows declaration' 161 | 162 | paths: 163 | - third_party$ 164 | - builtin$ 165 | - examples$ 166 | 167 | issues: 168 | max-issues-per-linter: 0 169 | max-same-issues: 0 170 | 171 | formatters: 172 | settings: 173 | gci: 174 | sections: 175 | - standard 176 | - default 177 | - prefix(github.com/crowdsecurity) 178 | - prefix(github.com/crowdsecurity/cs-cloudflare-bouncer) 179 | 180 | exclusions: 181 | paths: 182 | - third_party$ 183 | - builtin$ 184 | - examples$ 185 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GOVERSION=1.22 2 | 3 | FROM golang:${GOVERSION}-alpine AS build 4 | 5 | WORKDIR /go/src/cs-cloudflare-bouncer 6 | 7 | RUN apk add --update --no-cache make git 8 | COPY . . 9 | 10 | RUN make build DOCKER_BUILD=1 11 | 12 | FROM alpine:latest 13 | COPY --from=build /go/src/cs-cloudflare-bouncer/crowdsec-cloudflare-bouncer /usr/local/bin/crowdsec-cloudflare-bouncer 14 | COPY --from=build /go/src/cs-cloudflare-bouncer/config/crowdsec-cloudflare-bouncer-docker.yaml /etc/crowdsec/bouncers/crowdsec-cloudflare-bouncer.yaml 15 | 16 | ENTRYPOINT ["/usr/local/bin/crowdsec-cloudflare-bouncer", "-c", "/etc/crowdsec/bouncers/crowdsec-cloudflare-bouncer.yaml"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 crowdsec 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 | GO = go 2 | GOBUILD = $(GO) build 3 | GOTEST = $(GO) test 4 | 5 | BINARY_NAME=crowdsec-cloudflare-bouncer 6 | TARBALL_NAME=$(BINARY_NAME).tgz 7 | 8 | # Versioning information can be overridden in the environment 9 | BUILD_VERSION?=$(shell git describe --tags) 10 | BUILD_TIMESTAMP?=$(shell date +%F"_"%T) 11 | BUILD_TAG?=$(shell git rev-parse HEAD) 12 | 13 | LD_OPTS_VARS=\ 14 | -X 'github.com/crowdsecurity/go-cs-lib/version.Version=$(BUILD_VERSION)' \ 15 | -X 'github.com/crowdsecurity/go-cs-lib/version.BuildDate=$(BUILD_TIMESTAMP)' \ 16 | -X 'github.com/crowdsecurity/go-cs-lib/version.Tag=$(BUILD_TAG)' 17 | 18 | ifneq (,$(DOCKER_BUILD)) 19 | LD_OPTS_VARS += -X 'github.com/crowdsecurity/go-cs-lib/version.System=docker' 20 | endif 21 | 22 | export CGO_ENABLED=0 23 | export LD_OPTS=-ldflags "-s -extldflags '-static' $(LD_OPTS_VARS)" \ 24 | -trimpath -tags netgo 25 | 26 | .PHONY: all 27 | all: build test 28 | 29 | # same as "$(MAKE) -f debian/rules clean" but without the dependency on debhelper 30 | .PHONY: clean-debian 31 | clean-debian: 32 | @$(RM) -r debian/$(BINARY_NAME) 33 | @$(RM) -r debian/files 34 | @$(RM) -r debian/.debhelper 35 | @$(RM) -r debian/*.substvars 36 | @$(RM) -r debian/*-stamp 37 | 38 | .PHONY: clean-rpm 39 | clean-rpm: 40 | @$(RM) -r rpm/BUILD 41 | @$(RM) -r rpm/BUILDROOT 42 | @$(RM) -r rpm/RPMS 43 | @$(RM) -r rpm/SOURCES/*.tar.gz 44 | @$(RM) -r rpm/SRPMS 45 | 46 | # Remove everything including all platform binaries and tarballs 47 | .PHONY: clean 48 | clean: clean-release-dir clean-debian clean-rpm 49 | @$(RM) $(BINARY_NAME) 50 | @$(RM) $(TARBALL_NAME) 51 | @$(RM) -r $(BINARY_NAME)-* # platform binary name and leftover release dir 52 | @$(RM) $(BINARY_NAME)-*.tgz # platform release file 53 | 54 | # 55 | # Build binaries 56 | # 57 | 58 | .PHONY: binary 59 | binary: 60 | $(GOBUILD) $(LD_OPTS) -o $(BINARY_NAME) 61 | 62 | .PHONY: build 63 | build: clean binary 64 | 65 | # 66 | # Unit and integration tests 67 | # 68 | 69 | .PHONY: lint 70 | lint: 71 | golangci-lint run 72 | 73 | API_KEY:=test 74 | CF_ACC_ID:=test 75 | CF_TOKEN:=test 76 | CF_ZONE_ID:=test 77 | 78 | export API_KEY 79 | export CF_ACC_ID 80 | export CF_TOKEN 81 | export CF_ZONE_ID 82 | 83 | .PHONY: test 84 | test: 85 | @$(GOTEST) $(LD_OPTS) ./... 86 | 87 | .PHONY: func-tests 88 | func-tests: build 89 | pipenv install --dev 90 | pipenv run pytest -v 91 | 92 | # 93 | # Build release tarballs 94 | # 95 | 96 | RELDIR = $(BINARY_NAME)-$(BUILD_VERSION) 97 | 98 | .PHONY: vendor 99 | vendor: vendor-remove 100 | $(GO) mod vendor 101 | tar czf vendor.tgz vendor 102 | tar --create --auto-compress --file=$(RELDIR)-vendor.tar.xz vendor 103 | 104 | .PHONY: vendor-remove 105 | vendor-remove: 106 | $(RM) -r vendor vendor.tgz *-vendor.tar.xz 107 | 108 | # Called during platform-all, to reuse the directory for other platforms 109 | .PHONY: clean-release-dir 110 | clean-release-dir: 111 | @$(RM) -r $(RELDIR) 112 | 113 | .PHONY: tarball 114 | tarball: binary 115 | @if [ -z $(BUILD_VERSION) ]; then BUILD_VERSION="local" ; fi 116 | @if [ -d $(RELDIR) ]; then echo "$(RELDIR) already exists, please run 'make clean' and retry" ; exit 1 ; fi 117 | @echo Building Release to dir $(RELDIR) 118 | @mkdir -p $(RELDIR)/scripts 119 | @cp $(BINARY_NAME) $(RELDIR)/ 120 | @cp -R ./config $(RELDIR)/ 121 | @cp ./scripts/install.sh $(RELDIR)/ 122 | @cp ./scripts/uninstall.sh $(RELDIR)/ 123 | @cp ./scripts/upgrade.sh $(RELDIR)/ 124 | @cp ./scripts/_bouncer.sh $(RELDIR)/scripts/ 125 | @chmod +x $(RELDIR)/install.sh 126 | @chmod +x $(RELDIR)/uninstall.sh 127 | @chmod +x $(RELDIR)/upgrade.sh 128 | @tar cvzf $(TARBALL_NAME) $(RELDIR) 129 | 130 | .PHONY: release 131 | release: clean tarball 132 | 133 | # 134 | # Build binaries and release tarballs for all platforms 135 | # 136 | 137 | .PHONY: platform-all 138 | platform-all: clean 139 | python3 .github/release.py run-build $(BINARY_NAME) 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | CrowdSec 3 |

4 |

5 | 6 | 7 |

8 |

9 | 💠 Hub 10 | 💬 Discourse 11 |

12 | 13 | # CrowdSec Cloudflare Bouncer 14 | 15 | A bouncer for Cloudflare. 16 | 17 | ## How does it work 18 | 19 | A bouncer that syncs the decisions made by CrowdSec with CloudFlare's firewall. Manages multi user, multi account, multi zone setup. Supports IP, Country and AS scoped decisions. 20 | 21 | # Documentation 22 | 23 | Please follow the [official documentation](https://docs.crowdsec.net/docs/bouncers/cloudflare). -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "net" 10 | "net/http" 11 | "os" 12 | "os/signal" 13 | "path/filepath" 14 | "strings" 15 | "sync" 16 | "sync/atomic" 17 | "syscall" 18 | "time" 19 | 20 | "github.com/prometheus/client_golang/prometheus/promhttp" 21 | log "github.com/sirupsen/logrus" 22 | "golang.org/x/sync/errgroup" 23 | 24 | "github.com/crowdsecurity/crowdsec/pkg/apiclient" 25 | "github.com/crowdsecurity/crowdsec/pkg/models" 26 | csbouncer "github.com/crowdsecurity/go-cs-bouncer" 27 | "github.com/crowdsecurity/go-cs-lib/csdaemon" 28 | "github.com/crowdsecurity/go-cs-lib/version" 29 | 30 | "github.com/crowdsecurity/cs-cloudflare-bouncer/pkg/cf" 31 | "github.com/crowdsecurity/cs-cloudflare-bouncer/pkg/cfg" 32 | ) 33 | 34 | const ( 35 | DEFAULT_CONFIG_PATH = "/etc/crowdsec/bouncers/crowdsec-cloudflare-bouncer.yaml" 36 | name = "crowdsec-cloudflare-bouncer" 37 | ) 38 | 39 | func HandleSignals(ctx context.Context) error { 40 | signalChan := make(chan os.Signal, 1) 41 | signal.Notify(signalChan, syscall.SIGTERM, os.Interrupt) 42 | 43 | select { 44 | case s := <-signalChan: 45 | switch s { 46 | case syscall.SIGTERM: 47 | return fmt.Errorf("received SIGTERM") 48 | case os.Interrupt: // cross-platform SIGINT 49 | return fmt.Errorf("received interrupt") 50 | } 51 | case <-ctx.Done(): 52 | return ctx.Err() 53 | } 54 | return nil 55 | } 56 | 57 | func newAPILogger(logDir string, logAPIRequests *bool) (*log.Logger, error) { 58 | APILogger := log.New() 59 | if *logAPIRequests { 60 | f, err := os.OpenFile( 61 | filepath.Join(logDir, "crowdsec-cloudflare-bouncer-api-calls.log"), 62 | os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) 63 | if err != nil { 64 | return nil, err 65 | } 66 | APILogger.SetOutput(f) 67 | APILogger.Level = log.DebugLevel 68 | } else { 69 | APILogger.SetOutput(io.Discard) 70 | } 71 | return APILogger, nil 72 | } 73 | 74 | func Execute() error { 75 | // Create go routine per cloudflare account 76 | // By using channels, after every nth second feed the decisions to each cf routine. 77 | // Each cf routine maintains it's own IP list and cache. 78 | 79 | configTokens := flag.String("g", "", "comma separated tokens to generate config for") 80 | configOutputPath := flag.String("o", "", "path to store generated config to") 81 | configPath := flag.String("c", "", "path to config file") 82 | onlySetup := flag.Bool("s", false, "only setup the ip lists and rules for cloudflare and exit") 83 | delete := flag.Bool("d", false, "delete IP lists and firewall rules which are created by the bouncer") 84 | ver := flag.Bool("version", false, "Display version information and exit") 85 | logAPIRequests := flag.Bool("lc", false, "logs API requests") 86 | testConfig := flag.Bool("t", false, "test config and exit") 87 | showConfig := flag.Bool("T", false, "show full config (.yaml + .yaml.local) and exit") 88 | 89 | flag.Parse() 90 | 91 | if *ver { 92 | fmt.Print(version.FullString()) 93 | return nil 94 | } 95 | 96 | if *delete && *onlySetup { 97 | return fmt.Errorf("conflicting cli arguments, pass only one of '-d' or '-s'") 98 | } 99 | 100 | if configPath == nil || *configPath == "" { 101 | *configPath = DEFAULT_CONFIG_PATH 102 | } 103 | 104 | if configTokens != nil && *configTokens != "" { 105 | cfgTokenString, err := cfg.ConfigTokens(*configTokens, *configPath) 106 | if err != nil { 107 | return err 108 | } 109 | if configOutputPath != nil && *configOutputPath != "" { 110 | err := os.WriteFile(*configOutputPath, []byte(cfgTokenString), 0664) 111 | if err != nil { 112 | return err 113 | } 114 | log.Printf("Config successfully generated in %s", *configOutputPath) 115 | } else { 116 | fmt.Print(cfgTokenString) 117 | } 118 | return nil 119 | } 120 | 121 | configBytes, err := cfg.MergedConfig(*configPath) 122 | if err != nil { 123 | return fmt.Errorf("unable to read config file: %w", err) 124 | } 125 | 126 | if *showConfig { 127 | fmt.Println(string(configBytes)) 128 | return nil 129 | } 130 | 131 | conf, err := cfg.NewConfig(bytes.NewReader(configBytes)) 132 | if err != nil { 133 | return fmt.Errorf("unable to parse config: %w", err) 134 | } 135 | 136 | if *delete || *onlySetup { 137 | log.SetOutput(os.Stdout) 138 | } 139 | 140 | APILogger, err := newAPILogger(conf.Logging.LogDir, logAPIRequests) 141 | if err != nil { 142 | return err 143 | } 144 | 145 | var csLAPI *csbouncer.StreamBouncer 146 | 147 | zoneLocks := make([]cf.ZoneLock, 0) 148 | for _, account := range conf.CloudflareConfig.Accounts { 149 | for _, zone := range account.ZoneConfigs { 150 | zoneLocks = append(zoneLocks, cf.ZoneLock{ZoneID: zone.ID, Lock: &sync.Mutex{}}) 151 | } 152 | } 153 | 154 | g, ctx := errgroup.WithContext(context.Background()) 155 | // lapiStreams are used to forward the decisions to all the workers 156 | lapiStreams := make([]chan *models.DecisionsStreamResponse, 0) 157 | APICountByToken := make(map[string]*uint32) 158 | 159 | for _, account := range conf.CloudflareConfig.Accounts { 160 | lapiStream := make(chan *models.DecisionsStreamResponse) 161 | lapiStreams = append(lapiStreams, lapiStream) 162 | 163 | var tokenCallCount uint32 = 0 164 | // we want same reference of tokenCallCount per account token 165 | if _, ok := APICountByToken[account.Token]; !ok { 166 | APICountByToken[account.Token] = &tokenCallCount 167 | } 168 | 169 | worker := cf.CloudflareWorker{ 170 | Account: account, 171 | APILogger: APILogger, 172 | Ctx: ctx, 173 | ZoneLocks: zoneLocks, 174 | LAPIStream: lapiStream, 175 | UpdateFrequency: conf.CloudflareConfig.UpdateFrequency, 176 | CFStateByAction: make(map[string]*cf.CloudflareState), 177 | TokenCallCount: APICountByToken[account.Token], 178 | } 179 | if *onlySetup { 180 | g.Go(func() error { 181 | var err error 182 | worker.CFStateByAction = nil 183 | err = worker.Init() 184 | if err != nil { 185 | return err 186 | } 187 | err = worker.SetUpCloudflareResources() 188 | return err 189 | }) 190 | } else if *delete { 191 | g.Go(func() error { 192 | var err error 193 | err = worker.Init() 194 | if err != nil { 195 | return err 196 | } 197 | err = worker.DeleteExistingIPList() 198 | return err 199 | }) 200 | } else { 201 | g.Go(func() error { 202 | err := worker.Run() 203 | return err 204 | }) 205 | } 206 | } 207 | 208 | if !*onlySetup && !*delete { 209 | log.Infof("Starting %s %s", name, version.String()) 210 | csLAPI = &csbouncer.StreamBouncer{ 211 | APIKey: conf.CrowdSecLAPIKey, 212 | APIUrl: conf.CrowdSecLAPIUrl, 213 | TickerInterval: conf.CrowdsecUpdateFrequencyYAML, 214 | UserAgent: fmt.Sprintf("%s/%s", name, version.String()), 215 | Opts: apiclient.DecisionsStreamOpts{ 216 | Scopes: "ip,range,as,country", 217 | ScenariosNotContaining: strings.Join(conf.ExcludeScenariosContaining, ","), 218 | ScenariosContaining: strings.Join(conf.IncludeScenariosContaining, ","), 219 | Origins: strings.Join(conf.OnlyIncludeDecisionsFrom, ","), 220 | }, 221 | CertPath: conf.CertPath, 222 | KeyPath: conf.KeyPath, 223 | CAPath: conf.CAPath, 224 | InsecureSkipVerify: &conf.CrowdSecInsecureSkipVerify, 225 | } 226 | if err := csLAPI.Init(); err != nil { 227 | return err 228 | } 229 | if *testConfig { 230 | log.Info("config is valid") 231 | return nil 232 | } 233 | g.Go(func() error { 234 | csLAPI.Run(ctx) 235 | return fmt.Errorf("crowdsec LAPI stream has stopped") 236 | }) 237 | g.Go(func() error { 238 | for { 239 | // broadcast decision to each worker 240 | select { 241 | case decisions := <-csLAPI.Stream: 242 | for _, lapiStream := range lapiStreams { 243 | stream := lapiStream 244 | go func() { stream <- decisions }() 245 | } 246 | case <-ctx.Done(): 247 | return ctx.Err() 248 | } 249 | } 250 | }) 251 | } 252 | 253 | if conf.PrometheusConfig.Enabled { 254 | go func() { 255 | http.Handle("/metrics", promhttp.Handler()) 256 | log.Error(http.ListenAndServe(net.JoinHostPort(conf.PrometheusConfig.ListenAddress, conf.PrometheusConfig.ListenPort), nil)) 257 | }() 258 | } 259 | 260 | apiCallCounterWindow := time.NewTicker(time.Second) 261 | go func() { 262 | for { 263 | <-apiCallCounterWindow.C 264 | for token := range APICountByToken { 265 | atomic.SwapUint32(APICountByToken[token], 0) 266 | } 267 | } 268 | }() 269 | 270 | _ = csdaemon.Notify(csdaemon.Ready, log.StandardLogger()) 271 | 272 | g.Go(func() error { 273 | return HandleSignals(ctx) 274 | }) 275 | 276 | if err := g.Wait(); err != nil { 277 | return fmt.Errorf("process terminated with error: %w", err) 278 | } 279 | if *delete { 280 | log.Info("deleted all cf config") 281 | } 282 | if *onlySetup { 283 | log.Info("setup complete") 284 | } 285 | return nil 286 | } 287 | -------------------------------------------------------------------------------- /config/crowdsec-cloudflare-bouncer-docker.yaml: -------------------------------------------------------------------------------- 1 | # CrowdSec Config 2 | crowdsec_lapi_url: http://localhost:8080/ 3 | crowdsec_lapi_key: ${API_KEY} 4 | crowdsec_update_frequency: 10s 5 | 6 | include_scenarios_containing: [] # ignore IPs banned for triggering scenarios not containing either of provided word EG ["http"] 7 | exclude_scenarios_containing: [] # ignore IPs banned for triggering scenarios containing either of provided word EG ["ssh", "smb"] 8 | only_include_decisions_from: [] # only include IPs banned due to decisions orginating from provided sources. EG ["cscli", "crowdsec"] 9 | 10 | #Cloudflare Config. 11 | cloudflare_config: 12 | accounts: 13 | - id: 14 | token: 15 | ip_list_prefix: crowdsec 16 | default_action: managed_challenge 17 | zones: 18 | - actions: 19 | - managed_challenge # valid choices are either of managed_challenge, js_challenge, block 20 | zone_id: 21 | 22 | 23 | update_frequency: 30s # the frequency to update the cloudflare IP list 24 | 25 | # Bouncer Config 26 | daemon: false 27 | log_mode: stdout 28 | log_dir: /var/log/ 29 | log_level: info # valid choices are either debug, info, error 30 | 31 | prometheus: 32 | enabled: true 33 | listen_addr: 127.0.0.1 34 | listen_port: 2112 35 | -------------------------------------------------------------------------------- /config/crowdsec-cloudflare-bouncer.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=CrowdSec bouncer for cloudflare 3 | After=syslog.target crowdsec.service 4 | 5 | [Service] 6 | Type=simple 7 | ExecStart=${BIN} -c ${CFG}/crowdsec-cloudflare-bouncer.yaml 8 | ExecStartPre=${BIN} -c ${CFG}/crowdsec-cloudflare-bouncer.yaml -t 9 | Restart=always 10 | RestartSec=10 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /config/crowdsec-cloudflare-bouncer.yaml: -------------------------------------------------------------------------------- 1 | # CrowdSec Config 2 | crowdsec_lapi_url: http://localhost:8080/ 3 | crowdsec_lapi_key: ${API_KEY} 4 | crowdsec_update_frequency: 10s 5 | 6 | include_scenarios_containing: [] # ignore IPs banned for triggering scenarios not containing either of provided word EG ["http"] 7 | exclude_scenarios_containing: [] # ignore IPs banned for triggering scenarios containing either of provided word EG ["ssh", "smb"] 8 | only_include_decisions_from: [] # only include IPs banned due to decisions orginating from provided sources. EG ["cscli", "crowdsec"] 9 | 10 | #Cloudflare Config. 11 | cloudflare_config: 12 | accounts: 13 | - id: 14 | token: 15 | ip_list_prefix: crowdsec 16 | default_action: managed_challenge 17 | zones: 18 | - actions: 19 | - managed_challenge # valid choices are either of managed_challenge, js_challenge, block 20 | zone_id: 21 | 22 | update_frequency: 30s # the frequency to update the cloudflare IP list 23 | 24 | # Bouncer Config 25 | daemon: true 26 | log_mode: file 27 | log_dir: /var/log/ 28 | log_level: info # valid choices are either debug, info, error 29 | log_max_size: 40 30 | log_max_age: 30 31 | log_max_backups: 3 32 | compress_logs: true 33 | 34 | prometheus: 35 | enabled: false 36 | listen_addr: 127.0.0.1 37 | listen_port: 2112 38 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | crowdsec-cloudflare-bouncer (1.0.0) UNRELEASED; urgency=medium 2 | 3 | * Initial debian packaging 4 | 5 | -- Kevin Kadosh Fri, 10 Sep 2021 09:30:14 +0100 6 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 11 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: crowdsec-cloudflare-bouncer 2 | Maintainer: Crowdsec Team 3 | Build-Depends: debhelper 4 | Section: admin 5 | Priority: optional 6 | 7 | Package: crowdsec-cloudflare-bouncer 8 | Provides: crowdsec-cloudflare-bouncer 9 | Depends: gettext-base 10 | Description: Cloudflare bouncer for Crowdsec 11 | Architecture: any 12 | -------------------------------------------------------------------------------- /debian/postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | systemctl daemon-reload 4 | 5 | #shellcheck source=./scripts/_bouncer.sh 6 | . "/usr/lib/$DPKG_MAINTSCRIPT_PACKAGE/_bouncer.sh" 7 | START=1 8 | 9 | if [ "$1" = "configure" ]; then 10 | if need_api_key; then 11 | if ! set_api_key; then 12 | START=0 13 | fi 14 | fi 15 | fi 16 | 17 | 18 | echo "If this is fresh install or you've installed the package maintainer's version of configuration, please configure '/etc/crowdsec/bouncers/crowdsec-cloudflare-bouncer.yaml'." 19 | echo "Configuration can be autogenerated using 'sudo crowdsec-cloudflare-bouncer -g , -o /etc/crowdsec/bouncers/crowdsec-cloudflare-bouncer.yaml'." 20 | echo "After configuration run the command 'sudo systemctl start crowdsec-cloudflare-bouncer.service' to start the bouncer" 21 | echo "Don't forget to (re)generate CrowdSec API key if it is installed on another server or/and if you have upgraded and installed the package maintainer's version." 22 | 23 | if [ "$START" -eq 0 ]; then 24 | echo "no api key was generated, you can generate one on your LAPI server by running 'cscli bouncers add ' and add it to '$CONFIG'" >&2 25 | fi 26 | 27 | -------------------------------------------------------------------------------- /debian/postrm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | BOUNCER="crowdsec-cloudflare-bouncer" 6 | CONFIG="/etc/crowdsec/bouncers/$BOUNCER.yaml" 7 | 8 | if [ "$1" = "purge" ]; then 9 | if [ -f "$CONFIG.id" ]; then 10 | bouncer_id=$(cat "$CONFIG.id") 11 | cscli -oraw bouncers delete "$bouncer_id" 2>/dev/null || true 12 | rm -f "$CONFIG.id" 13 | fi 14 | fi 15 | -------------------------------------------------------------------------------- /debian/prerm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | BOUNCER="crowdsec-cloudflare-bouncer" 6 | 7 | systemctl stop "$BOUNCER" || echo "cannot stop service" 8 | systemctl disable "$BOUNCER" || echo "cannot disable service" 9 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | export DEB_VERSION=$(shell dpkg-parsechangelog | grep -E '^Version:' | cut -f 2 -d ' ') 4 | export BUILD_VERSION=v${DEB_VERSION}-debian-pragmatic 5 | 6 | %: 7 | dh $@ 8 | 9 | override_dh_systemd_start: 10 | echo "Not running dh_systemd_start" 11 | override_dh_auto_clean: 12 | override_dh_auto_test: 13 | override_dh_auto_build: 14 | override_dh_auto_install: 15 | @make build 16 | 17 | @BOUNCER=crowdsec-cloudflare-bouncer; \ 18 | PKG="$$BOUNCER"; \ 19 | install -D "$$BOUNCER" -t "debian/$$PKG/usr/bin/"; \ 20 | install -D "scripts/_bouncer.sh" -t "debian/$$PKG/usr/lib/$$PKG/"; \ 21 | install -D "config/$$BOUNCER.yaml" "debian/$$PKG/etc/crowdsec/bouncers/$$BOUNCER.yaml"; \ 22 | BIN="/usr/bin/$$BOUNCER" CFG="/etc/crowdsec/bouncers" envsubst '$$BIN $$CFG' < "config/$$BOUNCER.service" | install -D /dev/stdin "debian/$$PKG/etc/systemd/system/$$BOUNCER.service" 23 | 24 | execute_after_dh_fixperms: 25 | @BOUNCER=crowdsec-cloudflare-bouncer; \ 26 | PKG="$$BOUNCER"; \ 27 | chmod 0755 "debian/$$PKG/usr/bin/$$BOUNCER"; \ 28 | chmod 0600 "debian/$$PKG/usr/lib/$$PKG/_bouncer.sh"; \ 29 | chmod 0600 "debian/$$PKG/etc/crowdsec/bouncers/$$BOUNCER.yaml"; \ 30 | chmod 0644 "debian/$$PKG/etc/systemd/system/$$BOUNCER.service" 31 | -------------------------------------------------------------------------------- /docker/README.md: -------------------------------------------------------------------------------- 1 | # cloudflare-bouncer 2 | 3 | A bouncer that syncs the decisions made by CrowdSec with CloudFlare's firewall. Manages multi user, multi account, multi zone setup. Supports IP, Country and AS scoped decisions. 4 | 5 | ### Initial Setup 6 | 7 | ```bash 8 | docker run crowdsecurity/cloudflare-bouncer \ 9 | -g > cfg.yaml # auto-generate cloudflare config for provided space separated tokens 10 | vi cfg.yaml # review config and set `crowdsec_lapi_key` 11 | ``` 12 | 13 | The `crowdsec_lapi_key` can be obtained by running the following: 14 | ```bash 15 | sudo cscli -oraw bouncers add cloudflarebouncer # -oraw flag can discarded for human friendly output. 16 | ``` 17 | 18 | The `crowdsec_lapi_url` must be accessible from the container. 19 | 20 | ### Run the bouncer 21 | 22 | ```bash 23 | docker run \ 24 | -v $PWD/cfg.yaml:/etc/crowdsec/bouncers/crowdsec-cloudflare-bouncer.yaml \ 25 | -p 2112:2112 \ 26 | crowdsecurity/cloudflare-bouncer 27 | ``` 28 | 29 | 30 | # Configuration 31 | 32 | Configuration file must be at `/etc/crowdsec/bouncers/crowdsec-cloudflare-bouncer.yaml` 33 | 34 | ```yaml 35 | # CrowdSec Config 36 | crowdsec_lapi_url: http://localhost:8080/ 37 | crowdsec_lapi_key: ${API_KEY} 38 | crowdsec_update_frequency: 10s 39 | 40 | #Cloudflare Config. 41 | cloudflare_config: 42 | accounts: 43 | - id: 44 | token: 45 | ip_list_prefix: crowdsec 46 | default_action: challenge 47 | zones: 48 | - actions: 49 | - challenge # valid choices are either of challenge, js_challenge, block 50 | zone_id: 51 | 52 | update_frequency: 30s # the frequency to update the cloudflare IP list 53 | 54 | # Bouncer Config 55 | daemon: false 56 | log_mode: file 57 | log_dir: /var/log/ 58 | log_level: info # valid choices are either debug, info, error 59 | 60 | prometheus: 61 | enabled: true 62 | listen_addr: 127.0.0.1 63 | listen_port: 2112 64 | ``` 65 | 66 | ## Cloudflare Configuration 67 | 68 | **Background:** In Cloudflare, each user can have access to multiple accounts. Each account can own/access multiple zones. In this context a zone can be considered as a domain. Each domain registered with cloudflare gets a distinct `zone_id`. 69 | 70 | 71 | For obtaining the `token`: 72 | 1. Sign in as a user who has access to the desired account. 73 | 2. Go to [Tokens](https://dash.cloudflare.com/profile/api-tokens) and create the token. The bouncer requires the follwing permissions to function. 74 | ![image](https://raw.githubusercontent.com/crowdsecurity/cs-cloudflare-bouncer/main/docs/assets/token_permissions.png) 75 | 76 | To automatically generate config for cloudflare check the helper section below. 77 | 78 | 79 | :::note 80 | If the zone is subscribed to a paid Cloudflare plan then it can be configured to support multiple types of actions. For free plan zones only one action is supported. The first action is applied as default action. 81 | ::: 82 | 83 | 84 | ## Helpers 85 | 86 | The bouncer's binary has built in helper scripts to do various operations. 87 | 88 | ### Auto config generator 89 | 90 | Generates bouncer config by discovering all the accounts and the zones associated with provided list of tokens. 91 | 92 | Example Usage: 93 | 94 | ```bash 95 | docker run crowdsecurity/cloudflare-bouncer -g ,... > cfg.yaml 96 | ``` 97 | 98 | After reviewing the config you can bind mount it to the container at path `/etc/crowdsec/bouncers/crowdsec-cloudflare-bouncer.yaml` as shown in the setup gude. 99 | 100 | :::note 101 | This script only generates cloudflare related config. By default it refers to the config at `/etc/crowdsec/bouncers/crowdsec-cloudflare-bouncer.yaml` for crowdsec configuration. 102 | ::: 103 | 104 | Using custom config: 105 | ```bash 106 | docker run crowdsecurity/cloudflare-bouncer -c /cfg.yaml -g ,... -v $PWD/cfg.yaml:/cfg.yaml 107 | ``` 108 | 109 | Make sure that the custom config is mounted in the container. 110 | 111 | ### Cloudflare Setup 112 | 113 | This only creates the required IP lists and firewall rules at cloudflare and exits. 114 | 115 | Example Usage: 116 | ```bash 117 | docker run \ 118 | -v $PWD/cfg.yaml:/etc/crowdsec/bouncers/crowdsec-cloudflare-bouncer.yaml \ 119 | -p 2112:2112 \ 120 | crowdsecurity/cloudflare-bouncer -s 121 | ``` 122 | 123 | ### Cloudflare Cleanup 124 | 125 | This deletes all IP lists and firewall rules at cloudflare which were created by the bouncer. 126 | 127 | Example Usage: 128 | ```bash 129 | docker run \ 130 | -v $PWD/cfg.yaml:/etc/crowdsec/bouncers/crowdsec-cloudflare-bouncer.yaml \ 131 | -p 2112:2112 \ 132 | crowdsecurity/cloudflare-bouncer -s 133 | ``` 134 | 135 | # How it works 136 | 137 | The service polls the CrowdSec Local API for new decisions. It then makes API calls to Cloudflare 138 | to update IP lists and firewall rules depending upon the decision. 139 | 140 | 141 | # Troubleshooting 142 | - Metrics are exposed at port 2112 143 | -------------------------------------------------------------------------------- /docs/assets/crowdsec_cloudfare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-cloudflare-bouncer/81d0074a4375c50c7689e1dcfd44362a4ea2d6ef/docs/assets/crowdsec_cloudfare.png -------------------------------------------------------------------------------- /docs/assets/token_permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-cloudflare-bouncer/81d0074a4375c50c7689e1dcfd44362a4ea2d6ef/docs/assets/token_permissions.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/crowdsecurity/cs-cloudflare-bouncer 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/cloudflare/cloudflare-go v0.40.1-0.20220527055342-b3795adaff97 7 | github.com/crowdsecurity/crowdsec v1.6.3 8 | github.com/crowdsecurity/go-cs-bouncer v0.0.14 9 | github.com/crowdsecurity/go-cs-lib v0.0.15 10 | github.com/prometheus/client_golang v1.18.0 11 | github.com/sirupsen/logrus v1.9.3 12 | github.com/stretchr/testify v1.9.0 13 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 14 | golang.org/x/sync v0.6.0 15 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 16 | gopkg.in/yaml.v3 v3.0.1 17 | ) 18 | 19 | require ( 20 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 21 | github.com/beorn7/perks v1.0.1 // indirect 22 | github.com/blackfireio/osinfo v1.0.5 // indirect 23 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 24 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 25 | github.com/davecgh/go-spew v1.1.1 // indirect 26 | github.com/expr-lang/expr v1.16.9 // indirect 27 | github.com/fatih/color v1.16.0 // indirect 28 | github.com/go-openapi/analysis v0.21.4 // indirect 29 | github.com/go-openapi/errors v0.20.4 // indirect 30 | github.com/go-openapi/jsonpointer v0.20.0 // indirect 31 | github.com/go-openapi/jsonreference v0.20.2 // indirect 32 | github.com/go-openapi/loads v0.21.2 // indirect 33 | github.com/go-openapi/spec v0.20.9 // indirect 34 | github.com/go-openapi/strfmt v0.21.7 // indirect 35 | github.com/go-openapi/swag v0.22.4 // indirect 36 | github.com/go-openapi/validate v0.22.1 // indirect 37 | github.com/goccy/go-yaml v1.11.2 // indirect 38 | github.com/golang-jwt/jwt/v4 v4.5.0 // indirect 39 | github.com/google/go-querystring v1.1.0 // indirect 40 | github.com/josharian/intern v1.0.0 // indirect 41 | github.com/mailru/easyjson v0.7.7 // indirect 42 | github.com/mattn/go-colorable v0.1.13 // indirect 43 | github.com/mattn/go-isatty v0.0.20 // indirect 44 | github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 45 | github.com/mitchellh/mapstructure v1.5.0 // indirect 46 | github.com/oklog/ulid v1.3.1 // indirect 47 | github.com/pkg/errors v0.9.1 // indirect 48 | github.com/pmezard/go-difflib v1.0.0 // indirect 49 | github.com/prometheus/client_model v0.5.0 // indirect 50 | github.com/prometheus/common v0.45.0 // indirect 51 | github.com/prometheus/procfs v0.12.0 // indirect 52 | go.mongodb.org/mongo-driver v1.12.1 // indirect 53 | golang.org/x/net v0.24.0 // indirect 54 | golang.org/x/sys v0.24.0 // indirect 55 | golang.org/x/text v0.14.0 // indirect 56 | golang.org/x/time v0.3.0 // indirect 57 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 58 | google.golang.org/protobuf v1.33.0 // indirect 59 | gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect 60 | gopkg.in/yaml.v2 v2.4.0 // indirect 61 | ) 62 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= 3 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= 4 | github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 5 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= 6 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 7 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 8 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 9 | github.com/blackfireio/osinfo v1.0.5 h1:6hlaWzfcpb87gRmznVf7wSdhysGqLRz9V/xuSdCEXrA= 10 | github.com/blackfireio/osinfo v1.0.5/go.mod h1:Pd987poVNmd5Wsx6PRPw4+w7kLlf9iJxoRKPtPAjOrA= 11 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 12 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 13 | github.com/cloudflare/cloudflare-go v0.40.1-0.20220527055342-b3795adaff97 h1:6s5vrqA+xVaVLjT1FxJoDBx2FpaV7ML4WkJvBY9I4zU= 14 | github.com/cloudflare/cloudflare-go v0.40.1-0.20220527055342-b3795adaff97/go.mod h1:MmAqiRfD8rjKEuUe4MYNHfHjYhFWfW7PNe12CCQWqPY= 15 | github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= 16 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 17 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 18 | github.com/crowdsecurity/crowdsec v1.6.3 h1:L/6iT2/Gfl9bc9DQkHJz2BbpKM3P+yW6ocCKRyF4j1g= 19 | github.com/crowdsecurity/crowdsec v1.6.3/go.mod h1:LrdAX9l4vgaExQbNUVnvZIu/DPwD9pSE9gBj14D4MTo= 20 | github.com/crowdsecurity/go-cs-bouncer v0.0.14 h1:0hxOaa59pMT274qDzJXNxps4QfMnhSNss+oUn36HTpw= 21 | github.com/crowdsecurity/go-cs-bouncer v0.0.14/go.mod h1:4nSF37v7i98idHM6cw1o0V0XgiY25EjTLfFFXvqg6OA= 22 | github.com/crowdsecurity/go-cs-lib v0.0.15 h1:zNWqOPVLHgKUstlr6clom9d66S0eIIW66jQG3Y7FEvo= 23 | github.com/crowdsecurity/go-cs-lib v0.0.15/go.mod h1:ePyQyJBxp1W/1bq4YpVAilnLSz7HkzmtI7TRhX187EU= 24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 26 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/expr-lang/expr v1.16.9 h1:WUAzmR0JNI9JCiF0/ewwHB1gmcGw5wW7nWt8gc6PpCI= 28 | github.com/expr-lang/expr v1.16.9/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= 29 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 30 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 31 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 32 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 33 | github.com/go-openapi/analysis v0.21.2/go.mod h1:HZwRk4RRisyG8vx2Oe6aqeSQcoxRp47Xkp3+K6q+LdY= 34 | github.com/go-openapi/analysis v0.21.4 h1:ZDFLvSNxpDaomuCueM0BlSXxpANBlFYiBvr+GXrvIHc= 35 | github.com/go-openapi/analysis v0.21.4/go.mod h1:4zQ35W4neeZTqh3ol0rv/O8JBbka9QyAgQRPp9y3pfo= 36 | github.com/go-openapi/errors v0.19.8/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= 37 | github.com/go-openapi/errors v0.19.9/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= 38 | github.com/go-openapi/errors v0.20.2/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= 39 | github.com/go-openapi/errors v0.20.4 h1:unTcVm6PispJsMECE3zWgvG4xTiKda1LIR5rCRWLG6M= 40 | github.com/go-openapi/errors v0.20.4/go.mod h1:Z3FlZ4I8jEGxjUK+bugx3on2mIAk4txuAOhlsB1FSgk= 41 | github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= 42 | github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= 43 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 44 | github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= 45 | github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= 46 | github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= 47 | github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= 48 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 49 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 50 | github.com/go-openapi/loads v0.21.1/go.mod h1:/DtAMXXneXFjbQMGEtbamCZb+4x7eGwkvZCvBmwUG+g= 51 | github.com/go-openapi/loads v0.21.2 h1:r2a/xFIYeZ4Qd2TnGpWDIQNcP80dIaZgf704za8enro= 52 | github.com/go-openapi/loads v0.21.2/go.mod h1:Jq58Os6SSGz0rzh62ptiu8Z31I+OTHqmULx5e/gJbNw= 53 | github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= 54 | github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= 55 | github.com/go-openapi/spec v0.20.9 h1:xnlYNQAwKd2VQRRfwTEI0DcK+2cbuvI/0c7jx3gA8/8= 56 | github.com/go-openapi/spec v0.20.9/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= 57 | github.com/go-openapi/strfmt v0.21.0/go.mod h1:ZRQ409bWMj+SOgXofQAGTIo2Ebu72Gs+WaRADcS5iNg= 58 | github.com/go-openapi/strfmt v0.21.1/go.mod h1:I/XVKeLc5+MM5oPNN7P6urMOpuLXEcNrCX/rPGuWb0k= 59 | github.com/go-openapi/strfmt v0.21.3/go.mod h1:k+RzNO0Da+k3FrrynSNN8F7n/peCmQQqbbXjtDfvmGg= 60 | github.com/go-openapi/strfmt v0.21.7 h1:rspiXgNWgeUzhjo1YU01do6qsahtJNByjLVbPLNHb8k= 61 | github.com/go-openapi/strfmt v0.21.7/go.mod h1:adeGTkxE44sPyLk0JV235VQAO/ZXUr8KAzYjclFs3ew= 62 | github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= 63 | github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= 64 | github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= 65 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 66 | github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= 67 | github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 68 | github.com/go-openapi/validate v0.22.1 h1:G+c2ub6q47kfX1sOBLwIQwzBVt8qmOAARyo/9Fqs9NU= 69 | github.com/go-openapi/validate v0.22.1/go.mod h1:rjnrwK57VJ7A8xqfpAOEKRH8yQSGUriMu5/zuPSQ1hg= 70 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 71 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 72 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 73 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 74 | github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74= 75 | github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 76 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 77 | github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= 78 | github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= 79 | github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= 80 | github.com/gobuffalo/envy v1.6.15/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= 81 | github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI= 82 | github.com/gobuffalo/flect v0.1.0/go.mod h1:d2ehjJqGOH/Kjqcoz+F7jHTBbmDb38yXA598Hb50EGs= 83 | github.com/gobuffalo/flect v0.1.1/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= 84 | github.com/gobuffalo/flect v0.1.3/go.mod h1:8JCgGVbRjJhVgD6399mQr4fx5rRfGKVzFjbj6RE/9UI= 85 | github.com/gobuffalo/genny v0.0.0-20190329151137-27723ad26ef9/go.mod h1:rWs4Z12d1Zbf19rlsn0nurr75KqhYp52EAGGxTbBhNk= 86 | github.com/gobuffalo/genny v0.0.0-20190403191548-3ca520ef0d9e/go.mod h1:80lIj3kVJWwOrXWWMRzzdhW3DsrdjILVil/SFKBzF28= 87 | github.com/gobuffalo/genny v0.1.0/go.mod h1:XidbUqzak3lHdS//TPu2OgiFB+51Ur5f7CSnXZ/JDvo= 88 | github.com/gobuffalo/genny v0.1.1/go.mod h1:5TExbEyY48pfunL4QSXxlDOmdsD44RRq4mVZ0Ex28Xk= 89 | github.com/gobuffalo/gitgen v0.0.0-20190315122116-cc086187d211/go.mod h1:vEHJk/E9DmhejeLeNt7UVvlSGv3ziL+djtTr3yyzcOw= 90 | github.com/gobuffalo/gogen v0.0.0-20190315121717-8f38393713f5/go.mod h1:V9QVDIxsgKNZs6L2IYiGR8datgMhB577vzTDqypH360= 91 | github.com/gobuffalo/gogen v0.1.0/go.mod h1:8NTelM5qd8RZ15VjQTFkAW6qOMx5wBbW4dSCS3BY8gg= 92 | github.com/gobuffalo/gogen v0.1.1/go.mod h1:y8iBtmHmGc4qa3urIyo1shvOD8JftTtfcKi+71xfDNE= 93 | github.com/gobuffalo/logger v0.0.0-20190315122211-86e12af44bc2/go.mod h1:QdxcLw541hSGtBnhUc4gaNIXRjiDppFGaDqzbrBd3v8= 94 | github.com/gobuffalo/mapi v1.0.1/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= 95 | github.com/gobuffalo/mapi v1.0.2/go.mod h1:4VAGh89y6rVOvm5A8fKFxYG+wIW6LO1FMTG9hnKStFc= 96 | github.com/gobuffalo/packd v0.0.0-20190315124812-a385830c7fc0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= 97 | github.com/gobuffalo/packd v0.1.0/go.mod h1:M2Juc+hhDXf/PnmBANFCqx4DM3wRbgDvnVWeG2RIxq4= 98 | github.com/gobuffalo/packr/v2 v2.0.9/go.mod h1:emmyGweYTm6Kdper+iywB6YK5YzuKchGtJQZ0Odn4pQ= 99 | github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/VCm/3ptBN+0= 100 | github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= 101 | github.com/goccy/go-yaml v1.11.2 h1:joq77SxuyIs9zzxEjgyLBugMQ9NEgTWxXfz2wVqwAaQ= 102 | github.com/goccy/go-yaml v1.11.2/go.mod h1:wKnAMd44+9JAAnGQpWVEgBzGt3YuTaQ4uXoHvE4m7WU= 103 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 104 | github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= 105 | github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 106 | github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 107 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 108 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 109 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 110 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 111 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 112 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 113 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 114 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 115 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 116 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 117 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 118 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 119 | github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= 120 | github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= 121 | github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 122 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 123 | github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 124 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 125 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 126 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 127 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 128 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 129 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 130 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 131 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 132 | github.com/leodido/go-urn v1.3.0 h1:jX8FDLfW4ThVXctBNZ+3cIWnCSnrACDV73r76dy0aQQ= 133 | github.com/leodido/go-urn v1.3.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 134 | github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= 135 | github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= 136 | github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 137 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 138 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 139 | github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= 140 | github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= 141 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 142 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 143 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 144 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 145 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 146 | github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= 147 | github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= 148 | github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 149 | github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 150 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 151 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 152 | github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= 153 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 154 | github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= 155 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 156 | github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= 157 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 158 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 159 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 160 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 161 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 162 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 163 | github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= 164 | github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= 165 | github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 166 | github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 167 | github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= 168 | github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= 169 | github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 170 | github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 171 | github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 172 | github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 173 | github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 174 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 175 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 176 | github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 177 | github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= 178 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 179 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 180 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 181 | github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= 182 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 183 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 184 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 185 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 186 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 187 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 188 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 189 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 190 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 191 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 192 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 193 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 194 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 195 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 196 | github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= 197 | github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= 198 | github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs= 199 | github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= 200 | github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= 201 | github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= 202 | github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= 203 | github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= 204 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= 205 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 206 | go.mongodb.org/mongo-driver v1.7.3/go.mod h1:NqaYOwnXWr5Pm7AOpO5QFxKJ503nbMse/R79oO62zWg= 207 | go.mongodb.org/mongo-driver v1.7.5/go.mod h1:VXEWRZ6URJIkUq2SCAyapmhH0ZLRBP+FT4xhp5Zvxng= 208 | go.mongodb.org/mongo-driver v1.10.0/go.mod h1:wsihk0Kdgv8Kqu1Anit4sfK+22vSFbUrAVEYRhCXrA8= 209 | go.mongodb.org/mongo-driver v1.12.1 h1:nLkghSU8fQNaK7oUmDhQFsnrtcoNy7Z6LVFKsEecqgE= 210 | go.mongodb.org/mongo-driver v1.12.1/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ= 211 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 212 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 213 | golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= 214 | golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 215 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 216 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 217 | golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= 218 | golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 219 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= 220 | golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 221 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 222 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 223 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 224 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 225 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 226 | golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= 227 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 228 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 229 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 230 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 231 | golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 232 | golang.org/x/sync v0.0.0-20190412183630-56d357773e84/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 233 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 234 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 235 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 236 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 237 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 238 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 239 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 240 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 241 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 242 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 243 | golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 244 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 245 | golang.org/x/sys v0.0.0-20190531175056-4c3a928424d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 246 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 247 | golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 248 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 249 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 250 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 251 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 252 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 253 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 254 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 255 | golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= 256 | golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 257 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 258 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 259 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 260 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 261 | golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 262 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 263 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 264 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 265 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 266 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 267 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 268 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 269 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 270 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 271 | golang.org/x/tools v0.0.0-20190329151228-23e29df326fe/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 272 | golang.org/x/tools v0.0.0-20190416151739-9c9e1878f421/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 273 | golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 274 | golang.org/x/tools v0.0.0-20190531172133-b3315ee88b7d/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 275 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 276 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 277 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 278 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 279 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 280 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 281 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 282 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 283 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 284 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 285 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 286 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 287 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 288 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 289 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= 290 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 291 | gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 h1:yiW+nvdHb9LVqSHQBXfZCieqV4fzYhNBql77zY0ykqs= 292 | gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637/go.mod h1:BHsqpu/nsuzkT5BpiH1EMZPLyqSMM8JbIavyFACoFNk= 293 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 294 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 295 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 296 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 297 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 298 | gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 299 | gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 300 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 301 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 302 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 303 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | 6 | "github.com/crowdsecurity/cs-cloudflare-bouncer/cmd" 7 | ) 8 | 9 | func main() { 10 | err := cmd.Execute() 11 | if err != nil { 12 | log.Fatal(err) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pkg/cf/cloudflare.go: -------------------------------------------------------------------------------- 1 | package cf 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "net" 9 | "net/http" 10 | "reflect" 11 | "sort" 12 | "strings" 13 | "sync" 14 | "sync/atomic" 15 | "time" 16 | 17 | "github.com/cloudflare/cloudflare-go" 18 | "github.com/prometheus/client_golang/prometheus" 19 | "github.com/prometheus/client_golang/prometheus/promauto" 20 | log "github.com/sirupsen/logrus" 21 | 22 | "github.com/crowdsecurity/crowdsec/pkg/models" 23 | 24 | "github.com/crowdsecurity/cs-cloudflare-bouncer/pkg/cfg" 25 | ) 26 | 27 | const CallsPerSecondLimit uint32 = 4 28 | 29 | var CloudflareActionByDecisionType = map[string]string{ 30 | "captcha": "managed_challenge", 31 | "ban": "block", 32 | "js_challenge": "js_challenge", 33 | } 34 | 35 | var ResponseTime prometheus.Histogram = promauto.NewHistogram(prometheus.HistogramOpts{ 36 | Name: "response_time", 37 | Help: "response time by cloudflare", 38 | Buckets: prometheus.LinearBuckets(0, 100, 50), 39 | }, 40 | ) 41 | 42 | var TotalAPICalls prometheus.Counter = promauto.NewCounter(prometheus.CounterOpts{ 43 | Name: "cloudflare_api_calls", 44 | Help: "The total number of API calls to cloudflare made by CrowdSec bouncer", 45 | }, 46 | ) 47 | 48 | type ZoneLock struct { 49 | Lock *sync.Mutex 50 | ZoneID string 51 | } 52 | 53 | type IPSetItem struct { 54 | CreatedAt time.Time 55 | } 56 | 57 | type IPListState struct { 58 | IPList *cloudflare.IPList 59 | IPSet map[string]IPSetItem `json:"-"` 60 | } 61 | 62 | // one firewall rule per state. 63 | type CloudflareState struct { 64 | Action string 65 | AccountID string 66 | FilterIDByZoneID map[string]string // this contains all the zone ID -> filter ID which represent this state 67 | CurrExpr string 68 | IPListState IPListState 69 | CountrySet map[string]struct{} 70 | AutonomousSystemSet map[string]struct{} 71 | } 72 | 73 | func setToExprList(set map[string]struct{}, quotes bool) string { 74 | items := make([]string, len(set)) 75 | i := 0 76 | for str := range set { 77 | if quotes { 78 | items[i] = fmt.Sprintf(`"%s"`, str) 79 | } else { 80 | items[i] = str 81 | } 82 | i++ 83 | } 84 | sort.Strings(items) 85 | return fmt.Sprintf("{%s}", strings.Join(items, " ")) 86 | } 87 | 88 | func allZonesHaveAction(zones []cfg.ZoneConfig, action string) bool { 89 | allSupport := true 90 | for _, zone := range zones { 91 | if _, allSupport = zone.ActionSet[action]; !allSupport { 92 | break 93 | } 94 | } 95 | return allSupport 96 | } 97 | 98 | func calculateIPSetDiff(setA map[string]IPSetItem, setB map[string]IPSetItem) (int, int) { 99 | exclusiveToA := 0 100 | exclusiveToB := 0 101 | for item := range setA { 102 | if _, ok := setB[item]; !ok { 103 | exclusiveToA++ 104 | } 105 | } 106 | 107 | for item := range setB { 108 | if _, ok := setA[item]; !ok { 109 | exclusiveToB++ 110 | } 111 | } 112 | return exclusiveToA, exclusiveToB 113 | } 114 | 115 | func (cfState CloudflareState) computeExpression() string { 116 | var countryExpr, ASExpr, ipExpr string 117 | buff := make([]string, 0) 118 | 119 | if len(cfState.CountrySet) > 0 { 120 | countryExpr = fmt.Sprintf("(ip.geoip.country in %s)", setToExprList(cfState.CountrySet, true)) 121 | buff = append(buff, countryExpr) 122 | } 123 | 124 | if len(cfState.AutonomousSystemSet) > 0 { 125 | ASExpr = fmt.Sprintf("(ip.geoip.asnum in %s)", setToExprList(cfState.AutonomousSystemSet, false)) 126 | buff = append(buff, ASExpr) 127 | } 128 | 129 | if cfState.IPListState.IPList != nil { 130 | ipExpr = fmt.Sprintf("(ip.src in $%s)", cfState.IPListState.IPList.Name) 131 | buff = append(buff, ipExpr) 132 | } 133 | 134 | return strings.Join(buff, " or ") 135 | } 136 | 137 | // updates the expression for the state. Returns true if new rule is 138 | // different than the previous rule. 139 | func (cfState *CloudflareState) UpdateExpr() bool { 140 | computedExpr := cfState.computeExpression() 141 | isNew := computedExpr != cfState.CurrExpr 142 | cfState.CurrExpr = computedExpr 143 | return isNew 144 | } 145 | 146 | type CloudflareWorker struct { 147 | Logger *log.Entry 148 | APILogger *log.Logger 149 | Account cfg.AccountConfig 150 | ZoneLocks []ZoneLock 151 | Zones []cloudflare.Zone 152 | FirewallRulesByZoneID map[string]*[]cloudflare.FirewallRule 153 | CFStateByAction map[string]*CloudflareState 154 | Ctx context.Context 155 | LAPIStream chan *models.DecisionsStreamResponse 156 | UpdateFrequency time.Duration 157 | NewIPDecisions []*models.Decision 158 | ExpiredIPDecisions []*models.Decision 159 | NewASDecisions []*models.Decision 160 | ExpiredASDecisions []*models.Decision 161 | NewCountryDecisions []*models.Decision 162 | ExpiredCountryDecisions []*models.Decision 163 | API cloudflareAPI 164 | Count prometheus.Counter 165 | TokenCallCount *uint32 166 | } 167 | 168 | // this is useful for testing allowing us to mock it. 169 | type cloudflareAPI interface { 170 | Filters(ctx context.Context, zoneID string, pageOpts cloudflare.PaginationOptions) ([]cloudflare.Filter, error) 171 | ListZones(ctx context.Context, z ...string) ([]cloudflare.Zone, error) 172 | CreateIPList(ctx context.Context, accountID string, name string, desc string, typ string) (cloudflare.IPList, error) 173 | DeleteIPList(ctx context.Context, accountID string, id string) (cloudflare.IPListDeleteResponse, error) 174 | ListIPLists(ctx context.Context, accountID string) ([]cloudflare.IPList, error) 175 | CreateFirewallRules(ctx context.Context, zone string, rules []cloudflare.FirewallRule) ([]cloudflare.FirewallRule, error) 176 | DeleteFirewallRules(ctx context.Context, zoneID string, firewallRuleIDs []string) error 177 | FirewallRules(ctx context.Context, zone string, opts cloudflare.PaginationOptions) ([]cloudflare.FirewallRule, error) 178 | DeleteFilters(ctx context.Context, zoneID string, filterIDs []string) error 179 | UpdateFilters(ctx context.Context, zoneID string, filters []cloudflare.Filter) ([]cloudflare.Filter, error) 180 | ReplaceIPListItemsAsync(ctx context.Context, accountID string, id string, items []cloudflare.IPListItemCreateRequest) (cloudflare.IPListItemCreateResponse, error) 181 | GetIPListBulkOperation(ctx context.Context, accountID string, id string) (cloudflare.IPListBulkOperation, error) 182 | ListIPListItems(ctx context.Context, accountID string, id string) ([]cloudflare.IPListItem, error) 183 | DeleteIPListItems(ctx context.Context, accountID string, id string, items cloudflare.IPListItemDeleteRequest) ( 184 | []cloudflare.IPListItem, error) 185 | } 186 | 187 | func normalizeDecisionValue(value string) string { 188 | if strings.Count(value, ":") <= 1 { 189 | // it is a ipv4 190 | // Cloudflare does not allow duplicates, but LAPI can send us "duplicates" (e.g. 1.2.3.4 and 1.2.3.4/32) 191 | if strings.HasSuffix(value, "/32") { 192 | return value[:len(value)-3] 193 | } 194 | return value 195 | } 196 | var address *net.IPNet 197 | _, address, err := net.ParseCIDR(value) 198 | if err != nil { 199 | // doesn't have mask, we add one then. 200 | _, address, _ = net.ParseCIDR(value + "/64") 201 | // this would never cause error because crowdsec already validates IP 202 | } 203 | 204 | if ones, _ := address.Mask.Size(); ones < 64 { 205 | return address.String() 206 | } 207 | address.Mask = net.CIDRMask(64, 128) 208 | return address.String() 209 | } 210 | 211 | // Helper which removes dups and splits decisions according to their action. 212 | // Decisions with unsupported action are ignored 213 | func dedupAndClassifyDecisionsByAction(decisions []*models.Decision) map[string][]*models.Decision { 214 | decisionValueSet := make(map[string]struct{}) 215 | decisonsByAction := make(map[string][]*models.Decision) 216 | tmpDefaulted := make([]*models.Decision, 0) 217 | for _, decision := range decisions { 218 | *decision.Value = normalizeDecisionValue(*decision.Value) 219 | action := CloudflareActionByDecisionType[*decision.Type] 220 | if _, ok := decisionValueSet[*decision.Value]; ok { 221 | // dup 222 | continue 223 | } 224 | if action == "" { 225 | // unsupported decision type, ignore this if in case decision with supported action 226 | // for the same decision value is present. 227 | tmpDefaulted = append(tmpDefaulted, decision) 228 | continue 229 | } else { 230 | decisionValueSet[*decision.Value] = struct{}{} 231 | } 232 | decisonsByAction[action] = append(decisonsByAction[action], decision) 233 | } 234 | defaulted := make([]*models.Decision, 0) 235 | for _, decision := range tmpDefaulted { 236 | if _, ok := decisionValueSet[*decision.Value]; ok { 237 | // dup 238 | continue 239 | } 240 | defaulted = append(defaulted, decision) 241 | } 242 | decisonsByAction["defaulted"] = defaulted 243 | return decisonsByAction 244 | } 245 | 246 | // getters 247 | func (worker *CloudflareWorker) getMutexByZoneID(zoneID string) (*sync.Mutex, error) { 248 | for _, zoneLock := range worker.ZoneLocks { 249 | if zoneLock.ZoneID == zoneID { 250 | return zoneLock.Lock, nil 251 | } 252 | } 253 | return nil, fmt.Errorf("zone lock for the zone id %s not found", zoneID) 254 | } 255 | 256 | func (worker *CloudflareWorker) getAPI() cloudflareAPI { 257 | atomic.AddUint32(worker.TokenCallCount, 1) 258 | if *worker.TokenCallCount > CallsPerSecondLimit { 259 | time.Sleep(time.Second) 260 | } 261 | TotalAPICalls.Inc() 262 | return worker.API 263 | } 264 | 265 | func (worker *CloudflareWorker) deleteRulesContainingStringFromZoneIDs(str string, zonesIDs []string) error { 266 | for _, zoneID := range zonesIDs { 267 | zoneLogger := worker.Logger.WithFields(log.Fields{"zone_id": zoneID}) 268 | zoneLock, err := worker.getMutexByZoneID(zoneID) 269 | if err == nil { 270 | zoneLock.Lock() 271 | defer zoneLock.Unlock() 272 | } 273 | rules, err := worker.getAPI().FirewallRules(worker.Ctx, zoneID, cloudflare.PaginationOptions{}) 274 | if err != nil { 275 | return err 276 | } 277 | deleteRules := make([]string, 0) 278 | 279 | for _, rule := range rules { 280 | if strings.Contains(rule.Filter.Expression, str) { 281 | deleteRules = append(deleteRules, rule.ID) 282 | } 283 | } 284 | 285 | if len(deleteRules) > 0 { 286 | err = worker.getAPI().DeleteFirewallRules(worker.Ctx, zoneID, deleteRules) 287 | if err != nil { 288 | return err 289 | } 290 | zoneLogger.Infof("deleted %d firewall rules containing the string %s", len(deleteRules), str) 291 | } 292 | 293 | } 294 | return nil 295 | } 296 | 297 | func getIPListNameWithPrefixForAction(prefix string, action string) string { 298 | return fmt.Sprintf("%s_%s", prefix, action) 299 | } 300 | 301 | func (worker *CloudflareWorker) importExistingIPLists() error { 302 | IPLists, err := worker.getAPI().ListIPLists(worker.Ctx, worker.Account.ID) 303 | if err != nil { 304 | return err 305 | } 306 | for action := range worker.CFStateByAction { 307 | for _, IPList := range IPLists { 308 | if IPList.Name == getIPListNameWithPrefixForAction(worker.Account.IPListPrefix, action) { 309 | worker.Logger.Infof("using existing ip list %s %s", IPList.Name, IPList.ID) 310 | worker.CFStateByAction[action].IPListState.IPList = &IPList 311 | break 312 | } 313 | // TODO we can also import existing content here, to exclude user's custom banned IPs. 314 | } 315 | } 316 | return nil 317 | } 318 | 319 | func (worker *CloudflareWorker) importRulesAndFiltersForExistingIPList(IPListName string) error { 320 | for _, zone := range worker.Account.ZoneConfigs { 321 | rules, err := worker.cachedFirewallRules(zone.ID) 322 | if err != nil { 323 | return err 324 | } 325 | for action, state := range worker.CFStateByAction { 326 | for _, rule := range rules { 327 | if state == nil || state.IPListState.IPList == nil || state.IPListState.IPList.Name == "" { 328 | continue 329 | } 330 | if strings.Contains(rule.Description, fmt.Sprintf("CrowdSec %s rule", action)) && 331 | strings.Contains(rule.Filter.Expression, state.IPListState.IPList.Name) { 332 | worker.Logger.WithField("zone_id", zone.ID).Infof("found existing rule for %s action", action) 333 | worker.CFStateByAction[action].FilterIDByZoneID[zone.ID] = rule.Filter.ID 334 | worker.CFStateByAction[action].CurrExpr = rule.Filter.Expression 335 | } 336 | } 337 | } 338 | 339 | } 340 | return nil 341 | } 342 | 343 | func (worker *CloudflareWorker) importExistingInfra() error { 344 | if err := worker.importExistingIPLists(); err != nil { 345 | return err 346 | } 347 | for _, state := range worker.CFStateByAction { 348 | if state.IPListState.IPList != nil { 349 | if err := worker.importRulesAndFiltersForExistingIPList(state.IPListState.IPList.Name); err != nil { 350 | return err 351 | } 352 | } 353 | } 354 | return nil 355 | } 356 | 357 | func (worker *CloudflareWorker) deleteFiltersContainingStringFromZoneIDs(str string, zonesIDs []string) error { 358 | for _, zoneID := range zonesIDs { 359 | zoneLogger := worker.Logger.WithFields(log.Fields{"zone_id": zoneID}) 360 | zoneLock, err := worker.getMutexByZoneID(zoneID) 361 | if err == nil { 362 | zoneLock.Lock() 363 | defer zoneLock.Unlock() 364 | } 365 | filters, err := worker.getAPI().Filters(worker.Ctx, zoneID, cloudflare.PaginationOptions{}) 366 | if err != nil { 367 | return err 368 | } 369 | deleteFilters := make([]string, 0) 370 | for _, filter := range filters { 371 | if strings.Contains(filter.Expression, str) { 372 | deleteFilters = append(deleteFilters, filter.ID) 373 | zoneLogger.Infof("deleting %s filter with expression %s", filter.ID, filter.Expression) 374 | } 375 | } 376 | 377 | if len(deleteFilters) > 0 { 378 | zoneLogger.Infof("deleting %d filters", len(deleteFilters)) 379 | err = worker.getAPI().DeleteFilters(worker.Ctx, zoneID, deleteFilters) 380 | if err != nil { 381 | return err 382 | } 383 | } 384 | 385 | } 386 | return nil 387 | } 388 | 389 | func (worker *CloudflareWorker) DeleteExistingIPList() error { 390 | worker.Logger.Info("Getting all IP lists") 391 | IPLists, err := worker.getAPI().ListIPLists(worker.Ctx, worker.Account.ID) 392 | if err != nil { 393 | return err 394 | } 395 | for _, IPList := range IPLists { 396 | if !strings.Contains(IPList.Description, "IP list by crowdsec") { 397 | continue 398 | } 399 | worker.Logger.Infof("removing %s ip list", IPList.Name) 400 | err = worker.removeIPListDependencies(IPList.Name) 401 | if err != nil { 402 | return err 403 | } 404 | 405 | worker.Logger.Infof("deleting ip list %s", IPList.Name) 406 | _, err = worker.getAPI().DeleteIPList(worker.Ctx, worker.Account.ID, IPList.ID) 407 | if err != nil { 408 | return err 409 | } 410 | } 411 | return nil 412 | } 413 | 414 | // cached cachedListZones 415 | func (worker *CloudflareWorker) cachedListZones() ([]cloudflare.Zone, error) { 416 | if len(worker.Zones) != 0 { 417 | return worker.Zones, nil 418 | } 419 | zones, err := worker.getAPI().ListZones(worker.Ctx) 420 | if err != nil { 421 | return nil, err 422 | } 423 | worker.Zones = zones 424 | return zones, nil 425 | } 426 | 427 | func (worker *CloudflareWorker) cachedFirewallRules(zoneID string) ([]cloudflare.FirewallRule, error) { 428 | if worker.FirewallRulesByZoneID[zoneID] != nil { 429 | return *worker.FirewallRulesByZoneID[zoneID], nil 430 | } 431 | rules, err := worker.getAPI().FirewallRules(worker.Ctx, zoneID, cloudflare.PaginationOptions{}) 432 | if err != nil { 433 | return nil, err 434 | } 435 | worker.FirewallRulesByZoneID[zoneID] = &rules 436 | return rules, err 437 | } 438 | 439 | func (worker *CloudflareWorker) removeIPListDependencies(IPListName string) error { 440 | worker.Logger.Info("removing ip list dependencies") 441 | worker.Logger.Info("listing zones") 442 | zones, err := worker.cachedListZones() 443 | if err != nil { 444 | return err 445 | } 446 | 447 | zoneIDs := make([]string, len(zones)) 448 | for i, zone := range zones { 449 | zoneIDs[i] = zone.ID 450 | } 451 | 452 | worker.Logger.Infof("found %d zones on this account", len(zones)) 453 | worker.Logger.Infof("deleting rules containing $%s", IPListName) 454 | err = worker.deleteRulesContainingStringFromZoneIDs(fmt.Sprintf("$%s", IPListName), zoneIDs) 455 | if err != nil { 456 | return err 457 | } 458 | // A Filter can exist on it's own, they are not visible on UI, they are API only. 459 | // Clear these Filters. 460 | worker.Logger.Infof("deleting filters containing $%s", IPListName) 461 | err = worker.deleteFiltersContainingStringFromZoneIDs(fmt.Sprintf("$%s", IPListName), zoneIDs) 462 | if err != nil { 463 | return err 464 | } 465 | return nil 466 | } 467 | 468 | // nolint: unused 469 | func (worker *CloudflareWorker) getIPListID(IPListName string, IPLists []cloudflare.IPList) *string { 470 | for _, ipList := range IPLists { 471 | if ipList.Name == IPListName { 472 | return &ipList.ID 473 | } 474 | } 475 | return nil 476 | } 477 | 478 | func (worker *CloudflareWorker) createMissingIPLists() error { 479 | // if IP list already exists don't create one 480 | for action := range worker.CFStateByAction { 481 | if worker.CFStateByAction[action].IPListState.IPList == nil { 482 | ipList, err := worker.getAPI().CreateIPList( 483 | worker.Ctx, 484 | worker.Account.ID, 485 | fmt.Sprintf("%s_%s", worker.Account.IPListPrefix, action), 486 | fmt.Sprintf("%s IP list by crowdsec", action), 487 | "ip", 488 | ) 489 | if err != nil { 490 | return err 491 | } 492 | worker.CFStateByAction[action].IPListState.IPList = &ipList 493 | } 494 | worker.CFStateByAction[action].IPListState.IPSet = make(map[string]IPSetItem) 495 | worker.CFStateByAction[action].UpdateExpr() 496 | } 497 | return nil 498 | } 499 | 500 | func (worker *CloudflareWorker) createMissingRules() error { 501 | for _, zone := range worker.Account.ZoneConfigs { 502 | zoneLogger := worker.Logger.WithFields(log.Fields{"zone_id": zone.ID}) 503 | for _, action := range zone.Actions { 504 | if worker.CFStateByAction[action].FilterIDByZoneID[zone.ID] != "" { 505 | zoneLogger.Info("skipping rule creation for " + action) 506 | continue 507 | } 508 | ruleExpression := worker.CFStateByAction[action].CurrExpr 509 | firewallRules := []cloudflare.FirewallRule{{Filter: cloudflare.Filter{Expression: ruleExpression}, Action: action, Description: fmt.Sprintf("CrowdSec %s rule", action)}} 510 | rule, err := worker.getAPI().CreateFirewallRules(worker.Ctx, zone.ID, firewallRules) 511 | if err != nil { 512 | worker.Logger.WithFields(log.Fields{"zone_id": zone.ID}).Errorf("error %s in creating firewall rule %s", err.Error(), ruleExpression) 513 | return err 514 | } 515 | worker.CFStateByAction[action].FilterIDByZoneID[zone.ID] = rule[0].Filter.ID 516 | zoneLogger.Infof("created firewall rule for %s action", action) 517 | } 518 | } 519 | worker.Logger.Info("setup of firewall rules complete") 520 | return nil 521 | } 522 | 523 | func (worker *CloudflareWorker) UpdateIPLists() error { 524 | // IP decisions are applied at account level 525 | newDecisonsByAction := dedupAndClassifyDecisionsByAction(worker.NewIPDecisions) 526 | expiredDecisonsByAction := dedupAndClassifyDecisionsByAction(worker.ExpiredIPDecisions) 527 | newIPSetByAction := make(map[string]map[string]IPSetItem) 528 | 529 | for action, decisions := range newDecisonsByAction { 530 | // In case some zones support this action and others don't, we put this in account's default action. 531 | if !allZonesHaveAction(worker.Account.ZoneConfigs, action) { 532 | if worker.Account.DefaultAction == "none" { 533 | worker.Logger.Debugf("dropping IP decisions with unsupported action %s", action) 534 | continue 535 | } 536 | action = worker.Account.DefaultAction 537 | worker.Logger.Debugf("ip action defaulted to %s", action) 538 | } 539 | for ip, item := range worker.CFStateByAction[action].IPListState.IPSet { 540 | if _, ok := newIPSetByAction[action]; !ok { 541 | newIPSetByAction[action] = make(map[string]IPSetItem) 542 | } 543 | newIPSetByAction[action][ip] = item 544 | } 545 | 546 | for _, decision := range decisions { 547 | if _, ok := newIPSetByAction[action]; !ok { 548 | newIPSetByAction[action] = make(map[string]IPSetItem) 549 | } 550 | if _, ok := newIPSetByAction[action][*decision.Value]; !ok { 551 | newIPSetByAction[action][*decision.Value] = IPSetItem{ 552 | CreatedAt: time.Now(), 553 | } 554 | } 555 | } 556 | } 557 | 558 | for action, decisions := range expiredDecisonsByAction { 559 | // In case some zones support this action and others don't, we put this in account's default action. 560 | if !allZonesHaveAction(worker.Account.ZoneConfigs, action) { 561 | if worker.Account.DefaultAction == "none" { 562 | worker.Logger.Debugf("dropping IP decisions with unsupported action %s", action) 563 | continue 564 | } 565 | action = worker.Account.DefaultAction 566 | worker.Logger.Debugf("ip action defaulted to %s", action) 567 | } 568 | if _, ok := newIPSetByAction[action]; !ok { 569 | newIPSetByAction[action] = make(map[string]IPSetItem) 570 | for ip, item := range worker.CFStateByAction[action].IPListState.IPSet { 571 | newIPSetByAction[action][ip] = item 572 | } 573 | } 574 | for _, decision := range decisions { 575 | if _, ok := worker.CFStateByAction[action].IPListState.IPSet[*decision.Value]; ok { 576 | delete(newIPSetByAction[action], *decision.Value) 577 | } 578 | } 579 | } 580 | 581 | for action := range worker.CFStateByAction { 582 | var dropCount int 583 | newIPSetByAction[action], dropCount = keepLatestNIPSetItems(newIPSetByAction[action], *worker.Account.TotalIPListCapacity) 584 | if dropCount > 0 { 585 | worker.Logger.Warnf("%d IPs won't be inserted/kept to avoid exceeding IP list limit", dropCount) 586 | } 587 | } 588 | 589 | for action, set := range newIPSetByAction { 590 | if reflect.DeepEqual(worker.CFStateByAction[action].IPListState.IPSet, set) { 591 | log.Info("no changes to IP rules ") 592 | continue 593 | } 594 | if len(set) == 0 { 595 | // The ReplaceIPListItemsAsync method doesn't allow to empty the list. 596 | // Hence we only add one mock IP and later delete it. To do this we add the mock IP 597 | // in the set and continue as usual, and end up with 1 item in the IP list. Then the`` 598 | // defer call takes care of cleaning up the extra IP. 599 | worker.Logger.Warningf("emptying IP list for %s action", action) 600 | set["10.0.0.1"] = IPSetItem{ 601 | CreatedAt: time.Now(), 602 | } 603 | defer func(action string) { 604 | ipListID := worker.CFStateByAction[action].IPListState.IPList.ID 605 | items, err := worker.getAPI().ListIPListItems(worker.Ctx, worker.Account.ID, ipListID) 606 | if err != nil { 607 | worker.Logger.Error(err) 608 | return 609 | } 610 | _, err = worker.getAPI().DeleteIPListItems(worker.Ctx, worker.Account.ID, ipListID, cloudflare.IPListItemDeleteRequest{ 611 | Items: []cloudflare.IPListItemDeleteItemRequest{ 612 | { 613 | ID: items[0].ID, 614 | }, 615 | }, 616 | }) 617 | if err != nil { 618 | worker.Logger.Error(err) 619 | } 620 | worker.CFStateByAction[action].IPListState.IPSet = make(map[string]IPSetItem) 621 | worker.CFStateByAction[action].IPListState.IPList.NumItems = 0 622 | worker.Logger.Infof("emptied IP list for %s action", action) 623 | 624 | }(action) 625 | } 626 | req := make([]cloudflare.IPListItemCreateRequest, 0) 627 | for ip := range set { 628 | req = append(req, cloudflare.IPListItemCreateRequest{ 629 | IP: ip, 630 | }) 631 | } 632 | ret, err := worker.getAPI().ReplaceIPListItemsAsync(worker.Ctx, worker.Account.ID, worker.CFStateByAction[action].IPListState.IPList.ID, req) 633 | if err != nil { 634 | return err 635 | } 636 | POLL_LOOP: 637 | for { 638 | res, err := worker.getAPI().GetIPListBulkOperation(worker.Ctx, worker.Account.ID, ret.Result.OperationID) 639 | if err != nil { 640 | return err 641 | } 642 | switch res.Status { 643 | case "failed": 644 | return fmt.Errorf("failed during polling got error %s ", res.Error) 645 | case "pending", "running": 646 | case "completed": 647 | break POLL_LOOP 648 | default: 649 | return fmt.Errorf("unexpected status %s while polling ", res.Status) 650 | } 651 | time.Sleep(time.Second) 652 | } 653 | newItemCount, deletedItemCount := calculateIPSetDiff(set, worker.CFStateByAction[action].IPListState.IPSet) 654 | log.Infof("added %d new IPs and deleted %d IPs", newItemCount, deletedItemCount) 655 | worker.CFStateByAction[action].IPListState.IPSet = set 656 | worker.CFStateByAction[action].IPListState.IPList.NumItems = len(set) 657 | } 658 | 659 | worker.ExpiredIPDecisions = make([]*models.Decision, 0) 660 | worker.NewIPDecisions = make([]*models.Decision, 0) 661 | return nil 662 | } 663 | 664 | func (worker *CloudflareWorker) SetUpCloudflareResources() error { 665 | if err := worker.importExistingInfra(); err != nil { 666 | return err 667 | } 668 | 669 | err := worker.createMissingIPLists() 670 | if err != nil { 671 | worker.Logger.Errorf("error %s in creating IP List", err.Error()) 672 | return err 673 | } 674 | worker.Logger.Debug("ip list setup complete") 675 | err = worker.createMissingRules() 676 | if err != nil { 677 | worker.Logger.Error(err.Error()) 678 | return err 679 | } 680 | return nil 681 | } 682 | 683 | type InterceptLogger struct { 684 | Tripper http.RoundTripper 685 | logger *log.Logger 686 | } 687 | 688 | func (lrt InterceptLogger) RoundTrip(req *http.Request) (*http.Response, error) { 689 | if req.Body != nil { 690 | var buf bytes.Buffer 691 | tmp := io.TeeReader(req.Body, &buf) 692 | body, err := io.ReadAll(tmp) 693 | if err != nil { 694 | return nil, err 695 | } 696 | lrt.logger.Debugf("%s %s", req.URL, string(body)) 697 | req.Body = io.NopCloser(&buf) 698 | } else { 699 | lrt.logger.Debugf("%s ", req.URL) 700 | } 701 | beginTime := time.Now() 702 | res, e := lrt.Tripper.RoundTrip(req) 703 | finishTime := time.Now() 704 | ResponseTime.Observe(float64(finishTime.Sub(beginTime).Milliseconds())) 705 | return res, e 706 | } 707 | 708 | func NewCloudflareClient(token string, logger *log.Logger) (*cloudflare.API, error) { 709 | httpClient := &http.Client{ 710 | Transport: InterceptLogger{ 711 | Tripper: http.DefaultTransport, 712 | logger: logger, 713 | }, 714 | } 715 | z, err := cloudflare.NewWithAPIToken(token, cloudflare.HTTPClient(httpClient)) 716 | return z, err 717 | } 718 | 719 | func (worker *CloudflareWorker) Init() error { 720 | var err error 721 | worker.Logger = log.WithFields(log.Fields{"account_id": worker.Account.ID}) 722 | if worker.API == nil { // this for easy swapping during tests 723 | worker.API, err = NewCloudflareClient(worker.Account.Token, worker.APILogger) 724 | if err != nil { 725 | worker.Logger.Error(err.Error()) 726 | return err 727 | } 728 | } 729 | worker.NewIPDecisions = make([]*models.Decision, 0) 730 | worker.ExpiredIPDecisions = make([]*models.Decision, 0) 731 | worker.CFStateByAction = make(map[string]*CloudflareState) 732 | worker.FirewallRulesByZoneID = make(map[string]*[]cloudflare.FirewallRule) 733 | zones, err := worker.cachedListZones() 734 | if err != nil { 735 | worker.Logger.Error(err.Error()) 736 | return err 737 | } 738 | zoneByID := make(map[string]cloudflare.Zone) 739 | for _, zone := range zones { 740 | zoneByID[zone.ID] = zone 741 | } 742 | 743 | for _, z := range worker.Account.ZoneConfigs { 744 | zone, ok := zoneByID[z.ID] 745 | if !ok { 746 | return fmt.Errorf("account %s doesn't have access to zone %s", worker.Account.ID, z.ID) 747 | } 748 | 749 | if !zone.Plan.IsSubscribed && len(z.Actions) > 1 { 750 | // FIXME this is probably wrong. 751 | return fmt.Errorf("zone %s 's plan doesn't support multiple actionss", z.ID) 752 | } 753 | 754 | for _, action := range z.Actions { 755 | worker.CFStateByAction[action] = &CloudflareState{ 756 | AccountID: worker.Account.ID, 757 | Action: action, 758 | } 759 | worker.CFStateByAction[action].FilterIDByZoneID = make(map[string]string) 760 | worker.CFStateByAction[action].CountrySet = make(map[string]struct{}) 761 | worker.CFStateByAction[action].AutonomousSystemSet = make(map[string]struct{}) 762 | } 763 | } 764 | return err 765 | } 766 | 767 | func (worker *CloudflareWorker) getContainerByDecisionScope(scope string, decisionIsExpired bool) (*([]*models.Decision), error) { 768 | var containerByDecisionScope map[string]*([]*models.Decision) 769 | if decisionIsExpired { 770 | containerByDecisionScope = map[string]*([]*models.Decision){ 771 | "IP": &worker.ExpiredIPDecisions, 772 | "RANGE": &worker.ExpiredIPDecisions, // Cloudflare IP lists handle ranges fine 773 | "COUNTRY": &worker.ExpiredCountryDecisions, 774 | "AS": &worker.ExpiredASDecisions, 775 | } 776 | } else { 777 | containerByDecisionScope = map[string]*([]*models.Decision){ 778 | "IP": &worker.NewIPDecisions, 779 | "RANGE": &worker.NewIPDecisions, // Cloudflare IP lists handle ranges fine 780 | "COUNTRY": &worker.NewCountryDecisions, 781 | "AS": &worker.NewASDecisions, 782 | } 783 | } 784 | scope = strings.ToUpper(scope) 785 | if container, ok := containerByDecisionScope[scope]; !ok { 786 | return nil, fmt.Errorf("%s scope is not supported", scope) 787 | } else { 788 | return container, nil 789 | } 790 | } 791 | func (worker *CloudflareWorker) insertDecision(decision *models.Decision, decisionIsExpired bool) { 792 | container, err := worker.getContainerByDecisionScope(*decision.Scope, decisionIsExpired) 793 | if err != nil { 794 | worker.Logger.Debugf("ignored new decision with scope=%s, type=%s, value=%s", *decision.Scope, *decision.Type, *decision.Value) 795 | return 796 | } 797 | decisionStatus := "new" 798 | if decisionIsExpired { 799 | decisionStatus = "expired" 800 | } 801 | worker.Logger.Debugf("found %s decision with value=%s, scope=%s, type=%s", decisionStatus, *decision.Value, *decision.Scope, *decision.Type) 802 | *container = append(*container, decision) 803 | } 804 | 805 | func (worker *CloudflareWorker) CollectLAPIStream(streamDecision *models.DecisionsStreamResponse) { 806 | for _, decision := range streamDecision.New { 807 | worker.insertDecision(decision, false) 808 | } 809 | for _, decision := range streamDecision.Deleted { 810 | worker.insertDecision(decision, true) 811 | } 812 | } 813 | 814 | func (worker *CloudflareWorker) SendASBans() error { 815 | decisionsByAction := dedupAndClassifyDecisionsByAction(worker.NewASDecisions) 816 | for _, zoneCfg := range worker.Account.ZoneConfigs { 817 | zoneLogger := worker.Logger.WithFields(log.Fields{"zone_id": zoneCfg.ID}) 818 | for action, decisions := range decisionsByAction { 819 | action = worker.normalizeActionForZone(action, zoneCfg) 820 | for _, decision := range decisions { 821 | if _, ok := worker.CFStateByAction[action].AutonomousSystemSet[*decision.Value]; !ok { 822 | zoneLogger.Debugf("found new AS ban for %s", *decision.Value) 823 | worker.CFStateByAction[action].AutonomousSystemSet[*decision.Value] = struct{}{} 824 | } 825 | } 826 | } 827 | } 828 | worker.NewASDecisions = make([]*models.Decision, 0) 829 | return nil 830 | } 831 | 832 | func (worker *CloudflareWorker) DeleteASBans() error { 833 | decisionsByAction := dedupAndClassifyDecisionsByAction(worker.ExpiredASDecisions) 834 | for _, zoneCfg := range worker.Account.ZoneConfigs { 835 | zoneLogger := worker.Logger.WithFields(log.Fields{"zone_id": zoneCfg.ID}) 836 | for action, decisions := range decisionsByAction { 837 | action = worker.normalizeActionForZone(action, zoneCfg) 838 | for _, decision := range decisions { 839 | if _, ok := worker.CFStateByAction[action].AutonomousSystemSet[*decision.Value]; ok { 840 | zoneLogger.Debugf("found expired AS ban for %s", *decision.Value) 841 | delete(worker.CFStateByAction[action].AutonomousSystemSet, *decision.Value) 842 | } 843 | } 844 | } 845 | } 846 | worker.ExpiredASDecisions = make([]*models.Decision, 0) 847 | return nil 848 | } 849 | 850 | func keepLatestNIPSetItems(set map[string]IPSetItem, n int) (map[string]IPSetItem, int) { 851 | currentItems := len(set) 852 | if currentItems <= n { 853 | return set, 0 854 | } 855 | newSet := make(map[string]IPSetItem) 856 | itemsCreationTime := make([]time.Time, len(set)) 857 | i := 0 858 | for _, val := range set { 859 | itemsCreationTime[i] = val.CreatedAt 860 | i++ 861 | } 862 | // We use this to find the cutoff duration. This can be improved using more 863 | // sophisticated algo at cost of more code. 864 | sort.Slice(itemsCreationTime, func(i, j int) bool { 865 | return itemsCreationTime[i].After(itemsCreationTime[j]) 866 | }) 867 | dropCount := 0 868 | tc := 0 869 | for ip, item := range set { 870 | if item.CreatedAt.After(itemsCreationTime[n-1]) || item.CreatedAt.Equal(itemsCreationTime[n-1]) { 871 | newSet[ip] = item 872 | tc++ 873 | } else { 874 | dropCount++ 875 | } 876 | if tc == n { 877 | break 878 | } 879 | } 880 | 881 | return newSet, dropCount 882 | } 883 | 884 | func (worker *CloudflareWorker) normalizeActionForZone(action string, zoneCfg cfg.ZoneConfig) string { 885 | zoneLogger := worker.Logger.WithFields(log.Fields{"zone_id": zoneCfg.ID}) 886 | if _, spAction := zoneCfg.ActionSet[action]; action == "defaulted" || !spAction { 887 | if action != "defaulted" { 888 | zoneLogger.Debugf("defaulting %s action to %s action", action, zoneCfg.Actions[0]) 889 | } 890 | action = zoneCfg.Actions[0] 891 | } 892 | return action 893 | } 894 | 895 | func (worker *CloudflareWorker) SendCountryBans() error { 896 | decisionsByAction := dedupAndClassifyDecisionsByAction(worker.NewCountryDecisions) 897 | for _, zoneCfg := range worker.Account.ZoneConfigs { 898 | zoneLogger := worker.Logger.WithFields(log.Fields{"zone_id": zoneCfg.ID}) 899 | for action, decisions := range decisionsByAction { 900 | action = worker.normalizeActionForZone(action, zoneCfg) 901 | for _, decision := range decisions { 902 | if _, ok := worker.CFStateByAction[action].CountrySet[*decision.Value]; !ok { 903 | zoneLogger.Debugf("found new country ban for %s", *decision.Value) 904 | worker.CFStateByAction[action].CountrySet[*decision.Value] = struct{}{} 905 | } 906 | } 907 | } 908 | } 909 | worker.NewCountryDecisions = make([]*models.Decision, 0) 910 | return nil 911 | } 912 | 913 | func (worker *CloudflareWorker) DeleteCountryBans() error { 914 | decisionsByAction := dedupAndClassifyDecisionsByAction(worker.ExpiredCountryDecisions) 915 | for _, zoneCfg := range worker.Account.ZoneConfigs { 916 | zoneLogger := worker.Logger.WithFields(log.Fields{"zone_id": zoneCfg.ID}) 917 | for action, decisions := range decisionsByAction { 918 | action = worker.normalizeActionForZone(action, zoneCfg) 919 | for _, decision := range decisions { 920 | if _, ok := worker.CFStateByAction[action].CountrySet[*decision.Value]; ok { 921 | zoneLogger.Debugf("found expired country ban for %s", *decision.Value) 922 | delete(worker.CFStateByAction[action].CountrySet, *decision.Value) 923 | } 924 | } 925 | } 926 | } 927 | worker.ExpiredCountryDecisions = make([]*models.Decision, 0) 928 | return nil 929 | } 930 | 931 | func (worker *CloudflareWorker) UpdateRules() error { 932 | for action, state := range worker.CFStateByAction { 933 | if !worker.CFStateByAction[action].UpdateExpr() { 934 | // expression is still same, why bother. 935 | worker.Logger.Debugf("rule for %s action is unchanged", action) 936 | continue 937 | } 938 | for _, zone := range worker.Account.ZoneConfigs { 939 | zoneLogger := worker.Logger.WithFields(log.Fields{"zone_id": zone.ID}) 940 | updatedFilters := make([]cloudflare.Filter, 0) 941 | if _, ok := zone.ActionSet[action]; ok { 942 | // check whether this action is supported by this zone 943 | updatedFilters = append(updatedFilters, cloudflare.Filter{ID: state.FilterIDByZoneID[zone.ID], Expression: state.CurrExpr}) 944 | } 945 | if len(updatedFilters) > 0 { 946 | zoneLogger.Infof("updating %d rules", len(updatedFilters)) 947 | _, err := worker.getAPI().UpdateFilters(worker.Ctx, zone.ID, updatedFilters) 948 | if err != nil { 949 | return err 950 | } 951 | } else { 952 | zoneLogger.Debug("rules are same") 953 | } 954 | } 955 | } 956 | return nil 957 | } 958 | 959 | func (worker *CloudflareWorker) runProcessorOnDecisions(processor func() error, decisions []*models.Decision) { 960 | if len(decisions) == 0 { 961 | return 962 | } 963 | worker.Logger.Infof("processing decisions with scope=%s", *decisions[0].Scope) 964 | err := processor() 965 | if err != nil { 966 | worker.Logger.Error(err) 967 | } 968 | worker.Logger.Infof("done processing decisions with scope=%s", *decisions[0].Scope) 969 | 970 | } 971 | 972 | func (worker *CloudflareWorker) Run() error { 973 | err := worker.Init() 974 | if err != nil { 975 | worker.Logger.Error(err.Error()) 976 | return err 977 | } 978 | 979 | err = worker.SetUpCloudflareResources() 980 | if err != nil { 981 | worker.Logger.Error(err.Error()) 982 | return err 983 | } 984 | 985 | ticker := time.NewTicker(worker.UpdateFrequency) 986 | for { 987 | select { 988 | case <-worker.Ctx.Done(): 989 | // worker.Logger.Info("context cancelled") 990 | return worker.Ctx.Err() 991 | case <-ticker.C: 992 | worker.runProcessorOnDecisions(worker.UpdateIPLists, append(worker.NewIPDecisions, worker.ExpiredIPDecisions...)) 993 | worker.runProcessorOnDecisions(worker.DeleteCountryBans, worker.ExpiredCountryDecisions) 994 | worker.runProcessorOnDecisions(worker.SendCountryBans, worker.NewCountryDecisions) 995 | worker.runProcessorOnDecisions(worker.DeleteASBans, worker.ExpiredASDecisions) 996 | worker.runProcessorOnDecisions(worker.SendASBans, worker.NewASDecisions) 997 | 998 | err = worker.UpdateRules() 999 | if err != nil { 1000 | worker.Logger.Error(err) 1001 | return err 1002 | } 1003 | 1004 | case decisions := <-worker.LAPIStream: 1005 | worker.Logger.Debug("collecting decisions from LAPI") 1006 | worker.CollectLAPIStream(decisions) 1007 | } 1008 | } 1009 | 1010 | } 1011 | -------------------------------------------------------------------------------- /pkg/cf/cloudflare_test.go: -------------------------------------------------------------------------------- 1 | package cf 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/cloudflare/cloudflare-go" 12 | "github.com/prometheus/client_golang/prometheus" 13 | "github.com/prometheus/client_golang/prometheus/promauto" 14 | log "github.com/sirupsen/logrus" 15 | "github.com/stretchr/testify/require" 16 | "golang.org/x/exp/maps" 17 | 18 | "github.com/crowdsecurity/crowdsec/pkg/models" 19 | 20 | "github.com/crowdsecurity/cs-cloudflare-bouncer/pkg/cfg" 21 | ) 22 | 23 | var mockAPICallCounter uint32 = 0 24 | 25 | type mockCloudflareAPI struct { 26 | IPLists []cloudflare.IPList 27 | FirewallRulesList []cloudflare.FirewallRule 28 | FilterList []cloudflare.Filter 29 | IPListItems map[string][]cloudflare.IPListItem 30 | ZoneList []cloudflare.Zone 31 | } 32 | 33 | func (cfAPI *mockCloudflareAPI) Filters(ctx context.Context, zoneID string, pageOpts cloudflare.PaginationOptions) ([]cloudflare.Filter, error) { 34 | return cfAPI.FilterList, nil 35 | } 36 | 37 | func (cfAPI *mockCloudflareAPI) ListZones(ctx context.Context, z ...string) ([]cloudflare.Zone, error) { 38 | return cfAPI.ZoneList, nil 39 | } 40 | 41 | func (cfAPI *mockCloudflareAPI) CreateIPList(ctx context.Context, accountID string, name string, desc string, typ string) (cloudflare.IPList, error) { 42 | ipList := cloudflare.IPList{ID: strconv.Itoa(len(cfAPI.IPLists))} 43 | cfAPI.IPLists = append(cfAPI.IPLists, ipList) 44 | return ipList, nil 45 | } 46 | 47 | func (cfAPI *mockCloudflareAPI) DeleteIPList(ctx context.Context, accountID string, id string) (cloudflare.IPListDeleteResponse, error) { 48 | for i, j := range cfAPI.IPLists { 49 | if j.ID == id { 50 | cfAPI.IPLists = append(cfAPI.IPLists[:i], cfAPI.IPLists[i+1:]...) 51 | break 52 | } 53 | } 54 | return cloudflare.IPListDeleteResponse{}, nil 55 | } 56 | 57 | func (cfAPI *mockCloudflareAPI) ListIPLists(ctx context.Context, accountID string) ([]cloudflare.IPList, error) { 58 | return cfAPI.IPLists, nil 59 | } 60 | 61 | func (cfAPI *mockCloudflareAPI) CreateFirewallRules(ctx context.Context, zone string, rules []cloudflare.FirewallRule) ([]cloudflare.FirewallRule, error) { 62 | cfAPI.FirewallRulesList = append(cfAPI.FirewallRulesList, rules...) 63 | for i := range cfAPI.FirewallRulesList { 64 | cfAPI.FirewallRulesList[i].ID = strconv.Itoa(i) 65 | cfAPI.FirewallRulesList[i].Filter.ID = strconv.Itoa(i) 66 | } 67 | return rules, nil 68 | } 69 | func (cfAPI *mockCloudflareAPI) DeleteFirewallRule(ctx context.Context, zone string, id string) error { 70 | for i, j := range cfAPI.FirewallRulesList { 71 | if j.ID == id { 72 | cfAPI.FirewallRulesList = append(cfAPI.FirewallRulesList[:i], cfAPI.FirewallRulesList[i+1:]...) 73 | break 74 | } 75 | } 76 | return nil 77 | } 78 | func (cfAPI *mockCloudflareAPI) DeleteFirewallRules(ctx context.Context, zoneID string, firewallRuleIDs []string) error { 79 | for _, rule := range firewallRuleIDs { 80 | if err := cfAPI.DeleteFirewallRule(ctx, zoneID, rule); err != nil { 81 | return err 82 | } 83 | } 84 | return nil 85 | } 86 | func (cfAPI *mockCloudflareAPI) DeleteFilter(ctx context.Context, zone string, id string) error { 87 | for i, j := range cfAPI.FilterList { 88 | if j.ID == id { 89 | cfAPI.FilterList = append(cfAPI.FilterList[:i], cfAPI.FilterList[i+1:]...) 90 | break 91 | } 92 | } 93 | return nil 94 | } 95 | 96 | func (cfAPI *mockCloudflareAPI) DeleteFilters(ctx context.Context, zoneID string, filterIDs []string) error { 97 | for _, filterID := range filterIDs { 98 | if err := cfAPI.DeleteFilter(ctx, zoneID, filterID); err != nil { 99 | return err 100 | } 101 | } 102 | return nil 103 | } 104 | 105 | func (cfAPI *mockCloudflareAPI) DeleteIPListItems(ctx context.Context, accountID string, id string, items cloudflare.IPListItemDeleteRequest) ([]cloudflare.IPListItem, error) { 106 | for j := range cfAPI.IPLists { 107 | if cfAPI.IPLists[j].ID == id { 108 | cfAPI.IPLists[j].NumItems -= len(items.Items) 109 | break 110 | } 111 | } 112 | rm := make([]bool, len(cfAPI.IPListItems[id])) 113 | for _, item := range items.Items { 114 | for j, currItem := range cfAPI.IPListItems[id] { 115 | if currItem.ID == item.ID { 116 | rm[j] = true 117 | } 118 | } 119 | } 120 | newItems := make([]cloudflare.IPListItem, 0) 121 | for i, item := range cfAPI.IPListItems[id] { 122 | if !rm[i] { 123 | newItems = append(newItems, item) 124 | } 125 | } 126 | cfAPI.IPListItems[id] = newItems 127 | return cfAPI.IPListItems[id], nil 128 | } 129 | 130 | func (cfAPI *mockCloudflareAPI) ListIPListItems(ctx context.Context, accountID string, id string) ([]cloudflare.IPListItem, error) { 131 | return []cloudflare.IPListItem{ 132 | {ID: "1234"}, 133 | }, nil 134 | } 135 | 136 | func (cfAPI *mockCloudflareAPI) UpdateFilters(ctx context.Context, zoneID string, filters []cloudflare.Filter) ([]cloudflare.Filter, error) { 137 | for _, f := range filters { 138 | for j := range cfAPI.FilterList { 139 | if cfAPI.FilterList[j].ID == f.ID { 140 | cfAPI.FilterList[j] = f 141 | } 142 | } 143 | } 144 | return cfAPI.FilterList, nil 145 | } 146 | 147 | func (cfAPI *mockCloudflareAPI) FirewallRules(ctx context.Context, zone string, opts cloudflare.PaginationOptions) ([]cloudflare.FirewallRule, error) { 148 | return cfAPI.FirewallRulesList, nil 149 | } 150 | 151 | func (cfAPI *mockCloudflareAPI) GetIPListBulkOperation(ctx context.Context, accountID string, id string) (cloudflare.IPListBulkOperation, error) { 152 | return cloudflare.IPListBulkOperation{Status: "completed"}, nil 153 | } 154 | 155 | func (cfAPI *mockCloudflareAPI) ReplaceIPListItemsAsync(ctx context.Context, accountID string, id string, items []cloudflare.IPListItemCreateRequest) (cloudflare.IPListItemCreateResponse, error) { 156 | IPItems := make([]cloudflare.IPListItem, len(items)) 157 | for j := range cfAPI.IPLists { 158 | if cfAPI.IPLists[j].ID == id { 159 | cfAPI.IPLists[j].NumItems += len(items) 160 | break 161 | } 162 | } 163 | for i := range items { 164 | IPItems[i] = cloudflare.IPListItem{IP: items[i].IP} 165 | } 166 | cfAPI.IPListItems[id] = IPItems 167 | return cloudflare.IPListItemCreateResponse{}, nil 168 | } 169 | 170 | var dummyCFAccount = cfg.AccountConfig{ 171 | ID: "dummyID", 172 | ZoneConfigs: []cfg.ZoneConfig{ 173 | { 174 | ID: "zone1", 175 | Actions: []string{"block"}, 176 | }, 177 | }, 178 | IPListPrefix: "crowdsec", 179 | DefaultAction: "block", 180 | TotalIPListCapacity: &cfg.TotalIPListCapacity, 181 | } 182 | 183 | var mockCfAPI cloudflareAPI = &mockCloudflareAPI{ 184 | IPLists: []cloudflare.IPList{{ 185 | ID: "11", Name: "crowdsec_block", Description: "already", CreatedOn: &time.Time{}}, 186 | {ID: "12", Name: "crowd", CreatedOn: &time.Time{}}, 187 | }, 188 | FirewallRulesList: []cloudflare.FirewallRule{ 189 | {Filter: cloudflare.Filter{Expression: "ip in $crowdsec_block"}}, 190 | {Filter: cloudflare.Filter{Expression: "ip in $dummy"}}}, 191 | ZoneList: []cloudflare.Zone{ 192 | {ID: "zone1"}, 193 | }, 194 | IPListItems: make(map[string][]cloudflare.IPListItem), 195 | } 196 | 197 | func TestIPFirewallSetUp(t *testing.T) { 198 | ctx := context.Background() 199 | wg := sync.WaitGroup{} 200 | wg.Add(1) 201 | worker := CloudflareWorker{ 202 | API: mockCfAPI, 203 | Account: dummyCFAccount, 204 | Count: prometheus.NewCounter(prometheus.CounterOpts{}), 205 | TokenCallCount: &mockAPICallCounter, 206 | } 207 | 208 | err := worker.Init() 209 | require.NoError(t, err) 210 | 211 | err = worker.SetUpCloudflareResources() 212 | require.NoError(t, err) 213 | 214 | ipLists, err := mockCfAPI.ListIPLists(ctx, "") 215 | require.NoError(t, err) 216 | require.Len(t, ipLists, 2) 217 | 218 | require.Empty(t, ipLists[1].Description, "old iplist exists") 219 | 220 | fr, err := mockCfAPI.FirewallRules(ctx, "", cloudflare.PaginationOptions{}) 221 | require.NoError(t, err) 222 | require.Len(t, fr, 3) 223 | } 224 | 225 | func TestCollectLAPIStream(t *testing.T) { 226 | wg := sync.WaitGroup{} 227 | wg.Add(1) 228 | ip1 := "1.2.3.4" 229 | ip2 := "1.2.3.5" 230 | scope := "ip" 231 | a := "ban" 232 | 233 | addedDecisions := &models.Decision{Value: &ip1, Scope: &scope, Type: &a} 234 | deletedDecisions := &models.Decision{Value: &ip2, Scope: &scope, Type: &a} 235 | 236 | dummyResponse := &models.DecisionsStreamResponse{ 237 | New: []*models.Decision{addedDecisions}, 238 | Deleted: []*models.Decision{deletedDecisions}, 239 | } 240 | worker := CloudflareWorker{ 241 | Account: dummyCFAccount, 242 | API: mockCfAPI, 243 | Count: prometheus.NewCounter(prometheus.CounterOpts{}), 244 | TokenCallCount: &mockAPICallCounter, 245 | } 246 | 247 | err := worker.Init() 248 | require.NoError(t, err) 249 | 250 | err = worker.createMissingIPLists() 251 | require.NoError(t, err) 252 | 253 | worker.CollectLAPIStream(dummyResponse) 254 | require.Len(t, worker.NewIPDecisions, 1) 255 | require.Len(t, worker.ExpiredIPDecisions, 1) 256 | } 257 | 258 | func Test_setToExprList(t *testing.T) { 259 | type args struct { 260 | set map[string]struct{} 261 | quotes bool 262 | } 263 | tests := []struct { 264 | name string 265 | args args 266 | want string 267 | }{ 268 | { 269 | name: "unquoted", 270 | args: args{ 271 | set: map[string]struct{}{ 272 | "1.2.3.4": {}, 273 | "6.7.8.9": {}, 274 | }, 275 | quotes: false, 276 | }, 277 | want: `{1.2.3.4 6.7.8.9}`, 278 | }, 279 | { 280 | name: "quoted", 281 | args: args{ 282 | set: map[string]struct{}{ 283 | "US": {}, 284 | "UK": {}, 285 | }, 286 | quotes: true, 287 | }, 288 | want: `{"UK" "US"}`, 289 | }, 290 | } 291 | for _, tt := range tests { 292 | t.Run(tt.name, func(t *testing.T) { 293 | if got := setToExprList(tt.args.set, tt.args.quotes); got != tt.want { 294 | t.Errorf("setToExprList() = %v, want %v", got, tt.want) 295 | } 296 | }) 297 | } 298 | } 299 | 300 | func TestCloudflareState_computeExpression(t *testing.T) { 301 | type fields struct { 302 | ipListState IPListState 303 | countrySet map[string]struct{} 304 | autonomousSystemSet map[string]struct{} 305 | } 306 | tests := []struct { 307 | name string 308 | fields fields 309 | want string 310 | }{ 311 | { 312 | name: "all null", 313 | fields: fields{}, 314 | want: "", 315 | }, 316 | { 317 | name: "only country", 318 | fields: fields{countrySet: map[string]struct{}{ 319 | "US": {}, 320 | "UK": {}, 321 | }}, 322 | want: `(ip.geoip.country in {"UK" "US"})`, 323 | }, 324 | { 325 | name: "only ip list", 326 | fields: fields{ipListState: IPListState{IPList: &cloudflare.IPList{Name: "crowdsec_block"}}}, 327 | want: `(ip.src in $crowdsec_block)`, 328 | }, 329 | { 330 | name: "ip list + as ban", 331 | fields: fields{ 332 | ipListState: IPListState{IPList: &cloudflare.IPList{Name: "crowdsec_block"}}, 333 | autonomousSystemSet: map[string]struct{}{"1234": {}, "5432": {}}, 334 | }, 335 | want: `(ip.geoip.asnum in {1234 5432}) or (ip.src in $crowdsec_block)`, 336 | }, 337 | { 338 | name: "ip list + as ban + country", 339 | fields: fields{ 340 | ipListState: IPListState{IPList: &cloudflare.IPList{Name: "crowdsec_block"}}, 341 | autonomousSystemSet: map[string]struct{}{"1234": {}, "5432": {}}, 342 | countrySet: map[string]struct{}{"US": {}, "UK": {}}, 343 | }, 344 | want: `(ip.geoip.country in {"UK" "US"}) or (ip.geoip.asnum in {1234 5432}) or (ip.src in $crowdsec_block)`, 345 | }, 346 | } 347 | for _, tt := range tests { 348 | t.Run(tt.name, func(t *testing.T) { 349 | cfState := &CloudflareState{ 350 | IPListState: tt.fields.ipListState, 351 | CountrySet: tt.fields.countrySet, 352 | AutonomousSystemSet: tt.fields.autonomousSystemSet, 353 | } 354 | if got := cfState.computeExpression(); got != tt.want { 355 | t.Errorf("CloudflareState.computeExpression() = %v, want %v", got, tt.want) 356 | } 357 | }) 358 | } 359 | } 360 | 361 | func Test_classifyDecisionsByAction(t *testing.T) { 362 | ip1 := "1.2.3.4" 363 | ip2 := "1.2.3.5" 364 | 365 | captcha := "captcha" 366 | ban := "ban" 367 | random := "random" 368 | 369 | decision1 := models.Decision{Value: &ip1, Type: &ban} 370 | decision2 := models.Decision{Value: &ip2, Type: &captcha} 371 | decision2dup := models.Decision{Value: &ip2, Type: &ban} 372 | decisionUnsup := models.Decision{Value: &ip2, Type: &random} 373 | 374 | type args struct { 375 | decisions []*models.Decision 376 | } 377 | tests := []struct { 378 | name string 379 | args args 380 | want map[string][]*models.Decision 381 | }{ 382 | { 383 | name: "all supported, no dups", 384 | args: args{decisions: []*models.Decision{&decision1, &decision2}}, 385 | want: map[string][]*models.Decision{ 386 | "defaulted": {}, 387 | "block": { 388 | &decision1, 389 | }, 390 | "managed_challenge": { 391 | &decision2, 392 | }, 393 | }, 394 | }, 395 | { 396 | name: "with dups, all supported", 397 | args: args{decisions: []*models.Decision{&decision2, &decision2dup}}, 398 | want: map[string][]*models.Decision{ 399 | "defaulted": {}, 400 | "managed_challenge": {&decision2}, 401 | }, 402 | }, 403 | { 404 | name: "unsupported, no dups", 405 | args: args{decisions: []*models.Decision{&decision1, &decisionUnsup}}, 406 | want: map[string][]*models.Decision{ 407 | "defaulted": { 408 | &decisionUnsup, 409 | }, 410 | "block": { 411 | &decision1, 412 | }, 413 | }, 414 | }, 415 | { 416 | name: "unsupported with dups", 417 | args: args{ 418 | decisions: []*models.Decision{&decisionUnsup, &decision1, &decision2}, 419 | }, 420 | want: map[string][]*models.Decision{ 421 | "defaulted": {}, 422 | "block": {&decision1}, 423 | "managed_challenge": {&decision2}, 424 | }, 425 | }, 426 | } 427 | for _, tt := range tests { 428 | t.Run(tt.name, func(t *testing.T) { 429 | got := dedupAndClassifyDecisionsByAction(tt.args.decisions) 430 | require.Equal(t, tt.want, got) 431 | }) 432 | } 433 | } 434 | 435 | func Test_normalizeIP(t *testing.T) { 436 | type args struct { 437 | ip string 438 | } 439 | tests := []struct { 440 | name string 441 | args args 442 | want string 443 | }{ 444 | { 445 | name: "simple ipv4", 446 | args: args{ 447 | ip: "1.2.3.4", 448 | }, 449 | want: "1.2.3.4", 450 | }, 451 | { 452 | name: "full ipv6 must be shortened to /64 form", 453 | args: args{ 454 | ip: "2001:0db8:85a3:0000:0000:8a2e:0370:7334", 455 | }, 456 | want: "2001:db8:85a3::/64", 457 | }, 458 | { 459 | name: "full ipv6 in shortform must be converted to subnet form", 460 | args: args{ 461 | ip: "2001::", 462 | }, 463 | want: "2001::/64", 464 | }, 465 | { 466 | name: "full ipv6 with cidr should be made to atlease /64 form", 467 | args: args{ 468 | ip: "2001:0db8:85a3:0000:0000:8a2e:0370:7334/65", 469 | }, 470 | want: "2001:db8:85a3::/64", 471 | }, 472 | { 473 | name: "ipv6 shortform, but has valid tail", 474 | args: args{ 475 | ip: "2600:3c02::f03c:92ff:fe65:f0ff", // 2600:3c02:0000:0000:f03c:92ff:fe65:f0ff 476 | }, 477 | want: "2600:3c02::/64", 478 | }, 479 | } 480 | for _, tt := range tests { 481 | t.Run(tt.name, func(t *testing.T) { 482 | if got := normalizeDecisionValue(tt.args.ip); got != tt.want { 483 | t.Errorf("normalizeIP() = %v, want %v", got, tt.want) 484 | } 485 | }) 486 | } 487 | } 488 | 489 | func TestCloudflareWorker_SendASBans(t *testing.T) { 490 | ASNum1 := "1234" 491 | ASNum2 := "1235" 492 | 493 | action := "block" 494 | unSupAction := "toto" 495 | 496 | type fields struct { 497 | CFStateByAction map[string]*CloudflareState 498 | NewASDecisions []*models.Decision 499 | } 500 | tests := []struct { 501 | name string 502 | fields fields 503 | want []string 504 | }{ 505 | { 506 | name: "simple supported decision", 507 | fields: fields{ 508 | NewASDecisions: []*models.Decision{{Value: &ASNum1, Type: &action}}, 509 | }, 510 | want: []string{"1234"}, 511 | }, 512 | { 513 | name: "simple supported multiple decisions without duplicates", 514 | fields: fields{ 515 | NewASDecisions: []*models.Decision{ 516 | {Value: &ASNum1, Type: &action}, 517 | {Value: &ASNum2, Type: &action}, 518 | }, 519 | }, 520 | want: []string{"1234", "1235"}, 521 | }, 522 | { 523 | name: "unsupported decision should be defaulted ", 524 | fields: fields{ 525 | NewASDecisions: []*models.Decision{ 526 | {Value: &ASNum1, Type: &unSupAction}, 527 | }, 528 | }, 529 | want: []string{"1234"}, 530 | }, 531 | } 532 | for _, tt := range tests { 533 | t.Run(tt.name, func(t *testing.T) { 534 | worker := &CloudflareWorker{ 535 | CFStateByAction: tt.fields.CFStateByAction, 536 | NewASDecisions: tt.fields.NewASDecisions, 537 | Logger: log.WithFields(log.Fields{"account_id": "test worker"}), 538 | TokenCallCount: &mockAPICallCounter, 539 | } 540 | worker.CFStateByAction = make(map[string]*CloudflareState) 541 | worker.Account = dummyCFAccount 542 | worker.CFStateByAction[action] = &CloudflareState{AutonomousSystemSet: make(map[string]struct{})} 543 | err := worker.SendASBans() 544 | require.NoError(t, err) 545 | require.ElementsMatch(t, tt.want, maps.Keys(worker.CFStateByAction[action].AutonomousSystemSet)) 546 | }) 547 | } 548 | } 549 | 550 | func TestCloudflareWorker_DeleteASBans(t *testing.T) { 551 | ASNum1 := "1234" 552 | // ASNum2 := "1235" 553 | 554 | action := "block" 555 | // unSupAction := "toto" 556 | 557 | type fields struct { 558 | CFStateByAction map[string]*CloudflareState 559 | ExpiredASDecisions []*models.Decision 560 | } 561 | tests := []struct { 562 | name string 563 | fields fields 564 | want []string 565 | }{ 566 | { 567 | name: "simple delete AS", 568 | fields: fields{ 569 | CFStateByAction: map[string]*CloudflareState{ 570 | action: { 571 | AutonomousSystemSet: map[string]struct{}{"1234": {}, "1236": {}}, 572 | }, 573 | }, 574 | ExpiredASDecisions: []*models.Decision{{Value: &ASNum1, Type: &action}}, 575 | }, 576 | want: []string{"1236"}, 577 | }, 578 | { 579 | name: "delete something that does not exist", 580 | fields: fields{ 581 | CFStateByAction: map[string]*CloudflareState{ 582 | action: { 583 | AutonomousSystemSet: map[string]struct{}{"1235": {}}, 584 | }, 585 | }, 586 | ExpiredASDecisions: []*models.Decision{{Value: &ASNum1, Type: &action}}, 587 | }, 588 | want: []string{"1235"}, 589 | }, 590 | { 591 | name: "delete something multiple times", 592 | fields: fields{ 593 | CFStateByAction: map[string]*CloudflareState{ 594 | action: { 595 | AutonomousSystemSet: map[string]struct{}{"1234": {}, "9999": {}}, 596 | }, 597 | }, 598 | ExpiredASDecisions: []*models.Decision{{Value: &ASNum1, Type: &action}, {Value: &ASNum1, Type: &action}, {Value: &ASNum1, Type: &action}}, 599 | }, 600 | want: []string{"9999"}, 601 | }, 602 | } 603 | for _, tt := range tests { 604 | t.Run(tt.name, func(t *testing.T) { 605 | worker := &CloudflareWorker{ 606 | CFStateByAction: tt.fields.CFStateByAction, 607 | ExpiredASDecisions: tt.fields.ExpiredASDecisions, 608 | Logger: log.WithFields(log.Fields{"account_id": "test worker"}), 609 | TokenCallCount: &mockAPICallCounter, 610 | } 611 | worker.Account = dummyCFAccount 612 | err := worker.DeleteASBans() 613 | require.NoError(t, err) 614 | require.ElementsMatch(t, tt.want, maps.Keys(worker.CFStateByAction[action].AutonomousSystemSet)) 615 | }) 616 | } 617 | } 618 | 619 | func TestCloudflareWorker_SendCountryBans(t *testing.T) { 620 | Country1 := "IN" 621 | Country2 := "CH" 622 | 623 | action := "block" 624 | unSupAction := "toto" 625 | 626 | type fields struct { 627 | CFStateByAction map[string]*CloudflareState 628 | NewCountryDecisions []*models.Decision 629 | } 630 | tests := []struct { 631 | name string 632 | fields fields 633 | want []string 634 | }{ 635 | { 636 | name: "simple supported decision", 637 | fields: fields{ 638 | NewCountryDecisions: []*models.Decision{{Value: &Country1, Type: &action}}, 639 | }, 640 | want: []string{"IN"}, 641 | }, 642 | { 643 | name: "simple supported multiple decisions without duplicates", 644 | fields: fields{ 645 | NewCountryDecisions: []*models.Decision{ 646 | {Value: &Country1, Type: &action}, 647 | {Value: &Country2, Type: &action}, 648 | }, 649 | }, 650 | want: []string{"IN", "CH"}, 651 | }, 652 | { 653 | name: "unsupported decision should be defaulted ", 654 | fields: fields{ 655 | NewCountryDecisions: []*models.Decision{ 656 | {Value: &Country1, Type: &unSupAction}, 657 | }, 658 | }, 659 | want: []string{"IN"}, 660 | }, 661 | } 662 | for _, tt := range tests { 663 | t.Run(tt.name, func(t *testing.T) { 664 | worker := &CloudflareWorker{ 665 | CFStateByAction: tt.fields.CFStateByAction, 666 | NewCountryDecisions: tt.fields.NewCountryDecisions, 667 | Logger: log.WithFields(log.Fields{"account_id": "test worker"}), 668 | TokenCallCount: &mockAPICallCounter, 669 | } 670 | worker.CFStateByAction = make(map[string]*CloudflareState) 671 | worker.Account = dummyCFAccount 672 | worker.CFStateByAction[action] = &CloudflareState{CountrySet: make(map[string]struct{})} 673 | err := worker.SendCountryBans() 674 | require.NoError(t, err) 675 | require.ElementsMatch(t, tt.want, maps.Keys(worker.CFStateByAction[action].CountrySet)) 676 | }) 677 | } 678 | } 679 | 680 | func TestCloudflareWorker_DeleteCountryBans(t *testing.T) { 681 | Country1 := "UK" 682 | action := "block" 683 | 684 | type fields struct { 685 | CFStateByAction map[string]*CloudflareState 686 | ExpiredCountryDecisions []*models.Decision 687 | } 688 | tests := []struct { 689 | name string 690 | fields fields 691 | want []string 692 | }{ 693 | { 694 | name: "simple delete AS", 695 | fields: fields{ 696 | CFStateByAction: map[string]*CloudflareState{ 697 | action: { 698 | CountrySet: map[string]struct{}{"UK": {}, "1236": {}}, 699 | }, 700 | }, 701 | ExpiredCountryDecisions: []*models.Decision{{Value: &Country1, Type: &action}}, 702 | }, 703 | want: []string{"1236"}, 704 | }, 705 | { 706 | name: "delete something that does not exist", 707 | fields: fields{ 708 | CFStateByAction: map[string]*CloudflareState{ 709 | action: { 710 | CountrySet: map[string]struct{}{"1235": {}}, 711 | }, 712 | }, 713 | ExpiredCountryDecisions: []*models.Decision{{Value: &Country1, Type: &action}}, 714 | }, 715 | want: []string{"1235"}, 716 | }, 717 | { 718 | name: "delete something multiple times", 719 | fields: fields{ 720 | CFStateByAction: map[string]*CloudflareState{ 721 | action: { 722 | CountrySet: map[string]struct{}{"UK": {}, "9999": {}}, 723 | }, 724 | }, 725 | ExpiredCountryDecisions: []*models.Decision{{Value: &Country1, Type: &action}, {Value: &Country1, Type: &action}, {Value: &Country1, Type: &action}}, 726 | }, 727 | want: []string{"9999"}, 728 | }, 729 | { 730 | name: "ipv6 dups", 731 | fields: fields{ 732 | CFStateByAction: map[string]*CloudflareState{ 733 | action: { 734 | CountrySet: map[string]struct{}{"UK": {}, "9999": {}}, 735 | }, 736 | }, 737 | ExpiredCountryDecisions: []*models.Decision{{Value: &Country1, Type: &action}, {Value: &Country1, Type: &action}, {Value: &Country1, Type: &action}}, 738 | }, 739 | want: []string{"9999"}, 740 | }, 741 | } 742 | for _, tt := range tests { 743 | t.Run(tt.name, func(t *testing.T) { 744 | worker := &CloudflareWorker{ 745 | CFStateByAction: tt.fields.CFStateByAction, 746 | ExpiredCountryDecisions: tt.fields.ExpiredCountryDecisions, 747 | Logger: log.WithFields(log.Fields{"account_id": "test worker"}), 748 | TokenCallCount: &mockAPICallCounter, 749 | } 750 | worker.Account = dummyCFAccount 751 | err := worker.DeleteCountryBans() 752 | require.NoError(t, err) 753 | require.ElementsMatch(t, tt.want, maps.Keys(worker.CFStateByAction[action].CountrySet)) 754 | }) 755 | } 756 | } 757 | 758 | func Test_allZonesHaveAction(t *testing.T) { 759 | type args struct { 760 | zones []cfg.ZoneConfig 761 | action string 762 | } 763 | tests := []struct { 764 | name string 765 | args args 766 | want bool 767 | }{ 768 | { 769 | name: "true", 770 | args: args{ 771 | zones: []cfg.ZoneConfig{ 772 | { 773 | ActionSet: map[string]struct{}{ 774 | "block": {}, 775 | }, 776 | }, 777 | { 778 | ActionSet: map[string]struct{}{ 779 | "block": {}, 780 | }, 781 | }, 782 | }, 783 | action: "block", 784 | }, 785 | want: true, 786 | }, 787 | { 788 | name: "false", 789 | args: args{ 790 | zones: []cfg.ZoneConfig{ 791 | { 792 | ActionSet: map[string]struct{}{ 793 | "managed_challenge": {}, 794 | }, 795 | }, 796 | { 797 | ActionSet: map[string]struct{}{ 798 | "block": {}, 799 | }, 800 | }, 801 | }, 802 | action: "block", 803 | }, 804 | want: false, 805 | }, 806 | } 807 | for _, tt := range tests { 808 | t.Run(tt.name, func(t *testing.T) { 809 | if got := allZonesHaveAction(tt.args.zones, tt.args.action); got != tt.want { 810 | t.Errorf("allZonesHaveAction() = %v, want %v", got, tt.want) 811 | } 812 | }) 813 | } 814 | } 815 | 816 | func TestCloudflareWorker_AddNewIPs(t *testing.T) { 817 | // decision fixture 818 | ip1 := "1.2.3.4" 819 | action := "ban" 820 | scenario := "crowdsec/demo" 821 | randomAction := "foo" 822 | 823 | state := map[string]*CloudflareState{ 824 | "block": { 825 | AccountID: dummyCFAccount.ID, 826 | IPListState: IPListState{ 827 | IPSet: make(map[string]IPSetItem), 828 | IPList: &cloudflare.IPList{}, 829 | }, 830 | }, 831 | } 832 | 833 | type fields struct { 834 | Account cfg.AccountConfig 835 | CFStateByAction map[string]*CloudflareState 836 | NewIPDecisions []*models.Decision 837 | API cloudflareAPI 838 | } 839 | tests := []struct { 840 | name string 841 | fields fields 842 | want map[string]IPSetItem 843 | }{ 844 | { 845 | name: "supported ip decision", 846 | fields: fields{ 847 | Account: dummyCFAccount, 848 | CFStateByAction: state, 849 | NewIPDecisions: []*models.Decision{ 850 | {Value: &ip1, Type: &action, Scenario: &scenario}, 851 | }, 852 | API: mockCfAPI, 853 | }, 854 | want: map[string]IPSetItem{ 855 | "1.2.3.4": {}, 856 | }, 857 | }, 858 | { 859 | name: "unsupported ip decision", 860 | fields: fields{ 861 | Account: dummyCFAccount, 862 | CFStateByAction: state, 863 | NewIPDecisions: []*models.Decision{ 864 | {Value: &ip1, Type: &randomAction, Scenario: &scenario}, 865 | }, 866 | API: mockCfAPI, 867 | }, 868 | want: map[string]IPSetItem{ 869 | "1.2.3.4": {}, 870 | }, 871 | }, 872 | } 873 | 874 | for i, tt := range tests { 875 | t.Run(tt.name, func(t *testing.T) { 876 | worker := &CloudflareWorker{ 877 | Account: tt.fields.Account, 878 | CFStateByAction: tt.fields.CFStateByAction, 879 | NewIPDecisions: tt.fields.NewIPDecisions, 880 | API: mockCfAPI, 881 | Logger: log.WithFields(log.Fields{"account_id": "test worker"}), 882 | Count: promauto.NewCounter(prometheus.CounterOpts{Name: fmt.Sprintf("test%d", i), Help: "no help you're just a test"}), 883 | TokenCallCount: &mockAPICallCounter, 884 | } 885 | err := worker.UpdateIPLists() 886 | require.NoError(t, err) 887 | if !IPSetsAreEqual(tt.want, worker.CFStateByAction["block"].IPListState.IPSet) { 888 | t.Errorf("want=%+v, found=%+v", tt.want, worker.CFStateByAction["block"].IPListState.IPSet) 889 | } 890 | }) 891 | } 892 | } 893 | 894 | func TestCloudflareWorker_DeleteIPs(t *testing.T) { 895 | // decision fixture 896 | ip1 := "1.2.3.4" 897 | action := "ban" 898 | scenario := "crowdsec/demo" 899 | randomAction := "foo" 900 | 901 | state := map[string]*CloudflareState{ 902 | "block": { 903 | AccountID: dummyCFAccount.ID, 904 | IPListState: IPListState{ 905 | IPSet: map[string]IPSetItem{ 906 | "1.2.3.4": {}, 907 | "1.2.3.5": {}, 908 | }, 909 | IPList: &cloudflare.IPList{}, 910 | }, 911 | }, 912 | } 913 | 914 | type fields struct { 915 | Account cfg.AccountConfig 916 | CFStateByAction map[string]*CloudflareState 917 | ExpiredIPDecisions []*models.Decision 918 | API cloudflareAPI 919 | } 920 | tests := []struct { 921 | name string 922 | fields fields 923 | want map[string]IPSetItem 924 | }{ 925 | { 926 | name: "supported ip decision", 927 | fields: fields{ 928 | Account: dummyCFAccount, 929 | CFStateByAction: state, 930 | ExpiredIPDecisions: []*models.Decision{ 931 | {Value: &ip1, Type: &action, Scenario: &scenario}, 932 | }, 933 | API: mockCfAPI, 934 | }, 935 | want: map[string]IPSetItem{ 936 | "1.2.3.5": {}, 937 | }, 938 | }, 939 | { 940 | name: "unsupported ip decision", 941 | fields: fields{ 942 | Account: dummyCFAccount, 943 | CFStateByAction: state, 944 | ExpiredIPDecisions: []*models.Decision{ 945 | {Value: &ip1, Type: &randomAction, Scenario: &scenario}, 946 | }, 947 | API: mockCfAPI, 948 | }, 949 | want: map[string]IPSetItem{ 950 | "1.2.3.5": {}, 951 | }, 952 | }, 953 | } 954 | 955 | for i, tt := range tests { 956 | t.Run(tt.name, func(t *testing.T) { 957 | worker := &CloudflareWorker{ 958 | Account: tt.fields.Account, 959 | CFStateByAction: tt.fields.CFStateByAction, 960 | ExpiredIPDecisions: tt.fields.ExpiredIPDecisions, 961 | API: mockCfAPI, 962 | Logger: log.WithFields(log.Fields{"account_id": "test worker"}), 963 | Count: promauto.NewCounter(prometheus.CounterOpts{Name: fmt.Sprintf("test2%d", i), Help: "no help you're just a test"}), 964 | TokenCallCount: &mockAPICallCounter, 965 | } 966 | err := worker.UpdateIPLists() 967 | require.NoError(t, err) 968 | if !IPSetsAreEqual(tt.want, worker.CFStateByAction["block"].IPListState.IPSet) { 969 | t.Errorf("want=%+v, found=%+v", tt.want, worker.CFStateByAction["block"].IPListState.IPSet) 970 | } 971 | }) 972 | } 973 | } 974 | 975 | func timeForMonth(month time.Month) time.Time { 976 | return time.Date(2000, month, 1, 1, 1, 1, 1, time.UTC) 977 | } 978 | 979 | func Test_keepLatestNIPSetItems(t *testing.T) { 980 | type args struct { 981 | set map[string]IPSetItem 982 | n int 983 | } 984 | tests := []struct { 985 | name string 986 | args args 987 | want map[string]IPSetItem 988 | }{ 989 | { 990 | name: "regular", 991 | args: args{ 992 | set: map[string]IPSetItem{ 993 | "1.2.3.5": {CreatedAt: timeForMonth(time.May)}, 994 | "1.2.3.4": {CreatedAt: timeForMonth(time.April)}, 995 | "1.2.3.6": {CreatedAt: timeForMonth(time.March)}, 996 | }, 997 | n: 2, 998 | }, 999 | want: map[string]IPSetItem{ 1000 | "1.2.3.5": {CreatedAt: timeForMonth(time.May)}, 1001 | "1.2.3.4": {CreatedAt: timeForMonth(time.April)}, 1002 | }, 1003 | }, 1004 | { 1005 | name: "no items to drop", 1006 | args: args{ 1007 | set: map[string]IPSetItem{ 1008 | "1.2.3.5": {CreatedAt: timeForMonth(time.May)}, 1009 | "1.2.3.4": {CreatedAt: timeForMonth(time.April)}, 1010 | "1.2.3.6": {CreatedAt: timeForMonth(time.March)}, 1011 | }, 1012 | n: 3, 1013 | }, 1014 | want: map[string]IPSetItem{ 1015 | "1.2.3.5": {CreatedAt: timeForMonth(time.May)}, 1016 | "1.2.3.4": {CreatedAt: timeForMonth(time.April)}, 1017 | "1.2.3.6": {CreatedAt: timeForMonth(time.March)}, 1018 | }, 1019 | }, 1020 | } 1021 | for _, tt := range tests { 1022 | t.Run(tt.name, func(t *testing.T) { 1023 | got, _ := keepLatestNIPSetItems(tt.args.set, tt.args.n) 1024 | require.Equal(t, tt.want, got) 1025 | }) 1026 | } 1027 | } 1028 | 1029 | func Test_keepLatestNIPSetItemsBackwardCompat(t *testing.T) { 1030 | arg := map[string]IPSetItem{ 1031 | "1.2.3.5": {CreatedAt: timeForMonth(time.May)}, 1032 | "1.2.3.4": {CreatedAt: timeForMonth(time.May)}, 1033 | "1.2.3.6": {CreatedAt: timeForMonth(time.May)}, 1034 | } 1035 | 1036 | for n := 1; n <= len(arg); n++ { 1037 | res, _ := keepLatestNIPSetItems(arg, n) 1038 | require.Len(t, res, n) 1039 | } 1040 | } 1041 | 1042 | func IPSetsAreEqual(a map[string]IPSetItem, b map[string]IPSetItem) bool { 1043 | aOnly, bOnly := calculateIPSetDiff(a, b) 1044 | return aOnly == 0 && bOnly == 0 1045 | } 1046 | -------------------------------------------------------------------------------- /pkg/cfg/config.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/cloudflare/cloudflare-go" 12 | log "github.com/sirupsen/logrus" 13 | "gopkg.in/yaml.v3" 14 | 15 | "github.com/crowdsecurity/go-cs-lib/csstring" 16 | "github.com/crowdsecurity/go-cs-lib/yamlpatch" 17 | ) 18 | 19 | var TotalIPListCapacity int = 10000 20 | 21 | type ZoneConfig struct { 22 | ID string `yaml:"zone_id"` 23 | Actions []string `yaml:"actions,omitempty"` 24 | ActionSet map[string]struct{} `yaml:",omitempty"` 25 | } 26 | type AccountConfig struct { 27 | ID string `yaml:"id"` 28 | ZoneConfigs []ZoneConfig `yaml:"zones"` 29 | Token string `yaml:"token"` 30 | IPListPrefix string `yaml:"ip_list_prefix"` 31 | DefaultAction string `yaml:"default_action"` 32 | TotalIPListCapacity *int `yaml:"total_ip_list_capacity"` 33 | } 34 | type CloudflareConfig struct { 35 | Accounts []AccountConfig `yaml:"accounts"` 36 | UpdateFrequency time.Duration `yaml:"update_frequency"` 37 | } 38 | type PrometheusConfig struct { 39 | Enabled bool `yaml:"enabled"` 40 | ListenAddress string `yaml:"listen_addr"` 41 | ListenPort string `yaml:"listen_port"` 42 | } 43 | 44 | type bouncerConfig struct { 45 | CrowdSecLAPIUrl string `yaml:"crowdsec_lapi_url"` 46 | CrowdSecLAPIKey string `yaml:"crowdsec_lapi_key"` 47 | CrowdSecInsecureSkipVerify bool `yaml:"crowdsec_insecure_skip_verify"` 48 | CrowdsecUpdateFrequencyYAML string `yaml:"crowdsec_update_frequency"` 49 | IncludeScenariosContaining []string `yaml:"include_scenarios_containing"` 50 | ExcludeScenariosContaining []string `yaml:"exclude_scenarios_containing"` 51 | OnlyIncludeDecisionsFrom []string `yaml:"only_include_decisions_from"` 52 | CloudflareConfig CloudflareConfig `yaml:"cloudflare_config"` 53 | Daemon bool `yaml:"daemon"` 54 | Logging LoggingConfig `yaml:",inline"` 55 | PrometheusConfig PrometheusConfig `yaml:"prometheus"` 56 | KeyPath string `yaml:"key_path"` 57 | CertPath string `yaml:"cert_path"` 58 | CAPath string `yaml:"ca_cert_path"` 59 | } 60 | 61 | func MergedConfig(configPath string) ([]byte, error) { 62 | patcher := yamlpatch.NewPatcher(configPath, ".local") 63 | data, err := patcher.MergedPatchContent() 64 | if err != nil { 65 | return nil, err 66 | } 67 | return data, nil 68 | } 69 | 70 | // NewConfig creates bouncerConfig from the file at provided path 71 | func NewConfig(reader io.Reader) (*bouncerConfig, error) { 72 | config := &bouncerConfig{} 73 | 74 | content, err := io.ReadAll(reader) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | configBuff := csstring.StrictExpand(string(content), os.LookupEnv) 80 | 81 | err = yaml.Unmarshal([]byte(configBuff), &config) 82 | if err != nil { 83 | return nil, fmt.Errorf("failed to unmarshal: %w", err) 84 | } 85 | 86 | if err = config.Logging.setup("crowdsec-cloudflare-bouncer.log"); err != nil { 87 | return nil, fmt.Errorf("failed to setup logging: %w", err) 88 | } 89 | 90 | accountIDSet := make(map[string]bool) // for verifying that each account ID is unique 91 | zoneIDSet := make(map[string]bool) // for verifying that each zoneID is unique 92 | validAction := map[string]bool{"challenge": true, "block": true, "js_challenge": true, "managed_challenge": true} 93 | validChoiceMsg := "valid choices are either of 'block', 'js_challenge', 'challenge', 'managed_challenge'" 94 | 95 | for i, account := range config.CloudflareConfig.Accounts { 96 | if _, ok := accountIDSet[account.ID]; ok { 97 | return nil, fmt.Errorf("the account '%s' is duplicated", account.ID) 98 | } 99 | accountIDSet[account.ID] = true 100 | 101 | if account.Token == "" { 102 | return nil, fmt.Errorf("the account '%s' is missing token", account.ID) 103 | } 104 | 105 | if account.TotalIPListCapacity == nil { 106 | config.CloudflareConfig.Accounts[i].TotalIPListCapacity = &TotalIPListCapacity 107 | } 108 | if account.IPListPrefix == "" { 109 | config.CloudflareConfig.Accounts[i].IPListPrefix = "crowdsec" 110 | } 111 | 112 | if len(account.DefaultAction) == 0 { 113 | return nil, fmt.Errorf("account %s has no default action", account.ID) 114 | } 115 | if _, ok := validAction[account.DefaultAction]; !ok { 116 | return nil, fmt.Errorf("account %s 's default action is invalid. %s ", account.ID, validChoiceMsg) 117 | } 118 | zoneUsingChallenge := make([]string, 0) 119 | for j, zone := range account.ZoneConfigs { 120 | config.CloudflareConfig.Accounts[i].ZoneConfigs[j].ActionSet = map[string]struct{}{} 121 | if len(zone.Actions) == 0 { 122 | return nil, fmt.Errorf("account %s 's zone %s has no action", account.ID, zone.ID) 123 | } 124 | defaultActionIsSupported := false 125 | for _, a := range zone.Actions { 126 | if _, ok := validAction[a]; !ok { 127 | return nil, fmt.Errorf("invalid actions '%s', %s", a, validChoiceMsg) 128 | } 129 | if a == "challenge" { 130 | zoneUsingChallenge = append(zoneUsingChallenge, zone.ID) 131 | } 132 | if a == account.DefaultAction { 133 | defaultActionIsSupported = true 134 | } 135 | config.CloudflareConfig.Accounts[i].ZoneConfigs[j].ActionSet[a] = struct{}{} 136 | } 137 | 138 | if !defaultActionIsSupported { 139 | return nil, fmt.Errorf("zone %s doesn't support the default action %s for it's account", zone.ID, account.DefaultAction) 140 | } 141 | 142 | if _, ok := zoneIDSet[zone.ID]; ok { 143 | return nil, fmt.Errorf("zone id %s is duplicated", zone.ID) 144 | } 145 | zoneIDSet[zone.ID] = true 146 | } 147 | if len(zoneUsingChallenge) > 0 { 148 | log.Warningf( 149 | "zones %s uses 'challenge' action which is deprecated in favour of 'managed_challenge'. See migration guide at https://docs.crowdsec.net/docs/next/bouncers/cloudflare/#upgrading-from-v00x-to-v01y", 150 | strings.Join(zoneUsingChallenge, ", "), 151 | ) 152 | } 153 | } 154 | return config, nil 155 | } 156 | 157 | func lineComment(l string, zoneByID map[string]cloudflare.Zone, accountByID map[string]cloudflare.Account) string { 158 | words := strings.Split(l, " ") 159 | lastWord := words[len(words)-1] 160 | if zone, ok := zoneByID[lastWord]; ok { 161 | return zone.Name 162 | } 163 | if account, ok := accountByID[lastWord]; ok { 164 | return account.Name 165 | } 166 | if strings.Contains(l, "total_ip_list_capacity") { 167 | return "only this many latest IP decisions would be kept" 168 | } 169 | if strings.Contains(l, "exclude_scenarios_containing") { 170 | return "ignore IPs banned for triggering scenarios containing either of provided word" 171 | } 172 | if strings.Contains(l, "include_scenarios_containing") { 173 | return "ignore IPs banned for triggering scenarios not containing either of provided word" 174 | } 175 | if strings.Contains(l, "only_include_decisions_from") { 176 | return `only include IPs banned due to decisions orginating from provided sources. eg value ["cscli", "crowdsec"]` 177 | } 178 | return "" 179 | } 180 | 181 | func ConfigTokens(tokens string, baseConfigPath string) (string, error) { 182 | baseConfig := &bouncerConfig{} 183 | hasBaseConfig := true 184 | configBuff, err := os.ReadFile(baseConfigPath) 185 | if err != nil { 186 | hasBaseConfig = false 187 | } 188 | 189 | if hasBaseConfig { 190 | err = yaml.Unmarshal(configBuff, &baseConfig) 191 | if err != nil { 192 | return "", err 193 | } 194 | } else { 195 | setDefaults(baseConfig) 196 | } 197 | 198 | accountConfig := make([]AccountConfig, 0) 199 | zoneByID := make(map[string]cloudflare.Zone) 200 | accountByID := make(map[string]cloudflare.Account) 201 | ctx := context.Background() 202 | for _, token := range strings.Split(tokens, ",") { 203 | api, err := cloudflare.NewWithAPIToken(token) 204 | if err != nil { 205 | return "", err 206 | } 207 | accounts, _, err := api.Accounts(ctx, cloudflare.AccountsListParams{}) 208 | if err != nil { 209 | return "", err 210 | } 211 | for i, account := range accounts { 212 | accountConfig = append(accountConfig, AccountConfig{ 213 | ID: account.ID, 214 | ZoneConfigs: make([]ZoneConfig, 0), 215 | Token: token, 216 | IPListPrefix: "crowdsec", 217 | DefaultAction: "managed_challenge", 218 | TotalIPListCapacity: &TotalIPListCapacity, 219 | }) 220 | 221 | api.AccountID = account.ID 222 | accountByID[account.ID] = account 223 | zones, err := api.ListZones(ctx) 224 | if err != nil { 225 | return "", err 226 | } 227 | 228 | for _, zone := range zones { 229 | zoneByID[zone.ID] = zone 230 | if zone.Account.ID == account.ID { 231 | accountConfig[i].ZoneConfigs = append(accountConfig[i].ZoneConfigs, ZoneConfig{ 232 | ID: zone.ID, 233 | Actions: []string{"managed_challenge"}, 234 | }) 235 | } 236 | } 237 | } 238 | } 239 | cfConfig := CloudflareConfig{Accounts: accountConfig, UpdateFrequency: time.Second * 10} 240 | baseConfig.CloudflareConfig = cfConfig 241 | data, err := yaml.Marshal(baseConfig) 242 | if err != nil { 243 | return "", err 244 | } 245 | 246 | lineString := string(data) 247 | lines := strings.Split(lineString, "\n") 248 | if hasBaseConfig { 249 | lines = append([]string{ 250 | fmt.Sprintf("# Config generated by using %s as base", baseConfigPath), 251 | }, 252 | lines..., 253 | ) 254 | } else { 255 | lines = append([]string{ 256 | fmt.Sprintf("# Base config %s not found, please fill crowdsec credentials. ", baseConfigPath), 257 | }, 258 | lines..., 259 | ) 260 | } 261 | for i, line := range lines { 262 | comment := lineComment(line, zoneByID, accountByID) 263 | if comment != "" { 264 | lines[i] = line + " # " + comment 265 | } 266 | } 267 | 268 | return strings.Join(lines, "\n"), nil 269 | } 270 | 271 | func setDefaults(cfg *bouncerConfig) { 272 | cfg.CrowdSecLAPIUrl = "http://localhost:8080/" 273 | cfg.CrowdsecUpdateFrequencyYAML = "10s" 274 | 275 | cfg.Daemon = true 276 | cfg.ExcludeScenariosContaining = []string{ 277 | "ssh", 278 | "ftp", 279 | "smb", 280 | } 281 | cfg.OnlyIncludeDecisionsFrom = []string{ 282 | "CAPI", 283 | "cscli", 284 | "crowdsec", 285 | "lists", 286 | } 287 | 288 | cfg.PrometheusConfig = PrometheusConfig{ 289 | Enabled: true, 290 | ListenAddress: "127.0.0.1", 291 | ListenPort: "2112", 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /pkg/cfg/config_test.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | log "github.com/sirupsen/logrus" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/crowdsecurity/go-cs-lib/cstest" 12 | "github.com/crowdsecurity/go-cs-lib/ptr" 13 | ) 14 | 15 | func TestNewConfig(t *testing.T) { 16 | type args struct { 17 | configPath string 18 | } 19 | tests := []struct { 20 | name string 21 | args args 22 | want *bouncerConfig 23 | wantErr string 24 | }{ 25 | { 26 | name: "valid", 27 | args: args{"./testdata/valid_config.yaml"}, 28 | want: &bouncerConfig{ 29 | CrowdSecLAPIUrl: "http://localhost:8080/", 30 | CrowdSecLAPIKey: "test", 31 | CrowdsecUpdateFrequencyYAML: "10s", 32 | CloudflareConfig: CloudflareConfig{ 33 | Accounts: []AccountConfig{ 34 | { 35 | ID: "test", 36 | TotalIPListCapacity: &TotalIPListCapacity, 37 | ZoneConfigs: []ZoneConfig{ 38 | { 39 | ID: "test", 40 | Actions: []string{"challenge"}, 41 | ActionSet: map[string]struct{}{ 42 | "challenge": {}, 43 | }, 44 | }, 45 | }, 46 | Token: "test", 47 | IPListPrefix: "crowdsec", 48 | DefaultAction: "challenge", 49 | }, 50 | }, 51 | UpdateFrequency: time.Second * 30, 52 | }, 53 | Daemon: false, 54 | Logging: LoggingConfig{ 55 | LogMode: "stdout", 56 | LogDir: "/var/log/", 57 | LogLevel: ptr.Of(log.InfoLevel), 58 | LogMaxSize: 40, 59 | LogMaxFiles: 3, 60 | LogMaxAge: 30, 61 | CompressLogs: ptr.Of(true), 62 | }, 63 | }, 64 | }, 65 | { 66 | name: "invalid time", 67 | args: args{"./testdata/invalid_config_time.yaml"}, 68 | wantErr: "failed to unmarshal: yaml: unmarshal errors:\n line 18: cannot unmarshal !!str `blah` into time.Duration", 69 | }, 70 | { 71 | name: "invalid time", 72 | args: args{"./testdata/invalid_config_remedy.yaml"}, 73 | wantErr: "zone test doesn't support the default action challenge for it's account", 74 | }, 75 | } 76 | for _, tt := range tests { 77 | t.Run(tt.name, func(t *testing.T) { 78 | reader, err := os.Open(tt.args.configPath) 79 | require.NoError(t, err) 80 | got, err := NewConfig(reader) 81 | cstest.RequireErrorContains(t, err, tt.wantErr) 82 | require.Equal(t, tt.want, got) 83 | }) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /pkg/cfg/logging.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | log "github.com/sirupsen/logrus" 11 | "github.com/sirupsen/logrus/hooks/writer" 12 | "gopkg.in/natefinch/lumberjack.v2" 13 | 14 | "github.com/crowdsecurity/go-cs-lib/ptr" 15 | ) 16 | 17 | type LoggingConfig struct { 18 | LogLevel *log.Level `yaml:"log_level"` 19 | LogMode string `yaml:"log_mode"` 20 | LogDir string `yaml:"log_dir"` 21 | LogMaxSize int `yaml:"log_max_size,omitempty"` 22 | LogMaxFiles int `yaml:"log_max_files,omitempty"` 23 | LogMaxAge int `yaml:"log_max_age,omitempty"` 24 | CompressLogs *bool `yaml:"compress_logs,omitempty"` 25 | } 26 | 27 | func (c *LoggingConfig) LoggerForFile(fileName string) (io.Writer, error) { 28 | if c.LogMode == "stdout" { 29 | return os.Stderr, nil 30 | } 31 | 32 | // default permissions will be 0600 from lumberjack 33 | // and are preserved if the file already exists 34 | 35 | l := &lumberjack.Logger{ 36 | Filename: filepath.Join(c.LogDir, fileName), 37 | MaxSize: c.LogMaxSize, 38 | MaxBackups: c.LogMaxFiles, 39 | MaxAge: c.LogMaxAge, 40 | Compress: *c.CompressLogs, 41 | } 42 | 43 | return l, nil 44 | } 45 | 46 | func (c *LoggingConfig) setDefaults() { 47 | if c.LogMode == "" { 48 | c.LogMode = "stdout" 49 | } 50 | 51 | if c.LogDir == "" { 52 | c.LogDir = "/var/log/" 53 | } 54 | 55 | if c.LogLevel == nil { 56 | c.LogLevel = ptr.Of(log.InfoLevel) 57 | } 58 | 59 | if c.LogMaxSize == 0 { 60 | c.LogMaxSize = 40 61 | } 62 | 63 | if c.LogMaxFiles == 0 { 64 | c.LogMaxFiles = 3 65 | } 66 | 67 | if c.LogMaxAge == 0 { 68 | c.LogMaxAge = 30 69 | } 70 | 71 | if c.CompressLogs == nil { 72 | c.CompressLogs = ptr.Of(true) 73 | } 74 | } 75 | 76 | func (c *LoggingConfig) validate() error { 77 | if c.LogMode != "stdout" && c.LogMode != "file" { 78 | return fmt.Errorf("log_mode should be either 'stdout' or 'file'") 79 | } 80 | return nil 81 | } 82 | 83 | func (c *LoggingConfig) setup(fileName string) error { 84 | c.setDefaults() 85 | if err := c.validate(); err != nil { 86 | return err 87 | } 88 | log.SetLevel(*c.LogLevel) 89 | 90 | if c.LogMode == "stdout" { 91 | return nil 92 | } 93 | 94 | log.SetFormatter(&log.TextFormatter{TimestampFormat: time.RFC3339, FullTimestamp: true}) 95 | 96 | logger, err := c.LoggerForFile(fileName) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | log.SetOutput(logger) 102 | 103 | // keep stderr for panic/fatal, otherwise process failures 104 | // won't be visible enough 105 | log.AddHook(&writer.Hook{ 106 | Writer: os.Stderr, 107 | LogLevels: []log.Level{ 108 | log.PanicLevel, 109 | log.FatalLevel, 110 | }, 111 | }) 112 | 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /pkg/cfg/testdata/invalid_config_remedy.yaml: -------------------------------------------------------------------------------- 1 | # CrowdSec Config 2 | crowdsec_lapi_url: http://localhost:8080/ 3 | crowdsec_lapi_key: ${API_KEY} 4 | crowdsec_update_frequency: 10s 5 | 6 | cloudflare_config: 7 | accounts: 8 | - id: ${CF_ACC_ID} 9 | token: ${CF_TOKEN} 10 | ip_list_prefix: crowdsec 11 | default_action: challenge 12 | zones: 13 | - actions: 14 | - block 15 | zone_id: ${CF_ZONE_ID} 16 | 17 | update_frequency: 30s 18 | 19 | # Bouncer Config 20 | daemon: false 21 | log_mode: stdout 22 | log_dir: /var/log/ 23 | log_level: info -------------------------------------------------------------------------------- /pkg/cfg/testdata/invalid_config_time.yaml: -------------------------------------------------------------------------------- 1 | # CrowdSec Config 2 | crowdsec_lapi_url: http://localhost:8080/ 3 | crowdsec_lapi_key: ${API_KEY} 4 | crowdsec_update_frequency: -1s 5 | 6 | 7 | cloudflare_config: 8 | accounts: 9 | - id: ${CF_ACC_ID} 10 | token: ${CF_TOKEN} 11 | ip_list_prefix: crowdsec 12 | default_action: challenge 13 | zones: 14 | - actions: 15 | - block 16 | zone_id: ${CF_ZONE_ID} 17 | 18 | update_frequency: blah 19 | 20 | # Bouncer Config 21 | daemon: false 22 | log_mode: stdout 23 | log_dir: /var/log/ 24 | log_level: info -------------------------------------------------------------------------------- /pkg/cfg/testdata/valid_config.yaml: -------------------------------------------------------------------------------- 1 | # CrowdSec Config 2 | crowdsec_lapi_url: http://localhost:8080/ 3 | crowdsec_lapi_key: ${API_KEY} 4 | crowdsec_update_frequency: 10s 5 | 6 | 7 | cloudflare_config: 8 | accounts: 9 | - id: ${CF_ACC_ID} 10 | token: ${CF_TOKEN} 11 | ip_list_prefix: crowdsec 12 | default_action: challenge 13 | zones: 14 | - actions: 15 | - challenge 16 | zone_id: ${CF_ZONE_ID} 17 | 18 | update_frequency: 30s 19 | 20 | # Bouncer Config 21 | daemon: false 22 | log_mode: stdout 23 | log_dir: /var/log/ 24 | log_level: info -------------------------------------------------------------------------------- /rpm/SPECS/crowdsec-cloudflare-bouncer.spec: -------------------------------------------------------------------------------- 1 | Name: crowdsec-cloudflare-bouncer 2 | Version: %(echo $VERSION) 3 | Release: %(echo $PACKAGE_NUMBER)%{?dist} 4 | Summary: cloudflare bouncer for Crowdsec 5 | 6 | License: MIT 7 | URL: https://crowdsec.net 8 | Source0: https://github.com/crowdsecurity/%{name}/archive/v%(echo $VERSION).tar.gz 9 | BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) 10 | 11 | BuildRequires: make 12 | %{?fc33:BuildRequires: systemd-rpm-macros} 13 | 14 | Requires: gettext 15 | 16 | %define debug_package %{nil} 17 | 18 | %description 19 | 20 | %define version_number %(echo $VERSION) 21 | %define releasever %(echo $RELEASEVER) 22 | %global local_version v%{version_number}-%{releasever}-rpm 23 | %global name crowdsec-cloudflare-bouncer 24 | %global __mangle_shebangs_exclude_from /usr/bin/env 25 | 26 | %prep 27 | %setup -n %{name}-%{version} 28 | 29 | %build 30 | BUILD_VERSION=%{local_version} make 31 | 32 | %install 33 | rm -rf %{buildroot} 34 | mkdir -p %{buildroot}%{_bindir} 35 | install -m 755 -D %{name} %{buildroot}%{_bindir}/%{name} 36 | install -m 600 -D config/%{name}.yaml %{buildroot}/etc/crowdsec/bouncers/%{name}.yaml 37 | install -m 600 -D scripts/_bouncer.sh %{buildroot}/usr/lib/%{name}/_bouncer.sh 38 | BIN=%{_bindir}/%{name} CFG=/etc/crowdsec/bouncers envsubst '$BIN $CFG' < config/%{name}.service | install -m 0644 -D /dev/stdin %{buildroot}%{_unitdir}/%{name}.service 39 | 40 | %clean 41 | rm -rf %{buildroot} 42 | 43 | %files 44 | %defattr(-,root,root,-) 45 | %{_bindir}/%{name} 46 | /usr/lib/%{name}/_bouncer.sh 47 | %{_unitdir}/%{name}.service 48 | %config(noreplace) /etc/crowdsec/bouncers/%{name}.yaml 49 | 50 | %post 51 | systemctl daemon-reload 52 | 53 | . /usr/lib/%{name}/_bouncer.sh 54 | START=1 55 | 56 | if [ "$1" = "1" ]; then 57 | if need_api_key; then 58 | if ! set_api_key; then 59 | START=0 60 | fi 61 | fi 62 | fi 63 | 64 | echo "If this is fresh install or you've installed the package maintainer's version of configuration, please configure '$CONFIG'." 65 | echo "Configuration can be autogenerated using 'sudo $BOUNCER -g , -o $CONFIG'." 66 | echo "After configuration run the command 'sudo systemctl start $SERVICE' to start the bouncer" 67 | echo "Don't forget to (re)generate CrowdSec API key if it is installed on another server or/and if you have upgraded and installed the package maintainer's version." 68 | 69 | if [ "$START" -eq 0 ]; then 70 | echo "no api key was generated, you can generate one on your LAPI Server by running 'cscli bouncers add ' and add it to '$CONFIG'" >&2 71 | else 72 | %if 0%{?fc35} 73 | systemctl enable "$SERVICE" 74 | %endif 75 | systemctl start "$SERVICE" 76 | fi 77 | 78 | %changelog 79 | * Fri Sep 10 2021 Kevin Kadosh 80 | - First initial packaging 81 | 82 | %preun 83 | . /usr/lib/%{name}/_bouncer.sh 84 | 85 | if [ "$1" = "0" ]; then 86 | systemctl stop "$SERVICE" || echo "cannot stop service" 87 | systemctl disable "$SERVICE" || echo "cannot disable service" 88 | delete_bouncer 89 | fi 90 | 91 | %postun 92 | 93 | if [ "$1" == "1" ] ; then 94 | systemctl restart %{name} || echo "cannot restart service" 95 | fi 96 | -------------------------------------------------------------------------------- /scripts/_bouncer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | #shellcheck disable=SC3043 3 | 4 | set -eu 5 | 6 | BOUNCER="crowdsec-cloudflare-bouncer" 7 | BOUNCER_PREFIX=$(echo "$BOUNCER" | sed 's/crowdsec-/cs-/g') 8 | 9 | # This is a library of functions that can be sourced by other scripts 10 | # to install and configure bouncers. 11 | # 12 | # While not requiring bash, it is not strictly POSIX-compliant because 13 | # it uses local variables, but it should work with every modern shell. 14 | # 15 | # Since passing/parsing arguments in posix sh is tricky, we share 16 | # some environment variables with the functions. It's a matter of 17 | # readability balance between shorter vs cleaner code. 18 | 19 | if [ ! -t 0 ]; then 20 | # terminal is not interactive; no colors 21 | FG_RED="" 22 | FG_GREEN="" 23 | FG_YELLOW="" 24 | FG_CYAN="" 25 | RESET="" 26 | elif tput sgr0 >/dev/null 2>&1; then 27 | # terminfo 28 | FG_RED=$(tput setaf 1) 29 | FG_GREEN=$(tput setaf 2) 30 | FG_YELLOW=$(tput setaf 3) 31 | FG_CYAN=$(tput setaf 6) 32 | RESET=$(tput sgr0) 33 | else 34 | FG_RED=$(printf '%b' '\033[31m') 35 | FG_GREEN=$(printf '%b' '\033[32m') 36 | FG_YELLOW=$(printf '%b' '\033[33m') 37 | FG_CYAN=$(printf '%b' '\033[36m') 38 | RESET=$(printf '%b' '\033[0m') 39 | fi 40 | 41 | msg() { 42 | case "$1" in 43 | info) echo "${FG_CYAN}$2${RESET}" >&2 ;; 44 | warn) echo "${FG_YELLOW}WARN:${RESET} $2" >&2 ;; 45 | err) echo "${FG_RED}ERR:${RESET} $2" >&2 ;; 46 | succ) echo "${FG_GREEN}$2${RESET}" >&2 ;; 47 | *) echo "$1" >&2 ;; 48 | esac 49 | } 50 | 51 | require() { 52 | set | grep -q "^$1=" || { msg err "missing required variable \$$1"; exit 1; } 53 | shift 54 | [ "$#" -eq 0 ] || require "$@" 55 | } 56 | 57 | # shellcheck disable=SC2034 58 | { 59 | SERVICE="$BOUNCER.service" 60 | BIN_PATH_INSTALLED="/usr/local/bin/$BOUNCER" 61 | BIN_PATH="./$BOUNCER" 62 | CONFIG_DIR="/etc/crowdsec/bouncers" 63 | CONFIG_FILE="$BOUNCER.yaml" 64 | CONFIG="$CONFIG_DIR/$CONFIG_FILE" 65 | SYSTEMD_PATH_FILE="/etc/systemd/system/$SERVICE" 66 | } 67 | 68 | assert_root() { 69 | #shellcheck disable=SC2312 70 | if [ "$(id -u)" -ne 0 ]; then 71 | msg err "This script must be run as root" 72 | exit 1 73 | fi 74 | } 75 | 76 | # Check if the configuration file contains a variable 77 | # which has not yet been interpolated, like "$API_KEY", 78 | # and return true if it does. 79 | config_not_set() { 80 | require 'CONFIG' 81 | local varname before after 82 | 83 | varname=$1 84 | if [ "$varname" = "" ]; then 85 | msg err "missing required variable name" 86 | exit 1 87 | fi 88 | 89 | before=$("$BOUNCER" -c "$CONFIG" -T) 90 | # shellcheck disable=SC2016 91 | after=$(echo "$before" | envsubst "\$$varname") 92 | 93 | if [ "$before" = "$after" ]; then 94 | return 1 95 | fi 96 | return 0 97 | } 98 | 99 | need_api_key() { 100 | if config_not_set 'API_KEY'; then 101 | return 0 102 | fi 103 | return 1 104 | } 105 | 106 | # Interpolate a variable in the config file with a value. 107 | set_config_var_value() { 108 | require 'CONFIG' 109 | local varname value before 110 | 111 | varname=$1 112 | if [ "$varname" = "" ]; then 113 | msg err "missing required variable name" 114 | exit 1 115 | fi 116 | 117 | value=$2 118 | if [ "$value" = "" ]; then 119 | msg err "missing required variable value" 120 | exit 1 121 | fi 122 | 123 | before=$(cat "$CONFIG") 124 | echo "$before" | \ 125 | env "$varname=$value" envsubst "\$$varname" | \ 126 | install -m 0600 /dev/stdin "$CONFIG" 127 | } 128 | 129 | set_api_key() { 130 | require 'CONFIG' 'BOUNCER_PREFIX' 131 | local api_key ret bouncer_id before 132 | # if we can't set the key, the user will take care of it 133 | ret=0 134 | 135 | if command -v cscli >/dev/null; then 136 | echo "cscli/crowdsec is present, generating API key" >&2 137 | bouncer_id="$BOUNCER_PREFIX-$(date +%s)" 138 | api_key=$(cscli -oraw bouncers add "$bouncer_id" || true) 139 | if [ "$api_key" = "" ]; then 140 | echo "failed to create API key" >&2 141 | api_key="" 142 | ret=1 143 | else 144 | echo "API Key successfully created" >&2 145 | echo "$bouncer_id" > "$CONFIG.id" 146 | fi 147 | else 148 | echo "cscli/crowdsec is not present, please set the API key manually" >&2 149 | api_key="" 150 | ret=1 151 | fi 152 | 153 | if [ "$api_key" != "" ]; then 154 | set_config_var_value 'API_KEY' "$api_key" 155 | fi 156 | 157 | return "$ret" 158 | } 159 | 160 | set_local_port() { 161 | require 'CONFIG' 162 | local port 163 | command -v cscli >/dev/null || return 0 164 | # the following will fail with a non-LAPI local crowdsec, leaving empty port 165 | port=$(cscli config show -oraw --key "Config.API.Server.ListenURI" 2>/dev/null | cut -d ":" -f2 || true) 166 | if [ "$port" != "" ]; then 167 | sed -i "s/localhost:8080/127.0.0.1:$port/g" "$CONFIG" 168 | sed -i "s/127.0.0.1:8080/127.0.0.1:$port/g" "$CONFIG" 169 | fi 170 | } 171 | 172 | set_local_lapi_url() { 173 | require 'CONFIG' 174 | local port before varname 175 | # $varname is the name of the variable to interpolate 176 | # in the config file with the URL of the LAPI server, 177 | # assuming it is running on the same host as the 178 | # bouncer. 179 | varname=$1 180 | if [ "$varname" = "" ]; then 181 | msg err "missing required variable name" 182 | exit 1 183 | fi 184 | command -v cscli >/dev/null || return 0 185 | 186 | port=$(cscli config show -oraw --key "Config.API.Server.ListenURI" 2>/dev/null | cut -d ":" -f2 || true) 187 | if [ "$port" = "" ]; then 188 | port=8080 189 | fi 190 | 191 | set_config_var_value "$varname" "http://127.0.0.1:$port" 192 | } 193 | 194 | delete_bouncer() { 195 | require 'CONFIG' 196 | local bouncer_id 197 | if [ -f "$CONFIG.id" ]; then 198 | bouncer_id=$(cat "$CONFIG.id") 199 | cscli -oraw bouncers delete "$bouncer_id" 2>/dev/null || true 200 | rm -f "$CONFIG.id" 201 | fi 202 | } 203 | 204 | upgrade_bin() { 205 | require 'BIN_PATH' 'BIN_PATH_INSTALLED' 206 | rm "$BIN_PATH_INSTALLED" 207 | install -v -m 0755 -D "$BIN_PATH" "$BIN_PATH_INSTALLED" 208 | } 209 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | . ./scripts/_bouncer.sh 6 | 7 | assert_root 8 | 9 | # --------------------------------- # 10 | 11 | API_KEY="" 12 | 13 | gen_apikey() { 14 | if command -v cscli >/dev/null; then 15 | msg succ "cscli found, generating bouncer api key." 16 | bouncer_id="$BOUNCER_PREFIX-$(date +%s)" 17 | API_KEY=$(cscli -oraw bouncers add "$bouncer_id") 18 | echo "$bouncer_id" > "$CONFIG.id" 19 | msg info "API Key: $API_KEY" 20 | READY="yes" 21 | else 22 | msg warn "cscli not found, you will need to generate an api key." 23 | READY="no" 24 | fi 25 | } 26 | 27 | gen_config_file() { 28 | # shellcheck disable=SC2016 29 | API_KEY=${API_KEY} envsubst '$API_KEY' <"./config/$CONFIG_FILE" | \ 30 | install -D -m 0600 /dev/stdin "$CONFIG" 31 | } 32 | 33 | install_bouncer() { 34 | if [ ! -f "$BIN_PATH" ]; then 35 | msg err "$BIN_PATH not found, exiting." 36 | exit 1 37 | fi 38 | if [ -e "$BIN_PATH_INSTALLED" ]; then 39 | msg err "$BIN_PATH_INSTALLED is already installed. Exiting" 40 | exit 1 41 | fi 42 | msg info "Installing $BOUNCER" 43 | install -v -m 0755 -D "$BIN_PATH" "$BIN_PATH_INSTALLED" 44 | install -D -m 0600 "./config/$CONFIG_FILE" "$CONFIG" 45 | # shellcheck disable=SC2016 46 | CFG=${CONFIG_DIR} BIN=${BIN_PATH_INSTALLED} envsubst '$CFG $BIN' <"./config/$SERVICE" >"$SYSTEMD_PATH_FILE" 47 | systemctl daemon-reload 48 | gen_apikey 49 | gen_config_file 50 | } 51 | 52 | # --------------------------------- # 53 | 54 | install_bouncer 55 | 56 | systemctl enable "$SERVICE" 57 | if [ "$READY" = "yes" ]; then 58 | systemctl start "$SERVICE" 59 | else 60 | msg warn "service not started. You need to get an API key and configure it in $CONFIG" 61 | fi 62 | 63 | echo "Please configure '${CONFIG}'." 64 | echo "Configuration can be autogenerated using ${BIN_PATH_INSTALLED} -g , " 65 | echo "After configuration run the command 'systemctl start $SERVICE' to start the bouncer" 66 | exit 0 67 | -------------------------------------------------------------------------------- /scripts/uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | 5 | . ./scripts/_bouncer.sh 6 | 7 | assert_root 8 | 9 | # --------------------------------- # 10 | 11 | uninstall() { 12 | systemctl stop "$SERVICE" || true 13 | delete_bouncer 14 | rm -f "$CONFIG" 15 | rm -f "$SYSTEMD_PATH_FILE" 16 | rm -f "$BIN_PATH_INSTALLED" 17 | rm -f "/var/log/$BOUNCER.log" 18 | } 19 | 20 | uninstall 21 | msg succ "$BOUNCER has been successfully uninstalled" 22 | exit 0 23 | -------------------------------------------------------------------------------- /scripts/upgrade.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | . ./scripts/_bouncer.sh 6 | 7 | assert_root 8 | 9 | # --------------------------------- # 10 | 11 | systemctl stop "$SERVICE" 12 | 13 | if ! upgrade_bin; then 14 | msg err "failed to upgrade $BOUNCER" 15 | exit 1 16 | fi 17 | 18 | systemctl start "$SERVICE" || msg warn "$SERVICE failed to start, please check the systemd logs" 19 | 20 | msg succ "$BOUNCER upgraded successfully." 21 | exit 0 22 | -------------------------------------------------------------------------------- /test/.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /test/default.env: -------------------------------------------------------------------------------- 1 | CROWDSEC_TEST_VERSION="dev" 2 | CROWDSEC_TEST_FLAVORS="full" 3 | CROWDSEC_TEST_NETWORK="net-test" 4 | -------------------------------------------------------------------------------- /test/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "cs-cloudflare-bouncer-tests" 3 | version = "0.1.0" 4 | description = "Tests for cs-cloudflare-bouncer" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "pexpect>=4.9.0", 9 | "pytest>=8.3.5", 10 | "pytest-cs>=0.7.21", 11 | "pytest-dependency>=0.6.0", 12 | "pytest-dotenv>=0.5.2", 13 | "zxcvbn>=4.5.0", 14 | ] 15 | 16 | [tool.uv.sources] 17 | pytest-cs = { git = "https://github.com/crowdsecurity/pytest-cs" } 18 | 19 | [dependency-groups] 20 | dev = [ 21 | "basedpyright>=1.28.4", 22 | "ipdb>=0.13.13", 23 | "ruff>=0.11.2", 24 | ] 25 | 26 | [tool.ruff] 27 | 28 | line-length = 120 29 | 30 | [tool.ruff.lint] 31 | select = [ 32 | "ALL" 33 | ] 34 | 35 | ignore = [ 36 | "ANN", # Missing type annotations 37 | "ARG001", # Unused function argument: `...` 38 | "COM812", # Trailing comma missing 39 | "D100", # Missing docstring in public module 40 | "D103", # Missing docstring in public function 41 | "D104", # Missing docstring in public package 42 | "D203", # incorrect-blank-line-before-class 43 | "D212", # Multi-line docstring summary should start at the first line 44 | "D400", # First line should end with a period 45 | "D415", # First line should end with a period, question mark, or exclamation point 46 | "ERA001", 47 | "FIX002", # Line contains TODO, consider resolving the issue 48 | "FIX003", # Line contains XXX, consider resolving the issue 49 | "PLW1510", # `subprocess.run` without explicit `check` argument 50 | "S101", # Use of 'assert' detected 51 | "S603", # `subprocess` call: check for execution of untrusted input 52 | "S607", # Starting a process with a partial executable path 53 | "TD", 54 | "PLR2004", # Magic value used in comparison, consider replacing `...` with a constant variable 55 | "PLR0913", # Too many arguments in function definition (6 > 5) 56 | "PTH107", # `os.remove()` should be replaced by `Path.unlink()` 57 | "PTH108", # `os.unlink()` should be replaced by `Path.unlink()` 58 | "PTH110", # `os.path.exists()` should be replaced by `Path.exists()` 59 | "PTH116", # `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` 60 | "PTH123", # `open()` should be replaced by `Path.open()` 61 | "PT022", # No teardown in fixture `fw_cfg_factory`, use `return` instead of `yield` 62 | "UP022", # Prefer `capture_output` over sending `stdout` and `stderr` to `PIPE` 63 | "Q000", 64 | ] 65 | 66 | [tool.basedpyright] 67 | reportAny = "none" 68 | reportArgumentType = "none" 69 | reportAttributeAccessIssue = "none" 70 | reportMissingParameterType = "none" 71 | reportMissingTypeStubs = "none" 72 | reportOptionalMemberAccess = "none" 73 | reportUnknownArgumentType = "none" 74 | reportUnknownMemberType = "none" 75 | reportUnknownParameterType = "none" 76 | reportUnknownVariableType = "none" 77 | reportUnusedCallResult = "none" 78 | reportUnusedParameter = "none" 79 | -------------------------------------------------------------------------------- /test/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | --pdbcls=IPython.terminal.debugger:Pdb 4 | --ignore=tests/install 5 | --strict-markers 6 | markers: 7 | deb: mark tests related to deb packaging 8 | rpm: mark tests related to rpm packaging 9 | systemd_debug: dump systemd status and journal on test failure 10 | env_files = 11 | .env 12 | default.env 13 | -------------------------------------------------------------------------------- /test/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-cloudflare-bouncer/81d0074a4375c50c7689e1dcfd44362a4ea2d6ef/test/tests/__init__.py -------------------------------------------------------------------------------- /test/tests/bouncer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-cloudflare-bouncer/81d0074a4375c50c7689e1dcfd44362a4ea2d6ef/test/tests/bouncer/__init__.py -------------------------------------------------------------------------------- /test/tests/bouncer/test_cloudflare_bouncer.py: -------------------------------------------------------------------------------- 1 | 2 | def test_no_api_key(crowdsec, bouncer, cf_cfg_factory): 3 | cfg = cf_cfg_factory() 4 | cfg['crowdsec_lapi_url'] = 'http://localhost:8080' 5 | 6 | with bouncer(cfg) as cf: 7 | cf.wait_for_lines_fnmatch([ 8 | "*config does not contain LAPI key or certificate*", 9 | ]) 10 | cf.proc.wait(timeout=0.2) 11 | assert not cf.proc.is_running() 12 | 13 | cfg['crowdsec_lapi_key'] = '' 14 | 15 | with bouncer(cfg) as cf: 16 | cf.wait_for_lines_fnmatch([ 17 | "*config does not contain LAPI key or certificate*", 18 | ]) 19 | cf.proc.wait(timeout=0.2) 20 | assert not cf.proc.is_running() 21 | 22 | 23 | def test_no_lapi_url(bouncer, cf_cfg_factory): 24 | cfg = cf_cfg_factory() 25 | 26 | cfg['crowdsec_lapi_key'] = 'not-used' 27 | 28 | with bouncer(cfg) as cf: 29 | cf.wait_for_lines_fnmatch([ 30 | "*config does not contain LAPI url*", 31 | ]) 32 | cf.proc.wait(timeout=0.2) 33 | assert not cf.proc.is_running() 34 | 35 | cfg['crowdsec_lapi_url'] = '' 36 | 37 | with bouncer(cfg) as cf: 38 | cf.wait_for_lines_fnmatch([ 39 | "*config does not contain LAPI url*", 40 | ]) 41 | cf.proc.wait(timeout=0.2) 42 | assert not cf.proc.is_running() 43 | 44 | # TODO: test without update frequency 45 | 46 | 47 | def test_no_lapi(bouncer, cf_cfg_factory): 48 | cfg = cf_cfg_factory() 49 | cfg['crowdsec_lapi_key'] = 'not-used' 50 | cfg['crowdsec_lapi_url'] = 'http://localhost:8237' 51 | 52 | with bouncer(cfg) as cf: 53 | cf.wait_for_lines_fnmatch([ 54 | "*connect: connection refused*", 55 | "*process terminated with error: crowdsec LAPI stream has stopped*", 56 | ]) 57 | cf.proc.wait(timeout=0.2) 58 | assert not cf.proc.is_running() 59 | -------------------------------------------------------------------------------- /test/tests/bouncer/test_tls.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | def test_tls_server(crowdsec, certs_dir, api_key_factory, bouncer, cf_cfg_factory): 5 | """TLS with server-only certificate""" 6 | api_key = api_key_factory() 7 | 8 | lapi_env = { 9 | 'CACERT_FILE': '/etc/ssl/crowdsec/ca.crt', 10 | 'LAPI_CERT_FILE': '/etc/ssl/crowdsec/lapi.crt', 11 | 'LAPI_KEY_FILE': '/etc/ssl/crowdsec/lapi.key', 12 | 'USE_TLS': 'true', 13 | 'LOCAL_API_URL': 'https://localhost:8080', 14 | 'BOUNCER_KEY_custom': api_key, 15 | } 16 | 17 | certs = certs_dir(lapi_hostname='lapi') 18 | 19 | volumes = { 20 | certs: {'bind': '/etc/ssl/crowdsec', 'mode': 'ro'}, 21 | } 22 | 23 | with crowdsec(environment=lapi_env, volumes=volumes) as cs: 24 | cs.wait_for_log("*CrowdSec Local API listening*") 25 | # TODO: wait_for_https 26 | cs.wait_for_http(8080, '/health', want_status=None) 27 | 28 | port = cs.probe.get_bound_port('8080') 29 | cfg = cf_cfg_factory() 30 | cfg['crowdsec_lapi_url'] = f'https://localhost:{port}/' 31 | cfg['crowdsec_lapi_key'] = api_key 32 | 33 | with bouncer(cfg) as cf: 34 | cf.wait_for_lines_fnmatch([ 35 | "*Using API key auth*", 36 | "*auth-api: auth with api key failed*", 37 | "*tls: failed to verify certificate: x509: certificate signed by unknown authority*", 38 | ]) 39 | 40 | cfg['ca_cert_path'] = (certs / 'ca.crt').as_posix() 41 | 42 | with bouncer(cfg) as cf: 43 | cf.wait_for_lines_fnmatch([ 44 | "*Using CA cert*", 45 | "*Using API key auth*", 46 | ]) 47 | 48 | 49 | def test_tls_mutual(crowdsec, certs_dir, api_key_factory, bouncer, cf_cfg_factory, bouncer_under_test): 50 | """TLS with two-way bouncer/lapi authentication""" 51 | lapi_env = { 52 | 'CACERT_FILE': '/etc/ssl/crowdsec/ca.crt', 53 | 'LAPI_CERT_FILE': '/etc/ssl/crowdsec/lapi.crt', 54 | 'LAPI_KEY_FILE': '/etc/ssl/crowdsec/lapi.key', 55 | 'USE_TLS': 'true', 56 | 'LOCAL_API_URL': 'https://localhost:8080', 57 | } 58 | 59 | certs = certs_dir(lapi_hostname='lapi') 60 | 61 | volumes = { 62 | certs: {'bind': '/etc/ssl/crowdsec', 'mode': 'ro'}, 63 | } 64 | 65 | with crowdsec(environment=lapi_env, volumes=volumes) as cs: 66 | cs.wait_for_log("*CrowdSec Local API listening*") 67 | # TODO: wait_for_https 68 | cs.wait_for_http(8080, '/health', want_status=None) 69 | 70 | port = cs.probe.get_bound_port('8080') 71 | cfg = cf_cfg_factory() 72 | cfg['crowdsec_lapi_url'] = f'https://localhost:{port}/' 73 | cfg['ca_cert_path'] = (certs / 'ca.crt').as_posix() 74 | 75 | cfg['cert_path'] = (certs / 'agent.crt').as_posix() 76 | cfg['key_path'] = (certs / 'agent.key').as_posix() 77 | 78 | with bouncer(cfg) as cf: 79 | cf.wait_for_lines_fnmatch([ 80 | "*Starting crowdsec-cloudflare-bouncer*", 81 | "*Using CA cert*", 82 | "*Using cert auth with cert * and key *", 83 | "*API error: access forbidden*", 84 | ]) 85 | 86 | cs.wait_for_log("*client certificate OU ?agent-ou? doesn't match expected OU ?bouncer-ou?*") 87 | 88 | cfg['cert_path'] = (certs / 'bouncer.crt').as_posix() 89 | cfg['key_path'] = (certs / 'bouncer.key').as_posix() 90 | 91 | with bouncer(cfg) as cf: 92 | cf.wait_for_lines_fnmatch([ 93 | "*Starting crowdsec-cloudflare-bouncer*", 94 | "*Using CA cert*", 95 | "*Using cert auth with cert * and key *", 96 | ]) 97 | 98 | # check that the bouncer is registered 99 | res = cs.cont.exec_run('cscli bouncers list -o json') 100 | assert res.exit_code == 0 101 | bouncers = json.loads(res.output) 102 | assert len(bouncers) == 1 103 | assert bouncers[0]['name'].startswith('@') 104 | assert bouncers[0]['auth_type'] == 'tls' 105 | assert bouncers[0]['type'] == bouncer_under_test 106 | -------------------------------------------------------------------------------- /test/tests/bouncer/test_yaml_local.py: -------------------------------------------------------------------------------- 1 | 2 | def test_yaml_local(bouncer, cf_cfg_factory): 3 | cfg = cf_cfg_factory() 4 | 5 | with bouncer(cfg) as cf: 6 | cf.wait_for_lines_fnmatch([ 7 | "*config does not contain LAPI url*", 8 | ]) 9 | cf.proc.wait(timeout=0.2) 10 | assert not cf.proc.is_running() 11 | 12 | config_local = { 13 | 'crowdsec_lapi_url': 'http://localhost:8080', 14 | 'crowdsec_lapi_key': 'notused' 15 | } 16 | 17 | with bouncer(cfg, config_local=config_local) as cf: 18 | cf.wait_for_lines_fnmatch([ 19 | "*connect: connection refused*", 20 | "*process terminated with error: crowdsec LAPI stream has stopped*", 21 | ]) 22 | cf.proc.wait(timeout=0.2) 23 | assert not cf.proc.is_running() 24 | -------------------------------------------------------------------------------- /test/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | import pytest 4 | 5 | # from pytest_cs import plugin 6 | 7 | # pytest_exception_interact = plugin.pytest_exception_interact 8 | 9 | 10 | # provide the name of the bouncer binary to test 11 | @pytest.fixture(scope='session') 12 | def bouncer_under_test(): 13 | return 'crowdsec-cloudflare-bouncer' 14 | 15 | 16 | # Create a lapi container, register a bouncer and run it with the updated config. 17 | # - Return context manager that yields a tuple of (bouncer, lapi) 18 | @pytest.fixture(scope='session') 19 | def bouncer_with_lapi(bouncer, crowdsec, cf_cfg_factory, api_key_factory, tmp_path_factory, bouncer_binary): 20 | @contextlib.contextmanager 21 | def closure(config_lapi=None, config_bouncer=None, api_key=None): 22 | if config_bouncer is None: 23 | config_bouncer = {} 24 | if config_lapi is None: 25 | config_lapi = {} 26 | # can be overridden by config_lapi + config_bouncer 27 | api_key = api_key_factory() 28 | env = { 29 | 'BOUNCER_KEY_custom': api_key, 30 | } 31 | try: 32 | env.update(config_lapi) 33 | with crowdsec(environment=env) as lapi: 34 | lapi.wait_for_http(8080, '/health') 35 | port = lapi.probe.get_bound_port('8080') 36 | cfg = cf_cfg_factory() 37 | cfg['crowdsec_lapi_url'] = f'http://localhost:{port}/' 38 | cfg['crowdsec_lapi_key'] = api_key 39 | cfg.update(config_bouncer) 40 | with bouncer(cfg) as cb: 41 | yield cb, lapi 42 | finally: 43 | pass 44 | 45 | yield closure 46 | 47 | 48 | _default_config = { 49 | 'log_mode': 'stdout', 50 | 'log_level': 'info', 51 | 'crowdsec_update_frequency': '1s', 52 | } 53 | 54 | 55 | @pytest.fixture(scope='session') 56 | def cf_cfg_factory(): 57 | def closure(**kw): 58 | cfg = _default_config.copy() 59 | cfg |= kw 60 | return cfg | kw 61 | yield closure 62 | -------------------------------------------------------------------------------- /test/tests/install/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-cloudflare-bouncer/81d0074a4375c50c7689e1dcfd44362a4ea2d6ef/test/tests/install/__init__.py -------------------------------------------------------------------------------- /test/tests/install/no_crowdsec/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-cloudflare-bouncer/81d0074a4375c50c7689e1dcfd44362a4ea2d6ef/test/tests/install/no_crowdsec/__init__.py -------------------------------------------------------------------------------- /test/tests/install/no_crowdsec/test_no_crowdsec_deb.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | import pytest 5 | 6 | pytestmark = pytest.mark.deb 7 | 8 | 9 | def test_deb_install_purge(deb_package_path, bouncer_under_test, must_be_root): 10 | # test the full install-purge cycle, doing that in separate tests would 11 | # be a bit too much 12 | 13 | # TODO: remove and reinstall 14 | 15 | # use the package built as non-root by test_deb_build() 16 | assert deb_package_path.exists(), f'This test requires {deb_package_path}' 17 | 18 | bouncer_exe = f"/usr/bin/{bouncer_under_test}" 19 | assert not os.path.exists(bouncer_exe) 20 | 21 | config = f"/etc/crowdsec/bouncers/{bouncer_under_test}.yaml" 22 | assert not os.path.exists(config) 23 | 24 | # install the package 25 | p = subprocess.run( 26 | ['dpkg', '--install', deb_package_path.as_posix()], 27 | stdout=subprocess.PIPE, 28 | stderr=subprocess.PIPE, 29 | encoding='utf-8' 30 | ) 31 | assert p.returncode == 0, f'Failed to install {deb_package_path}' 32 | 33 | assert os.path.exists(bouncer_exe) 34 | assert os.stat(bouncer_exe).st_mode & 0o777 == 0o755 35 | 36 | assert os.path.exists(config) 37 | assert os.stat(config).st_mode & 0o777 == 0o600 38 | 39 | p = subprocess.check_output( 40 | ['dpkg-deb', '-f', deb_package_path.as_posix(), 'Package'], 41 | encoding='utf-8' 42 | ) 43 | package_name = p.strip() 44 | 45 | p = subprocess.run( 46 | ['dpkg', '--purge', package_name], 47 | stdout=subprocess.PIPE, 48 | stderr=subprocess.PIPE, 49 | encoding='utf-8' 50 | ) 51 | assert p.returncode == 0, f'Failed to purge {package_name}' 52 | 53 | assert not os.path.exists(bouncer_exe) 54 | assert not os.path.exists(config) 55 | -------------------------------------------------------------------------------- /test/tests/install/no_crowdsec/test_no_crowdsec_scripts.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pexpect 4 | import pytest 5 | import yaml 6 | 7 | BOUNCER = "crowdsec-cloudflare-bouncer" 8 | CONFIG = f"/etc/crowdsec/bouncers/{BOUNCER}.yaml" 9 | 10 | 11 | @pytest.mark.dependency 12 | def test_install_no_crowdsec(project_repo, bouncer_binary, must_be_root): 13 | c = pexpect.spawn( 14 | '/usr/bin/sh', ['scripts/install.sh'], 15 | cwd=project_repo 16 | ) 17 | 18 | c.expect(f"Installing {BOUNCER}") 19 | c.expect("WARN.* cscli not found, you will need to generate an api key.") 20 | c.expect(f"WARN.* service not started. You need to get an API key and configure it in {CONFIG}") 21 | c.expect(f"Please configure '{CONFIG}'") 22 | c.expect(f"Configuration can be autogenerated using /usr/local/bin/{BOUNCER} -g ,") 23 | c.expect(f"After configuration run the command 'systemctl start {BOUNCER}.service' to start the bouncer") 24 | c.wait() 25 | assert c.terminated 26 | assert c.exitstatus == 0 27 | 28 | with open(CONFIG) as f: 29 | y = yaml.safe_load(f) 30 | assert y['crowdsec_lapi_key'] == '' 31 | assert y['crowdsec_lapi_url'] == 'http://localhost:8080/' 32 | 33 | assert os.path.exists(CONFIG) 34 | assert os.stat(CONFIG).st_mode & 0o777 == 0o600 35 | assert os.path.exists(f'/usr/local/bin/{BOUNCER}') 36 | assert os.stat(f'/usr/local/bin/{BOUNCER}').st_mode & 0o777 == 0o755 37 | 38 | c = pexpect.spawn( 39 | '/usr/bin/sh', ['scripts/install.sh'], 40 | cwd=project_repo 41 | ) 42 | 43 | c.expect(f"ERR.* /usr/local/bin/{BOUNCER} is already installed. Exiting") 44 | 45 | 46 | @pytest.mark.dependency(depends=['test_install_no_crowdsec']) 47 | def test_upgrade_no_crowdsec(project_repo, must_be_root): 48 | os.remove(f'/usr/local/bin/{BOUNCER}') 49 | 50 | c = pexpect.spawn( 51 | '/usr/bin/sh', ['scripts/upgrade.sh'], 52 | cwd=project_repo 53 | ) 54 | 55 | c.expect(f"{BOUNCER} upgraded successfully") 56 | c.wait() 57 | assert c.terminated 58 | assert c.exitstatus == 0 59 | 60 | assert os.path.exists(f'/usr/local/bin/{BOUNCER}') 61 | assert os.stat(f'/usr/local/bin/{BOUNCER}').st_mode & 0o777 == 0o755 62 | 63 | 64 | @pytest.mark.dependency(depends=['test_upgrade_no_crowdsec']) 65 | def test_uninstall_no_crowdsec(project_repo, must_be_root): 66 | c = pexpect.spawn( 67 | '/usr/bin/sh', ['scripts/uninstall.sh'], 68 | cwd=project_repo 69 | ) 70 | 71 | c.expect(f"{BOUNCER} has been successfully uninstalled") 72 | c.wait() 73 | assert c.terminated 74 | assert c.exitstatus == 0 75 | 76 | assert not os.path.exists(CONFIG) 77 | assert not os.path.exists(f'/usr/local/bin/{BOUNCER}') 78 | -------------------------------------------------------------------------------- /test/tests/install/with_crowdsec/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-cloudflare-bouncer/81d0074a4375c50c7689e1dcfd44362a4ea2d6ef/test/tests/install/with_crowdsec/__init__.py -------------------------------------------------------------------------------- /test/tests/install/with_crowdsec/test_crowdsec_deb.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | from pathlib import Path 4 | 5 | import pytest 6 | import yaml 7 | from zxcvbn import zxcvbn 8 | 9 | pytestmark = pytest.mark.deb 10 | 11 | 12 | # TODO: use fixtures to install/purge and register/unregister bouncers 13 | 14 | 15 | def test_deb_install_purge(deb_package_path, bouncer_under_test, must_be_root): 16 | # test the full install-purge cycle, doing that in separate tests would 17 | # be a bit too much 18 | 19 | # TODO: remove and reinstall 20 | 21 | # use the package built as non-root by test_deb_build() 22 | assert deb_package_path.exists(), f'This test requires {deb_package_path}' 23 | 24 | p = subprocess.check_output( 25 | ['dpkg-deb', '-f', deb_package_path.as_posix(), 'Package'], 26 | encoding='utf-8' 27 | ) 28 | package_name = p.strip() 29 | 30 | subprocess.check_call(['dpkg', '--purge', package_name]) 31 | 32 | bouncer_exe = f"/usr/bin/{bouncer_under_test}" 33 | assert not os.path.exists(bouncer_exe) 34 | 35 | config = f"/etc/crowdsec/bouncers/{bouncer_under_test}.yaml" 36 | assert not os.path.exists(config) 37 | 38 | # install the package 39 | p = subprocess.run( 40 | ['dpkg', '--install', deb_package_path.as_posix()], 41 | stdout=subprocess.PIPE, 42 | stderr=subprocess.PIPE, 43 | encoding='utf-8' 44 | ) 45 | assert p.returncode == 0, f'Failed to install {deb_package_path}' 46 | 47 | assert os.path.exists(bouncer_exe) 48 | assert os.stat(bouncer_exe).st_mode & 0o777 == 0o755 49 | 50 | assert os.path.exists(config) 51 | assert os.stat(config).st_mode & 0o777 == 0o600 52 | 53 | with open(config) as f: 54 | cfg = yaml.safe_load(f) 55 | api_key = cfg['crowdsec_lapi_key'] 56 | # the api key has been set to a random value 57 | assert zxcvbn(api_key)['score'] == 4, f"weak api_key: '{api_key}'" 58 | 59 | with open(config+'.id') as f: 60 | bouncer_name = f.read().strip() 61 | 62 | p = subprocess.check_output(['cscli', 'bouncers', 'list', '-o', 'json']) 63 | bouncers = yaml.safe_load(p) 64 | assert len([b for b in bouncers if b['name'] == bouncer_name]) == 1 65 | 66 | p = subprocess.run( 67 | ['dpkg', '--purge', package_name], 68 | stdout=subprocess.PIPE, 69 | stderr=subprocess.PIPE, 70 | encoding='utf-8' 71 | ) 72 | assert p.returncode == 0, f'Failed to purge {package_name}' 73 | 74 | assert not os.path.exists(bouncer_exe) 75 | assert not os.path.exists(config) 76 | 77 | 78 | def test_deb_install_purge_yaml_local(deb_package_path, bouncer_under_test, must_be_root): 79 | """ 80 | Check .deb package installation with: 81 | 82 | - a pre-existing .yaml.local file with an api key 83 | - a pre-registered bouncer 84 | 85 | => the configuration files are not touched (no new api key) 86 | """ 87 | assert deb_package_path.exists(), f'This test requires {deb_package_path}' 88 | 89 | p = subprocess.check_output( 90 | ['dpkg-deb', '-f', deb_package_path.as_posix(), 'Package'], 91 | encoding='utf-8' 92 | ) 93 | package_name = p.strip() 94 | 95 | subprocess.check_call(['dpkg', '--purge', package_name]) 96 | subprocess.run(['cscli', 'bouncers', 'delete', 'testbouncer']) 97 | 98 | bouncer_exe = f"/usr/bin/{bouncer_under_test}" 99 | config = Path(f"/etc/crowdsec/bouncers/{bouncer_under_test}.yaml") 100 | config.parent.mkdir(parents=True, exist_ok=True) 101 | 102 | subprocess.check_call(['cscli', 'bouncers', 'add', 'testbouncer', '-k', '123456']) 103 | 104 | with open(config.with_suffix('.yaml.local'), 'w') as f: 105 | f.write('crowdsec_lapi_key: "123456"') 106 | 107 | p = subprocess.run( 108 | ['dpkg', '--install', deb_package_path.as_posix()], 109 | stdout=subprocess.PIPE, 110 | stderr=subprocess.PIPE, 111 | encoding='utf-8' 112 | ) 113 | assert p.returncode == 0, f'Failed to install {deb_package_path}' 114 | 115 | assert os.path.exists(bouncer_exe) 116 | assert os.path.exists(config) 117 | 118 | with open(config) as f: 119 | cfg = yaml.safe_load(f) 120 | api_key = cfg['crowdsec_lapi_key'] 121 | # the api key has not been set 122 | assert api_key == '${API_KEY}' 123 | 124 | p = subprocess.check_output([bouncer_exe, '-c', config, '-T']) 125 | merged_config = yaml.safe_load(p) 126 | assert merged_config['crowdsec_lapi_key'] == '123456' 127 | 128 | os.unlink(config.with_suffix('.yaml.local')) 129 | 130 | p = subprocess.run( 131 | ['dpkg', '--purge', package_name], 132 | stdout=subprocess.PIPE, 133 | stderr=subprocess.PIPE, 134 | encoding='utf-8' 135 | ) 136 | assert p.returncode == 0, f'Failed to purge {package_name}' 137 | 138 | assert not os.path.exists(bouncer_exe) 139 | assert not os.path.exists(config) 140 | -------------------------------------------------------------------------------- /test/tests/install/with_crowdsec/test_crowdsec_scripts.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pexpect 4 | import pytest 5 | import yaml 6 | from pytest_cs.lib import cscli, text 7 | 8 | BOUNCER = "crowdsec-cloudflare-bouncer" 9 | CONFIG = f"/etc/crowdsec/bouncers/{BOUNCER}.yaml" 10 | 11 | 12 | @pytest.mark.systemd_debug(BOUNCER) 13 | @pytest.mark.dependency 14 | def test_install_crowdsec(project_repo, bouncer_binary, must_be_root): 15 | c = pexpect.spawn( 16 | '/usr/bin/sh', ['scripts/install.sh'], 17 | encoding='utf-8', 18 | cwd=project_repo 19 | ) 20 | 21 | c.expect(f"Installing {BOUNCER}") 22 | c.expect("cscli found, generating bouncer api key.") 23 | c.expect("API Key: (.*)") 24 | api_key = text.nocolor(c.match.group(1).strip()) 25 | # XXX: what do we expect here ? 26 | c.wait() 27 | assert c.terminated 28 | # XXX: partial configuration, the service won't start 29 | # assert c.exitstatus == 0 30 | 31 | # installed files 32 | assert os.path.exists(CONFIG) 33 | assert os.stat(CONFIG).st_mode & 0o777 == 0o600 34 | assert os.path.exists(f'/usr/local/bin/{BOUNCER}') 35 | assert os.stat(f'/usr/local/bin/{BOUNCER}').st_mode & 0o777 == 0o755 36 | 37 | # configuration check 38 | with open(CONFIG) as f: 39 | y = yaml.safe_load(f) 40 | assert y['crowdsec_lapi_key'] == api_key 41 | assert y['crowdsec_lapi_url'] == 'http://localhost:8080/' 42 | 43 | # the bouncer is registered 44 | with open(f"{CONFIG}.id") as f: 45 | bouncer_name = f.read().strip() 46 | 47 | assert len(list(cscli.get_bouncers(name=bouncer_name))) == 1 48 | 49 | c = pexpect.spawn( 50 | '/usr/bin/sh', ['scripts/install.sh'], 51 | encoding='utf-8', 52 | cwd=project_repo 53 | ) 54 | 55 | c.expect(f"ERR:.* /usr/local/bin/{BOUNCER} is already installed. Exiting") 56 | 57 | 58 | @pytest.mark.dependency(depends=['test_install_crowdsec']) 59 | def test_upgrade_crowdsec(project_repo, must_be_root): 60 | os.remove(f'/usr/local/bin/{BOUNCER}') 61 | 62 | c = pexpect.spawn( 63 | '/usr/bin/sh', ['scripts/upgrade.sh'], 64 | encoding='utf-8', 65 | cwd=project_repo 66 | ) 67 | 68 | c.expect(f"{BOUNCER} upgraded successfully") 69 | c.wait() 70 | assert c.terminated 71 | assert c.exitstatus == 0 72 | 73 | assert os.path.exists(f'/usr/local/bin/{BOUNCER}') 74 | assert os.stat(f'/usr/local/bin/{BOUNCER}').st_mode & 0o777 == 0o755 75 | 76 | 77 | @pytest.mark.dependency(depends=['test_upgrade_crowdsec']) 78 | def test_uninstall_crowdsec(project_repo, must_be_root): 79 | # the bouncer is registered 80 | with open(f"{CONFIG}.id") as f: 81 | bouncer_name = f.read().strip() 82 | 83 | c = pexpect.spawn( 84 | '/usr/bin/sh', ['scripts/uninstall.sh'], 85 | encoding='utf-8', 86 | cwd=project_repo 87 | ) 88 | 89 | c.expect(f"{BOUNCER} has been successfully uninstalled") 90 | c.wait() 91 | assert c.terminated 92 | assert c.exitstatus == 0 93 | 94 | # installed files 95 | assert not os.path.exists(CONFIG) 96 | assert not os.path.exists(f'/usr/local/bin/{BOUNCER}') 97 | 98 | # the bouncer is unregistered 99 | assert len(list(cscli.get_bouncers(name=bouncer_name))) == 0 100 | -------------------------------------------------------------------------------- /test/tests/pkg/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-cloudflare-bouncer/81d0074a4375c50c7689e1dcfd44362a4ea2d6ef/test/tests/pkg/__init__.py -------------------------------------------------------------------------------- /test/tests/pkg/test_build_deb.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytestmark = pytest.mark.deb 4 | 5 | 6 | # This test has the side effect of building the package and leaving it in the 7 | # project's parent directory. 8 | def test_deb_build(deb_package, skip_unless_deb): 9 | """Test that the package can be built.""" 10 | assert deb_package.exists(), f'Package {deb_package} not found' 11 | -------------------------------------------------------------------------------- /test/tests/pkg/test_build_rpm.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytestmark = pytest.mark.rpm 4 | 5 | 6 | def test_rpm_build(rpm_package, skip_unless_rpm): 7 | """Test that the package can be built.""" 8 | assert rpm_package.exists(), f'Package {rpm_package} not found' 9 | -------------------------------------------------------------------------------- /test/tests/pkg/test_scripts_nonroot.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | 5 | def test_scripts_nonroot(project_repo, bouncer_binary, must_be_nonroot): 6 | assert os.geteuid() != 0, "This test must be run as non-root" 7 | 8 | for script in ['install.sh', 'upgrade.sh', 'uninstall.sh']: 9 | c = subprocess.run( 10 | ['/usr/bin/sh', f'scripts/{script}'], 11 | stdout=subprocess.PIPE, 12 | stderr=subprocess.PIPE, 13 | cwd=project_repo, 14 | encoding='utf-8', 15 | ) 16 | 17 | assert c.returncode == 1 18 | assert c.stdout == '' 19 | assert 'This script must be run as root' in c.stderr 20 | --------------------------------------------------------------------------------