├── .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 |
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 | 
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 |
--------------------------------------------------------------------------------