├── .envrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yaml │ ├── config.yml │ └── feature_request.yaml ├── governance.yml ├── release-drafter.yml ├── release.py └── workflows │ ├── build-binary-package.yml │ ├── governance-bot.yaml │ ├── lint.yml │ ├── release-drafter.yml │ ├── tests.yml │ └── tests_deb.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── cmd └── root.go ├── config ├── crowdsec-firewall-bouncer.service └── crowdsec-firewall-bouncer.yaml ├── debian ├── changelog ├── compat ├── control ├── crowdsec-firewall-bouncer-iptables.postinst ├── crowdsec-firewall-bouncer-iptables.postrm ├── crowdsec-firewall-bouncer-iptables.preinst ├── crowdsec-firewall-bouncer-iptables.prerm ├── crowdsec-firewall-bouncer-nftables.postinst ├── crowdsec-firewall-bouncer-nftables.postrm ├── crowdsec-firewall-bouncer-nftables.preinst ├── crowdsec-firewall-bouncer-nftables.prerm └── rules ├── docs └── assets │ └── crowdsec_linux_logo.png ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── main.go ├── pkg ├── backend │ └── backend.go ├── cfg │ ├── config.go │ └── logging.go ├── dryrun │ └── dryrun.go ├── ipsetcmd │ └── ipset.go ├── iptables │ ├── iptables.go │ ├── iptables_context.go │ ├── iptables_stub.go │ └── metrics.go ├── metrics │ └── metrics.go ├── nftables │ ├── metrics.go │ ├── nftables.go │ ├── nftables_context.go │ └── nftables_stub.go ├── pf │ ├── metrics.go │ ├── metrics_test.go │ ├── pf.go │ └── pf_context.go └── types │ └── types.go ├── rpm ├── SOURCES │ └── 80-crowdsec-firewall-bouncer.preset └── SPECS │ └── crowdsec-firewall-bouncer.spec ├── scripts ├── _bouncer.sh ├── install.sh ├── uninstall.sh └── upgrade.sh └── test ├── .python-version ├── README.md ├── default.env ├── pyproject.toml ├── pytest.ini ├── tests ├── __init__.py ├── backends │ ├── __init__.py │ ├── iptables │ │ ├── __init__.py │ │ ├── crowdsec-firewall-bouncer-logging.yaml │ │ ├── crowdsec-firewall-bouncer.yaml │ │ └── test_iptables.py │ ├── mock_lapi.py │ ├── nftables │ │ ├── __init__.py │ │ ├── crowdsec-firewall-bouncer.yaml │ │ └── test_nftables.py │ └── utils.py ├── bouncer │ ├── __init__.py │ ├── test_firewall_bouncer.py │ ├── test_iptables_deny_action.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 /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report a bug encountered while operating crowdsec 3 | labels: kind/bug 4 | body: 5 | - type: textarea 6 | id: problem 7 | attributes: 8 | label: What happened? 9 | description: | 10 | Please provide as much info as possible. Not doing so may result in your bug not being addressed in a timely manner. 11 | If this matter is security related, please disclose it privately to security@crowdsec.net 12 | validations: 13 | required: true 14 | 15 | - type: textarea 16 | id: expected 17 | attributes: 18 | label: What did you expect to happen? 19 | validations: 20 | required: true 21 | 22 | - type: textarea 23 | id: repro 24 | attributes: 25 | label: How can we reproduce it (as minimally and precisely as possible)? 26 | validations: 27 | required: true 28 | 29 | - type: textarea 30 | id: additional 31 | attributes: 32 | label: Anything else we need to know? 33 | 34 | - type: textarea 35 | id: Version 36 | attributes: 37 | label: version 38 | value: | 39 | remediation component version: 40 | 41 |
42 | 43 | ```console 44 | $ crowdsec-firewall-bouncer --version 45 | # paste output here 46 | ``` 47 | 48 |
49 | validations: 50 | required: true 51 | - type: textarea 52 | id: CS-Version 53 | attributes: 54 | label: crowdsec version 55 | value: | 56 | crowdsec version: 57 | 58 |
59 | 60 | ```console 61 | $ crowdsec --version 62 | # paste output here 63 | ``` 64 | 65 |
66 | validations: 67 | required: true 68 | 69 | - type: textarea 70 | id: osVersion 71 | attributes: 72 | label: OS version 73 | value: | 74 |
75 | 76 | ```console 77 | # On Linux: 78 | $ cat /etc/os-release 79 | # paste output here 80 | $ uname -a 81 | # paste output here 82 | 83 | # On Windows: 84 | C:\> wmic os get Caption, Version, BuildNumber, OSArchitecture 85 | # paste output here 86 | ``` 87 | 88 |
-------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Support Request 3 | url: https://discourse.crowdsec.net 4 | about: Support request or question relating to Crowdsec -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an improvement or a new feature 3 | body: 4 | - type: textarea 5 | id: feature 6 | attributes: 7 | label: What would you like to be added? 8 | description: | 9 | Significant feature requests are unlikely to make progress as issues. Please consider engaging on discord (discord.gg/crowdsec) and forums (https://discourse.crowdsec.net), instead. 10 | value: | 11 | For feature request please pick a kind label by removing `` that wrap the example lines below 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | validations: 20 | required: true 21 | 22 | - type: textarea 23 | id: rationale 24 | attributes: 25 | label: Why is this needed? 26 | validations: 27 | required: true 28 | -------------------------------------------------------------------------------- /.github/governance.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | 3 | issue: 4 | captures: 5 | - regex: 'version: v(.+)-' 6 | github_release: true 7 | ignore_case: true 8 | label: 'version/$CAPTURED' 9 | 10 | labels: 11 | - prefix: triage 12 | list: ['accepted'] 13 | multiple: false 14 | author_association: 15 | collaborator: true 16 | member: true 17 | owner: true 18 | needs: 19 | comment: | 20 | @$AUTHOR: Thanks for opening an issue, it is currently awaiting triage. 21 | 22 | In the meantime, you can: 23 | 24 | 1. Check [Documentation](https://docs.crowdsec.net/docs/next/bouncers/firewall) to see if your issue can be self resolved. 25 | 2. You can also join our [Discord](https://discord.gg/crowdsec) 26 | 27 | - prefix: kind 28 | list: ['feature', 'bug', 'packaging', 'enhancement'] 29 | multiple: false 30 | author_association: 31 | author: true 32 | collaborator: true 33 | member: true 34 | owner: true 35 | needs: 36 | comment: | 37 | @$AUTHOR: There are no 'kind' label on this issue. You need a 'kind' label to start the triage process. 38 | * `/kind feature` 39 | * `/kind enhancement` 40 | * `/kind bug` 41 | * `/kind packaging` 42 | -------------------------------------------------------------------------------- /.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-firewall-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-file: go.mod 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/governance-bot.yaml: -------------------------------------------------------------------------------- 1 | # .github/workflow/governance.yml 2 | 3 | on: 4 | pull_request_target: 5 | types: [ synchronize, opened, labeled, unlabeled ] 6 | issues: 7 | types: [ opened, labeled, unlabeled ] 8 | issue_comment: 9 | types: [ created ] 10 | 11 | # You can use permissions to modify the default permissions granted to the GITHUB_TOKEN, 12 | # adding or removing access as required, so that you only allow the minimum required access. 13 | permissions: 14 | contents: read 15 | issues: write 16 | pull-requests: write 17 | statuses: write 18 | checks: write 19 | 20 | jobs: 21 | governance: 22 | name: Governance 23 | runs-on: ubuntu-latest 24 | steps: 25 | # Semantic versioning, lock to different version: v2, v2.0 or a commit hash. 26 | - uses: BirthdayResearch/oss-governance-bot@v3 27 | with: 28 | # You can use a PAT to post a comment/label/status so that it shows up as a user instead of github-actions 29 | github-token: ${{secrets.GITHUB_TOKEN}} # optional, default to '${{ github.token }}' 30 | config-path: .github/governance.yml # optional, default to '.github/governance.yml' -------------------------------------------------------------------------------- /.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 | - 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: Initialize CodeQL 30 | uses: github/codeql-action/init@v3 31 | with: 32 | languages: go, python 33 | 34 | - name: Build 35 | run: | 36 | make build 37 | 38 | - name: golangci-lint 39 | uses: golangci/golangci-lint-action@v7 40 | with: 41 | version: v2.1 42 | args: --issues-exit-code=1 --timeout 10m 43 | only-new-issues: false 44 | 45 | - name: Perform CodeQL Analysis 46 | uses: github/codeql-action/analyze@v3 47 | -------------------------------------------------------------------------------- /.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/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 | sudo apt update 60 | sudo apt install -y nftables iptables ipset 61 | docker network create net-test 62 | 63 | - name: Run functional tests 64 | env: 65 | CROWDSEC_TEST_VERSION: dev 66 | CROWDSEC_TEST_FLAVORS: full 67 | CROWDSEC_TEST_NETWORK: net-test 68 | CROWDSEC_TEST_TIMEOUT: 60 69 | PYTEST_ADDOPTS: --durations=0 -vv --color=yes -m "not (deb or rpm)" 70 | working-directory: ./test 71 | run: | 72 | # everything except for 73 | # - install (requires root, ignored by default) 74 | # - backends (requires root, ignored by default) 75 | # - deb/rpm (on their own workflows) 76 | uv run pytest 77 | # these need root 78 | sudo -E $(which uv) run pytest ./tests/backends 79 | sudo -E $(which uv) run pytest ./tests/install/no_crowdsec 80 | # these need a running crowdsec 81 | docker run -d --name crowdsec -e CI_TESTING=true -e DISABLE_ONLINE_API=true -ti crowdsecurity/crowdsec 82 | install -m 0755 /dev/stdin /usr/local/bin/cscli <<'EOT' 83 | #!/bin/sh 84 | docker exec crowdsec cscli "$@" 85 | EOT 86 | sleep 5 87 | sudo -E $(which uv) run pytest ./tests/install/with_crowdsec 88 | 89 | - name: Lint 90 | working-directory: ./test 91 | run: | 92 | uv run ruff check 93 | uv run basedpyright 94 | 95 | -------------------------------------------------------------------------------- /.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 nftables iptables ipset 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-firewall-bouncer 20 | /crowdsec-firewall-bouncer-* 21 | /crowdsec-firewall-bouncer.tgz 22 | 23 | # built by dpkg-buildpackage 24 | /debian/crowdsec-firewall-bouncer-iptables 25 | /debian/crowdsec-firewall-bouncer-nftables 26 | /debian/files 27 | /debian/*.substvars 28 | /debian/*.debhelper 29 | /debian/*-stamp 30 | 31 | # built by rpmbuild 32 | /rpm/BUILD 33 | /rpm/BUILDROOT 34 | /rpm/RPMS 35 | /rpm/SOURCES/*.tar.gz 36 | /rpm/SRPMS 37 | 38 | # nix generated dirs 39 | .direnv 40 | .devenv 41 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | linters: 4 | default: all 5 | disable: 6 | - cyclop # revive 7 | - funlen # revive 8 | - gocognit # revive 9 | - gocyclo # revive 10 | - lll # revive 11 | 12 | - dupl 13 | - err113 14 | - exhaustruct 15 | - gochecknoglobals 16 | - goconst 17 | - godox 18 | - gosec 19 | - ireturn 20 | - maintidx 21 | - mnd 22 | - nlreturn 23 | - paralleltest 24 | - tagliatelle 25 | - testpackage 26 | - unparam 27 | - varnamelen 28 | - whitespace 29 | - wrapcheck 30 | - wsl 31 | - funcorder 32 | settings: 33 | 34 | depguard: 35 | rules: 36 | main: 37 | deny: 38 | - pkg: github.com/pkg/errors 39 | desc: errors.Wrap() is deprecated in favor of fmt.Errorf() 40 | 41 | errcheck: 42 | check-type-assertions: false 43 | 44 | gocritic: 45 | enable-all: true 46 | disabled-checks: 47 | - appendCombine 48 | - paramTypeCombine 49 | - sloppyReassign 50 | - unnamedResult 51 | - importShadow 52 | 53 | govet: 54 | disable: 55 | - fieldalignment 56 | enable-all: true 57 | 58 | misspell: 59 | locale: US 60 | 61 | nestif: 62 | # lower this after refactoring 63 | min-complexity: 13 64 | 65 | nlreturn: 66 | block-size: 4 67 | 68 | nolintlint: 69 | require-explanation: false 70 | require-specific: false 71 | allow-unused: false 72 | 73 | revive: 74 | severity: error 75 | enable-all-rules: true 76 | rules: 77 | - name: add-constant 78 | disabled: true 79 | - name: cognitive-complexity 80 | arguments: 81 | # lower this after refactoring 82 | - 49 83 | - name: comment-spacings 84 | disabled: true 85 | - name: confusing-results 86 | disabled: true 87 | - name: cyclomatic 88 | arguments: 89 | # lower this after refactoring 90 | - 28 91 | - name: flag-parameter 92 | disabled: true 93 | - name: function-length 94 | arguments: 95 | # lower this after refactoring 96 | - 74 97 | - 149 98 | - name: import-alias-naming 99 | disabled: true 100 | - name: import-shadowing 101 | disabled: true 102 | - name: line-length-limit 103 | disabled: true 104 | - name: nested-structs 105 | disabled: true 106 | - name: exported 107 | disabled: true 108 | - name: unexported-return 109 | disabled: true 110 | - name: unhandled-error 111 | arguments: 112 | - fmt.Print 113 | - fmt.Printf 114 | - fmt.Println 115 | - name: function-result-limit 116 | arguments: 117 | - 5 118 | staticcheck: 119 | checks: 120 | - all 121 | wsl: 122 | allow-trailing-comment: true 123 | exclusions: 124 | presets: 125 | - comments 126 | - common-false-positives 127 | - legacy 128 | - std-error-handling 129 | rules: 130 | - linters: 131 | - govet 132 | text: 'shadow: declaration of "(err|ctx)" shadows declaration' 133 | 134 | - linters: 135 | - perfsprint 136 | text: fmt.Sprintf can be replaced .* 137 | paths: 138 | - third_party$ 139 | - builtin$ 140 | - examples$ 141 | 142 | issues: 143 | max-issues-per-linter: 0 144 | max-same-issues: 0 145 | 146 | formatters: 147 | settings: 148 | gci: 149 | sections: 150 | - standard 151 | - default 152 | - prefix(github.com/crowdsecurity) 153 | - prefix(github.com/crowdsecurity/crowdsec) 154 | - prefix(github.com/crowdsecurity/cs-firewall-bouncer) 155 | exclusions: 156 | paths: 157 | - third_party$ 158 | - builtin$ 159 | - examples$ 160 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 crowdsecurity 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. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO = go 2 | GOBUILD = $(GO) build 3 | GOTEST = $(GO) test 4 | 5 | BINARY_NAME=crowdsec-firewall-bouncer 6 | TARBALL_NAME=$(BINARY_NAME).tgz 7 | 8 | ifdef BUILD_STATIC 9 | $(warning WARNING: The BUILD_STATIC variable is deprecated and has no effect. Builds are static by default now.) 10 | endif 11 | 12 | # Versioning information can be overridden in the environment 13 | BUILD_VERSION?=$(shell git describe --tags) 14 | BUILD_TIMESTAMP?=$(shell date +%F"_"%T) 15 | BUILD_TAG?=$(shell git rev-parse HEAD) 16 | 17 | LD_OPTS_VARS=\ 18 | -X 'github.com/crowdsecurity/go-cs-lib/version.Version=$(BUILD_VERSION)' \ 19 | -X 'github.com/crowdsecurity/go-cs-lib/version.BuildDate=$(BUILD_TIMESTAMP)' \ 20 | -X 'github.com/crowdsecurity/go-cs-lib/version.Tag=$(BUILD_TAG)' 21 | 22 | ifneq (,$(DOCKER_BUILD)) 23 | LD_OPTS_VARS += -X 'github.com/crowdsecurity/go-cs-lib/version.System=docker' 24 | endif 25 | 26 | export CGO_ENABLED=0 27 | export LD_OPTS=-ldflags "-s -extldflags '-static' $(LD_OPTS_VARS)" \ 28 | -trimpath -tags netgo 29 | 30 | .PHONY: all 31 | all: build test 32 | 33 | # same as "$(MAKE) -f debian/rules clean" but without the dependency on debhelper 34 | .PHONY: clean-debian 35 | clean-debian: 36 | @$(RM) -r debian/crowdsec-firewall-bouncer-iptables 37 | @$(RM) -r debian/crowdsec-firewall-bouncer-nftables 38 | @$(RM) -r debian/files 39 | @$(RM) -r debian/.debhelper 40 | @$(RM) -r debian/*.substvars 41 | @$(RM) -r debian/*-stamp 42 | 43 | .PHONY: clean-rpm 44 | clean-rpm: 45 | @$(RM) -r rpm/BUILD 46 | @$(RM) -r rpm/BUILDROOT 47 | @$(RM) -r rpm/RPMS 48 | @$(RM) -r rpm/SOURCES/*.tar.gz 49 | @$(RM) -r rpm/SRPMS 50 | 51 | # Remove everything including all platform binaries and tarballs 52 | .PHONY: clean 53 | clean: clean-release-dir clean-debian clean-rpm 54 | @$(RM) $(BINARY_NAME) 55 | @$(RM) $(TARBALL_NAME) 56 | @$(RM) -r $(BINARY_NAME)-* # platform binary name and leftover release dir 57 | @$(RM) $(BINARY_NAME)-*.tgz # platform release file 58 | 59 | # 60 | # Build binaries 61 | # 62 | 63 | .PHONY: binary 64 | binary: 65 | $(GOBUILD) $(LD_OPTS) -o $(BINARY_NAME) 66 | 67 | .PHONY: build 68 | build: clean binary 69 | 70 | # 71 | # Unit and integration tests 72 | # 73 | 74 | .PHONY: lint 75 | lint: 76 | golangci-lint run 77 | 78 | .PHONY: test 79 | test: 80 | @$(GOTEST) $(LD_OPTS) ./... 81 | 82 | .PHONY: func-tests 83 | func-tests: build 84 | pipenv install --dev 85 | pipenv run pytest -v 86 | 87 | # 88 | # Build release tarballs 89 | # 90 | 91 | RELDIR = $(BINARY_NAME)-$(BUILD_VERSION) 92 | 93 | .PHONY: vendor 94 | vendor: vendor-remove 95 | $(GO) mod vendor 96 | tar czf vendor.tgz vendor 97 | tar --create --auto-compress --file=$(RELDIR)-vendor.tar.xz vendor 98 | 99 | .PHONY: vendor-remove 100 | vendor-remove: 101 | $(RM) -r vendor vendor.tgz *-vendor.tar.xz 102 | 103 | # Called during platform-all, to reuse the directory for other platforms 104 | .PHONY: clean-release-dir 105 | clean-release-dir: 106 | @$(RM) -r $(RELDIR) 107 | 108 | .PHONY: tarball 109 | tarball: binary 110 | @if [ -z $(BUILD_VERSION) ]; then BUILD_VERSION="local" ; fi 111 | @if [ -d $(RELDIR) ]; then echo "$(RELDIR) already exists, please run 'make clean' and retry" ; exit 1 ; fi 112 | @echo Building Release to dir $(RELDIR) 113 | @mkdir -p $(RELDIR)/scripts 114 | @cp $(BINARY_NAME) $(RELDIR)/ 115 | @cp -R ./config $(RELDIR)/ 116 | @cp ./scripts/install.sh $(RELDIR)/ 117 | @cp ./scripts/uninstall.sh $(RELDIR)/ 118 | @cp ./scripts/upgrade.sh $(RELDIR)/ 119 | @cp ./scripts/_bouncer.sh $(RELDIR)/scripts/ 120 | @chmod +x $(RELDIR)/install.sh 121 | @chmod +x $(RELDIR)/uninstall.sh 122 | @chmod +x $(RELDIR)/upgrade.sh 123 | @tar cvzf $(TARBALL_NAME) $(RELDIR) 124 | 125 | .PHONY: release 126 | release: clean tarball 127 | 128 | # 129 | # Build binaries and release tarballs for all platforms 130 | # 131 | 132 | .PHONY: platform-all 133 | platform-all: clean 134 | python3 .github/release.py run-build $(BINARY_NAME) 135 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | CrowdSec 3 |

4 |

5 | 6 | 7 |

8 |

9 | 📚 Documentation 10 | 💠 Hub 11 | 💬 Discourse 12 |

13 | 14 | 15 | # crowdsec-firewall-bouncer 16 | Crowdsec bouncer written in golang for firewalls. 17 | 18 | crowdsec-firewall-bouncer will fetch new and old decisions from a CrowdSec API to add them in a blocklist used by supported firewalls. 19 | 20 | Supported firewalls: 21 | - iptables (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: ) 22 | - nftables (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: ) 23 | - ipset only (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: ) 24 | - pf (IPV4 :heavy_check_mark: / IPV6 :heavy_check_mark: ) 25 | 26 | # Installation 27 | 28 | Please follow the [official documentation](https://doc.crowdsec.net/docs/bouncers/firewall). 29 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "net" 9 | "net/http" 10 | "os" 11 | "os/signal" 12 | "slices" 13 | "strings" 14 | "syscall" 15 | 16 | "github.com/prometheus/client_golang/prometheus" 17 | "github.com/prometheus/client_golang/prometheus/promhttp" 18 | log "github.com/sirupsen/logrus" 19 | "golang.org/x/sync/errgroup" 20 | 21 | csbouncer "github.com/crowdsecurity/go-cs-bouncer" 22 | "github.com/crowdsecurity/go-cs-lib/csdaemon" 23 | "github.com/crowdsecurity/go-cs-lib/csstring" 24 | "github.com/crowdsecurity/go-cs-lib/version" 25 | 26 | "github.com/crowdsecurity/crowdsec/pkg/models" 27 | 28 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/backend" 29 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" 30 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics" 31 | ) 32 | 33 | const bouncerType = "crowdsec-firewall-bouncer" 34 | 35 | func backendCleanup(backend *backend.BackendCTX) { 36 | log.Info("Shutting down backend") 37 | 38 | if err := backend.ShutDown(); err != nil { 39 | log.Errorf("while shutting down backend: %s", err) 40 | } 41 | } 42 | 43 | func HandleSignals(ctx context.Context) error { 44 | signalChan := make(chan os.Signal, 1) 45 | signal.Notify(signalChan, syscall.SIGTERM, os.Interrupt) 46 | 47 | select { 48 | case s := <-signalChan: 49 | switch s { 50 | case syscall.SIGTERM: 51 | return errors.New("received SIGTERM") 52 | case os.Interrupt: // cross-platform SIGINT 53 | return errors.New("received interrupt") 54 | } 55 | case <-ctx.Done(): 56 | return ctx.Err() 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func deleteDecisions(backend *backend.BackendCTX, decisions []*models.Decision, config *cfg.BouncerConfig) { 63 | nbDeletedDecisions := 0 64 | 65 | for _, d := range decisions { 66 | if !slices.Contains(config.SupportedDecisionsTypes, strings.ToLower(*d.Type)) { 67 | log.Debugf("decisions for ip '%s' will not be deleted because its type is '%s'", *d.Value, *d.Type) 68 | continue 69 | } 70 | 71 | if err := backend.Delete(d); err != nil { 72 | if !strings.Contains(err.Error(), "netlink receive: no such file or directory") { 73 | log.Errorf("unable to delete decision for '%s': %s", *d.Value, err) 74 | } 75 | 76 | continue 77 | } 78 | 79 | log.Debugf("deleted %s", *d.Value) 80 | 81 | nbDeletedDecisions++ 82 | } 83 | 84 | noun := "decisions" 85 | if nbDeletedDecisions == 1 { 86 | noun = "decision" 87 | } 88 | 89 | if nbDeletedDecisions > 0 { 90 | log.Debug("committing expired decisions") 91 | 92 | if err := backend.Commit(); err != nil { 93 | log.Errorf("unable to commit expired decisions %v", err) 94 | return 95 | } 96 | 97 | log.Debug("committed expired decisions") 98 | log.Infof("%d %s deleted", nbDeletedDecisions, noun) 99 | } 100 | } 101 | 102 | func addDecisions(backend *backend.BackendCTX, decisions []*models.Decision, config *cfg.BouncerConfig) { 103 | nbNewDecisions := 0 104 | 105 | for _, d := range decisions { 106 | if !slices.Contains(config.SupportedDecisionsTypes, strings.ToLower(*d.Type)) { 107 | log.Debugf("decisions for ip '%s' will not be added because its type is '%s'", *d.Value, *d.Type) 108 | continue 109 | } 110 | 111 | if err := backend.Add(d); err != nil { 112 | log.Errorf("unable to insert decision for '%s': %s", *d.Value, err) 113 | continue 114 | } 115 | 116 | log.Debugf("Adding '%s' for '%s'", *d.Value, *d.Duration) 117 | 118 | nbNewDecisions++ 119 | } 120 | 121 | noun := "decisions" 122 | if nbNewDecisions == 1 { 123 | noun = "decision" 124 | } 125 | 126 | if nbNewDecisions > 0 { 127 | log.Debug("committing added decisions") 128 | 129 | if err := backend.Commit(); err != nil { 130 | log.Errorf("unable to commit add decisions %v", err) 131 | return 132 | } 133 | 134 | log.Debug("committed added decisions") 135 | log.Infof("%d %s added", nbNewDecisions, noun) 136 | } 137 | } 138 | 139 | func Execute() error { 140 | configPath := flag.String("c", "", "path to crowdsec-firewall-bouncer.yaml") 141 | verbose := flag.Bool("v", false, "set verbose mode") 142 | bouncerVersion := flag.Bool("V", false, "display version and exit (deprecated)") 143 | flag.BoolVar(bouncerVersion, "version", *bouncerVersion, "display version and exit") 144 | testConfig := flag.Bool("t", false, "test config and exit") 145 | showConfig := flag.Bool("T", false, "show full config (.yaml + .yaml.local) and exit") 146 | 147 | flag.Parse() 148 | 149 | if *bouncerVersion { 150 | fmt.Fprint(os.Stdout, version.FullString()) 151 | return nil 152 | } 153 | 154 | if configPath == nil || *configPath == "" { 155 | return errors.New("configuration file is required") 156 | } 157 | 158 | configMerged, err := cfg.MergedConfig(*configPath) 159 | if err != nil { 160 | return fmt.Errorf("unable to read config file: %w", err) 161 | } 162 | 163 | if *showConfig { 164 | fmt.Fprintln(os.Stdout, string(configMerged)) 165 | return nil 166 | } 167 | 168 | configExpanded := csstring.StrictExpand(string(configMerged), os.LookupEnv) 169 | 170 | config, err := cfg.NewConfig(strings.NewReader(configExpanded)) 171 | if err != nil { 172 | return fmt.Errorf("unable to load configuration: %w", err) 173 | } 174 | 175 | if *verbose && !log.IsLevelEnabled(log.DebugLevel) { 176 | log.SetLevel(log.DebugLevel) 177 | } 178 | 179 | log.Infof("Starting %s %s", bouncerType, version.String()) 180 | 181 | backend, err := backend.NewBackend(config) 182 | if err != nil { 183 | return err 184 | } 185 | 186 | if err = backend.Init(); err != nil { 187 | return err 188 | } 189 | 190 | defer backendCleanup(backend) 191 | 192 | bouncer := &csbouncer.StreamBouncer{} 193 | 194 | err = bouncer.ConfigReader(strings.NewReader(configExpanded)) 195 | if err != nil { 196 | return err 197 | } 198 | 199 | bouncer.UserAgent = fmt.Sprintf("%s/%s", bouncerType, version.String()) 200 | if err := bouncer.Init(); err != nil { 201 | return fmt.Errorf("unable to configure bouncer: %w", err) 202 | } 203 | 204 | if *testConfig { 205 | log.Info("config is valid") 206 | return nil 207 | } 208 | 209 | if bouncer.InsecureSkipVerify != nil { 210 | log.Debugf("InsecureSkipVerify is set to %t", *bouncer.InsecureSkipVerify) 211 | } 212 | 213 | g, ctx := errgroup.WithContext(context.Background()) 214 | 215 | g.Go(func() error { 216 | bouncer.Run(ctx) 217 | return errors.New("bouncer stream halted") 218 | }) 219 | 220 | mHandler := metrics.Handler{ 221 | Backend: backend, 222 | } 223 | 224 | metricsProvider, err := csbouncer.NewMetricsProvider(bouncer.APIClient, bouncerType, mHandler.MetricsUpdater, log.StandardLogger()) 225 | if err != nil { 226 | return fmt.Errorf("unable to create metrics provider: %w", err) 227 | } 228 | 229 | g.Go(func() error { 230 | return metricsProvider.Run(ctx) 231 | }) 232 | 233 | if config.Mode == cfg.IptablesMode || config.Mode == cfg.NftablesMode || config.Mode == cfg.IpsetMode || config.Mode == cfg.PfMode { 234 | metrics.Map.MustRegisterAll() 235 | } 236 | 237 | prometheus.MustRegister(csbouncer.TotalLAPICalls, csbouncer.TotalLAPIError) 238 | 239 | if config.PrometheusConfig.Enabled { 240 | go func() { 241 | http.Handle("/metrics", mHandler.ComputeMetricsHandler(promhttp.Handler())) 242 | 243 | listenOn := net.JoinHostPort( 244 | config.PrometheusConfig.ListenAddress, 245 | config.PrometheusConfig.ListenPort, 246 | ) 247 | log.Infof("Serving metrics at %s", listenOn+"/metrics") 248 | log.Error(http.ListenAndServe(listenOn, nil)) 249 | }() 250 | } 251 | 252 | g.Go(func() error { 253 | log.Infof("Processing new and deleted decisions . . .") 254 | 255 | for { 256 | select { 257 | case <-ctx.Done(): 258 | return nil 259 | case decisions := <-bouncer.Stream: 260 | if decisions == nil { 261 | continue 262 | } 263 | 264 | deleteDecisions(backend, decisions.Deleted, config) 265 | addDecisions(backend, decisions.New, config) 266 | } 267 | } 268 | }) 269 | 270 | if config.Daemon != nil { 271 | if *config.Daemon { 272 | log.Debug("Ignoring deprecated 'daemonize' option") 273 | } else { 274 | log.Warn("The 'daemonize' config option is deprecated and treated as always true") 275 | } 276 | } 277 | 278 | _ = csdaemon.Notify(csdaemon.Ready, log.StandardLogger()) 279 | 280 | g.Go(func() error { 281 | return HandleSignals(ctx) 282 | }) 283 | 284 | if err := g.Wait(); err != nil { 285 | return fmt.Errorf("process terminated with error: %w", err) 286 | } 287 | 288 | return nil 289 | } 290 | -------------------------------------------------------------------------------- /config/crowdsec-firewall-bouncer.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=The firewall bouncer for CrowdSec 3 | After=syslog.target network.target remote-fs.target nss-lookup.target crowdsec.service 4 | 5 | [Service] 6 | Type=notify 7 | ExecStart=${BIN} -c ${CFG}/crowdsec-firewall-bouncer.yaml 8 | ExecStartPre=${BIN} -c ${CFG}/crowdsec-firewall-bouncer.yaml -t 9 | ExecStartPost=/bin/sleep 0.1 10 | Restart=always 11 | RestartSec=10 12 | LimitNOFILE=65536 13 | # don't send a termination signal to the children processes, 14 | # because the iptables backend needs to run ipset multiple times to properly shutdown 15 | KillMode=mixed 16 | 17 | [Install] 18 | WantedBy=multi-user.target 19 | -------------------------------------------------------------------------------- /config/crowdsec-firewall-bouncer.yaml: -------------------------------------------------------------------------------- 1 | mode: ${BACKEND} 2 | update_frequency: 10s 3 | log_mode: file 4 | log_dir: /var/log/ 5 | log_level: info 6 | log_compression: true 7 | log_max_size: 100 8 | log_max_backups: 3 9 | log_max_age: 30 10 | api_url: http://127.0.0.1:8080/ 11 | api_key: ${API_KEY} 12 | ## TLS Authentication 13 | # cert_path: /etc/crowdsec/tls/cert.pem 14 | # key_path: /etc/crowdsec/tls/key.pem 15 | # ca_cert_path: /etc/crowdsec/tls/ca.crt 16 | insecure_skip_verify: false 17 | disable_ipv6: false 18 | deny_action: DROP 19 | deny_log: false 20 | supported_decisions_types: 21 | - ban 22 | #to change log prefix 23 | #deny_log_prefix: "crowdsec: " 24 | #to change the blacklists name 25 | blacklists_ipv4: crowdsec-blacklists 26 | blacklists_ipv6: crowdsec6-blacklists 27 | #type of ipset to use 28 | ipset_type: nethash 29 | #if present, insert rule in those chains 30 | iptables_chains: 31 | - INPUT 32 | # - FORWARD 33 | # - DOCKER-USER 34 | iptables_add_rule_comments: true 35 | 36 | ## nftables 37 | nftables: 38 | ipv4: 39 | enabled: true 40 | set-only: false 41 | table: crowdsec 42 | chain: crowdsec-chain 43 | priority: -10 44 | ipv6: 45 | enabled: true 46 | set-only: false 47 | table: crowdsec6 48 | chain: crowdsec6-chain 49 | priority: -10 50 | 51 | nftables_hooks: 52 | - input 53 | - forward 54 | 55 | # packet filter 56 | pf: 57 | # an empty string disables the anchor 58 | anchor_name: "" 59 | 60 | prometheus: 61 | enabled: false 62 | listen_addr: 127.0.0.1 63 | listen_port: 60601 64 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | crowdsec-firewall-bouncer (1.0.12) UNRELEASED; urgency=medium 2 | 3 | * debian package 4 | * pf support 5 | 6 | -- Manuel Sabban Mon, 08 Feb 2021 09:38:06 +0100 7 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 11 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: crowdsec-firewall-bouncer 2 | Maintainer: Crowdsec Team 3 | Build-Depends: debhelper 4 | Section: admin 5 | Priority: optional 6 | 7 | Package: crowdsec-firewall-bouncer-iptables 8 | Architecture: any 9 | Description: Firewall bouncer for Crowdsec (iptables+ipset) 10 | Depends: gettext-base, iptables, ipset 11 | Replaces: crowdsec-firewall-bouncer 12 | Conflicts: crowdsec-firewall-bouncer-nftables 13 | 14 | Package: crowdsec-firewall-bouncer-nftables 15 | Architecture: any 16 | Description: Firewall bouncer for Crowdsec (nftables) 17 | Depends: gettext-base, nftables 18 | Replaces: crowdsec-firewall-bouncer 19 | Conflicts: crowdsec-firewall-bouncer-iptables 20 | -------------------------------------------------------------------------------- /debian/crowdsec-firewall-bouncer-iptables.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 | systemctl --quiet is-enabled "$SERVICE" || systemctl unmask "$SERVICE" && systemctl enable "$SERVICE" 18 | 19 | set_local_port 20 | 21 | if [ "$START" -eq 0 ]; then 22 | 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 23 | else 24 | systemctl start "$SERVICE" 25 | fi 26 | -------------------------------------------------------------------------------- /debian/crowdsec-firewall-bouncer-iptables.postrm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | BOUNCER="crowdsec-firewall-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/crowdsec-firewall-bouncer-iptables.preinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # Source debconf library. 6 | . /usr/share/debconf/confmodule 7 | -------------------------------------------------------------------------------- /debian/crowdsec-firewall-bouncer-iptables.prerm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | BOUNCER="crowdsec-firewall-bouncer" 6 | 7 | systemctl stop "$BOUNCER" || echo "cannot stop service" 8 | systemctl disable "$BOUNCER" || echo "cannot disable service" 9 | -------------------------------------------------------------------------------- /debian/crowdsec-firewall-bouncer-nftables.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 | systemctl --quiet is-enabled "$SERVICE" || systemctl unmask "$SERVICE" && systemctl enable "$SERVICE" 18 | 19 | set_local_port 20 | 21 | if [ "$START" -eq 0 ]; then 22 | 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 23 | else 24 | systemctl start "$SERVICE" 25 | fi 26 | -------------------------------------------------------------------------------- /debian/crowdsec-firewall-bouncer-nftables.postrm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | BOUNCER="crowdsec-firewall-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/crowdsec-firewall-bouncer-nftables.preinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # Source debconf library. 6 | . /usr/share/debconf/confmodule 7 | 8 | echo "pre-inst (nftables)" 9 | -------------------------------------------------------------------------------- /debian/crowdsec-firewall-bouncer-nftables.prerm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -eu 4 | 5 | BOUNCER="crowdsec-firewall-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-firewall-bouncer; \ 18 | for BACKEND in iptables nftables; do \ 19 | PKG="$$BOUNCER-$$BACKEND"; \ 20 | install -D $$BOUNCER -t "debian/$$PKG/usr/bin/"; \ 21 | install -D scripts/_bouncer.sh -t "debian/$$PKG/usr/lib/$$PKG/"; \ 22 | BACKEND=$$BACKEND envsubst '$$BACKEND' < config/$$BOUNCER.yaml | install -D /dev/stdin "debian/$$PKG/etc/crowdsec/bouncers/$$BOUNCER.yaml"; \ 23 | 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"; \ 24 | mkdir -p "debian/$$PKG/usr/sbin/"; \ 25 | ln -s "/usr/bin/$$BOUNCER" "debian/$$PKG/usr/sbin/$$BOUNCER"; \ 26 | done 27 | 28 | execute_after_dh_fixperms: 29 | @BOUNCER=crowdsec-firewall-bouncer; \ 30 | for BACKEND in iptables nftables; do \ 31 | PKG="$$BOUNCER-$$BACKEND"; \ 32 | chmod 0755 "debian/$$PKG/usr/bin/$$BOUNCER"; \ 33 | chmod 0600 "debian/$$PKG/usr/lib/$$PKG/_bouncer.sh"; \ 34 | chmod 0600 "debian/$$PKG/etc/crowdsec/bouncers/$$BOUNCER.yaml"; \ 35 | chmod 0644 "debian/$$PKG/etc/systemd/system/$$BOUNCER.service"; \ 36 | done 37 | -------------------------------------------------------------------------------- /docs/assets/crowdsec_linux_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/docs/assets/crowdsec_linux_logo.png -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1739736696, 6 | "narHash": "sha256-zON2GNBkzsIyALlOCFiEBcIjI4w38GYOb+P+R4S8Jsw=", 7 | "rev": "d74a2335ac9c133d6bbec9fc98d91a77f1604c1f", 8 | "revCount": 754461, 9 | "type": "tarball", 10 | "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.754461%2Brev-d74a2335ac9c133d6bbec9fc98d91a77f1604c1f/01951426-5a87-7b75-8413-1a0d9ec5ff04/source.tar.gz" 11 | }, 12 | "original": { 13 | "type": "tarball", 14 | "url": "https://flakehub.com/f/NixOS/nixpkgs/0.1.%2A.tar.gz" 15 | } 16 | }, 17 | "root": { 18 | "inputs": { 19 | "nixpkgs": "nixpkgs" 20 | } 21 | } 22 | }, 23 | "root": "root", 24 | "version": 7 25 | } 26 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A Nix-flake-based Go 1.22 development environment"; 3 | 4 | inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1.*.tar.gz"; 5 | 6 | outputs = { self, nixpkgs }: 7 | let 8 | goVersion = 24; # Change this to update the whole stack 9 | 10 | supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; 11 | forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f { 12 | pkgs = import nixpkgs { 13 | inherit system; 14 | overlays = [ self.overlays.default ]; 15 | }; 16 | }); 17 | in 18 | { 19 | overlays.default = final: prev: { 20 | go = final."go_1_${toString goVersion}"; 21 | }; 22 | 23 | devShells = forEachSupportedSystem ({ pkgs }: { 24 | default = pkgs.mkShell { 25 | packages = with pkgs; [ 26 | # go (version is specified by overlay) 27 | go 28 | 29 | # goimports, godoc, etc. 30 | gotools 31 | 32 | # https://github.com/golangci/golangci-lint 33 | golangci-lint 34 | golangci-lint-langserver 35 | gopls 36 | ]; 37 | }; 38 | }); 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/crowdsecurity/cs-firewall-bouncer 2 | 3 | go 1.24.1 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/crowdsecurity/crowdsec v1.6.8 9 | github.com/crowdsecurity/go-cs-bouncer v0.0.16 10 | github.com/crowdsecurity/go-cs-lib v0.0.16 11 | github.com/google/nftables v0.2.0 12 | github.com/prometheus/client_golang v1.21.1 13 | github.com/prometheus/client_model v0.6.1 14 | github.com/sirupsen/logrus v1.9.3 15 | github.com/stretchr/testify v1.10.0 16 | golang.org/x/sync v0.12.0 17 | golang.org/x/sys v0.31.0 18 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 19 | gopkg.in/yaml.v2 v2.4.0 20 | ) 21 | 22 | require ( 23 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 24 | github.com/beorn7/perks v1.0.1 // indirect 25 | github.com/blackfireio/osinfo v1.0.5 // indirect 26 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 27 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 28 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 29 | github.com/expr-lang/expr v1.16.9 // indirect 30 | github.com/fatih/color v1.18.0 // indirect 31 | github.com/go-openapi/analysis v0.23.0 // indirect 32 | github.com/go-openapi/errors v0.22.0 // indirect 33 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 34 | github.com/go-openapi/jsonreference v0.21.0 // indirect 35 | github.com/go-openapi/loads v0.22.0 // indirect 36 | github.com/go-openapi/spec v0.21.0 // indirect 37 | github.com/go-openapi/strfmt v0.23.0 // indirect 38 | github.com/go-openapi/swag v0.23.0 // indirect 39 | github.com/go-openapi/validate v0.24.0 // indirect 40 | github.com/goccy/go-yaml v1.12.0 // indirect 41 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 42 | github.com/google/go-cmp v0.6.0 // indirect 43 | github.com/google/go-querystring v1.1.0 // indirect 44 | github.com/google/uuid v1.6.0 // indirect 45 | github.com/josharian/intern v1.0.0 // indirect 46 | github.com/josharian/native v1.1.0 // indirect 47 | github.com/klauspost/compress v1.17.11 // indirect 48 | github.com/mailru/easyjson v0.7.7 // indirect 49 | github.com/mattn/go-colorable v0.1.13 // indirect 50 | github.com/mattn/go-isatty v0.0.20 // indirect 51 | github.com/mdlayher/netlink v1.7.2 // indirect 52 | github.com/mdlayher/socket v0.5.1 // indirect 53 | github.com/mitchellh/mapstructure v1.5.0 // indirect 54 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 55 | github.com/oklog/ulid v1.3.1 // indirect 56 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 57 | github.com/prometheus/common v0.62.0 // indirect 58 | github.com/prometheus/procfs v0.15.1 // indirect 59 | go.mongodb.org/mongo-driver v1.16.1 // indirect 60 | golang.org/x/net v0.37.0 // indirect 61 | golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect 62 | google.golang.org/protobuf v1.36.3 // indirect 63 | gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect 64 | gopkg.in/yaml.v3 v3.0.1 // indirect 65 | ) 66 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | 6 | "github.com/crowdsecurity/cs-firewall-bouncer/cmd" 7 | ) 8 | 9 | func main() { 10 | err := cmd.Execute() 11 | if err != nil { 12 | log.Fatal(err) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pkg/backend/backend.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "runtime" 7 | 8 | log "github.com/sirupsen/logrus" 9 | 10 | "github.com/crowdsecurity/crowdsec/pkg/models" 11 | 12 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" 13 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/dryrun" 14 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/iptables" 15 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/nftables" 16 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/pf" 17 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types" 18 | ) 19 | 20 | type BackendCTX struct { 21 | firewall types.Backend 22 | } 23 | 24 | func (b *BackendCTX) Init() error { 25 | return b.firewall.Init() 26 | } 27 | 28 | func (b *BackendCTX) Commit() error { 29 | return b.firewall.Commit() 30 | } 31 | 32 | func (b *BackendCTX) ShutDown() error { 33 | return b.firewall.ShutDown() 34 | } 35 | 36 | func (b *BackendCTX) Add(decision *models.Decision) error { 37 | return b.firewall.Add(decision) 38 | } 39 | 40 | func (b *BackendCTX) Delete(decision *models.Decision) error { 41 | return b.firewall.Delete(decision) 42 | } 43 | 44 | func (b *BackendCTX) CollectMetrics() { 45 | log.Trace("Collecting backend-specific metrics") 46 | b.firewall.CollectMetrics() 47 | } 48 | 49 | func isPFSupported(runtimeOS string) bool { 50 | var supported bool 51 | 52 | switch runtimeOS { 53 | case "openbsd", "freebsd": 54 | supported = true 55 | default: 56 | supported = false 57 | } 58 | 59 | return supported 60 | } 61 | 62 | func NewBackend(config *cfg.BouncerConfig) (*BackendCTX, error) { 63 | var err error 64 | 65 | b := &BackendCTX{} 66 | 67 | log.Infof("backend type: %s", config.Mode) 68 | 69 | if config.DisableIPV6 { 70 | log.Info("IPV6 is disabled") 71 | } 72 | 73 | if config.DisableIPV4 { 74 | log.Info("IPV4 is disabled") 75 | } 76 | 77 | switch config.Mode { 78 | case cfg.IptablesMode, cfg.IpsetMode: 79 | if runtime.GOOS != "linux" { 80 | return nil, errors.New("iptables and ipset is linux only") 81 | } 82 | 83 | b.firewall, err = iptables.NewIPTables(config) 84 | if err != nil { 85 | return nil, err 86 | } 87 | case cfg.NftablesMode: 88 | if runtime.GOOS != "linux" { 89 | return nil, errors.New("nftables is linux only") 90 | } 91 | 92 | b.firewall, err = nftables.NewNFTables(config) 93 | if err != nil { 94 | return nil, err 95 | } 96 | case "pf": 97 | if !isPFSupported(runtime.GOOS) { 98 | log.Warning("pf mode can only work with openbsd and freebsd. It is available on other platforms only for testing purposes") 99 | } 100 | 101 | b.firewall, err = pf.NewPF(config) 102 | if err != nil { 103 | return nil, err 104 | } 105 | case "dry-run": 106 | b.firewall, err = dryrun.NewDryRun(config) 107 | if err != nil { 108 | return nil, err 109 | } 110 | default: 111 | return b, fmt.Errorf("firewall '%s' is not supported", config.Mode) 112 | } 113 | 114 | return b, nil 115 | } 116 | -------------------------------------------------------------------------------- /pkg/cfg/config.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | 8 | log "github.com/sirupsen/logrus" 9 | "gopkg.in/yaml.v2" 10 | 11 | "github.com/crowdsecurity/go-cs-lib/ptr" 12 | "github.com/crowdsecurity/go-cs-lib/yamlpatch" 13 | ) 14 | 15 | type PrometheusConfig struct { 16 | Enabled bool `yaml:"enabled"` 17 | ListenAddress string `yaml:"listen_addr"` 18 | ListenPort string `yaml:"listen_port"` 19 | } 20 | 21 | type nftablesFamilyConfig struct { 22 | Enabled *bool `yaml:"enabled"` 23 | SetOnly bool `yaml:"set-only"` 24 | Table string `yaml:"table"` 25 | Chain string `yaml:"chain"` 26 | Priority int `yaml:"priority"` 27 | } 28 | 29 | const ( 30 | IpsetMode = "ipset" 31 | IptablesMode = "iptables" 32 | NftablesMode = "nftables" 33 | PfMode = "pf" 34 | DryRunMode = "dry-run" 35 | ) 36 | 37 | type BouncerConfig struct { 38 | Mode string `yaml:"mode"` // ipset,iptables,tc 39 | PidDir string `yaml:"pid_dir"` // unused 40 | UpdateFrequency string `yaml:"update_frequency"` 41 | Daemon *bool `yaml:"daemonize"` // unused 42 | Logging LoggingConfig `yaml:",inline"` 43 | DisableIPV6 bool `yaml:"disable_ipv6"` 44 | DisableIPV4 bool `yaml:"disable_ipv4"` 45 | DenyAction string `yaml:"deny_action"` 46 | DenyLog bool `yaml:"deny_log"` 47 | DenyLogPrefix string `yaml:"deny_log_prefix"` 48 | BlacklistsIpv4 string `yaml:"blacklists_ipv4"` 49 | BlacklistsIpv6 string `yaml:"blacklists_ipv6"` 50 | SetType string `yaml:"ipset_type"` 51 | SetSize int `yaml:"ipset_size"` 52 | SetDisableTimeouts bool `yaml:"ipset_disable_timeouts"` 53 | 54 | // specific to iptables, following https://github.com/crowdsecurity/cs-firewall-bouncer/issues/19 55 | IptablesChains []string `yaml:"iptables_chains"` 56 | IptablesAddRuleComments bool `yaml:"iptables_add_rule_comments"` 57 | 58 | SupportedDecisionsTypes []string `yaml:"supported_decisions_types"` 59 | // specific to nftables, following https://github.com/crowdsecurity/cs-firewall-bouncer/issues/74 60 | Nftables struct { 61 | Ipv4 nftablesFamilyConfig `yaml:"ipv4"` 62 | Ipv6 nftablesFamilyConfig `yaml:"ipv6"` 63 | } `yaml:"nftables"` 64 | NftablesHooks []string `yaml:"nftables_hooks"` 65 | PF struct { 66 | AnchorName string `yaml:"anchor_name"` 67 | BatchSize int `yaml:"batch_size"` 68 | } `yaml:"pf"` 69 | PrometheusConfig PrometheusConfig `yaml:"prometheus"` 70 | } 71 | 72 | // MergedConfig() returns the byte content of the patched configuration file (with .yaml.local). 73 | func MergedConfig(configPath string) ([]byte, error) { 74 | patcher := yamlpatch.NewPatcher(configPath, ".local") 75 | 76 | data, err := patcher.MergedPatchContent() 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | return data, nil 82 | } 83 | 84 | func NewConfig(reader io.Reader) (*BouncerConfig, error) { 85 | config := &BouncerConfig{ 86 | IptablesAddRuleComments: true, 87 | } 88 | 89 | fcontent, err := io.ReadAll(reader) 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | err = yaml.Unmarshal(fcontent, &config) 95 | if err != nil { 96 | return nil, fmt.Errorf("failed to unmarshal: %w", err) 97 | } 98 | 99 | if err = config.Logging.setup("crowdsec-firewall-bouncer.log"); err != nil { 100 | return nil, fmt.Errorf("failed to setup logging: %w", err) 101 | } 102 | 103 | if config.Mode == "" { 104 | return nil, errors.New("config does not contain 'mode'") 105 | } 106 | 107 | if len(config.SupportedDecisionsTypes) == 0 { 108 | config.SupportedDecisionsTypes = []string{"ban"} 109 | } 110 | 111 | if config.PidDir != "" { 112 | log.Debug("Ignoring deprecated 'pid_dir' option") 113 | } 114 | 115 | if config.DenyLog && config.DenyLogPrefix == "" { 116 | config.DenyLogPrefix = "crowdsec drop: " 117 | } 118 | 119 | // for config file backward compatibility 120 | if config.BlacklistsIpv4 == "" { 121 | config.BlacklistsIpv4 = "crowdsec-blacklists" 122 | } 123 | 124 | if config.BlacklistsIpv6 == "" { 125 | config.BlacklistsIpv6 = "crowdsec6-blacklists" 126 | } 127 | 128 | if config.SetType == "" { 129 | config.SetType = "nethash" 130 | } 131 | 132 | if config.SetSize == 0 { 133 | config.SetSize = 131072 134 | } 135 | 136 | if config.DisableIPV4 && config.DisableIPV6 && config.Mode != NftablesMode { 137 | // we return an error for pf or iptables because nftables has it own way to handle this 138 | return nil, errors.New("both IPv4 and IPv6 disabled, doing nothing") 139 | } 140 | 141 | switch config.Mode { 142 | case NftablesMode: 143 | err := nftablesConfig(config) 144 | if err != nil { 145 | return nil, err 146 | } 147 | case IpsetMode, IptablesMode: 148 | // nothing specific to do 149 | case PfMode: 150 | err := pfConfig(config) 151 | if err != nil { 152 | return nil, err 153 | } 154 | case DryRunMode: 155 | // nothing specific to do 156 | default: 157 | log.Warningf("unexpected %s mode", config.Mode) 158 | } 159 | 160 | return config, nil 161 | } 162 | 163 | func pfConfig(_ *BouncerConfig) error { 164 | return nil 165 | } 166 | 167 | func nftablesConfig(config *BouncerConfig) error { 168 | // deal with defaults in a backward compatible way 169 | if config.Nftables.Ipv4.Enabled == nil { 170 | config.Nftables.Ipv4.Enabled = ptr.Of(!config.DisableIPV4) 171 | } 172 | 173 | if config.Nftables.Ipv6.Enabled == nil { 174 | config.Nftables.Ipv6.Enabled = ptr.Of(!config.DisableIPV6) 175 | } 176 | 177 | if *config.Nftables.Ipv4.Enabled { 178 | if config.Nftables.Ipv4.Table == "" { 179 | config.Nftables.Ipv4.Table = "crowdsec" 180 | } 181 | 182 | if config.Nftables.Ipv4.Chain == "" { 183 | config.Nftables.Ipv4.Chain = "crowdsec-chain" 184 | } 185 | } 186 | 187 | if *config.Nftables.Ipv6.Enabled { 188 | if config.Nftables.Ipv6.Table == "" { 189 | config.Nftables.Ipv6.Table = "crowdsec6" 190 | } 191 | 192 | if config.Nftables.Ipv6.Chain == "" { 193 | config.Nftables.Ipv6.Chain = "crowdsec6-chain" 194 | } 195 | } 196 | 197 | if !*config.Nftables.Ipv4.Enabled && !*config.Nftables.Ipv6.Enabled { 198 | return errors.New("both IPv4 and IPv6 disabled, doing nothing") 199 | } 200 | 201 | if len(config.NftablesHooks) == 0 { 202 | config.NftablesHooks = []string{"input"} 203 | } 204 | 205 | return nil 206 | } 207 | -------------------------------------------------------------------------------- /pkg/cfg/logging.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | import ( 4 | "errors" 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 = 500 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 errors.New("log_mode should be either 'stdout' or 'file'") 79 | } 80 | 81 | return nil 82 | } 83 | 84 | func (c *LoggingConfig) setup(fileName string) error { 85 | c.setDefaults() 86 | 87 | if err := c.validate(); err != nil { 88 | return err 89 | } 90 | 91 | log.SetLevel(*c.LogLevel) 92 | 93 | if c.LogMode == "stdout" { 94 | return nil 95 | } 96 | 97 | log.SetFormatter(&log.TextFormatter{TimestampFormat: time.RFC3339, FullTimestamp: true}) 98 | 99 | logger, err := c.LoggerForFile(fileName) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | log.SetOutput(logger) 105 | 106 | // keep stderr for panic/fatal, otherwise process failures 107 | // won't be visible enough 108 | log.AddHook(&writer.Hook{ 109 | Writer: os.Stderr, 110 | LogLevels: []log.Level{ 111 | log.PanicLevel, 112 | log.FatalLevel, 113 | }, 114 | }) 115 | 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /pkg/dryrun/dryrun.go: -------------------------------------------------------------------------------- 1 | package dryrun 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | 6 | "github.com/crowdsecurity/crowdsec/pkg/models" 7 | 8 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" 9 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types" 10 | ) 11 | 12 | type dryRun struct{} 13 | 14 | func NewDryRun(_ *cfg.BouncerConfig) (types.Backend, error) { 15 | return &dryRun{}, nil 16 | } 17 | 18 | func (*dryRun) Init() error { 19 | log.Infof("backend.Init() called") 20 | return nil 21 | } 22 | 23 | func (*dryRun) Commit() error { 24 | log.Infof("backend.Commit() called") 25 | return nil 26 | } 27 | 28 | func (*dryRun) Add(decision *models.Decision) error { 29 | log.Infof("backend.Add() called with %s", *decision.Value) 30 | return nil 31 | } 32 | 33 | func (*dryRun) CollectMetrics() { 34 | log.Infof("backend.CollectMetrics() called") 35 | } 36 | 37 | func (*dryRun) Delete(decision *models.Decision) error { 38 | log.Infof("backend.Delete() called with %s", *decision.Value) 39 | return nil 40 | } 41 | 42 | func (*dryRun) ShutDown() error { 43 | log.Infof("backend.ShutDown() called") 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/ipsetcmd/ipset.go: -------------------------------------------------------------------------------- 1 | package ipsetcmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os/exec" 7 | "strconv" 8 | "strings" 9 | 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | type IPSet struct { 14 | binaryPath string 15 | setName string 16 | } 17 | 18 | type CreateOptions struct { 19 | Timeout string 20 | MaxElem string 21 | Family string 22 | Type string 23 | DisableTimeouts bool 24 | } 25 | 26 | const ipsetBinary = "ipset" 27 | 28 | func NewIPSet(setName string) (*IPSet, error) { 29 | ipsetBin, err := exec.LookPath(ipsetBinary) 30 | if err != nil { 31 | return nil, errors.New("unable to find ipset") 32 | } 33 | 34 | return &IPSet{ 35 | binaryPath: ipsetBin, 36 | setName: setName, 37 | }, nil 38 | } 39 | 40 | // Wraps all the ipset commands. 41 | func (i *IPSet) Create(opts CreateOptions) error { 42 | cmdArgs := []string{"create", i.setName} 43 | 44 | if opts.Type != "" { 45 | cmdArgs = append(cmdArgs, opts.Type) 46 | } 47 | 48 | if opts.Timeout != "" && !opts.DisableTimeouts { 49 | cmdArgs = append(cmdArgs, "timeout", opts.Timeout) 50 | } 51 | 52 | if opts.MaxElem != "" { 53 | cmdArgs = append(cmdArgs, "maxelem", opts.MaxElem) 54 | } 55 | 56 | if opts.Family != "" { 57 | cmdArgs = append(cmdArgs, "family", opts.Family) 58 | } 59 | 60 | cmd := exec.Command(i.binaryPath, cmdArgs...) 61 | 62 | log.Debugf("ipset create command: %v", cmd.String()) 63 | 64 | out, err := cmd.CombinedOutput() 65 | if err != nil { 66 | return fmt.Errorf("error creating ipset: %s", out) 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func (i *IPSet) Add(entry string) error { 73 | cmd := exec.Command(i.binaryPath, "add", i.setName, entry) 74 | 75 | log.Debugf("ipset add command: %v", cmd.String()) 76 | 77 | out, err := cmd.CombinedOutput() 78 | if err != nil { 79 | return fmt.Errorf("error creating ipset: %s", out) 80 | } 81 | 82 | return nil 83 | } 84 | 85 | func (i *IPSet) DeleteEntry(entry string) error { 86 | cmd := exec.Command(i.binaryPath, "del", i.setName, entry) 87 | 88 | log.Debugf("ipset delete entry command: %v", cmd.String()) 89 | 90 | out, err := cmd.CombinedOutput() 91 | if err != nil { 92 | return fmt.Errorf("error creating ipset: %s", out) 93 | } 94 | 95 | return nil 96 | } 97 | 98 | func (i *IPSet) List() ([]string, error) { 99 | cmd := exec.Command(i.binaryPath, "list", i.setName) 100 | 101 | log.Debugf("ipset list command: %v", cmd.String()) 102 | 103 | out, err := cmd.CombinedOutput() 104 | if err != nil { 105 | return nil, fmt.Errorf("error listing ipset: %s", out) 106 | } 107 | 108 | return strings.Split(string(out), "\n"), nil 109 | } 110 | 111 | func (i *IPSet) Flush() error { 112 | cmd := exec.Command(i.binaryPath, "flush", i.setName) 113 | 114 | log.Debugf("ipset flush command: %v", cmd.String()) 115 | 116 | out, err := cmd.CombinedOutput() 117 | if err != nil { 118 | return fmt.Errorf("error flushing ipset: %s", out) 119 | } 120 | 121 | return nil 122 | } 123 | 124 | func (i *IPSet) Destroy() error { 125 | cmd := exec.Command(i.binaryPath, "destroy", i.setName) 126 | 127 | log.Debugf("ipset destroy command: %v", cmd.String()) 128 | 129 | out, err := cmd.CombinedOutput() 130 | if err != nil { 131 | return fmt.Errorf("error destroying ipset: %s", out) 132 | } 133 | 134 | return nil 135 | } 136 | 137 | func (i *IPSet) Rename(toSetName string) error { 138 | cmd := exec.Command(i.binaryPath, "rename", i.setName, toSetName) 139 | 140 | log.Debugf("ipset rename command: %v", cmd.String()) 141 | 142 | out, err := cmd.CombinedOutput() 143 | if err != nil { 144 | return fmt.Errorf("error renaming ipset: %s", out) 145 | } 146 | 147 | i.setName = toSetName 148 | 149 | return nil 150 | } 151 | 152 | func (i *IPSet) Test(entry string) error { 153 | cmd := exec.Command(i.binaryPath, "test", i.setName, entry) 154 | 155 | log.Debugf("ipset test command: %v", cmd.String()) 156 | 157 | out, err := cmd.CombinedOutput() 158 | if err != nil { 159 | return fmt.Errorf("error testing ipset: %s", out) 160 | } 161 | 162 | return nil 163 | } 164 | 165 | func (i *IPSet) Save() ([]string, error) { 166 | cmd := exec.Command(i.binaryPath, "save", i.setName) 167 | 168 | log.Debugf("ipset save command: %v", cmd.String()) 169 | 170 | out, err := cmd.CombinedOutput() 171 | if err != nil { 172 | return nil, fmt.Errorf("error saving ipset: %s", out) 173 | } 174 | 175 | return strings.Split(string(out), "\n"), nil 176 | } 177 | 178 | func (i *IPSet) Restore(filename string) error { 179 | cmd := exec.Command(i.binaryPath, "restore", "-file", filename) 180 | 181 | log.Debugf("ipset restore command: %v", cmd.String()) 182 | 183 | out, err := cmd.CombinedOutput() 184 | if err != nil { 185 | return fmt.Errorf("error restoring ipset: %s", out) 186 | } 187 | 188 | return nil 189 | } 190 | 191 | func (i *IPSet) Swap(toSetName string) error { 192 | cmd := exec.Command(i.binaryPath, "swap", i.setName, toSetName) 193 | 194 | log.Debugf("ipset swap command: %v", cmd.String()) 195 | 196 | out, err := cmd.CombinedOutput() 197 | if err != nil { 198 | return fmt.Errorf("error swapping ipset: %s", out) 199 | } 200 | 201 | i.setName = toSetName 202 | 203 | return nil 204 | } 205 | 206 | func (i *IPSet) Name() string { 207 | return i.setName 208 | } 209 | 210 | func (i *IPSet) Exists() bool { 211 | cmd := exec.Command(i.binaryPath, "list", i.setName) 212 | 213 | err := cmd.Run() 214 | 215 | return err == nil 216 | } 217 | 218 | func (i *IPSet) Len() int { 219 | cmd := exec.Command(i.binaryPath, "list", i.setName) 220 | 221 | log.Debugf("ipset list command: %v", cmd.String()) 222 | 223 | out, err := cmd.CombinedOutput() 224 | if err != nil { 225 | return 0 226 | } 227 | 228 | for _, line := range strings.Split(string(out), "\n") { 229 | if !strings.Contains(strings.ToLower(line), "number of entries:") { 230 | continue 231 | } 232 | 233 | fields := strings.Split(line, ":") 234 | if len(fields) != 2 { 235 | continue 236 | } 237 | 238 | count, err := strconv.Atoi(strings.TrimSpace(fields[1])) 239 | if err != nil { 240 | return 0 241 | } 242 | 243 | return count 244 | } 245 | 246 | return 0 247 | } 248 | 249 | // Helpers. 250 | func GetSetsStartingWith(name string) (map[string]*IPSet, error) { 251 | cmd := exec.Command(ipsetBinary, "list", "-name") 252 | 253 | log.Debugf("ipset list command: %v", cmd.String()) 254 | 255 | out, err := cmd.CombinedOutput() 256 | if err != nil { 257 | return nil, fmt.Errorf("error listing ipset: %s", out) 258 | } 259 | 260 | sets := make(map[string]*IPSet, 0) 261 | 262 | for _, line := range strings.Split(string(out), "\n") { 263 | if !strings.HasPrefix(line, name) { 264 | continue 265 | } 266 | 267 | fields := strings.Fields(line) 268 | if len(fields) != 1 { 269 | continue 270 | } 271 | 272 | set, err := NewIPSet(fields[0]) 273 | if err != nil { 274 | return nil, err 275 | } 276 | 277 | sets[fields[0]] = set 278 | } 279 | 280 | return sets, nil 281 | } 282 | -------------------------------------------------------------------------------- /pkg/iptables/iptables.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package iptables 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | "os/exec" 9 | "slices" 10 | "strings" 11 | 12 | log "github.com/sirupsen/logrus" 13 | 14 | "github.com/crowdsecurity/crowdsec/pkg/models" 15 | 16 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" 17 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/ipsetcmd" 18 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types" 19 | ) 20 | 21 | const ( 22 | IPTablesDroppedPacketIdx = 0 23 | IPTablesDroppedByteIdx = 1 24 | ) 25 | 26 | type iptables struct { 27 | v4 *ipTablesContext 28 | v6 *ipTablesContext 29 | } 30 | 31 | func NewIPTables(config *cfg.BouncerConfig) (types.Backend, error) { 32 | var err error 33 | 34 | ret := &iptables{} 35 | 36 | defaultSet, err := ipsetcmd.NewIPSet("") 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | allowedActions := []string{"DROP", "REJECT", "TARPIT", "LOG"} 42 | 43 | target := strings.ToUpper(config.DenyAction) 44 | if target == "" { 45 | target = "DROP" 46 | } 47 | 48 | log.Infof("using '%s' as deny_action", target) 49 | 50 | if !slices.Contains(allowedActions, target) { 51 | return nil, fmt.Errorf("invalid deny_action '%s', must be one of %s", config.DenyAction, strings.Join(allowedActions, ", ")) 52 | } 53 | 54 | v4Sets := make(map[string]*ipsetcmd.IPSet) 55 | v6Sets := make(map[string]*ipsetcmd.IPSet) 56 | 57 | ipv4Ctx := &ipTablesContext{ 58 | version: "v4", 59 | SetName: config.BlacklistsIpv4, 60 | SetType: config.SetType, 61 | SetSize: config.SetSize, 62 | ipsetDisableTimeouts: config.SetDisableTimeouts, 63 | Chains: []string{}, 64 | defaultSet: defaultSet, 65 | target: target, 66 | loggingEnabled: config.DenyLog, 67 | loggingPrefix: config.DenyLogPrefix, 68 | addRuleComments: config.IptablesAddRuleComments, 69 | } 70 | ipv6Ctx := &ipTablesContext{ 71 | version: "v6", 72 | SetName: config.BlacklistsIpv6, 73 | SetType: config.SetType, 74 | SetSize: config.SetSize, 75 | ipsetDisableTimeouts: config.SetDisableTimeouts, 76 | Chains: []string{}, 77 | defaultSet: defaultSet, 78 | target: target, 79 | loggingEnabled: config.DenyLog, 80 | loggingPrefix: config.DenyLogPrefix, 81 | addRuleComments: config.IptablesAddRuleComments, 82 | } 83 | 84 | if !config.DisableIPV4 { 85 | ipv4Ctx.iptablesSaveBin, err = exec.LookPath("iptables-save") 86 | if err != nil { 87 | return nil, errors.New("unable to find iptables-save") 88 | } 89 | 90 | if config.Mode == cfg.IpsetMode { 91 | ipv4Ctx.ipsetContentOnly = true 92 | 93 | set, err := ipsetcmd.NewIPSet(config.BlacklistsIpv4) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | v4Sets["ipset"] = set 99 | } else { 100 | ipv4Ctx.iptablesBin, err = exec.LookPath("iptables") 101 | if err != nil { 102 | return nil, errors.New("unable to find iptables") 103 | } 104 | 105 | // Try to "adopt" any leftover sets from a previous run if we crashed 106 | // They will get flushed/deleted just after 107 | v4Sets, _ = ipsetcmd.GetSetsStartingWith(config.BlacklistsIpv4) 108 | v6Sets, _ = ipsetcmd.GetSetsStartingWith(config.BlacklistsIpv6) 109 | 110 | ipv4Ctx.Chains = config.IptablesChains 111 | } 112 | 113 | ipv4Ctx.ipsets = v4Sets 114 | ret.v4 = ipv4Ctx 115 | } 116 | 117 | if !config.DisableIPV6 { 118 | ipv6Ctx.iptablesSaveBin, err = exec.LookPath("ip6tables-save") 119 | if err != nil { 120 | return nil, errors.New("unable to find ip6tables-save") 121 | } 122 | 123 | if config.Mode == cfg.IpsetMode { 124 | ipv6Ctx.ipsetContentOnly = true 125 | 126 | set, err := ipsetcmd.NewIPSet(config.BlacklistsIpv6) 127 | if err != nil { 128 | return nil, err 129 | } 130 | 131 | v6Sets["ipset"] = set 132 | } else { 133 | ipv6Ctx.iptablesBin, err = exec.LookPath("ip6tables") 134 | if err != nil { 135 | return nil, errors.New("unable to find ip6tables") 136 | } 137 | 138 | ipv6Ctx.Chains = config.IptablesChains 139 | } 140 | 141 | ipv6Ctx.ipsets = v6Sets 142 | ret.v6 = ipv6Ctx 143 | } 144 | return ret, nil 145 | } 146 | 147 | func (ipt *iptables) Init() error { 148 | if ipt.v4 != nil { 149 | log.Info("iptables for ipv4 initiated") 150 | 151 | // flush before init 152 | if err := ipt.v4.shutDown(); err != nil { 153 | return fmt.Errorf("iptables shutdown failed: %w", err) 154 | } 155 | 156 | if !ipt.v4.ipsetContentOnly { 157 | ipt.v4.setupChain() 158 | } 159 | } 160 | 161 | if ipt.v6 != nil { 162 | log.Info("iptables for ipv6 initiated") 163 | 164 | if err := ipt.v6.shutDown(); err != nil { 165 | return fmt.Errorf("iptables shutdown failed: %w", err) 166 | } 167 | 168 | if !ipt.v6.ipsetContentOnly { 169 | ipt.v6.setupChain() 170 | } 171 | } 172 | 173 | return nil 174 | } 175 | 176 | func (ipt *iptables) Commit() error { 177 | if ipt.v4 != nil { 178 | err := ipt.v4.commit() 179 | if err != nil { 180 | return fmt.Errorf("ipset for ipv4 commit failed: %w", err) 181 | } 182 | } 183 | 184 | if ipt.v6 != nil { 185 | err := ipt.v6.commit() 186 | if err != nil { 187 | return fmt.Errorf("ipset for ipv6 commit failed: %w", err) 188 | } 189 | } 190 | 191 | return nil 192 | } 193 | 194 | func (ipt *iptables) Add(decision *models.Decision) error { 195 | if strings.HasPrefix(*decision.Type, "simulation:") { 196 | log.Debugf("measure against '%s' is in simulation mode, skipping it", *decision.Value) 197 | return nil 198 | } 199 | 200 | if strings.Contains(*decision.Value, ":") { 201 | if ipt.v6 == nil { 202 | log.Debugf("not adding '%s' because ipv6 is disabled", *decision.Value) 203 | return nil 204 | } 205 | 206 | ipt.v6.add(decision) 207 | } else { 208 | if ipt.v4 == nil { 209 | log.Debugf("not adding '%s' because ipv4 is disabled", *decision.Value) 210 | return nil 211 | } 212 | ipt.v4.add(decision) 213 | } 214 | 215 | return nil 216 | } 217 | 218 | func (ipt *iptables) ShutDown() error { 219 | if ipt.v4 != nil { 220 | if err := ipt.v4.shutDown(); err != nil { 221 | return fmt.Errorf("iptables for ipv4 shutdown failed: %w", err) 222 | } 223 | } 224 | 225 | if ipt.v6 != nil { 226 | if err := ipt.v6.shutDown(); err != nil { 227 | return fmt.Errorf("iptables for ipv6 shutdown failed: %w", err) 228 | } 229 | } 230 | 231 | return nil 232 | } 233 | 234 | func (ipt *iptables) Delete(decision *models.Decision) error { 235 | done := false 236 | 237 | if strings.Contains(*decision.Value, ":") { 238 | if ipt.v6 == nil { 239 | log.Debugf("not deleting '%s' because ipv6 is disabled", *decision.Value) 240 | return nil 241 | } 242 | 243 | if err := ipt.v6.delete(decision); err != nil { 244 | return errors.New("failed deleting ban") 245 | } 246 | 247 | done = true 248 | } 249 | 250 | if strings.Contains(*decision.Value, ".") { 251 | if ipt.v4 == nil { 252 | log.Debugf("not deleting '%s' because ipv4 is disabled", *decision.Value) 253 | return nil 254 | } 255 | 256 | if err := ipt.v4.delete(decision); err != nil { 257 | return errors.New("failed deleting ban") 258 | } 259 | 260 | done = true 261 | } 262 | 263 | if !done { 264 | return fmt.Errorf("failed deleting ban: ip %s was not recognized", *decision.Value) 265 | } 266 | 267 | return nil 268 | } 269 | -------------------------------------------------------------------------------- /pkg/iptables/iptables_context.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package iptables 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "slices" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | log "github.com/sirupsen/logrus" 15 | 16 | "github.com/crowdsecurity/crowdsec/pkg/models" 17 | 18 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/ipsetcmd" 19 | ) 20 | 21 | const ( 22 | chainName = "CROWDSEC_CHAIN" 23 | loggingChainName = "CROWDSEC_LOG" 24 | maxBanSeconds = 2147483 25 | defaultTimeout = "300" 26 | ) 27 | 28 | type ipTablesContext struct { 29 | version string 30 | iptablesBin string 31 | iptablesSaveBin string 32 | SetName string // crowdsec-netfilter 33 | SetType string 34 | SetSize int 35 | ipsetContentOnly bool 36 | ipsetDisableTimeouts bool 37 | Chains []string 38 | 39 | target string 40 | 41 | ipsets map[string]*ipsetcmd.IPSet 42 | defaultSet *ipsetcmd.IPSet // This one is only used to restore the content, as the file will contain the name of the set for each decision 43 | 44 | toAdd []*models.Decision 45 | toDel []*models.Decision 46 | 47 | // To avoid issues with set name length (ipsest name length is limited to 31 characters) 48 | // Store the origin of the decisions, and use the index in the slice as the name 49 | // This is not stable (ie, between two runs, the index of a set can change), but it's (probably) not an issue 50 | originSetMapping []string 51 | 52 | loggingEnabled bool 53 | loggingPrefix string 54 | 55 | addRuleComments bool 56 | } 57 | 58 | func (ctx *ipTablesContext) setupChain() { 59 | cmd := []string{"-N", chainName, "-t", "filter"} 60 | 61 | c := exec.Command(ctx.iptablesBin, cmd...) 62 | 63 | log.Infof("Creating chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) 64 | 65 | if out, err := c.CombinedOutput(); err != nil { 66 | log.Errorf("error while creating chain : %v --> %s", err, string(out)) 67 | return 68 | } 69 | 70 | for _, chain := range ctx.Chains { 71 | cmd = []string{"-I", chain, "-j", chainName} 72 | 73 | c = exec.Command(ctx.iptablesBin, cmd...) 74 | 75 | log.Infof("Adding rule : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) 76 | 77 | if out, err := c.CombinedOutput(); err != nil { 78 | log.Errorf("error while adding rule : %v --> %s", err, string(out)) 79 | continue 80 | } 81 | } 82 | 83 | if ctx.loggingEnabled { 84 | // Create the logging chain 85 | cmd = []string{"-N", loggingChainName, "-t", "filter"} 86 | 87 | c = exec.Command(ctx.iptablesBin, cmd...) 88 | 89 | log.Infof("Creating logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) 90 | 91 | if out, err := c.CombinedOutput(); err != nil { 92 | log.Errorf("error while creating logging chain : %v --> %s", err, string(out)) 93 | return 94 | } 95 | 96 | // Insert the logging rule 97 | cmd = []string{"-I", loggingChainName, "-j", "LOG", "--log-prefix", ctx.loggingPrefix} 98 | 99 | c = exec.Command(ctx.iptablesBin, cmd...) 100 | 101 | log.Infof("Adding logging rule : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) 102 | 103 | if out, err := c.CombinedOutput(); err != nil { 104 | log.Errorf("error while adding logging rule : %v --> %s", err, string(out)) 105 | } 106 | 107 | // Add the desired target to the logging chain 108 | 109 | cmd = []string{"-A", loggingChainName, "-j", ctx.target} 110 | 111 | c = exec.Command(ctx.iptablesBin, cmd...) 112 | 113 | log.Infof("Adding target rule to logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) 114 | 115 | if out, err := c.CombinedOutput(); err != nil { 116 | log.Errorf("error while setting logging chain policy : %v --> %s", err, string(out)) 117 | } 118 | } 119 | } 120 | 121 | func (ctx *ipTablesContext) deleteChain() { 122 | for _, chain := range ctx.Chains { 123 | cmd := []string{"-D", chain, "-j", chainName} 124 | 125 | c := exec.Command(ctx.iptablesBin, cmd...) 126 | 127 | log.Infof("Deleting rule : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) 128 | 129 | if out, err := c.CombinedOutput(); err != nil { 130 | log.Errorf("error while removing rule : %v --> %s", err, string(out)) 131 | } 132 | } 133 | 134 | cmd := []string{"-F", chainName} 135 | 136 | c := exec.Command(ctx.iptablesBin, cmd...) 137 | 138 | log.Infof("Flushing chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) 139 | 140 | if out, err := c.CombinedOutput(); err != nil { 141 | log.Errorf("error while flushing chain : %v --> %s", err, string(out)) 142 | } 143 | 144 | cmd = []string{"-X", chainName} 145 | 146 | c = exec.Command(ctx.iptablesBin, cmd...) 147 | 148 | log.Infof("Deleting chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) 149 | 150 | if out, err := c.CombinedOutput(); err != nil { 151 | log.Errorf("error while deleting chain : %v --> %s", err, string(out)) 152 | } 153 | 154 | if ctx.loggingEnabled { 155 | cmd = []string{"-F", loggingChainName} 156 | 157 | c = exec.Command(ctx.iptablesBin, cmd...) 158 | 159 | log.Infof("Flushing logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) 160 | 161 | if out, err := c.CombinedOutput(); err != nil { 162 | log.Errorf("error while flushing logging chain : %v --> %s", err, string(out)) 163 | } 164 | 165 | cmd = []string{"-X", loggingChainName} 166 | 167 | c = exec.Command(ctx.iptablesBin, cmd...) 168 | 169 | log.Infof("Deleting logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) 170 | 171 | if out, err := c.CombinedOutput(); err != nil { 172 | log.Errorf("error while deleting logging chain : %v --> %s", err, string(out)) 173 | } 174 | } 175 | } 176 | 177 | func (ctx *ipTablesContext) createRule(setName string, origin string) { 178 | target := ctx.target 179 | 180 | if ctx.loggingEnabled { 181 | target = loggingChainName 182 | } 183 | 184 | cmd := []string{"-I", chainName, "-m", "set", "--match-set", setName, "src", "-j", target} 185 | 186 | if ctx.addRuleComments { 187 | cmd = append(cmd, "-m", "comment", "--comment", "CrowdSec: "+origin) 188 | } 189 | 190 | c := exec.Command(ctx.iptablesBin, cmd...) 191 | 192 | log.Infof("Creating rule : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) 193 | 194 | if out, err := c.CombinedOutput(); err != nil { 195 | log.Errorf("error while inserting set entry in iptables : %v --> %s", err, string(out)) 196 | } 197 | } 198 | 199 | func (ctx *ipTablesContext) commit() error { 200 | tmpFile, err := os.CreateTemp("", "cs-firewall-bouncer-ipset-") 201 | if err != nil { 202 | return err 203 | } 204 | 205 | defer func() { 206 | tmpFile.Close() 207 | os.Remove(tmpFile.Name()) 208 | 209 | ctx.toAdd = nil 210 | ctx.toDel = nil 211 | }() 212 | 213 | for _, decision := range ctx.toDel { 214 | var ( 215 | set *ipsetcmd.IPSet 216 | ok bool 217 | ) 218 | 219 | // Decisions coming from lists will have "lists" as origin, and the scenario will be the list name 220 | // We use those to build a custom origin because we want to track metrics per list 221 | // In case of other origin (crowdsec, cscli, ...), we do not really care about the scenario, it would be too noisy 222 | origin := *decision.Origin 223 | if origin == "lists" { 224 | origin = origin + ":" + *decision.Scenario 225 | } 226 | 227 | if ctx.ipsetContentOnly { 228 | set = ctx.ipsets["ipset"] 229 | } else { 230 | set, ok = ctx.ipsets[origin] 231 | if !ok { 232 | // No set for this origin, skip, as there's nothing to delete 233 | continue 234 | } 235 | } 236 | 237 | delCmd := fmt.Sprintf("del %s %s -exist\n", set.Name(), *decision.Value) 238 | 239 | log.Debugf("%s", delCmd) 240 | 241 | _, err = tmpFile.WriteString(delCmd) 242 | if err != nil { 243 | log.Errorf("error while writing to temp file : %s", err) 244 | continue 245 | } 246 | } 247 | 248 | for _, decision := range ctx.toAdd { 249 | banDuration, err := time.ParseDuration(*decision.Duration) 250 | if err != nil { 251 | log.Errorf("error while parsing ban duration : %s", err) 252 | continue 253 | } 254 | 255 | var ( 256 | set *ipsetcmd.IPSet 257 | ok bool 258 | ) 259 | 260 | if banDuration.Seconds() > maxBanSeconds { 261 | log.Warnf("Ban duration too long (%d seconds), maximum for ipset is %d, setting duration to %d", int(banDuration.Seconds()), maxBanSeconds, maxBanSeconds-1) 262 | banDuration = time.Duration(maxBanSeconds-1) * time.Second 263 | } 264 | 265 | origin := *decision.Origin 266 | 267 | if origin == "lists" { 268 | origin = origin + ":" + *decision.Scenario 269 | } 270 | 271 | if ctx.ipsetContentOnly { 272 | set = ctx.ipsets["ipset"] 273 | } else { 274 | set, ok = ctx.ipsets[origin] 275 | 276 | if !ok { 277 | idx := slices.Index(ctx.originSetMapping, origin) 278 | 279 | if idx == -1 { 280 | ctx.originSetMapping = append(ctx.originSetMapping, origin) 281 | idx = len(ctx.originSetMapping) - 1 282 | } 283 | 284 | setName := fmt.Sprintf("%s-%d", ctx.SetName, idx) 285 | 286 | log.Infof("Using %s as set for origin %s", setName, origin) 287 | 288 | set, err = ipsetcmd.NewIPSet(setName) 289 | if err != nil { 290 | log.Errorf("error while creating ipset : %s", err) 291 | continue 292 | } 293 | 294 | family := "inet" 295 | 296 | if ctx.version == "v6" { 297 | family = "inet6" 298 | } 299 | 300 | err = set.Create(ipsetcmd.CreateOptions{ 301 | Family: family, 302 | Timeout: defaultTimeout, 303 | MaxElem: strconv.Itoa(ctx.SetSize), 304 | Type: ctx.SetType, 305 | DisableTimeouts: ctx.ipsetDisableTimeouts, 306 | }) 307 | // Ignore errors if the set already exists 308 | if err != nil { 309 | log.Errorf("error while creating ipset : %s", err) 310 | continue 311 | } 312 | 313 | ctx.ipsets[origin] = set 314 | 315 | if !ctx.ipsetContentOnly { 316 | // Create the rule to use the set 317 | ctx.createRule(set.Name(), origin) 318 | } 319 | } 320 | } 321 | 322 | var addCmd string 323 | if ctx.ipsetDisableTimeouts { 324 | addCmd = fmt.Sprintf("add %s %s -exist\n", set.Name(), *decision.Value) 325 | } else { 326 | addCmd = fmt.Sprintf("add %s %s timeout %d -exist\n", set.Name(), *decision.Value, int(banDuration.Seconds())) 327 | } 328 | 329 | log.Debugf("%s", addCmd) 330 | 331 | _, err = tmpFile.WriteString(addCmd) 332 | if err != nil { 333 | log.Errorf("error while writing to temp file : %s", err) 334 | continue 335 | } 336 | } 337 | 338 | if len(ctx.toAdd) == 0 && len(ctx.toDel) == 0 { 339 | return nil 340 | } 341 | 342 | return ctx.defaultSet.Restore(tmpFile.Name()) 343 | } 344 | 345 | func (ctx *ipTablesContext) add(decision *models.Decision) { 346 | ctx.toAdd = append(ctx.toAdd, decision) 347 | } 348 | 349 | func (ctx *ipTablesContext) shutDown() error { 350 | // Remove rules 351 | if !ctx.ipsetContentOnly { 352 | ctx.deleteChain() 353 | } 354 | 355 | time.Sleep(1 * time.Second) 356 | 357 | // Clean sets 358 | for _, set := range ctx.ipsets { 359 | if ctx.ipsetContentOnly { 360 | err := set.Flush() 361 | if err != nil { 362 | log.Errorf("error while flushing ipset : %s", err) 363 | } 364 | } else { 365 | err := set.Destroy() 366 | if err != nil { 367 | log.Errorf("error while destroying set %s : %s", set.Name(), err) 368 | } 369 | } 370 | } 371 | 372 | if !ctx.ipsetContentOnly { 373 | // In case we are starting, just reset the map 374 | ctx.ipsets = make(map[string]*ipsetcmd.IPSet) 375 | } 376 | 377 | return nil 378 | } 379 | 380 | func (ctx *ipTablesContext) delete(decision *models.Decision) error { 381 | ctx.toDel = append(ctx.toDel, decision) 382 | return nil 383 | } 384 | -------------------------------------------------------------------------------- /pkg/iptables/iptables_stub.go: -------------------------------------------------------------------------------- 1 | //go:build !linux 2 | // +build !linux 3 | 4 | package iptables 5 | 6 | import ( 7 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" 8 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types" 9 | ) 10 | 11 | func NewIPTables(config *cfg.BouncerConfig) (types.Backend, error) { 12 | return nil, nil 13 | } 14 | -------------------------------------------------------------------------------- /pkg/iptables/metrics.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package iptables 4 | 5 | import ( 6 | "bufio" 7 | "os/exec" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/prometheus/client_golang/prometheus" 13 | log "github.com/sirupsen/logrus" 14 | 15 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics" 16 | ) 17 | 18 | // iptables does not provide a "nice" way to get the counters for a rule, so we have to parse the output of iptables-save 19 | // chainRegexp is just used to get the counters for the chain CROWDSEC_CHAIN (the chain managed by the bouncer that will contains our rules) from the JUMP rule 20 | // ruleRegexp is used to get the counters for the rules we have added that will actually block the traffic 21 | // Example output of iptables-save : 22 | // [2080:13210403] -A INPUT -j CROWDSEC_CHAIN 23 | // ... 24 | // [0:0] -A CROWDSEC_CHAIN -m set --match-set test-set-ipset-mode-0 src -j DROP 25 | // First number is the number of packets, second is the number of bytes 26 | // In case of a jump, the counters represent the number of packets and bytes that have been processed by the chain (ie, whether the packets have been accepted or dropped) 27 | // In case of a rule, the counters represent the number of packets and bytes that have been matched by the rule (ie, the packets that have been dropped). 28 | 29 | var ( 30 | chainRegexp = regexp.MustCompile(`^\[(\d+):(\d+)\]`) 31 | ruleRegexp = regexp.MustCompile(`^\[(\d+):(\d+)\] -A [0-9A-Za-z_-]+ -m set --match-set (.*) src .*-j \w+`) 32 | ) 33 | 34 | // In ipset mode, we have to track the numbers of processed bytes/packets at the chain level 35 | // This is not really accurate, as a rule *before* the crowdsec rule could impact the numbers, but we don't have any other way. 36 | 37 | var ( 38 | ipsetChainDeclaration = regexp.MustCompile(`^:([0-9A-Za-z_-]+) ([0-9A-Za-z_-]+) \[(\d+):(\d+)\]`) 39 | ipsetRule = regexp.MustCompile(`^\[(\d+):(\d+)\] -A ([0-9A-Za-z_-]+)`) 40 | ) 41 | 42 | func (ctx *ipTablesContext) collectMetricsIptables(scanner *bufio.Scanner) (map[string]int, map[string]int, int, int) { 43 | processedBytes := 0 44 | processedPackets := 0 45 | 46 | droppedBytes := make(map[string]int) 47 | droppedPackets := make(map[string]int) 48 | 49 | for scanner.Scan() { 50 | line := scanner.Text() 51 | 52 | // Ignore chain declaration 53 | if line[0] == ':' { 54 | continue 55 | } 56 | 57 | // Jump to our chain, we can get the processed packets and bytes 58 | if strings.Contains(line, "-j "+chainName) { 59 | matches := chainRegexp.FindStringSubmatch(line) 60 | if len(matches) != 3 { 61 | log.Errorf("error while parsing counters : %s | not enough matches", line) 62 | continue 63 | } 64 | 65 | val, err := strconv.Atoi(matches[1]) 66 | if err != nil { 67 | log.Errorf("error while parsing counters : %s", line) 68 | continue 69 | } 70 | 71 | processedPackets += val 72 | 73 | val, err = strconv.Atoi(matches[2]) 74 | if err != nil { 75 | log.Errorf("error while parsing counters : %s", line) 76 | continue 77 | } 78 | 79 | processedBytes += val 80 | 81 | continue 82 | } 83 | 84 | // This is a rule 85 | if strings.Contains(line, "-A "+chainName) { 86 | matches := ruleRegexp.FindStringSubmatch(line) 87 | if len(matches) != 4 { 88 | log.Errorf("error while parsing counters : %s | not enough matches", line) 89 | continue 90 | } 91 | 92 | originIDStr, found := strings.CutPrefix(matches[3], ctx.SetName+"-") 93 | if !found { 94 | log.Errorf("error while parsing counters : %s | no origin found", line) 95 | continue 96 | } 97 | 98 | originID, err := strconv.Atoi(originIDStr) 99 | if err != nil { 100 | log.Errorf("error while parsing counters : %s | %s", line, err) 101 | continue 102 | } 103 | 104 | if len(ctx.originSetMapping) < originID { 105 | log.Errorf("Found unknown origin id : %d", originID) 106 | continue 107 | } 108 | 109 | origin := ctx.originSetMapping[originID] 110 | 111 | val, err := strconv.Atoi(matches[1]) 112 | if err != nil { 113 | log.Errorf("error while parsing counters : %s | %s", line, err) 114 | continue 115 | } 116 | 117 | droppedPackets[origin] += val 118 | 119 | val, err = strconv.Atoi(matches[2]) 120 | if err != nil { 121 | log.Errorf("error while parsing counters : %s | %s", line, err) 122 | continue 123 | } 124 | 125 | droppedBytes[origin] += val 126 | } 127 | } 128 | 129 | return droppedPackets, droppedBytes, processedPackets, processedBytes 130 | } 131 | 132 | type chainCounters struct { 133 | bytes int 134 | packets int 135 | } 136 | 137 | // In ipset mode, we only get dropped packets and bytes by matching on the set name in the rule 138 | // It's probably not perfect, but good enough for most users. 139 | func (ctx *ipTablesContext) collectMetricsIpset(scanner *bufio.Scanner) (map[string]int, map[string]int, int, int) { 140 | processedBytes := 0 141 | processedPackets := 0 142 | 143 | droppedBytes := make(map[string]int) 144 | droppedPackets := make(map[string]int) 145 | 146 | // We need to store the counters for all chains 147 | // As we don't know in which chain the user has setup the rules 148 | // We'll resolve the value laters. 149 | chainsCounter := make(map[string]chainCounters) 150 | 151 | // Hardcode the origin to ipset as we cannot know it based on the rule. 152 | droppedBytes["ipset"] = 0 153 | droppedPackets["ipset"] = 0 154 | 155 | for scanner.Scan() { 156 | line := scanner.Text() 157 | 158 | // Chain declaration 159 | if line[0] == ':' { 160 | matches := ipsetChainDeclaration.FindStringSubmatch(line) 161 | if len(matches) != 5 { 162 | log.Errorf("error while parsing counters : %s | not enough matches", line) 163 | continue 164 | } 165 | 166 | log.Debugf("Found chain %s with matches %+v", matches[1], matches) 167 | 168 | c, ok := chainsCounter[matches[1]] 169 | if !ok { 170 | c = chainCounters{} 171 | } 172 | 173 | val, err := strconv.Atoi(matches[3]) 174 | if err != nil { 175 | log.Errorf("error while parsing counters : %s", line) 176 | continue 177 | } 178 | 179 | c.packets += val 180 | 181 | val, err = strconv.Atoi(matches[4]) 182 | if err != nil { 183 | log.Errorf("error while parsing counters : %s", line) 184 | continue 185 | } 186 | 187 | c.bytes += val 188 | chainsCounter[matches[1]] = c 189 | 190 | continue 191 | } 192 | 193 | // Assume that if a line contains the set name, it's a rule we are interested in. 194 | if strings.Contains(line, ctx.SetName) { 195 | matches := ipsetRule.FindStringSubmatch(line) 196 | if len(matches) != 4 { 197 | log.Errorf("error while parsing counters : %s | not enough matches", line) 198 | continue 199 | } 200 | 201 | val, err := strconv.Atoi(matches[1]) 202 | if err != nil { 203 | log.Errorf("error while parsing counters : %s", line) 204 | continue 205 | } 206 | 207 | droppedPackets["ipset"] += val 208 | 209 | val, err = strconv.Atoi(matches[2]) 210 | if err != nil { 211 | log.Errorf("error while parsing counters : %s", line) 212 | continue 213 | } 214 | 215 | droppedBytes["ipset"] += val 216 | 217 | // Resolve the chain counters 218 | c, ok := chainsCounter[matches[3]] 219 | if !ok { 220 | log.Errorf("error while parsing counters : %s | chain not found", line) 221 | continue 222 | } 223 | 224 | processedPackets += c.packets 225 | processedBytes += c.bytes 226 | } 227 | } 228 | 229 | return droppedPackets, droppedBytes, processedPackets, processedBytes 230 | } 231 | 232 | func (ctx *ipTablesContext) collectMetrics() (map[string]int, map[string]int, int, int, error) { 233 | //-c is required to get the counters 234 | cmd := []string{ctx.iptablesSaveBin, "-c", "-t", "filter"} 235 | saveCmd := exec.Command(cmd[0], cmd[1:]...) 236 | 237 | out, err := saveCmd.CombinedOutput() 238 | if err != nil { 239 | log.Errorf("error while getting iptables rules with cmd %+v : %v --> %s", cmd, err, string(out)) 240 | return nil, nil, 0, 0, err 241 | } 242 | 243 | var ( 244 | processedBytes int 245 | processedPackets int 246 | droppedBytes map[string]int 247 | droppedPackets map[string]int 248 | ) 249 | 250 | scanner := bufio.NewScanner(strings.NewReader(string(out))) 251 | 252 | if !ctx.ipsetContentOnly { 253 | droppedPackets, droppedBytes, processedPackets, processedBytes = ctx.collectMetricsIptables(scanner) 254 | } else { 255 | droppedPackets, droppedBytes, processedPackets, processedBytes = ctx.collectMetricsIpset(scanner) 256 | } 257 | 258 | log.Debugf("Processed %d packets and %d bytes", processedPackets, processedBytes) 259 | log.Debugf("Dropped packets : %v", droppedPackets) 260 | log.Debugf("Dropped bytes : %v", droppedBytes) 261 | 262 | return droppedPackets, droppedBytes, processedPackets, processedBytes, nil 263 | } 264 | 265 | func (ipt *iptables) CollectMetrics() { 266 | if ipt.v4 != nil { 267 | for origin, set := range ipt.v4.ipsets { 268 | metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": origin}).Set(float64(set.Len())) 269 | } 270 | 271 | ipv4DroppedPackets, ipv4DroppedBytes, ipv4ProcessedPackets, ipv4ProcessedBytes, err := ipt.v4.collectMetrics() 272 | 273 | if err != nil { 274 | log.Errorf("can't collect dropped packets for ipv4 from iptables: %s", err) 275 | } else { 276 | metrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv4"}).Set(float64(ipv4ProcessedPackets)) 277 | metrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv4"}).Set(float64(ipv4ProcessedBytes)) 278 | 279 | for origin, count := range ipv4DroppedPackets { 280 | metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": origin}).Set(float64(count)) 281 | } 282 | 283 | for origin, count := range ipv4DroppedBytes { 284 | metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": origin}).Set(float64(count)) 285 | } 286 | } 287 | } 288 | 289 | if ipt.v6 != nil { 290 | for origin, set := range ipt.v6.ipsets { 291 | metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": origin}).Set(float64(set.Len())) 292 | } 293 | 294 | ipv6DroppedPackets, ipv6DroppedBytes, ipv6ProcessedPackets, ipv6ProcessedBytes, err := ipt.v6.collectMetrics() 295 | 296 | if err != nil { 297 | log.Errorf("can't collect dropped packets for ipv6 from iptables: %s", err) 298 | } else { 299 | metrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv6"}).Set(float64(ipv6ProcessedPackets)) 300 | metrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv6"}).Set(float64(ipv6ProcessedBytes)) 301 | 302 | for origin, count := range ipv6DroppedPackets { 303 | metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": origin}).Set(float64(count)) 304 | } 305 | 306 | for origin, count := range ipv6DroppedBytes { 307 | metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": origin}).Set(float64(count)) 308 | } 309 | } 310 | } 311 | } 312 | -------------------------------------------------------------------------------- /pkg/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/prometheus/client_golang/prometheus" 8 | io_prometheus_client "github.com/prometheus/client_model/go" 9 | log "github.com/sirupsen/logrus" 10 | 11 | "github.com/crowdsecurity/go-cs-lib/ptr" 12 | 13 | "github.com/crowdsecurity/crowdsec/pkg/models" 14 | ) 15 | 16 | const CollectionInterval = time.Second * 10 17 | 18 | type metricName string 19 | 20 | const ( 21 | DroppedPackets metricName = "fw_bouncer_dropped_packets" 22 | DroppedBytes metricName = "fw_bouncer_dropped_bytes" 23 | ProcessedPackets metricName = "fw_bouncer_processed_packets" 24 | ProcessedBytes metricName = "fw_bouncer_processed_bytes" 25 | ActiveBannedIPs metricName = "fw_bouncer_banned_ips" 26 | ) 27 | 28 | type backendCollector interface { 29 | CollectMetrics() 30 | } 31 | 32 | type Handler struct { 33 | Backend backendCollector 34 | } 35 | 36 | type metricConfig struct { 37 | Name string 38 | Unit string 39 | Gauge *prometheus.GaugeVec 40 | LabelKeys []string 41 | LastValueMap map[string]float64 // keep last value to send deltas -- nil if absolute 42 | KeyFunc func(labels []*io_prometheus_client.LabelPair) string 43 | } 44 | 45 | type metricMap map[metricName]*metricConfig 46 | 47 | func (m metricMap) MustRegisterAll() { 48 | for _, met := range m { 49 | prometheus.MustRegister(met.Gauge) 50 | } 51 | } 52 | 53 | var Map = metricMap{ 54 | ActiveBannedIPs: { 55 | Name: "active_decisions", 56 | Unit: "ip", 57 | Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{ 58 | Name: string(ActiveBannedIPs), 59 | Help: "Denotes the number of IPs which are currently banned", 60 | }, []string{"origin", "ip_type"}), 61 | LabelKeys: []string{"origin", "ip_type"}, 62 | LastValueMap: nil, 63 | KeyFunc: func([]*io_prometheus_client.LabelPair) string { return "" }, 64 | }, 65 | DroppedBytes: { 66 | Name: "dropped", 67 | Unit: "byte", 68 | Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{ 69 | Name: string(DroppedBytes), 70 | Help: "Denotes the number of total dropped bytes because of rule(s) created by crowdsec", 71 | }, []string{"origin", "ip_type"}), 72 | LabelKeys: []string{"origin", "ip_type"}, 73 | LastValueMap: make(map[string]float64), 74 | KeyFunc: func(labels []*io_prometheus_client.LabelPair) string { 75 | return getLabelValue(labels, "origin") + getLabelValue(labels, "ip_type") 76 | }, 77 | }, 78 | DroppedPackets: { 79 | Name: "dropped", 80 | Unit: "packet", 81 | Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{ 82 | Name: string(DroppedPackets), 83 | Help: "Denotes the number of total dropped packets because of rule(s) created by crowdsec", 84 | }, []string{"origin", "ip_type"}), 85 | LabelKeys: []string{"origin", "ip_type"}, 86 | LastValueMap: make(map[string]float64), 87 | KeyFunc: func(labels []*io_prometheus_client.LabelPair) string { 88 | return getLabelValue(labels, "origin") + getLabelValue(labels, "ip_type") 89 | }, 90 | }, 91 | ProcessedBytes: { 92 | Name: "processed", 93 | Unit: "byte", 94 | Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{ 95 | Name: string(ProcessedBytes), 96 | Help: "Denotes the number of total processed bytes by the rules created by crowdsec", 97 | }, []string{"ip_type"}), 98 | LabelKeys: []string{"ip_type"}, 99 | LastValueMap: make(map[string]float64), 100 | KeyFunc: func(labels []*io_prometheus_client.LabelPair) string { 101 | return getLabelValue(labels, "ip_type") 102 | }, 103 | }, 104 | ProcessedPackets: { 105 | Name: "processed", 106 | Unit: "packet", 107 | Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{ 108 | Name: string(ProcessedPackets), 109 | Help: "Denotes the number of total processed packets by the rules created by crowdsec", 110 | }, []string{"ip_type"}), 111 | LabelKeys: []string{"ip_type"}, 112 | LastValueMap: make(map[string]float64), 113 | KeyFunc: func(labels []*io_prometheus_client.LabelPair) string { 114 | return getLabelValue(labels, "ip_type") 115 | }, 116 | }, 117 | } 118 | 119 | func getLabelValue(labels []*io_prometheus_client.LabelPair, key string) string { 120 | for _, label := range labels { 121 | if label.GetName() == key { 122 | return label.GetValue() 123 | } 124 | } 125 | 126 | return "" 127 | } 128 | 129 | // MetricsUpdater receives a metrics struct with basic data and populates it with the current metrics. 130 | func (m Handler) MetricsUpdater(met *models.RemediationComponentsMetrics, updateInterval time.Duration) { 131 | log.Debugf("Updating metrics") 132 | 133 | m.Backend.CollectMetrics() 134 | 135 | // Most of the common fields are set automatically by the metrics provider 136 | // We only need to care about the metrics themselves 137 | 138 | promMetrics, err := prometheus.DefaultGatherer.Gather() 139 | if err != nil { 140 | log.Errorf("unable to gather prometheus metrics: %s", err) 141 | return 142 | } 143 | 144 | met.Metrics = append(met.Metrics, &models.DetailedMetrics{ 145 | Meta: &models.MetricsMeta{ 146 | UtcNowTimestamp: ptr.Of(time.Now().Unix()), 147 | WindowSizeSeconds: ptr.Of(int64(updateInterval.Seconds())), 148 | }, 149 | Items: make([]*models.MetricsDetailItem, 0), 150 | }) 151 | 152 | for _, metricFamily := range promMetrics { 153 | cfg, ok := Map[metricName(metricFamily.GetName())] 154 | if !ok { 155 | continue 156 | } 157 | 158 | for _, metric := range metricFamily.GetMetric() { 159 | labels := metric.GetLabel() 160 | value := metric.GetGauge().GetValue() 161 | 162 | labelMap := make(map[string]string) 163 | for _, key := range cfg.LabelKeys { 164 | labelMap[key] = getLabelValue(labels, key) 165 | } 166 | 167 | finalValue := value 168 | 169 | if cfg.LastValueMap == nil { 170 | // always send absolute values 171 | log.Debugf("Sending %s for %+v %f", cfg.Name, labelMap, finalValue) 172 | } else { 173 | // the final value to send must be relative, and never negative 174 | // because the firewall counter may have been reset since last collection. 175 | key := cfg.KeyFunc(labels) 176 | 177 | // no need to guard access to LastValueMap, as we are in the main thread -- it's 178 | // the gauge that is updated by the requests 179 | finalValue = value - cfg.LastValueMap[key] 180 | 181 | if finalValue < 0 { 182 | finalValue = -finalValue 183 | 184 | log.Warningf("metric value for %s %+v is negative, assuming external counter was reset", cfg.Name, labelMap) 185 | } 186 | 187 | cfg.LastValueMap[key] = value 188 | log.Debugf("Sending %s for %+v %f | current value: %f | previous value: %f", cfg.Name, labelMap, finalValue, value, cfg.LastValueMap[key]) 189 | } 190 | 191 | met.Metrics[0].Items = append(met.Metrics[0].Items, &models.MetricsDetailItem{ 192 | Name: ptr.Of(cfg.Name), 193 | Value: &finalValue, 194 | Labels: labelMap, 195 | Unit: ptr.Of(cfg.Unit), 196 | }) 197 | } 198 | } 199 | } 200 | 201 | func (m Handler) ComputeMetricsHandler(next http.Handler) http.Handler { 202 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 203 | m.Backend.CollectMetrics() 204 | next.ServeHTTP(w, r) 205 | }) 206 | } 207 | -------------------------------------------------------------------------------- /pkg/nftables/metrics.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package nftables 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "github.com/google/nftables/expr" 11 | "github.com/prometheus/client_golang/prometheus" 12 | log "github.com/sirupsen/logrus" 13 | 14 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics" 15 | ) 16 | 17 | func (c *nftContext) collectDroppedPackets() (map[string]int, map[string]int, int, int, error) { 18 | droppedPackets := make(map[string]int) 19 | droppedBytes := make(map[string]int) 20 | processedPackets := 0 21 | processedBytes := 0 22 | // setName := "" 23 | for chainName, chain := range c.chains { 24 | rules, err := c.conn.GetRules(c.table, chain) 25 | if err != nil { 26 | log.Errorf("can't get rules for chain %s: %s", chainName, err) 27 | continue 28 | } 29 | 30 | for _, rule := range rules { 31 | for _, xpr := range rule.Exprs { 32 | obj, ok := xpr.(*expr.Counter) 33 | if ok { 34 | log.Debugf("rule %d (%s): packets %d, bytes %d (%s)", rule.Position, rule.Table.Name, obj.Packets, obj.Bytes, rule.UserData) 35 | 36 | if string(rule.UserData) == "processed" { 37 | processedPackets += int(obj.Packets) 38 | processedBytes += int(obj.Bytes) 39 | 40 | continue 41 | } 42 | 43 | origin, _ := strings.CutPrefix(string(rule.UserData), c.blacklists+"-") 44 | 45 | if origin == "" { 46 | continue 47 | } 48 | 49 | droppedPackets[origin] += int(obj.Packets) 50 | droppedBytes[origin] += int(obj.Bytes) 51 | } 52 | } 53 | } 54 | } 55 | 56 | return droppedPackets, droppedBytes, processedPackets, processedBytes, nil 57 | } 58 | 59 | func (c *nftContext) collectActiveBannedIPs() (map[string]int, error) { 60 | // Find the size of the set we have created 61 | ret := make(map[string]int) 62 | 63 | for origin, set := range c.sets { 64 | setContent, err := c.conn.GetSetElements(set) 65 | if err != nil { 66 | return nil, fmt.Errorf("can't get set elements for %s: %w", set.Name, err) 67 | } 68 | 69 | if c.setOnly { 70 | ret[c.blacklists] = len(setContent) 71 | } else { 72 | ret[origin] = len(setContent) 73 | } 74 | 75 | return ret, nil 76 | } 77 | 78 | return ret, nil 79 | } 80 | 81 | func (c *nftContext) collectDropped() (map[string]int, map[string]int, int, int, map[string]int) { 82 | if c.conn == nil { 83 | return nil, nil, 0, 0, nil 84 | } 85 | 86 | droppedPackets, droppedBytes, processedPackets, processedBytes, err := c.collectDroppedPackets() 87 | if err != nil { 88 | log.Errorf("can't collect dropped packets for ip%s from nft: %s", c.version, err) 89 | } 90 | 91 | banned, err := c.collectActiveBannedIPs() 92 | if err != nil { 93 | log.Errorf("can't collect total banned IPs for ip%s from nft: %s", c.version, err) 94 | } 95 | 96 | return droppedPackets, droppedBytes, processedPackets, processedBytes, banned 97 | } 98 | 99 | func getOriginForList(origin string) string { 100 | if !strings.HasPrefix(origin, "lists-") { 101 | return origin 102 | } 103 | 104 | return strings.Replace(origin, "-", ":", 1) 105 | } 106 | 107 | func (n *nft) CollectMetrics() { 108 | startTime := time.Now() 109 | ip4DroppedPackets, ip4DroppedBytes, ip4ProcessedPackets, ip4ProcessedBytes, bannedIP4 := n.v4.collectDropped() 110 | ip6DroppedPackets, ip6DroppedBytes, ip6ProcessedPackets, ip6ProcessedBytes, bannedIP6 := n.v6.collectDropped() 111 | 112 | log.Debugf("metrics collection took %s", time.Since(startTime)) 113 | log.Debugf("ip4: dropped packets: %+v, dropped bytes: %+v, banned IPs: %+v, proccessed packets: %d, processed bytes: %d", ip4DroppedPackets, ip4DroppedBytes, bannedIP4, ip4ProcessedPackets, ip4ProcessedBytes) 114 | log.Debugf("ip6: dropped packets: %+v, dropped bytes: %+v, banned IPs: %+v, proccessed packets: %d, processed bytes: %d", ip6DroppedPackets, ip6DroppedBytes, bannedIP6, ip6ProcessedPackets, ip6ProcessedBytes) 115 | 116 | metrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv4"}).Set(float64(ip4ProcessedPackets)) 117 | metrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv4"}).Set(float64(ip4ProcessedBytes)) 118 | 119 | metrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv6"}).Set(float64(ip6ProcessedPackets)) 120 | metrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv6"}).Set(float64(ip6ProcessedBytes)) 121 | 122 | for origin, count := range bannedIP4 { 123 | origin = getOriginForList(origin) 124 | metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv4"}).Set(float64(count)) 125 | } 126 | 127 | for origin, count := range bannedIP6 { 128 | origin = getOriginForList(origin) 129 | metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv6"}).Set(float64(count)) 130 | } 131 | 132 | for origin, count := range ip4DroppedPackets { 133 | origin = getOriginForList(origin) 134 | metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv4"}).Set(float64(count)) 135 | } 136 | 137 | for origin, count := range ip6DroppedPackets { 138 | origin = getOriginForList(origin) 139 | metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv6"}).Set(float64(count)) 140 | } 141 | 142 | for origin, count := range ip4DroppedBytes { 143 | origin = getOriginForList(origin) 144 | metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv4"}).Set(float64(count)) 145 | } 146 | 147 | for origin, count := range ip6DroppedBytes { 148 | origin = getOriginForList(origin) 149 | metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv6"}).Set(float64(count)) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /pkg/nftables/nftables.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package nftables 4 | 5 | import ( 6 | "fmt" 7 | "net" 8 | "strings" 9 | "time" 10 | 11 | "github.com/google/nftables" 12 | "github.com/google/nftables/binaryutil" 13 | log "github.com/sirupsen/logrus" 14 | 15 | "github.com/crowdsecurity/crowdsec/pkg/models" 16 | 17 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" 18 | ) 19 | 20 | const ( 21 | chunkSize = 200 22 | defaultTimeout = "4h" 23 | ) 24 | 25 | type nft struct { 26 | v4 *nftContext 27 | v6 *nftContext 28 | decisionsToAdd []*models.Decision 29 | decisionsToDelete []*models.Decision 30 | DenyAction string 31 | DenyLog bool 32 | DenyLogPrefix string 33 | Hooks []string 34 | } 35 | 36 | func NewNFTables(config *cfg.BouncerConfig) (*nft, error) { 37 | ret := &nft{ 38 | v4: NewNFTV4Context(config), 39 | v6: NewNFTV6Context(config), 40 | DenyAction: config.DenyAction, 41 | DenyLog: config.DenyLog, 42 | DenyLogPrefix: config.DenyLogPrefix, 43 | Hooks: config.NftablesHooks, 44 | } 45 | 46 | return ret, nil 47 | } 48 | 49 | func (n *nft) Init() error { 50 | log.Debug("nftables: Init()") 51 | 52 | if err := n.v4.init(n.Hooks); err != nil { 53 | return err 54 | } 55 | 56 | if err := n.v6.init(n.Hooks); err != nil { 57 | return err 58 | } 59 | 60 | log.Infof("nftables initiated") 61 | 62 | return nil 63 | } 64 | 65 | func (n *nft) Add(decision *models.Decision) error { 66 | n.decisionsToAdd = append(n.decisionsToAdd, decision) 67 | return nil 68 | } 69 | 70 | func (n *nft) getBannedState() (map[string]struct{}, error) { 71 | banned := make(map[string]struct{}) 72 | if err := n.v4.setBanned(banned); err != nil { 73 | return nil, err 74 | } 75 | 76 | if err := n.v6.setBanned(banned); err != nil { 77 | return nil, err 78 | } 79 | 80 | return banned, nil 81 | } 82 | 83 | func (n *nft) reset() { 84 | n.decisionsToAdd = make([]*models.Decision, 0) 85 | n.decisionsToDelete = make([]*models.Decision, 0) 86 | } 87 | 88 | func (n *nft) commitDeletedDecisions() error { 89 | banned, err := n.getBannedState() 90 | if err != nil { 91 | return fmt.Errorf("failed to get current state: %w", err) 92 | } 93 | 94 | ip4 := []nftables.SetElement{} 95 | ip6 := []nftables.SetElement{} 96 | 97 | n.decisionsToDelete = normalizedDecisions(n.decisionsToDelete) 98 | 99 | for _, decision := range n.decisionsToDelete { 100 | ip := net.ParseIP(*decision.Value) 101 | if _, ok := banned[ip.String()]; !ok { 102 | log.Debugf("not deleting %s since it's not in the set", ip) 103 | continue 104 | } 105 | 106 | if strings.Contains(ip.String(), ":") { 107 | if n.v6.conn != nil { 108 | log.Tracef("adding %s to buffer", ip) 109 | 110 | ip6 = append(ip6, nftables.SetElement{Key: ip.To16()}) 111 | } 112 | 113 | continue 114 | } 115 | 116 | if n.v4.conn != nil { 117 | log.Tracef("adding %s to buffer", ip) 118 | 119 | ip4 = append(ip4, nftables.SetElement{Key: ip.To4()}) 120 | } 121 | } 122 | 123 | if len(ip4) > 0 { 124 | log.Debugf("removing %d ip%s elements from set", len(ip4), n.v4.version) 125 | 126 | if err := n.v4.deleteElements(ip4); err != nil { 127 | return err 128 | } 129 | } 130 | 131 | if len(ip6) > 0 { 132 | log.Debugf("removing %d ip%s elements from set", len(ip6), n.v6.version) 133 | 134 | if err := n.v6.deleteElements(ip6); err != nil { 135 | return err 136 | } 137 | } 138 | 139 | return nil 140 | } 141 | 142 | func (n *nft) createSetAndRuleForOrigin(ctx *nftContext, origin string) error { 143 | if _, ok := ctx.sets[origin]; !ok { 144 | // First time we see this origin, create the rule/set for all hooks 145 | set := &nftables.Set{ 146 | Name: fmt.Sprintf("%s-%s", ctx.blacklists, origin), 147 | Table: ctx.table, 148 | KeyType: ctx.typeIPAddr, 149 | KeyByteOrder: binaryutil.BigEndian, 150 | HasTimeout: true, 151 | } 152 | 153 | ctx.sets[origin] = set 154 | 155 | if err := ctx.conn.AddSet(set, []nftables.SetElement{}); err != nil { 156 | return err 157 | } 158 | 159 | for _, chain := range ctx.chains { 160 | rule, err := ctx.createRule(chain, set, n.DenyLog, n.DenyLogPrefix, n.DenyAction) 161 | if err != nil { 162 | return err 163 | } 164 | 165 | ctx.conn.AddRule(rule) 166 | log.Infof("Created set and rule for origin %s and type %s in chain %s", origin, ctx.typeIPAddr.Name, chain.Name) 167 | } 168 | } 169 | 170 | return nil 171 | } 172 | 173 | func (n *nft) commitAddedDecisions() error { 174 | banned, err := n.getBannedState() 175 | if err != nil { 176 | return fmt.Errorf("failed to get current state: %w", err) 177 | } 178 | 179 | ip4 := make(map[string][]nftables.SetElement, 0) 180 | ip6 := make(map[string][]nftables.SetElement, 0) 181 | 182 | n.decisionsToAdd = normalizedDecisions(n.decisionsToAdd) 183 | 184 | for _, decision := range n.decisionsToAdd { 185 | ip := net.ParseIP(*decision.Value) 186 | if _, ok := banned[ip.String()]; ok { 187 | log.Debugf("not adding %s since it's already in the set", ip) 188 | continue 189 | } 190 | 191 | t, _ := time.ParseDuration(*decision.Duration) 192 | 193 | origin := *decision.Origin 194 | 195 | if origin == "lists" { 196 | origin = origin + "-" + *decision.Scenario 197 | } 198 | 199 | if strings.Contains(ip.String(), ":") { 200 | if n.v6.conn != nil { 201 | if n.v6.setOnly { 202 | origin = n.v6.blacklists 203 | } 204 | 205 | log.Tracef("adding %s to buffer", ip) 206 | 207 | if _, ok := ip6[origin]; !ok { 208 | ip6[origin] = make([]nftables.SetElement, 0) 209 | } 210 | 211 | ip6[origin] = append(ip6[origin], nftables.SetElement{Timeout: t, Key: ip.To16()}) 212 | 213 | if !n.v6.setOnly { 214 | err := n.createSetAndRuleForOrigin(n.v6, origin) 215 | if err != nil { 216 | return err 217 | } 218 | } 219 | } 220 | 221 | continue 222 | } 223 | 224 | if n.v4.conn != nil { 225 | if n.v4.setOnly { 226 | origin = n.v4.blacklists 227 | } 228 | 229 | log.Tracef("adding %s to buffer", ip) 230 | 231 | if _, ok := ip4[origin]; !ok { 232 | ip4[origin] = make([]nftables.SetElement, 0) 233 | } 234 | 235 | ip4[origin] = append(ip4[origin], nftables.SetElement{Timeout: t, Key: ip.To4()}) 236 | 237 | if !n.v4.setOnly { 238 | err := n.createSetAndRuleForOrigin(n.v4, origin) 239 | if err != nil { 240 | return err 241 | } 242 | } 243 | } 244 | } 245 | 246 | if err := n.v4.addElements(ip4); err != nil { 247 | return err 248 | } 249 | 250 | return n.v6.addElements(ip6) 251 | } 252 | 253 | func (n *nft) Commit() error { 254 | defer n.reset() 255 | 256 | if err := n.commitDeletedDecisions(); err != nil { 257 | return err 258 | } 259 | 260 | return n.commitAddedDecisions() 261 | } 262 | 263 | type tmpDecisions struct { 264 | duration time.Duration 265 | origin string 266 | scenario string 267 | } 268 | 269 | // remove duplicates, normalize decision timeouts, keep the longest decision when dups are present. 270 | func normalizedDecisions(decisions []*models.Decision) []*models.Decision { 271 | vals := make(map[string]tmpDecisions) 272 | finalDecisions := make([]*models.Decision, 0) 273 | 274 | for _, d := range decisions { 275 | t, err := time.ParseDuration(*d.Duration) 276 | if err != nil { 277 | t, _ = time.ParseDuration(defaultTimeout) 278 | } 279 | 280 | *d.Value = strings.Split(*d.Value, "/")[0] 281 | if longest, ok := vals[*d.Value]; !ok || t > longest.duration { 282 | vals[*d.Value] = tmpDecisions{ 283 | duration: t, 284 | origin: *d.Origin, 285 | scenario: *d.Scenario, 286 | } 287 | } 288 | } 289 | 290 | for ip, decision := range vals { 291 | d := decision.duration.String() 292 | i := ip // copy it because we don't same value for all decisions as `ip` is same pointer :) 293 | origin := decision.origin 294 | scenario := decision.scenario 295 | 296 | finalDecisions = append(finalDecisions, &models.Decision{ 297 | Duration: &d, 298 | Value: &i, 299 | Origin: &origin, 300 | Scenario: &scenario, 301 | }) 302 | } 303 | 304 | return finalDecisions 305 | } 306 | 307 | func (n *nft) Delete(decision *models.Decision) error { 308 | n.decisionsToDelete = append(n.decisionsToDelete, decision) 309 | return nil 310 | } 311 | 312 | func (n *nft) ShutDown() error { 313 | if err := n.v4.shutDown(); err != nil { 314 | return err 315 | } 316 | 317 | return n.v6.shutDown() 318 | } 319 | -------------------------------------------------------------------------------- /pkg/nftables/nftables_stub.go: -------------------------------------------------------------------------------- 1 | //go:build !linux 2 | // +build !linux 3 | 4 | package nftables 5 | 6 | import ( 7 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" 8 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types" 9 | ) 10 | 11 | func NewNFTables(config *cfg.BouncerConfig) (types.Backend, error) { 12 | return nil, nil 13 | } 14 | -------------------------------------------------------------------------------- /pkg/pf/metrics.go: -------------------------------------------------------------------------------- 1 | package pf 2 | 3 | import ( 4 | "bufio" 5 | "regexp" 6 | "slices" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/prometheus/client_golang/prometheus" 11 | log "github.com/sirupsen/logrus" 12 | 13 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics" 14 | ) 15 | 16 | type counter struct { 17 | packets int 18 | bytes int 19 | } 20 | 21 | var ( 22 | // table names can contain _ or - characters. 23 | rexpTable = regexp.MustCompile(`^block .* from <(?P[^ ]+)> .*"$`) 24 | rexpMetrics = regexp.MustCompile(`^\s+\[.*Packets: (?P\d+)\s+Bytes: (?P\d+).*\]$`) 25 | ) 26 | 27 | func parseMetrics(reader *strings.Reader, tables []string) map[string]counter { 28 | ret := make(map[string]counter) 29 | 30 | // scan until we find a table name between <> 31 | scanner := bufio.NewScanner(reader) 32 | for scanner.Scan() { 33 | line := scanner.Text() 34 | // parse the line and extract the table name 35 | match := rexpTable.FindStringSubmatch(line) 36 | if len(match) == 0 { 37 | continue 38 | } 39 | 40 | table := match[1] 41 | // if the table is not in the list of tables we want to parse, skip it 42 | if !slices.Contains(tables, table) { 43 | continue 44 | } 45 | 46 | // parse the line with the actual metrics 47 | if !scanner.Scan() { 48 | break 49 | } 50 | 51 | line = scanner.Text() 52 | 53 | match = rexpMetrics.FindStringSubmatch(line) 54 | if len(match) == 0 { 55 | log.Errorf("failed to parse metrics: %s", line) 56 | continue 57 | } 58 | 59 | packets, err := strconv.Atoi(match[1]) 60 | if err != nil { 61 | log.Errorf("failed to parse metrics - dropped packets: %s", err) 62 | 63 | packets = 0 64 | } 65 | 66 | bytes, err := strconv.Atoi(match[2]) 67 | if err != nil { 68 | log.Errorf("failed to parse metrics - dropped bytes: %s", err) 69 | 70 | bytes = 0 71 | } 72 | 73 | ret[table] = counter{ 74 | packets: packets, 75 | bytes: bytes, 76 | } 77 | } 78 | 79 | return ret 80 | } 81 | 82 | // countIPs returns the number of IPs in a table. 83 | func countIPs(table string) int { 84 | cmd := execPfctl("", "-T", "show", "-t", table) 85 | 86 | out, err := cmd.Output() 87 | if err != nil { 88 | log.Errorf("failed to run 'pfctl -T show -t %s': %s", table, err) 89 | return 0 90 | } 91 | 92 | // one IP per line 93 | return strings.Count(string(out), "\n") 94 | } 95 | 96 | // CollectMetrics collects metrics from pfctl. 97 | // In pf mode the firewall rules are not controlled by the bouncer, so we can only 98 | // trust they are set up correctly, and retrieve stats from the pfctl tables. 99 | func (pf *pf) CollectMetrics() { 100 | tables := []string{} 101 | 102 | if pf.inet != nil { 103 | tables = append(tables, pf.inet.table) 104 | } 105 | 106 | if pf.inet6 != nil { 107 | tables = append(tables, pf.inet6.table) 108 | } 109 | 110 | cmd := execPfctl("", "-v", "-sr") 111 | 112 | out, err := cmd.Output() 113 | if err != nil { 114 | log.Errorf("failed to run 'pfctl -v -sr': %s", err) 115 | return 116 | } 117 | 118 | reader := strings.NewReader(string(out)) 119 | stats := parseMetrics(reader, tables) 120 | 121 | for _, table := range tables { 122 | st, ok := stats[table] 123 | if !ok { 124 | continue 125 | } 126 | 127 | droppedPackets := float64(st.packets) 128 | droppedBytes := float64(st.bytes) 129 | bannedIPs := countIPs(table) 130 | 131 | if pf.inet != nil && table == pf.inet.table { 132 | metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": ""}).Set(droppedPackets) 133 | metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": ""}).Set(droppedBytes) 134 | metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": ""}).Set(float64(bannedIPs)) 135 | } else if pf.inet6 != nil && table == pf.inet6.table { 136 | metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": ""}).Set(droppedPackets) 137 | metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": ""}).Set(droppedBytes) 138 | metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": ""}).Set(float64(bannedIPs)) 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /pkg/pf/metrics_test.go: -------------------------------------------------------------------------------- 1 | package pf 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParseMetrics(t *testing.T) { 12 | metricsInput := `block drop in quick inet from to any label "CrowdSec IPv4" 13 | [ Evaluations: 1519 Packets: 16 Bytes: 4096 States: 0 ] 14 | [ Inserted: uid 0 pid 14219 State Creations: 0 ] 15 | block drop in quick inet6 from to any label "CrowdSec IPv6" 16 | [ Evaluations: 914 Packets: 8 Bytes: 2048 States: 0 ] 17 | [ Inserted: uid 0 pid 14219 State Creations: 0 ]` 18 | 19 | reader := strings.NewReader(metricsInput) 20 | tables := []string{"crowdsec_blacklists", "crowdsec6_blacklists"} 21 | 22 | metrics := parseMetrics(reader, tables) 23 | 24 | require.Contains(t, metrics, "crowdsec_blacklists") 25 | require.Contains(t, metrics, "crowdsec6_blacklists") 26 | 27 | ip4Metrics := metrics["crowdsec_blacklists"] 28 | assert.Equal(t, 16, ip4Metrics.packets) 29 | assert.Equal(t, 4096, ip4Metrics.bytes) 30 | 31 | ip6Metrics := metrics["crowdsec6_blacklists"] 32 | assert.Equal(t, 8, ip6Metrics.packets) 33 | assert.Equal(t, 2048, ip6Metrics.bytes) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/pf/pf.go: -------------------------------------------------------------------------------- 1 | package pf 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "strings" 8 | 9 | log "github.com/sirupsen/logrus" 10 | 11 | "github.com/crowdsecurity/crowdsec/pkg/models" 12 | 13 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" 14 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types" 15 | ) 16 | 17 | type pf struct { 18 | inet *pfContext 19 | inet6 *pfContext 20 | decisionsToAdd []*models.Decision 21 | decisionsToDelete []*models.Decision 22 | } 23 | 24 | const ( 25 | pfctlCmd = "/sbin/pfctl" 26 | pfDevice = "/dev/pf" 27 | ) 28 | 29 | func NewPF(config *cfg.BouncerConfig) (types.Backend, error) { 30 | ret := &pf{} 31 | 32 | batchSize := config.PF.BatchSize 33 | if batchSize == 0 { 34 | batchSize = 2000 35 | } 36 | 37 | inetCtx := &pfContext{ 38 | table: config.BlacklistsIpv4, 39 | proto: "inet", 40 | anchor: config.PF.AnchorName, 41 | version: "ipv4", 42 | batchSize: batchSize, 43 | } 44 | 45 | inet6Ctx := &pfContext{ 46 | table: config.BlacklistsIpv6, 47 | proto: "inet6", 48 | anchor: config.PF.AnchorName, 49 | version: "ipv6", 50 | batchSize: batchSize, 51 | } 52 | 53 | if !config.DisableIPV4 { 54 | ret.inet = inetCtx 55 | } 56 | 57 | if !config.DisableIPV6 { 58 | ret.inet6 = inet6Ctx 59 | } 60 | 61 | return ret, nil 62 | } 63 | 64 | // execPfctl runs a pfctl command by prepending the anchor name if needed. 65 | // Some commands return an error if an anchor is specified. 66 | func execPfctl(anchor string, arg ...string) *exec.Cmd { 67 | if anchor != "" { 68 | arg = append([]string{"-a", anchor}, arg...) 69 | } 70 | 71 | log.Debugf("Running: %s %s", pfctlCmd, arg) 72 | 73 | return exec.Command(pfctlCmd, arg...) 74 | } 75 | 76 | func (pf *pf) Init() error { 77 | if _, err := os.Stat(pfDevice); err != nil { 78 | return fmt.Errorf("%s device not found: %w", pfDevice, err) 79 | } 80 | 81 | if _, err := exec.LookPath(pfctlCmd); err != nil { 82 | return fmt.Errorf("%s command not found: %w", pfctlCmd, err) 83 | } 84 | 85 | if pf.inet != nil { 86 | if err := pf.inet.init(); err != nil { 87 | return err 88 | } 89 | } 90 | 91 | if pf.inet6 != nil { 92 | if err := pf.inet6.init(); err != nil { 93 | return err 94 | } 95 | } 96 | 97 | return nil 98 | } 99 | 100 | func (pf *pf) Commit() error { 101 | defer pf.reset() 102 | 103 | if err := pf.commitDeletedDecisions(); err != nil { 104 | return err 105 | } 106 | 107 | return pf.commitAddedDecisions() 108 | } 109 | 110 | func (pf *pf) Add(decision *models.Decision) error { 111 | pf.decisionsToAdd = append(pf.decisionsToAdd, decision) 112 | return nil 113 | } 114 | 115 | func (pf *pf) reset() { 116 | pf.decisionsToAdd = make([]*models.Decision, 0) 117 | pf.decisionsToDelete = make([]*models.Decision, 0) 118 | } 119 | 120 | func (pf *pf) commitDeletedDecisions() error { 121 | ipv4decisions := make([]*models.Decision, 0) 122 | ipv6decisions := make([]*models.Decision, 0) 123 | 124 | for _, d := range pf.decisionsToDelete { 125 | if strings.Contains(*d.Value, ":") && pf.inet6 != nil { 126 | ipv6decisions = append(ipv6decisions, d) 127 | } else if pf.inet != nil { 128 | ipv4decisions = append(ipv4decisions, d) 129 | } 130 | } 131 | 132 | if len(ipv6decisions) > 0 { 133 | if pf.inet6 == nil { 134 | log.Debugf("not removing '%d' decisions because ipv6 is disabled", len(ipv6decisions)) 135 | } else if err := pf.inet6.delete(ipv6decisions); err != nil { 136 | return err 137 | } 138 | } 139 | 140 | if len(ipv4decisions) > 0 { 141 | if pf.inet == nil { 142 | log.Debugf("not removing '%d' decisions because ipv4 is disabled", len(ipv4decisions)) 143 | } else if err := pf.inet.delete(ipv4decisions); err != nil { 144 | return err 145 | } 146 | } 147 | 148 | return nil 149 | } 150 | 151 | func (pf *pf) commitAddedDecisions() error { 152 | ipv4decisions := make([]*models.Decision, 0) 153 | ipv6decisions := make([]*models.Decision, 0) 154 | 155 | for _, d := range pf.decisionsToAdd { 156 | if strings.Contains(*d.Value, ":") && pf.inet6 != nil { 157 | ipv6decisions = append(ipv6decisions, d) 158 | } else if pf.inet != nil { 159 | ipv4decisions = append(ipv4decisions, d) 160 | } 161 | } 162 | 163 | if len(ipv6decisions) > 0 { 164 | if pf.inet6 == nil { 165 | log.Debugf("not adding '%d' decisions because ipv6 is disabled", len(ipv6decisions)) 166 | } else if err := pf.inet6.add(ipv6decisions); err != nil { 167 | return err 168 | } 169 | } 170 | 171 | if len(ipv4decisions) > 0 { 172 | if pf.inet == nil { 173 | log.Debugf("not adding '%d' decisions because ipv4 is disabled", len(ipv4decisions)) 174 | } else if err := pf.inet.add(ipv4decisions); err != nil { 175 | return err 176 | } 177 | } 178 | 179 | return nil 180 | } 181 | 182 | func (pf *pf) Delete(decision *models.Decision) error { 183 | pf.decisionsToDelete = append(pf.decisionsToDelete, decision) 184 | return nil 185 | } 186 | 187 | func (pf *pf) ShutDown() error { 188 | log.Infof("flushing 'crowdsec' table(s)") 189 | 190 | if pf.inet != nil { 191 | if err := pf.inet.shutDown(); err != nil { 192 | return fmt.Errorf("unable to flush %s table (%s): ", pf.inet.version, pf.inet.table) 193 | } 194 | } 195 | 196 | if pf.inet6 != nil { 197 | if err := pf.inet6.shutDown(); err != nil { 198 | return fmt.Errorf("unable to flush %s table (%s): ", pf.inet6.version, pf.inet6.table) 199 | } 200 | } 201 | 202 | return nil 203 | } 204 | -------------------------------------------------------------------------------- /pkg/pf/pf_context.go: -------------------------------------------------------------------------------- 1 | package pf 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os/exec" 7 | "strings" 8 | 9 | log "github.com/sirupsen/logrus" 10 | 11 | "github.com/crowdsecurity/go-cs-lib/slicetools" 12 | 13 | "github.com/crowdsecurity/crowdsec/pkg/models" 14 | ) 15 | 16 | type pfContext struct { 17 | proto string 18 | anchor string 19 | table string 20 | version string 21 | batchSize int 22 | } 23 | 24 | const backendName = "pf" 25 | 26 | func (ctx *pfContext) checkTable() error { 27 | log.Infof("Checking pf table: %s", ctx.table) 28 | 29 | cmd := execPfctl(ctx.anchor, "-s", "Tables") 30 | 31 | out, err := cmd.CombinedOutput() 32 | if err != nil { 33 | return fmt.Errorf("pfctl error: %s - %w", out, err) 34 | } 35 | 36 | if !strings.Contains(string(out), ctx.table) { 37 | if ctx.anchor != "" { 38 | return fmt.Errorf("table %s in anchor %s doesn't exist", ctx.table, ctx.anchor) 39 | } 40 | 41 | return fmt.Errorf("table %s doesn't exist", ctx.table) 42 | } 43 | 44 | return nil 45 | } 46 | 47 | func (ctx *pfContext) shutDown() error { 48 | cmd := execPfctl(ctx.anchor, "-t", ctx.table, "-T", "flush") 49 | log.Infof("pf table clean-up: %s", cmd) 50 | 51 | if out, err := cmd.CombinedOutput(); err != nil { 52 | log.Errorf("Error while flushing table (%s): %v --> %s", cmd, err, out) 53 | } 54 | 55 | return nil 56 | } 57 | 58 | // getStateIPs returns a list of IPs that are currently in the state table. 59 | func getStateIPs() (map[string]bool, error) { 60 | ret := make(map[string]bool) 61 | 62 | cmd := exec.Command(pfctlCmd, "-s", "states") 63 | 64 | out, err := cmd.Output() 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | scanner := bufio.NewScanner(strings.NewReader(string(out))) 70 | for scanner.Scan() { 71 | fields := strings.Fields(scanner.Text()) 72 | if len(fields) < 6 { 73 | continue 74 | } 75 | 76 | // don't bother to parse the direction, we'll block both anyway 77 | 78 | // right side 79 | ip := fields[4] 80 | if strings.Contains(ip, ":") { 81 | ip = strings.Split(ip, ":")[0] 82 | } 83 | 84 | ret[ip] = true 85 | 86 | // left side 87 | ip = fields[2] 88 | if strings.Contains(ip, ":") { 89 | ip = strings.Split(ip, ":")[0] 90 | } 91 | 92 | ret[ip] = true 93 | } 94 | 95 | log.Debugf("Found IPs in state table: %v", len(ret)) 96 | 97 | return ret, nil 98 | } 99 | 100 | func (ctx *pfContext) add(decisions []*models.Decision) error { 101 | chunks := slicetools.Chunks(decisions, ctx.batchSize) 102 | for _, chunk := range chunks { 103 | if err := ctx.addChunk(chunk); err != nil { 104 | log.Errorf("error while adding decision chunk: %s", err) 105 | } 106 | } 107 | 108 | bannedIPs := make(map[string]bool) 109 | for _, d := range decisions { 110 | bannedIPs[*d.Value] = true 111 | } 112 | 113 | if len(bannedIPs) == 0 { 114 | log.Debugf("No new banned IPs") 115 | return nil 116 | } 117 | 118 | log.Debugf("New banned IPs: %v", bannedIPs) 119 | 120 | stateIPs, err := getStateIPs() 121 | if err != nil { 122 | return fmt.Errorf("error while getting state IPs: %w", err) 123 | } 124 | 125 | // Reset the states of connections coming from or going to an IP if it's both in stateIPs and bannedIPs 126 | 127 | for ip := range bannedIPs { 128 | if stateIPs[ip] { 129 | // incoming 130 | cmd := execPfctl("", "-k", ip) 131 | if out, err := cmd.CombinedOutput(); err != nil { 132 | log.Errorf("Error while flushing state (%s): %v --> %s", cmd, err, out) 133 | } 134 | 135 | // outgoing 136 | cmd = execPfctl("", "-k", "0.0.0.0/0", "-k", ip) 137 | if out, err := cmd.CombinedOutput(); err != nil { 138 | log.Errorf("Error while flushing state (%s): %v --> %s", cmd, err, out) 139 | } 140 | } 141 | } 142 | 143 | return nil 144 | } 145 | 146 | func (ctx *pfContext) addChunk(decisions []*models.Decision) error { 147 | log.Debugf("Adding chunk with %d decisions", len(decisions)) 148 | 149 | addArgs := []string{"-t", ctx.table, "-T", "add"} 150 | 151 | for _, d := range decisions { 152 | addArgs = append(addArgs, *d.Value) 153 | } 154 | 155 | cmd := execPfctl(ctx.anchor, addArgs...) 156 | if out, err := cmd.CombinedOutput(); err != nil { 157 | return fmt.Errorf("error while adding to table (%s): %w --> %s", cmd, err, out) 158 | } 159 | 160 | return nil 161 | } 162 | 163 | func (ctx *pfContext) delete(decisions []*models.Decision) error { 164 | chunks := slicetools.Chunks(decisions, ctx.batchSize) 165 | for _, chunk := range chunks { 166 | if err := ctx.deleteChunk(chunk); err != nil { 167 | log.Errorf("error while deleting decision chunk: %s", err) 168 | } 169 | } 170 | 171 | return nil 172 | } 173 | 174 | func (ctx *pfContext) deleteChunk(decisions []*models.Decision) error { 175 | delArgs := []string{"-t", ctx.table, "-T", "delete"} 176 | 177 | for _, d := range decisions { 178 | delArgs = append(delArgs, *d.Value) 179 | } 180 | 181 | cmd := execPfctl(ctx.anchor, delArgs...) 182 | if out, err := cmd.CombinedOutput(); err != nil { 183 | log.Infof("Error while deleting from table (%s): %v --> %s", cmd, err, out) 184 | } 185 | 186 | return nil 187 | } 188 | 189 | func (ctx *pfContext) init() error { 190 | if err := ctx.shutDown(); err != nil { 191 | return fmt.Errorf("pf table flush failed: %w", err) 192 | } 193 | 194 | if err := ctx.checkTable(); err != nil { 195 | return fmt.Errorf("pf init failed: %w", err) 196 | } 197 | 198 | log.Infof("%s initiated for %s", backendName, ctx.version) 199 | 200 | return nil 201 | } 202 | -------------------------------------------------------------------------------- /pkg/types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "github.com/crowdsecurity/crowdsec/pkg/models" 5 | ) 6 | 7 | type Backend interface { 8 | Init() error 9 | ShutDown() error 10 | Add(decision *models.Decision) error 11 | Delete(decision *models.Decision) error 12 | Commit() error 13 | CollectMetrics() 14 | } 15 | -------------------------------------------------------------------------------- /rpm/SOURCES/80-crowdsec-firewall-bouncer.preset: -------------------------------------------------------------------------------- 1 | # This file is part of crowdsec-firewall-bouncer 2 | 3 | enable crowdsec-firewall-bouncer.service 4 | -------------------------------------------------------------------------------- /rpm/SPECS/crowdsec-firewall-bouncer.spec: -------------------------------------------------------------------------------- 1 | Name: crowdsec-firewall-bouncer-iptables 2 | Version: %(echo $VERSION) 3 | Release: %(echo $PACKAGE_NUMBER)%{?dist} 4 | Summary: Firewall bouncer for Crowdsec (iptables+ipset configuration) 5 | 6 | License: MIT 7 | URL: https://crowdsec.net 8 | Source0: https://github.com/crowdsecurity/%{name}/archive/v%(echo $VERSION).tar.gz 9 | Source1: 80-crowdsec-firewall-bouncer.preset 10 | BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) 11 | 12 | BuildRequires: make 13 | %{?fc33:BuildRequires: systemd-rpm-macros} 14 | 15 | Requires: gettext,iptables,ipset,ipset-libs 16 | 17 | %define debug_package %{nil} 18 | 19 | %define version_number %(echo $VERSION) 20 | %define releasever %(echo $RELEASEVER) 21 | %global local_version v%{version_number}-%{releasever}-rpm 22 | %global name crowdsec-firewall-bouncer 23 | %global __mangle_shebangs_exclude_from /usr/bin/env 24 | 25 | %prep 26 | %setup -q -T -b 0 -n %{name}-%{version_number} 27 | 28 | %build 29 | BUILD_VERSION=%{local_version} make 30 | 31 | %install 32 | rm -rf %{buildroot} 33 | 34 | mkdir -p %{buildroot}%{_bindir} 35 | install -m 755 %{name} %{buildroot}%{_bindir}/%{name} 36 | # symlink for compatibility with old versions 37 | 38 | mkdir -p %{buildroot}/etc/crowdsec/bouncers 39 | install -m 600 config/%{name}.yaml %{buildroot}/etc/crowdsec/bouncers/%{name}.yaml 40 | 41 | mkdir -p %{buildroot}/usr/lib/%{name} 42 | install -m 600 scripts/_bouncer.sh %{buildroot}/usr/lib/%{name}/_bouncer.sh 43 | 44 | mkdir -p %{buildroot}%{_unitdir} 45 | BIN=%{_bindir}/%{name} CFG=/etc/crowdsec/bouncers envsubst '$BIN $CFG' < config/%{name}.service | install -m 0644 /dev/stdin %{buildroot}%{_unitdir}/%{name}.service 46 | 47 | mkdir -p %{buildroot}%{_presetdir} 48 | install -D -m 644 %{SOURCE1} %{buildroot}%{_presetdir}/ 49 | 50 | %clean 51 | rm -rf %{buildroot} 52 | 53 | %changelog 54 | * Tue Feb 16 2021 Manuel Sabban 55 | - First initial packaging 56 | 57 | # ------------------------------------ 58 | # iptables 59 | # ------------------------------------ 60 | 61 | %description -n %{name}-iptables 62 | 63 | %files -n %{name}-iptables 64 | %defattr(-,root,root,-) 65 | %{_bindir}/%{name} 66 | /usr/lib/%{name}/_bouncer.sh 67 | %{_unitdir}/%{name}.service 68 | %config(noreplace) /etc/crowdsec/bouncers/%{name}.yaml 69 | %config(noreplace) %{_presetdir}/80-crowdsec-firewall-bouncer.preset 70 | 71 | %post -n %{name}-iptables 72 | systemctl daemon-reload 73 | 74 | . /usr/lib/%{name}/_bouncer.sh 75 | START=1 76 | 77 | if grep -q '${BACKEND}' "$CONFIG"; then 78 | newconfig=$(BACKEND="iptables" envsubst '$BACKEND' < "$CONFIG") 79 | echo "$newconfig" | install -m 0600 /dev/stdin "$CONFIG" 80 | fi 81 | 82 | if [ "$1" = "1" ]; then 83 | if need_api_key; then 84 | if ! set_api_key; then 85 | START=0 86 | fi 87 | fi 88 | fi 89 | 90 | set_local_port 91 | 92 | if [ ! -e /usr/sbin/crowdsec-firewall-bouncer ]; then 93 | if [ ! -L /usr/sbin ]; then 94 | ln -s ../bin/crowdsec-firewall-bouncer /usr/sbin/crowdsec-firewall-bouncer 95 | fi 96 | fi 97 | 98 | 99 | %systemd_post %{name}.service 100 | 101 | if [ "$START" -eq 0 ]; then 102 | 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 103 | else 104 | %if 0%{?fc35} 105 | systemctl enable "$SERVICE" 106 | %endif 107 | systemctl start "$SERVICE" 108 | fi 109 | 110 | echo "$BOUNCER has been successfully installed" 111 | 112 | %preun -n %{name}-iptables 113 | . /usr/lib/%{name}/_bouncer.sh 114 | 115 | if [ "$1" = "0" ]; then 116 | systemctl stop "$SERVICE" || echo "cannot stop service" 117 | systemctl disable "$SERVICE" || echo "cannot disable service" 118 | delete_bouncer 119 | fi 120 | 121 | %postun -n %{name}-iptables 122 | if [ "$1" = "1" ]; then 123 | systemctl restart %{name} || echo "cannot restart service" 124 | fi 125 | 126 | if [ -L /usr/sbin/crowdsec-firewall-bouncer ]; then 127 | rm -f /usr/sbin/crowdsec-firewall-bouncer 128 | fi 129 | 130 | 131 | # ------------------------------------ 132 | # nftables 133 | # ------------------------------------ 134 | 135 | %package -n %{name}-nftables 136 | Summary: Firewall bouncer for Crowdsec (nftables configuration) 137 | Requires: nftables,gettext 138 | 139 | %description -n %{name}-nftables 140 | 141 | %files -n %{name}-nftables 142 | %defattr(-,root,root,-) 143 | %{_bindir}/%{name} 144 | /usr/lib/%{name}/_bouncer.sh 145 | %{_unitdir}/%{name}.service 146 | %config(noreplace) /etc/crowdsec/bouncers/%{name}.yaml 147 | %config(noreplace) %{_presetdir}/80-crowdsec-firewall-bouncer.preset 148 | 149 | %post -n %{name}-nftables 150 | systemctl daemon-reload 151 | 152 | . /usr/lib/%{name}/_bouncer.sh 153 | START=1 154 | 155 | if grep -q '${BACKEND}' "$CONFIG"; then 156 | newconfig=$(BACKEND="nftables" envsubst '$BACKEND' < "$CONFIG") 157 | echo "$newconfig" | install -m 0600 /dev/stdin "$CONFIG" 158 | fi 159 | 160 | if [ "$1" = "1" ]; then 161 | if need_api_key; then 162 | if ! set_api_key; then 163 | START=0 164 | fi 165 | fi 166 | fi 167 | 168 | set_local_port 169 | 170 | if [ ! -e /usr/sbin/crowdsec-firewall-bouncer ]; then 171 | if [ ! -L /usr/sbin ]; then 172 | ln -s ../bin/crowdsec-firewall-bouncer /usr/sbin/crowdsec-firewall-bouncer 173 | fi 174 | fi 175 | 176 | %systemd_post %{name}.service 177 | 178 | if [ "$START" -eq 0 ]; then 179 | 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 180 | else 181 | %if 0%{?fc35} 182 | systemctl enable "$SERVICE" 183 | %endif 184 | systemctl start "$SERVICE" 185 | fi 186 | 187 | echo "$BOUNCER has been successfully installed" 188 | 189 | %preun -n %{name}-nftables 190 | . /usr/lib/%{name}/_bouncer.sh 191 | 192 | if [ "$1" = "0" ]; then 193 | systemctl stop "$SERVICE" || echo "cannot stop service" 194 | systemctl disable "$SERVICE" || echo "cannot disable service" 195 | delete_bouncer 196 | fi 197 | 198 | %postun -n %{name}-nftables 199 | if [ "$1" = "1" ]; then 200 | systemctl restart %{name} || echo "cannot restart service" 201 | fi 202 | 203 | if [ -L /usr/sbin/crowdsec-firewall-bouncer ]; then 204 | rm -f /usr/sbin/crowdsec-firewall-bouncer 205 | fi 206 | -------------------------------------------------------------------------------- /scripts/_bouncer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | #shellcheck disable=SC3043 3 | 4 | set -eu 5 | 6 | BOUNCER="crowdsec-firewall-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 | install_pkg() { 14 | pkg="$1" 15 | if [ -f /etc/redhat-release ]; then 16 | yum install -y "$pkg" 17 | elif grep -q "Amazon Linux release 2 (Karoo)" /etc/system-release 2>/dev/null; then 18 | yum install -y "$pkg" 19 | elif grep -q "suse" /etc/os-release 2>/dev/null; then 20 | zypper install -y "$pkg" 21 | elif [ -f /etc/debian_version ]; then 22 | apt install -y "$pkg" 23 | else 24 | msg warn "This distribution is not supported" 25 | return 1 26 | fi 27 | msg succ "$pkg successfully installed" 28 | return 0 29 | } 30 | 31 | check_firewall() { 32 | # Default firewall backend is nftables 33 | FW_BACKEND="nftables" 34 | 35 | iptables="true" 36 | if command -v iptables >/dev/null; then 37 | FW_BACKEND="iptables" 38 | msg info "iptables found" 39 | else 40 | msg warn "iptables not found" 41 | iptables="false" 42 | fi 43 | 44 | nftables="true" 45 | if command -v nft >/dev/null; then 46 | FW_BACKEND="nftables" 47 | msg info "nftables found" 48 | else 49 | msg warn "nftables not found" 50 | nftables="false" 51 | fi 52 | 53 | if [ "$nftables" = "false" ] && [ "$iptables" = "false" ]; then 54 | printf '%s ' "No firewall found, do you want to install nftables (Y/n) ?" 55 | read -r answer 56 | if echo "$answer" | grep -iq '^n'; then 57 | msg err "unable to continue without nftables. Please install nftables or iptables to use this bouncer." 58 | exit 1 59 | fi 60 | # shellcheck disable=SC2310 61 | install_pkg nftables || ( msg err "Cannot install nftables, please install it manually"; exit 1 ) 62 | fi 63 | 64 | if [ "$nftables" = "true" ] && [ "$iptables" = "true" ]; then 65 | printf '%s ' "Found nftables (default) and iptables, which firewall do you want to use (nftables/iptables) ?" 66 | read -r answer 67 | if [ "$answer" = "iptables" ]; then 68 | FW_BACKEND="iptables" 69 | fi 70 | fi 71 | 72 | if [ "$FW_BACKEND" = "iptables" ]; then 73 | check_ipset 74 | fi 75 | } 76 | 77 | check_ipset() { 78 | if ! command -v ipset >/dev/null; then 79 | printf '%s ' "ipset not found, do you want to install it (Y/n) ?" 80 | read -r answer 81 | if echo "$answer" | grep -iq '^n'; then 82 | msg err "unable to continue without ipset. Exiting" 83 | exit 1 84 | fi 85 | # shellcheck disable=SC2310 86 | install_pkg ipset || ( msg err "Cannot install ipset, please install it manually"; exit 1 ) 87 | fi 88 | } 89 | 90 | gen_apikey() { 91 | if command -v cscli >/dev/null; then 92 | msg succ "cscli found, generating bouncer api key." 93 | bouncer_id="$BOUNCER_PREFIX-$(date +%s)" 94 | API_KEY=$(cscli -oraw bouncers add "$bouncer_id") 95 | echo "$bouncer_id" > "$CONFIG.id" 96 | msg info "API Key: $API_KEY" 97 | READY="yes" 98 | else 99 | msg warn "cscli not found, you will need to generate an api key." 100 | READY="no" 101 | fi 102 | } 103 | 104 | gen_config_file() { 105 | # shellcheck disable=SC2016 106 | API_KEY=${API_KEY} BACKEND=${FW_BACKEND} envsubst '$API_KEY $BACKEND' <"./config/$CONFIG_FILE" | \ 107 | install -D -m 0600 /dev/stdin "$CONFIG" 108 | } 109 | 110 | install_bouncer() { 111 | if [ ! -f "$BIN_PATH" ]; then 112 | msg err "$BIN_PATH not found, exiting." 113 | exit 1 114 | fi 115 | if [ -e "$BIN_PATH_INSTALLED" ]; then 116 | msg err "$BIN_PATH_INSTALLED is already installed. Exiting" 117 | exit 1 118 | fi 119 | msg "Installing $BOUNCER" 120 | check_firewall 121 | install -v -m 0755 -D "$BIN_PATH" "$BIN_PATH_INSTALLED" 122 | install -D -m 0600 "./config/$CONFIG_FILE" "$CONFIG" 123 | # shellcheck disable=SC2016 124 | CFG=${CONFIG_DIR} BIN=${BIN_PATH_INSTALLED} envsubst '$CFG $BIN' <"./config/$SERVICE" >"$SYSTEMD_PATH_FILE" 125 | systemctl daemon-reload 126 | gen_apikey 127 | gen_config_file 128 | set_local_port 129 | } 130 | 131 | # --------------------------------- # 132 | 133 | install_bouncer 134 | 135 | systemctl enable "$SERVICE" 136 | if [ "$READY" = "yes" ]; then 137 | systemctl start "$SERVICE" 138 | else 139 | msg warn "service not started. You need to get an API key and configure it in $CONFIG" 140 | fi 141 | 142 | msg succ "The $BOUNCER service has been installed." 143 | exit 0 144 | -------------------------------------------------------------------------------- /scripts/uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 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/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/README.md -------------------------------------------------------------------------------- /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-firewall-bouncer-tests" 3 | version = "0.1.0" 4 | description = "Tests for cs-firewall-bouncer" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "flask>=3.1.0", 9 | "pexpect>=4.9.0", 10 | "psutil>=6.1.1", 11 | "pytest>=8.3.4", 12 | "pytest-cs", 13 | "pytest-dependency>=0.6.0", 14 | "pytest-dotenv>=0.5.2", 15 | "pytimeparse>=1.1.8", 16 | "zxcvbn>=4.4.28", 17 | ] 18 | 19 | [tool.uv.sources] 20 | pytest-cs = { git = "https://github.com/crowdsecurity/pytest-cs" } 21 | 22 | [dependency-groups] 23 | dev = [ 24 | "basedpyright>=1.26.0", 25 | "ipdb>=0.13.13", 26 | "ruff>=0.9.4", 27 | ] 28 | 29 | #[tool.uv.sources] 30 | #pytest-cs = { path = "../../../pytest-cs", editable = true } 31 | 32 | 33 | [tool.ruff] 34 | 35 | line-length = 120 36 | 37 | [tool.ruff.lint] 38 | select = [ 39 | "ALL" 40 | ] 41 | 42 | ignore = [ 43 | "ANN", # Missing type annotations 44 | "A002", # Function argument `id` is shadowing a Python builtin 45 | "ARG001", # Unused function argument: `...` 46 | "COM812", # Trailing comma missing 47 | "D100", # Missing docstring in public module 48 | "D101", # Missing docstring in public class 49 | "D102", # Missing docstring in public method 50 | "D103", # Missing docstring in public function 51 | "D104", # Missing docstring in public package 52 | "D107", # Missing docstring in __init__ 53 | "D203", # incorrect-blank-line-before-class 54 | "D212", # Multi-line docstring summary should start at the first line 55 | "D212", # Multi-line docstring summary should start at the first line 56 | "D400", # First line should end with a period 57 | "D415", # First line should end with a period, question mark, or exclamation point 58 | "DTZ005", # `datetime.datetime.now()` called without a `tz` argument 59 | "EM102", # Exception must not use an f-string literal, assign to variable first 60 | "ERA001", # Found commented-out code 61 | "FBT002", # Boolean default positional argument in function definition 62 | "FIX002", # Line contains TODO, consider resolving the issue 63 | "FIX003", # Line contains XXX, consider resolving the issue 64 | "N802", # Function name `testLogging` should be lowercase 65 | "PLW1510", # `subprocess.run` without explicit `check` argument 66 | "S101", # Use of 'assert' detected 67 | "S104", # Possible binding to all interfaces 68 | "S314", # Using `xml` to parse untrusted data is known to be vulnerable to XML attacks; use `defusedxml` equivalents 69 | "S603", # `subprocess` call: check for execution of untrusted input 70 | "S604", # Function call with `shell=True` parameter identified, security issue 71 | "S607", # Starting a process with a partial executable path 72 | "SIM108", # Use ternary operator `...` instead of `if`-`else`-block 73 | "TD001", # Invalid TODO tag: `XXX` 74 | "TD002", # Missing author in TODO 75 | "TD003", # Missing issue link for this TODO 76 | "TRY003", # Avoid specifying long messages outside the exception class 77 | "PLR2004", # Magic value used in comparison, consider replacing `...` with a constant variable 78 | "PLR0913", # Too many arguments in function definition (6 > 5) 79 | "PTH107", # `os.remove()` should be replaced by `Path.unlink()` 80 | "PTH108", # `os.unlink()` should be replaced by `Path.unlink()` 81 | "PTH110", # `os.path.exists()` should be replaced by `Path.exists()` 82 | "PTH116", # `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` 83 | "PTH120", # `os.path.dirname()` should be replaced by `Path.parent` 84 | "PTH123", # `open()` should be replaced by `Path.open()` 85 | "PT009", # Use a regular `assert` instead of unittest-style `assertEqual` 86 | "PT022", # No teardown in fixture `fw_cfg_factory`, use `return` instead of `yield` 87 | "TID252", # Prefer absolute imports over relative imports from parent modules 88 | "UP022", # Prefer `capture_output` over sending `stdout` and `stderr` to `PIPE` 89 | ] 90 | 91 | [tool.basedpyright] 92 | reportAny = "none" 93 | reportArgumentType = "none" 94 | reportAttributeAccessIssue = "none" 95 | reportImplicitOverride = "none" 96 | reportImplicitStringConcatenation = "none" 97 | reportMissingParameterType = "none" 98 | reportMissingTypeStubs = "none" 99 | reportOptionalMemberAccess = "none" 100 | reportUnannotatedClassAttribute = "none" 101 | reportUninitializedInstanceVariable = "none" 102 | reportUnknownArgumentType = "none" 103 | reportUnknownMemberType = "none" 104 | reportUnknownParameterType = "none" 105 | reportUnknownVariableType = "none" 106 | reportUnusedCallResult = "none" 107 | reportUnusedParameter = "none" 108 | -------------------------------------------------------------------------------- /test/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = 3 | --pdbcls=IPython.terminal.debugger:Pdb 4 | --ignore=tests/install 5 | --ignore=tests/backends 6 | --strict-markers 7 | markers: 8 | deb: mark tests related to deb packaging 9 | rpm: mark tests related to rpm packaging 10 | systemd_debug: dump systemd status and journal on test failure 11 | env_files = 12 | .env 13 | default.env 14 | -------------------------------------------------------------------------------- /test/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/__init__.py -------------------------------------------------------------------------------- /test/tests/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/backends/__init__.py -------------------------------------------------------------------------------- /test/tests/backends/iptables/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/backends/iptables/__init__.py -------------------------------------------------------------------------------- /test/tests/backends/iptables/crowdsec-firewall-bouncer-logging.yaml: -------------------------------------------------------------------------------- 1 | mode: iptables 2 | update_frequency: 0.1s 3 | log_mode: stdout 4 | log_dir: ./ 5 | log_level: info 6 | api_url: http://127.0.0.1:8081/ 7 | api_key: 1237adaf7a1724ac68a3288828820a67 8 | disable_ipv6: false 9 | deny_action: DROP 10 | deny_log: true 11 | deny_log_prefix: "blocked by crowdsec" 12 | supported_decisions_types: 13 | - ban 14 | iptables_chains: 15 | - INPUT 16 | -------------------------------------------------------------------------------- /test/tests/backends/iptables/crowdsec-firewall-bouncer.yaml: -------------------------------------------------------------------------------- 1 | mode: iptables 2 | update_frequency: 0.1s 3 | log_mode: stdout 4 | log_dir: ./ 5 | log_level: info 6 | api_url: http://127.0.0.1:8081/ 7 | api_key: 1237adaf7a1724ac68a3288828820a67 8 | disable_ipv6: false 9 | deny_action: DROP 10 | deny_log: false 11 | supported_decisions_types: 12 | - ban 13 | iptables_chains: 14 | - INPUT 15 | -------------------------------------------------------------------------------- /test/tests/backends/iptables/test_iptables.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import unittest 4 | import xml.etree.ElementTree as ET 5 | from ipaddress import ip_address 6 | from pathlib import Path 7 | from time import sleep 8 | 9 | from ..mock_lapi import MockLAPI 10 | from ..utils import generate_n_decisions, new_decision, run_cmd 11 | 12 | SCRIPT_DIR = Path(os.path.dirname(os.path.realpath(__file__))) 13 | PROJECT_ROOT = SCRIPT_DIR.parent.parent.parent.parent 14 | BINARY_PATH = PROJECT_ROOT.joinpath("crowdsec-firewall-bouncer") 15 | CONFIG_PATH = SCRIPT_DIR.joinpath("crowdsec-firewall-bouncer.yaml") 16 | CONFIG_PATH_LOGGING = SCRIPT_DIR.joinpath("crowdsec-firewall-bouncer-logging.yaml") 17 | 18 | SET_NAME_IPV4 = "crowdsec-blacklists-0" 19 | SET_NAME_IPV6 = "crowdsec6-blacklists-0" 20 | 21 | RULES_CHAIN_NAME = "CROWDSEC_CHAIN" 22 | LOGGING_CHAIN_NAME = "CROWDSEC_LOG" 23 | CHAIN_NAME = "INPUT" 24 | 25 | 26 | class TestIPTables(unittest.TestCase): 27 | def setUp(self): 28 | self.fb = subprocess.Popen([BINARY_PATH, "-c", CONFIG_PATH]) 29 | self.lapi = MockLAPI() 30 | self.lapi.start() 31 | return super().setUp() 32 | 33 | def tearDown(self): 34 | self.fb.kill() 35 | self.fb.wait() 36 | self.lapi.stop() 37 | 38 | def test_table_rule_set_are_created(self): 39 | d1 = generate_n_decisions(3) 40 | d2 = generate_n_decisions(1, ipv4=False) 41 | self.lapi.ds.insert_decisions(d1 + d2) 42 | sleep(3) 43 | 44 | # IPV4 Chain 45 | # Check the rules with the sets 46 | output = run_cmd("iptables", "-L", RULES_CHAIN_NAME) 47 | rules = [line for line in output.split("\n") if SET_NAME_IPV4 in line] 48 | 49 | self.assertEqual(len(rules), 1) 50 | assert f"match-set {SET_NAME_IPV4} src" in rules[0] 51 | 52 | # Check the JUMP to CROWDSEC_CHAIN 53 | output = run_cmd("iptables", "-L", CHAIN_NAME) 54 | rules = [line for line in output.split("\n") if RULES_CHAIN_NAME in line] 55 | 56 | self.assertEqual(len(rules), 1) 57 | assert f"{RULES_CHAIN_NAME}" in rules[0] 58 | 59 | # IPV6 Chain 60 | output = run_cmd("ip6tables", "-L", RULES_CHAIN_NAME) 61 | rules = [line for line in output.split("\n") if SET_NAME_IPV6 in line] 62 | 63 | self.assertEqual(len(rules), 1) 64 | assert f"match-set {SET_NAME_IPV6} src" in rules[0] 65 | 66 | # Check the JUMP to CROWDSEC_CHAIN 67 | output = run_cmd("ip6tables", "-L", CHAIN_NAME) 68 | rules = [line for line in output.split("\n") if RULES_CHAIN_NAME in line] 69 | 70 | self.assertEqual(len(rules), 1) 71 | assert f"{RULES_CHAIN_NAME}" in rules[0] 72 | 73 | output = run_cmd("ipset", "list") 74 | 75 | assert SET_NAME_IPV6 in output 76 | assert SET_NAME_IPV4 in output 77 | 78 | def test_duplicate_decisions_across_decision_stream(self): 79 | d1, d2, d3 = generate_n_decisions(3, dup_count=1) 80 | self.lapi.ds.insert_decisions([d1]) 81 | sleep(3) 82 | res = get_set_elements(SET_NAME_IPV4) 83 | self.assertEqual(res, {"0.0.0.0"}) 84 | 85 | self.lapi.ds.insert_decisions([d2, d3]) 86 | sleep(3) 87 | assert self.fb.poll() is None 88 | self.assertEqual(get_set_elements(SET_NAME_IPV4), {"0.0.0.0", "0.0.0.1"}) 89 | 90 | self.lapi.ds.delete_decision_by_id(d1["id"]) 91 | self.lapi.ds.delete_decision_by_id(d2["id"]) 92 | sleep(3) 93 | self.assertEqual(get_set_elements(SET_NAME_IPV4), set()) 94 | assert self.fb.poll() is None 95 | 96 | self.lapi.ds.delete_decision_by_id(d3["id"]) 97 | sleep(3) 98 | self.assertEqual(get_set_elements(SET_NAME_IPV6), set()) 99 | assert self.fb.poll() is None 100 | 101 | def test_decision_insertion_deletion_ipv4(self): 102 | total_decisions, duplicate_decisions = 100, 23 103 | decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions) 104 | self.lapi.ds.insert_decisions(decisions) 105 | sleep(3) # let the bouncer insert the decisions 106 | 107 | set_elements = get_set_elements(SET_NAME_IPV4) 108 | self.assertEqual(len(set_elements), total_decisions - duplicate_decisions) 109 | self.assertEqual({i["value"] for i in decisions}, set_elements) 110 | self.assertIn("0.0.0.0", set_elements) 111 | 112 | self.lapi.ds.delete_decisions_by_ip("0.0.0.0") 113 | sleep(3) 114 | 115 | set_elements = get_set_elements(SET_NAME_IPV4) 116 | self.assertEqual({i["value"] for i in decisions if i["value"] != "0.0.0.0"}, set_elements) 117 | self.assertEqual(len(set_elements), total_decisions - duplicate_decisions - 1) 118 | self.assertNotIn("0.0.0.0", set_elements) 119 | 120 | def test_decision_insertion_deletion_ipv6(self): 121 | total_decisions, duplicate_decisions = 100, 23 122 | decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions, ipv4=False) 123 | self.lapi.ds.insert_decisions(decisions) 124 | sleep(3) 125 | 126 | set_elements = get_set_elements(SET_NAME_IPV6) 127 | set_elements = set(map(ip_address, set_elements)) 128 | self.assertEqual(len(set_elements), total_decisions - duplicate_decisions) 129 | self.assertEqual({ip_address(i["value"]) for i in decisions}, set_elements) 130 | self.assertIn(ip_address("::1:0:3"), set_elements) 131 | 132 | self.lapi.ds.delete_decisions_by_ip("::1:0:3") 133 | sleep(3) 134 | 135 | set_elements = get_set_elements(SET_NAME_IPV6) 136 | set_elements = set(map(ip_address, set_elements)) 137 | self.assertEqual(len(set_elements), total_decisions - duplicate_decisions - 1) 138 | self.assertEqual( 139 | {ip_address(i["value"]) for i in decisions if ip_address(i["value"]) != ip_address("::1:0:3")}, 140 | set_elements, 141 | ) 142 | self.assertNotIn(ip_address("::1:0:3"), set_elements) 143 | 144 | def test_longest_decision_insertion(self): 145 | decisions = [ 146 | { 147 | "value": "123.45.67.12", 148 | "scope": "ip", 149 | "type": "ban", 150 | "origin": "script", 151 | "duration": f"{i}h", 152 | "reason": "for testing", 153 | } 154 | for i in range(1, 201) 155 | ] 156 | self.lapi.ds.insert_decisions(decisions) 157 | sleep(3) 158 | elems = get_set_elements(SET_NAME_IPV4, with_timeout=True) 159 | self.assertEqual(len(elems), 1) 160 | elems = list(elems) 161 | self.assertEqual(elems[0][0], "123.45.67.12") 162 | self.assertLessEqual(abs(elems[0][1] - 200 * 60 * 60), 15) 163 | 164 | 165 | def get_set_elements(set_name, with_timeout=False): 166 | output = run_cmd("ipset", "list", "-o", "xml") 167 | root = ET.fromstring(output) 168 | elements = set() 169 | for member in root.findall(f"ipset[@name='{set_name}']/members/member"): 170 | if with_timeout: 171 | to_add = (member.find("elem").text, int(member.find("timeout").text)) 172 | else: 173 | to_add = member.find("elem").text 174 | elements.add(to_add) 175 | return elements 176 | 177 | 178 | class TestIPTablesLogging(unittest.TestCase): 179 | def setUp(self): 180 | self.fb = subprocess.Popen([BINARY_PATH, "-c", CONFIG_PATH_LOGGING]) 181 | self.lapi = MockLAPI() 182 | self.lapi.start() 183 | return super().setUp() 184 | 185 | def tearDown(self): 186 | self.fb.kill() 187 | self.fb.wait() 188 | self.lapi.stop() 189 | 190 | def testLogging(self): 191 | # We use 1.1.1.1 because we want to see some dropped packets in the logs 192 | # We know this IP responds to ping, and the response will be dropped by the firewall 193 | d = new_decision("1.1.1.1") 194 | self.lapi.ds.insert_decisions([d]) 195 | sleep(3) 196 | 197 | # Check if our logging chain is in place 198 | 199 | output = run_cmd("iptables", "-L", LOGGING_CHAIN_NAME) 200 | rules = [line for line in output.split("\n") if "anywhere" in line] 201 | 202 | # 2 rules: one logging, one generic drop 203 | self.assertEqual(len(rules), 2) 204 | 205 | # Check if the logging chain is called from the main chain 206 | output = run_cmd("iptables", "-L", CHAIN_NAME) 207 | 208 | rules = [line for line in output.split("\n") if RULES_CHAIN_NAME in line] 209 | 210 | self.assertEqual(len(rules), 1) 211 | 212 | # Check if logging/drop chain is called from the rules chain 213 | output = run_cmd("iptables", "-L", RULES_CHAIN_NAME) 214 | 215 | rules = [line for line in output.split("\n") if LOGGING_CHAIN_NAME in line] 216 | 217 | self.assertEqual(len(rules), 1) 218 | 219 | # Now, try to ping the IP 220 | 221 | output = run_cmd( 222 | "curl", "--connect-timeout", "1", "1.1.1.1", ignore_error=True 223 | ) # We don't care about the output, we just want to trigger the rule 224 | 225 | # Check if the firewall has logged the dropped response 226 | 227 | output = run_cmd("dmesg | tail -n 10", shell=True) 228 | 229 | assert "blocked by crowdsec" in output 230 | -------------------------------------------------------------------------------- /test/tests/backends/mock_lapi.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | from datetime import timedelta 4 | from ipaddress import ip_address 5 | from threading import Thread 6 | from time import sleep 7 | 8 | from flask import Flask, abort, request 9 | from pytimeparse.timeparse import timeparse 10 | from werkzeug.serving import make_server 11 | 12 | 13 | # This is the "database" of our dummy LAPI 14 | class DataStore: 15 | def __init__(self) -> None: 16 | self.id = 0 17 | self.decisions = [] 18 | self.bouncer_lastpull_by_api_key = {} 19 | 20 | def insert_decisions(self, decisions): 21 | for i, _ in enumerate(decisions): 22 | decisions[i]["created_at"] = datetime.datetime.now() 23 | decisions[i]["deleted_at"] = self.get_decision_expiry_time(decisions[i]) 24 | decisions[i]["id"] = self.id 25 | self.id += 1 26 | self.decisions.extend(decisions) 27 | 28 | # This methods can be made more generic by taking lambda expr as input for filtering 29 | # decisions to delete 30 | def delete_decisions_by_ip(self, ip): 31 | for i, decision in enumerate(self.decisions): 32 | if ip_address(decision["value"]) == ip_address(ip): 33 | self.decisions[i]["deleted_at"] = datetime.datetime.now() 34 | 35 | def delete_decision_by_id(self, id): 36 | for i, decision in enumerate(self.decisions): 37 | if decision["id"] == id: 38 | self.decisions[i]["deleted_at"] = datetime.datetime.now() 39 | break 40 | 41 | def update_bouncer_pull(self, api_key): 42 | self.bouncer_lastpull_by_api_key[api_key] = datetime.datetime.now() 43 | 44 | def get_active_and_expired_decisions_since(self, since): 45 | expired_decisions = [] 46 | active_decisions = [] 47 | 48 | for decision in self.decisions: 49 | # decision["deleted_at"] > datetime.datetime.now() means that decision hasn't yet expired 50 | if decision["deleted_at"] > since and decision["deleted_at"] < datetime.datetime.now(): 51 | expired_decisions.append(decision) 52 | 53 | elif decision["created_at"] > since: 54 | active_decisions.append(decision) 55 | return active_decisions, expired_decisions 56 | 57 | def get_decisions_for_bouncer(self, api_key, startup=False): 58 | if startup or api_key not in self.bouncer_lastpull_by_api_key: 59 | since = datetime.datetime.min 60 | self.bouncer_lastpull_by_api_key[api_key] = since 61 | else: 62 | since = self.bouncer_lastpull_by_api_key[api_key] 63 | 64 | self.update_bouncer_pull(api_key) 65 | return self.get_active_and_expired_decisions_since(since) 66 | 67 | @staticmethod 68 | def get_decision_expiry_time(decision): 69 | return decision["created_at"] + timedelta(seconds=timeparse(decision["duration"])) 70 | 71 | 72 | class MockLAPI: 73 | def __init__(self) -> None: 74 | self.app = Flask(__name__) 75 | self.app.add_url_rule("/v1/decisions/stream", view_func=self.decisions) 76 | log = logging.getLogger("werkzeug") 77 | log.setLevel(logging.ERROR) 78 | self.app.logger.disabled = True 79 | log.disabled = True 80 | self.ds = DataStore() 81 | 82 | def decisions(self): 83 | api_key = request.headers.get("x-api-key") 84 | if not api_key: 85 | abort(404) 86 | startup = request.args.get("startup") == "true" 87 | active_decisions, expired_decisions = self.ds.get_decisions_for_bouncer(api_key, startup) 88 | return { 89 | "new": formatted_decisions(active_decisions), 90 | "deleted": formatted_decisions(expired_decisions), 91 | } 92 | 93 | def start(self, port=8081): 94 | self.server_thread = ServerThread(self.app, port=port) 95 | self.server_thread.start() 96 | 97 | def stop(self): 98 | self.server_thread.shutdown() 99 | 100 | 101 | def formatted_decisions(decisions): 102 | formatted_decisions = [] 103 | for decision in decisions: 104 | expiry_time = decision["created_at"] + timedelta(seconds=timeparse(decision["duration"])) 105 | duration = expiry_time - datetime.datetime.now() 106 | formatted_decisions.append( 107 | { 108 | "duration": f"{duration.total_seconds()}s", 109 | "id": decision["id"], 110 | "origin": decision["origin"], 111 | "scenario": "cscli", 112 | "scope": decision["scope"], 113 | "type": decision["type"], 114 | "value": decision["value"], 115 | } 116 | ) 117 | return formatted_decisions 118 | 119 | 120 | # Copied from https://stackoverflow.com/a/45017691 . 121 | # We run server inside thread instead of process to avoid 122 | # huge complexity of sharing objects 123 | class ServerThread(Thread): 124 | def __init__(self, app, port=8081): 125 | Thread.__init__(self) 126 | self.server = make_server("127.0.0.1", port, app) 127 | self.ctx = app.app_context() 128 | self.ctx.push() 129 | 130 | def run(self): 131 | self.server.serve_forever() 132 | 133 | def shutdown(self): 134 | self.server.shutdown() 135 | 136 | 137 | if __name__ == "__main__": 138 | MockLAPI().start() 139 | sleep(100) 140 | -------------------------------------------------------------------------------- /test/tests/backends/nftables/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/backends/nftables/__init__.py -------------------------------------------------------------------------------- /test/tests/backends/nftables/crowdsec-firewall-bouncer.yaml: -------------------------------------------------------------------------------- 1 | mode: nftables 2 | update_frequency: 0.01s 3 | log_mode: stdout 4 | log_dir: ./ 5 | log_level: info 6 | api_url: http://127.0.0.1:8081/ 7 | api_key: 1237adaf7a1724ac68a3288828820a67 8 | disable_ipv6: false 9 | deny_action: DROP 10 | deny_log: false 11 | supported_decisions_types: 12 | - ban 13 | iptables_chains: 14 | - INPUT 15 | 16 | nftables_hooks: 17 | - input 18 | - forward 19 | 20 | nftables: 21 | ipv4: 22 | enabled: true 23 | set-only: false 24 | table: crowdsec 25 | chain: crowdsec-chain 26 | ipv6: 27 | enabled: true 28 | set-only: false 29 | table: crowdsec6 30 | chain: crowdsec6-chain 31 | -------------------------------------------------------------------------------- /test/tests/backends/nftables/test_nftables.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import subprocess 4 | import unittest 5 | from ipaddress import ip_address 6 | from pathlib import Path 7 | from time import sleep 8 | 9 | from ..mock_lapi import MockLAPI 10 | from ..utils import generate_n_decisions, run_cmd 11 | 12 | SCRIPT_DIR = Path(os.path.dirname(os.path.realpath(__file__))) 13 | PROJECT_ROOT = SCRIPT_DIR.parent.parent.parent.parent 14 | BINARY_PATH = PROJECT_ROOT.joinpath("crowdsec-firewall-bouncer") 15 | CONFIG_PATH = SCRIPT_DIR.joinpath("crowdsec-firewall-bouncer.yaml") 16 | 17 | 18 | class TestNFTables(unittest.TestCase): 19 | def setUp(self): 20 | self.fb = subprocess.Popen([BINARY_PATH, "-c", CONFIG_PATH]) 21 | self.lapi = MockLAPI() 22 | self.lapi.start() 23 | return super().setUp() 24 | 25 | def tearDown(self): 26 | self.fb.kill() 27 | self.fb.wait() 28 | self.lapi.stop() 29 | run_cmd("nft", "delete", "table", "ip", "crowdsec", ignore_error=True) 30 | run_cmd("nft", "delete", "table", "ip6", "crowdsec6", ignore_error=True) 31 | 32 | def test_table_rule_set_are_created(self): 33 | d1 = generate_n_decisions(3) 34 | d2 = generate_n_decisions(1, ipv4=False) 35 | self.lapi.ds.insert_decisions(d1 + d2) 36 | sleep(1) 37 | output = json.loads(run_cmd("nft", "-j", "list", "tables")) 38 | tables = {(node["table"]["family"], node["table"]["name"]) for node in output["nftables"] if "table" in node} 39 | assert ("ip6", "crowdsec6") in tables 40 | assert ("ip", "crowdsec") in tables 41 | 42 | # IPV4 43 | output = json.loads(run_cmd("nft", "-j", "list", "table", "ip", "crowdsec")) 44 | sets = { 45 | (node["set"]["family"], node["set"]["name"], node["set"]["type"]) 46 | for node in output["nftables"] 47 | if "set" in node 48 | } 49 | assert ("ip", "crowdsec-blacklists-script", "ipv4_addr") in sets 50 | rules = {node["rule"]["chain"] for node in output["nftables"] if "rule" in node} # maybe stricter check ? 51 | assert "crowdsec-chain-forward" in rules 52 | assert "crowdsec-chain-input" in rules 53 | 54 | # IPV6 55 | output = json.loads(run_cmd("nft", "-j", "list", "table", "ip6", "crowdsec6")) 56 | sets = { 57 | (node["set"]["family"], node["set"]["name"], node["set"]["type"]) 58 | for node in output["nftables"] 59 | if "set" in node 60 | } 61 | assert ("ip6", "crowdsec6-blacklists-script", "ipv6_addr") in sets 62 | 63 | rules = {node["rule"]["chain"] for node in output["nftables"] if "rule" in node} # maybe stricter check ? 64 | assert "crowdsec6-chain-input" in rules 65 | assert "crowdsec6-chain-forward" in rules 66 | 67 | def test_duplicate_decisions_across_decision_stream(self): 68 | d1, d2, d3 = generate_n_decisions(3, dup_count=1) 69 | self.lapi.ds.insert_decisions([d1]) 70 | sleep(1) 71 | self.assertEqual( 72 | get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script"), 73 | {"0.0.0.0"}, 74 | ) 75 | 76 | self.lapi.ds.insert_decisions([d2, d3]) 77 | sleep(1) 78 | assert self.fb.poll() is None 79 | self.assertEqual( 80 | get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script"), 81 | {"0.0.0.0", "0.0.0.1"}, 82 | ) 83 | 84 | self.lapi.ds.delete_decision_by_id(d1["id"]) 85 | self.lapi.ds.delete_decision_by_id(d2["id"]) 86 | sleep(1) 87 | self.assertEqual(get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script"), set()) 88 | assert self.fb.poll() is None 89 | 90 | self.lapi.ds.delete_decision_by_id(d3["id"]) 91 | sleep(1) 92 | self.assertEqual(get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script"), set()) 93 | assert self.fb.poll() is None 94 | 95 | def test_decision_insertion_deletion_ipv4(self): 96 | total_decisions, duplicate_decisions = 100, 23 97 | decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions) 98 | self.lapi.ds.insert_decisions(decisions) 99 | sleep(1) # let the bouncer insert the decisions 100 | 101 | set_elements = get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script") 102 | self.assertEqual(len(set_elements), total_decisions - duplicate_decisions) 103 | assert {i["value"] for i in decisions} == set_elements 104 | assert "0.0.0.0" in set_elements 105 | 106 | self.lapi.ds.delete_decisions_by_ip("0.0.0.0") 107 | sleep(1) 108 | 109 | set_elements = get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script") 110 | assert {i["value"] for i in decisions if i["value"] != "0.0.0.0"} == set_elements 111 | assert len(set_elements) == total_decisions - duplicate_decisions - 1 112 | assert "0.0.0.0" not in set_elements 113 | 114 | def test_decision_insertion_deletion_ipv6(self): 115 | total_decisions, duplicate_decisions = 100, 23 116 | decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions, ipv4=False) 117 | self.lapi.ds.insert_decisions(decisions) 118 | sleep(1) 119 | 120 | set_elements = get_set_elements("ip6", "crowdsec6", "crowdsec6-blacklists-script") 121 | set_elements = set(map(ip_address, set_elements)) 122 | assert len(set_elements) == total_decisions - duplicate_decisions 123 | assert {ip_address(i["value"]) for i in decisions} == set_elements 124 | assert ip_address("::1:0:3") in set_elements 125 | 126 | self.lapi.ds.delete_decisions_by_ip("::1:0:3") 127 | sleep(1) 128 | 129 | set_elements = get_set_elements("ip6", "crowdsec6", "crowdsec6-blacklists-script") 130 | set_elements = set(map(ip_address, set_elements)) 131 | self.assertEqual(len(set_elements), total_decisions - duplicate_decisions - 1) 132 | assert ( 133 | {ip_address(i["value"]) for i in decisions if ip_address(i["value"]) != ip_address("::1:0:3")} 134 | ) == set_elements 135 | assert ip_address("::1:0:3") not in set_elements 136 | 137 | def test_longest_decision_insertion(self): 138 | decisions = [ 139 | { 140 | "value": "123.45.67.12", 141 | "scope": "ip", 142 | "type": "ban", 143 | "origin": "script", 144 | "duration": f"{i}h", 145 | "reason": "for testing", 146 | } 147 | for i in range(1, 201) 148 | ] 149 | self.lapi.ds.insert_decisions(decisions) 150 | sleep(1) 151 | elems = get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script", with_timeout=True) 152 | assert len(elems) == 1 153 | elems = list(elems) 154 | assert elems[0][0] == "123.45.67.12" 155 | assert abs(elems[0][1] - 200 * 60 * 60) <= 3 156 | 157 | 158 | def get_set_elements(family, table_name, set_name, with_timeout=False): 159 | output = json.loads(run_cmd("nft", "-j", "list", "set", family, table_name, set_name)) 160 | for node in output["nftables"]: 161 | if "set" not in node or "elem" not in node["set"]: 162 | continue 163 | if not isinstance(node["set"]["elem"][0], dict): 164 | return set(node["set"]["elem"]) 165 | 166 | if not with_timeout: 167 | return {elem["elem"]["val"] for elem in node["set"]["elem"]} 168 | return {(elem["elem"]["val"], elem["elem"]["timeout"]) for elem in node["set"]["elem"]} 169 | return set() 170 | -------------------------------------------------------------------------------- /test/tests/backends/utils.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from ipaddress import ip_address 3 | 4 | 5 | def run_cmd(*cmd, ignore_error=False, shell=False): 6 | p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, shell=shell) 7 | if not ignore_error and p.returncode: 8 | raise SystemExit(f"{cmd} exited with non-zero code with following logs:\n {p.stdout}") 9 | 10 | return p.stdout 11 | 12 | 13 | def generate_n_decisions(n: int, action="ban", dup_count=0, ipv4=True, duration="4h"): 14 | if dup_count >= n: 15 | raise SystemExit(f"generate_n_decisions got dup_count={dup_count} which is >=n") 16 | 17 | unique_decision_count = n - dup_count 18 | decisions = [] 19 | for i in range(unique_decision_count): 20 | if ipv4: 21 | ip = ip_address(i) 22 | else: 23 | ip = ip_address(2**32 + i) 24 | decisions.append( 25 | { 26 | "value": ip.__str__(), 27 | "scope": "ip", 28 | "type": action, 29 | "origin": "script", 30 | "duration": duration, 31 | "reason": "for testing", 32 | } 33 | ) 34 | decisions += decisions[: n % unique_decision_count] 35 | decisions *= n // unique_decision_count 36 | return decisions 37 | 38 | 39 | def new_decision(ip: str): 40 | return { 41 | "value": ip, 42 | "scope": "ip", 43 | "type": "ban", 44 | "origin": "script", 45 | "duration": "4h", 46 | "reason": "for testing", 47 | } 48 | -------------------------------------------------------------------------------- /test/tests/bouncer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/bouncer/__init__.py -------------------------------------------------------------------------------- /test/tests/bouncer/test_firewall_bouncer.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | def test_backend_mode(bouncer, fw_cfg_factory): 5 | cfg = fw_cfg_factory() 6 | 7 | del cfg["mode"] 8 | 9 | with bouncer(cfg) as fw: 10 | fw.wait_for_lines_fnmatch( 11 | [ 12 | "*unable to load configuration: config does not contain 'mode'*", 13 | ] 14 | ) 15 | fw.proc.wait(timeout=0.2) 16 | assert not fw.proc.is_running() 17 | 18 | cfg["mode"] = "whatever" 19 | 20 | with bouncer(cfg) as fw: 21 | fw.wait_for_lines_fnmatch( 22 | [ 23 | "*firewall 'whatever' is not supported*", 24 | ] 25 | ) 26 | fw.proc.wait(timeout=0.2) 27 | assert not fw.proc.is_running() 28 | 29 | cfg["mode"] = "dry-run" 30 | 31 | with bouncer(cfg) as fw: 32 | fw.wait_for_lines_fnmatch( 33 | [ 34 | "*Starting crowdsec-firewall-bouncer*", 35 | "*backend type: dry-run*", 36 | "*backend.Init() called*", 37 | "*unable to configure bouncer: config does not contain LAPI url*", 38 | ] 39 | ) 40 | fw.proc.wait(timeout=0.2) 41 | assert not fw.proc.is_running() 42 | 43 | 44 | def test_api_url(crowdsec, bouncer, fw_cfg_factory): 45 | cfg = fw_cfg_factory() 46 | 47 | with bouncer(cfg) as fw: 48 | fw.wait_for_lines_fnmatch( 49 | [ 50 | "*unable to configure bouncer: config does not contain LAPI url*", 51 | ] 52 | ) 53 | fw.proc.wait() 54 | assert not fw.proc.is_running() 55 | 56 | cfg["api_url"] = "" 57 | 58 | with bouncer(cfg) as fw: 59 | fw.wait_for_lines_fnmatch( 60 | [ 61 | "*unable to configure bouncer: config does not contain LAPI url*", 62 | ] 63 | ) 64 | fw.proc.wait() 65 | assert not fw.proc.is_running() 66 | 67 | 68 | def test_api_key(crowdsec, bouncer, fw_cfg_factory, api_key_factory, bouncer_under_test): 69 | api_key = api_key_factory() 70 | env = {"BOUNCER_KEY_bouncer": api_key} 71 | 72 | with crowdsec(environment=env) as lapi: 73 | lapi.wait_for_http(8080, "/health") 74 | port = lapi.probe.get_bound_port("8080") 75 | 76 | cfg = fw_cfg_factory() 77 | cfg["api_url"] = f"http://localhost:{port}" 78 | 79 | with bouncer(cfg) as fw: 80 | fw.wait_for_lines_fnmatch( 81 | [ 82 | "*unable to configure bouncer: config does not contain LAPI key or certificate*", 83 | ] 84 | ) 85 | fw.proc.wait() 86 | assert not fw.proc.is_running() 87 | 88 | cfg["api_key"] = "badkey" 89 | 90 | with bouncer(cfg) as fw: 91 | fw.wait_for_lines_fnmatch( 92 | [ 93 | "*Using API key auth*", 94 | "*API error: access forbidden*", 95 | "*process terminated with error: bouncer stream halted*", 96 | ] 97 | ) 98 | fw.proc.wait() 99 | assert not fw.proc.is_running() 100 | 101 | cfg["api_key"] = api_key 102 | 103 | with bouncer(cfg) as fw: 104 | fw.wait_for_lines_fnmatch( 105 | [ 106 | "*Using API key auth*", 107 | "*Processing new and deleted decisions*", 108 | ] 109 | ) 110 | assert fw.proc.is_running() 111 | 112 | # check that the bouncer is registered 113 | res = lapi.cont.exec_run("cscli bouncers list -o json") 114 | assert res.exit_code == 0 115 | bouncers = json.loads(res.output) 116 | assert len(bouncers) == 1 117 | assert bouncers[0]["name"] == "bouncer" 118 | assert bouncers[0]["auth_type"] == "api-key" 119 | assert bouncers[0]["type"] == bouncer_under_test 120 | -------------------------------------------------------------------------------- /test/tests/bouncer/test_iptables_deny_action.py: -------------------------------------------------------------------------------- 1 | def test_iptables_deny_action(bouncer, fw_cfg_factory): 2 | cfg = fw_cfg_factory() 3 | 4 | cfg["log_level"] = "trace" 5 | cfg["mode"] = "iptables" 6 | 7 | with bouncer(cfg) as fw: 8 | fw.wait_for_lines_fnmatch( 9 | [ 10 | "*using 'DROP' as deny_action*", 11 | ] 12 | ) 13 | fw.proc.wait(timeout=5) 14 | assert not fw.proc.is_running() 15 | 16 | cfg["deny_action"] = "drop" 17 | 18 | with bouncer(cfg) as fw: 19 | fw.wait_for_lines_fnmatch( 20 | [ 21 | "*using 'DROP' as deny_action*", 22 | ] 23 | ) 24 | fw.proc.wait(timeout=5) 25 | assert not fw.proc.is_running() 26 | 27 | cfg["deny_action"] = "reject" 28 | 29 | with bouncer(cfg) as fw: 30 | fw.wait_for_lines_fnmatch( 31 | [ 32 | "*using 'REJECT' as deny_action*", 33 | ] 34 | ) 35 | fw.proc.wait(timeout=5) 36 | assert not fw.proc.is_running() 37 | 38 | cfg["deny_action"] = "tarpit" 39 | 40 | with bouncer(cfg) as fw: 41 | fw.wait_for_lines_fnmatch( 42 | [ 43 | "*using 'TARPIT' as deny_action*", 44 | ] 45 | ) 46 | fw.proc.wait(timeout=5) 47 | assert not fw.proc.is_running() 48 | 49 | cfg["deny_action"] = "somethingelse" 50 | 51 | with bouncer(cfg) as fw: 52 | fw.wait_for_lines_fnmatch( 53 | [ 54 | "*invalid deny_action 'somethingelse', must be one of DROP, REJECT, TARPIT*", 55 | ] 56 | ) 57 | fw.proc.wait(timeout=5) 58 | assert not fw.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, fw_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_bouncer": 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 = fw_cfg_factory() 30 | cfg["api_url"] = f"https://localhost:{port}" 31 | cfg["api_key"] = api_key 32 | 33 | with bouncer(cfg) as cb: 34 | cb.wait_for_lines_fnmatch( 35 | [ 36 | "*backend type: dry-run*", 37 | "*Using API key auth*", 38 | "*auth-api: auth with api key failed*", 39 | "*tls: failed to verify certificate: x509: certificate signed by unknown authority*", 40 | ] 41 | ) 42 | 43 | cfg["ca_cert_path"] = (certs / "ca.crt").as_posix() 44 | 45 | with bouncer(cfg) as cb: 46 | cb.wait_for_lines_fnmatch( 47 | [ 48 | "*backend type: dry-run*", 49 | "*Using CA cert *ca.crt*", 50 | "*Using API key auth*", 51 | "*Processing new and deleted decisions*", 52 | ] 53 | ) 54 | 55 | 56 | def test_tls_mutual(crowdsec, certs_dir, api_key_factory, bouncer, fw_cfg_factory, bouncer_under_test): 57 | """TLS with two-way bouncer/lapi authentication""" 58 | lapi_env = { 59 | "CACERT_FILE": "/etc/ssl/crowdsec/ca.crt", 60 | "LAPI_CERT_FILE": "/etc/ssl/crowdsec/lapi.crt", 61 | "LAPI_KEY_FILE": "/etc/ssl/crowdsec/lapi.key", 62 | "USE_TLS": "true", 63 | "LOCAL_API_URL": "https://localhost:8080", 64 | } 65 | 66 | certs = certs_dir(lapi_hostname="lapi") 67 | 68 | volumes = { 69 | certs: {"bind": "/etc/ssl/crowdsec", "mode": "ro"}, 70 | } 71 | 72 | with crowdsec(environment=lapi_env, volumes=volumes) as cs: 73 | cs.wait_for_log("*CrowdSec Local API listening*") 74 | # TODO: wait_for_https 75 | cs.wait_for_http(8080, "/health", want_status=None) 76 | 77 | port = cs.probe.get_bound_port("8080") 78 | cfg = fw_cfg_factory() 79 | cfg["api_url"] = f"https://localhost:{port}" 80 | cfg["ca_cert_path"] = (certs / "ca.crt").as_posix() 81 | 82 | cfg["cert_path"] = (certs / "agent.crt").as_posix() 83 | cfg["key_path"] = (certs / "agent.key").as_posix() 84 | 85 | with bouncer(cfg) as cb: 86 | cb.wait_for_lines_fnmatch( 87 | [ 88 | "*Starting crowdsec-firewall-bouncer*", 89 | "*Using CA cert*", 90 | "*Using cert auth with cert * and key *", 91 | "*API error: access forbidden*", 92 | ] 93 | ) 94 | 95 | cs.wait_for_log("*client certificate OU ?agent-ou? doesn't match expected OU ?bouncer-ou?*") 96 | 97 | cfg["cert_path"] = (certs / "bouncer.crt").as_posix() 98 | cfg["key_path"] = (certs / "bouncer.key").as_posix() 99 | 100 | with bouncer(cfg) as cb: 101 | cb.wait_for_lines_fnmatch( 102 | [ 103 | "*backend type: dry-run*", 104 | "*Using CA cert*", 105 | "*Using cert auth with cert * and key *", 106 | "*Processing new and deleted decisions . . .*", 107 | ] 108 | ) 109 | 110 | # check that the bouncer is registered 111 | res = cs.cont.exec_run("cscli bouncers list -o json") 112 | assert res.exit_code == 0 113 | bouncers = json.loads(res.output) 114 | assert len(bouncers) == 1 115 | assert bouncers[0]["name"].startswith("@") 116 | assert bouncers[0]["auth_type"] == "tls" 117 | assert bouncers[0]["type"] == bouncer_under_test 118 | 119 | 120 | def test_api_key_and_cert(crowdsec, certs_dir, api_key_factory, bouncer, fw_cfg_factory): 121 | """Attempt to send an api key and a certificate too""" 122 | api_key = api_key_factory() 123 | 124 | lapi_env = { 125 | "CACERT_FILE": "/etc/ssl/crowdsec/ca.crt", 126 | "LAPI_CERT_FILE": "/etc/ssl/crowdsec/lapi.crt", 127 | "LAPI_KEY_FILE": "/etc/ssl/crowdsec/lapi.key", 128 | "USE_TLS": "true", 129 | "LOCAL_API_URL": "https://localhost:8080", 130 | "BOUNCER_KEY_bouncer": api_key, 131 | } 132 | 133 | certs = certs_dir(lapi_hostname="lapi") 134 | 135 | volumes = { 136 | certs: {"bind": "/etc/ssl/crowdsec", "mode": "ro"}, 137 | } 138 | 139 | with crowdsec(environment=lapi_env, volumes=volumes) as cs: 140 | cs.wait_for_log("*CrowdSec Local API listening*") 141 | cs.wait_for_http(8080, "/health", want_status=None) 142 | 143 | port = cs.probe.get_bound_port("8080") 144 | cfg = fw_cfg_factory() 145 | cfg["api_url"] = f"https://localhost:{port}" 146 | cfg["ca_cert_path"] = (certs / "ca.crt").as_posix() 147 | cfg["api_key"] = api_key 148 | 149 | cfg["cert_path"] = (certs / "bouncer.crt").as_posix() 150 | cfg["key_path"] = (certs / "bouncer.key").as_posix() 151 | 152 | cs.wait_for_log("*Starting processing data*") 153 | 154 | with bouncer(cfg) as cb: 155 | cb.wait_for_lines_fnmatch( 156 | [ 157 | "*Starting crowdsec-firewall-bouncer*", 158 | "*unable to configure bouncer: api client init: cannot use both API key and certificate auth*", 159 | ] 160 | ) 161 | -------------------------------------------------------------------------------- /test/tests/bouncer/test_yaml_local.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def test_yaml_local(bouncer, fw_cfg_factory): 5 | cfg = fw_cfg_factory() 6 | 7 | cfg.pop("mode") 8 | 9 | with bouncer(cfg) as fw: 10 | fw.wait_for_lines_fnmatch( 11 | [ 12 | "*unable to load configuration: config does not contain 'mode'*", 13 | ] 14 | ) 15 | fw.proc.wait(timeout=0.2) 16 | assert not fw.proc.is_running() 17 | 18 | config_local = {"mode": "whatever"} 19 | 20 | with bouncer(cfg, config_local=config_local) as fw: 21 | fw.wait_for_lines_fnmatch( 22 | [ 23 | "*firewall 'whatever' is not supported*", 24 | ] 25 | ) 26 | fw.proc.wait(timeout=0.2) 27 | assert not fw.proc.is_running() 28 | 29 | # variable expansion 30 | 31 | config_local = {"mode": "$BOUNCER_MODE"} 32 | 33 | os.environ["BOUNCER_MODE"] = "fromenv" 34 | 35 | with bouncer(cfg, config_local=config_local) as fw: 36 | fw.wait_for_lines_fnmatch( 37 | [ 38 | "*firewall 'fromenv' is not supported*", 39 | ] 40 | ) 41 | fw.proc.wait(timeout=0.2) 42 | assert not fw.proc.is_running() 43 | -------------------------------------------------------------------------------- /test/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | import pytest 4 | from pytest_cs import plugin 5 | 6 | # pytest_exception_interact = plugin.pytest_exception_interact 7 | 8 | 9 | # provide the name of the bouncer binary to test 10 | @pytest.fixture(scope="session") 11 | def bouncer_under_test(): 12 | return "crowdsec-firewall-bouncer" 13 | 14 | 15 | # Create a lapi container, register a bouncer and run it with the updated config. 16 | # - Return context manager that yields a tuple of (bouncer, lapi) 17 | @pytest.fixture(scope="session") 18 | def bouncer_with_lapi(bouncer, crowdsec, fw_cfg_factory, api_key_factory: plugin.ApiKeyFactoryType): 19 | @contextlib.contextmanager 20 | def closure(config_lapi=None, config_bouncer=None, api_key=None): 21 | if config_bouncer is None: 22 | config_bouncer = {} 23 | if config_lapi is None: 24 | config_lapi = {} 25 | # can be overridden by config_lapi + config_bouncer 26 | api_key = api_key_factory() 27 | env = { 28 | "BOUNCER_KEY_custom": api_key, 29 | } 30 | try: 31 | env.update(config_lapi) 32 | with crowdsec(environment=env) as lapi: 33 | lapi.wait_for_http(8080, "/health") 34 | port = lapi.probe.get_bound_port("8080") 35 | cfg = fw_cfg_factory() 36 | cfg["api_url"] = f"http://localhost:{port}/" 37 | cfg["api_key"] = api_key 38 | cfg.update(config_bouncer) 39 | with bouncer(cfg) as cb: 40 | yield cb, lapi 41 | finally: 42 | pass 43 | 44 | yield closure 45 | 46 | 47 | _default_config = { 48 | "mode": "dry-run", 49 | "log_level": "info", 50 | } 51 | 52 | 53 | @pytest.fixture(scope="session") 54 | def fw_cfg_factory(): 55 | def closure(**kw): 56 | cfg = _default_config.copy() 57 | cfg |= kw 58 | return cfg | kw 59 | 60 | yield closure 61 | -------------------------------------------------------------------------------- /test/tests/install/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/install/__init__.py -------------------------------------------------------------------------------- /test/tests/install/no_crowdsec/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/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(["dpkg-deb", "-f", deb_package_path.as_posix(), "Package"], encoding="utf-8") 40 | package_name = p.strip() 41 | 42 | p = subprocess.run( 43 | ["dpkg", "--purge", package_name], 44 | stdout=subprocess.PIPE, 45 | stderr=subprocess.PIPE, 46 | encoding="utf-8", 47 | ) 48 | assert p.returncode == 0, f"Failed to purge {package_name}" 49 | 50 | assert not os.path.exists(bouncer_exe) 51 | assert not os.path.exists(config) 52 | -------------------------------------------------------------------------------- /test/tests/install/no_crowdsec/test_no_crowdsec_scripts.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | import pexpect 5 | import pytest 6 | import yaml 7 | 8 | BOUNCER = "crowdsec-firewall-bouncer" 9 | CONFIG = f"/etc/crowdsec/bouncers/{BOUNCER}.yaml" 10 | 11 | 12 | @pytest.mark.dependency 13 | def test_install_no_crowdsec(project_repo, bouncer_binary, must_be_root): 14 | c = pexpect.spawn("/usr/bin/sh", ["scripts/install.sh"], cwd=project_repo) 15 | 16 | c.expect(f"Installing {BOUNCER}") 17 | c.expect("iptables found") 18 | c.expect("nftables found") 19 | c.expect(re.escape("Found nftables (default) and iptables, which firewall do you want to use (nftables/iptables)")) 20 | c.sendline("nftables") 21 | c.expect("WARN.* cscli not found, you will need to generate an api key.") 22 | c.expect(f"WARN.* service not started. You need to get an API key and configure it in {CONFIG}") 23 | c.expect(f"The {BOUNCER} service has been installed.") 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["api_key"] == "" 31 | assert y["mode"] == "nftables" 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("/usr/bin/sh", ["scripts/install.sh"], cwd=project_repo) 39 | 40 | c.expect(f"ERR.* /usr/local/bin/{BOUNCER} is already installed. Exiting") 41 | 42 | 43 | @pytest.mark.dependency(depends=["test_install_no_crowdsec"]) 44 | def test_upgrade_no_crowdsec(project_repo, must_be_root): 45 | os.remove(f"/usr/local/bin/{BOUNCER}") 46 | 47 | c = pexpect.spawn("/usr/bin/sh", ["scripts/upgrade.sh"], cwd=project_repo) 48 | 49 | c.expect(f"{BOUNCER} upgraded successfully") 50 | c.wait() 51 | assert c.terminated 52 | assert c.exitstatus == 0 53 | 54 | assert os.path.exists(f"/usr/local/bin/{BOUNCER}") 55 | assert os.stat(f"/usr/local/bin/{BOUNCER}").st_mode & 0o777 == 0o755 56 | 57 | 58 | @pytest.mark.dependency(depends=["test_upgrade_no_crowdsec"]) 59 | def test_uninstall_no_crowdsec(project_repo, must_be_root): 60 | c = pexpect.spawn("/usr/bin/sh", ["scripts/uninstall.sh"], cwd=project_repo) 61 | 62 | c.expect(f"{BOUNCER} has been successfully uninstalled") 63 | c.wait() 64 | assert c.terminated 65 | assert c.exitstatus == 0 66 | 67 | assert not os.path.exists(CONFIG) 68 | assert not os.path.exists(f"/usr/local/bin/{BOUNCER}") 69 | -------------------------------------------------------------------------------- /test/tests/install/with_crowdsec/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/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(["dpkg-deb", "-f", deb_package_path.as_posix(), "Package"], encoding="utf-8") 25 | package_name = p.strip() 26 | 27 | subprocess.check_call(["dpkg", "--purge", package_name]) 28 | 29 | bouncer_exe = f"/usr/bin/{bouncer_under_test}" 30 | assert not os.path.exists(bouncer_exe) 31 | 32 | config = f"/etc/crowdsec/bouncers/{bouncer_under_test}.yaml" 33 | assert not os.path.exists(config) 34 | 35 | # install the package 36 | p = subprocess.run( 37 | ["dpkg", "--install", deb_package_path.as_posix()], 38 | stdout=subprocess.PIPE, 39 | stderr=subprocess.PIPE, 40 | encoding="utf-8", 41 | ) 42 | assert p.returncode == 0, f"Failed to install {deb_package_path}" 43 | 44 | assert os.path.exists(bouncer_exe) 45 | assert os.stat(bouncer_exe).st_mode & 0o777 == 0o755 46 | 47 | assert os.path.exists(config) 48 | assert os.stat(config).st_mode & 0o777 == 0o600 49 | 50 | with open(config) as f: 51 | cfg = yaml.safe_load(f) 52 | api_key = cfg["api_key"] 53 | # the api key has been set to a random value 54 | assert zxcvbn(api_key)["score"] == 4, f"weak api_key: '{api_key}'" 55 | 56 | with open(config + ".id") as f: 57 | bouncer_name = f.read().strip() 58 | 59 | p = subprocess.check_output(["cscli", "bouncers", "list", "-o", "json"]) 60 | bouncers = yaml.safe_load(p) 61 | assert len([b for b in bouncers if b["name"] == bouncer_name]) == 1 62 | 63 | p = subprocess.run( 64 | ["dpkg", "--purge", package_name], 65 | stdout=subprocess.PIPE, 66 | stderr=subprocess.PIPE, 67 | encoding="utf-8", 68 | ) 69 | assert p.returncode == 0, f"Failed to purge {package_name}" 70 | 71 | assert not os.path.exists(bouncer_exe) 72 | assert not os.path.exists(config) 73 | 74 | 75 | def test_deb_install_purge_yaml_local(deb_package_path, bouncer_under_test, must_be_root): 76 | """ 77 | Check .deb package installation with: 78 | 79 | - a pre-existing .yaml.local file with an api key 80 | - a pre-registered bouncer 81 | 82 | => the configuration files are not touched (no new api key) 83 | """ 84 | assert deb_package_path.exists(), f"This test requires {deb_package_path}" 85 | 86 | p = subprocess.check_output(["dpkg-deb", "-f", deb_package_path.as_posix(), "Package"], encoding="utf-8") 87 | package_name = p.strip() 88 | 89 | subprocess.check_call(["dpkg", "--purge", package_name]) 90 | subprocess.run(["cscli", "bouncers", "delete", "testbouncer"]) 91 | 92 | bouncer_exe = f"/usr/bin/{bouncer_under_test}" 93 | config = Path(f"/etc/crowdsec/bouncers/{bouncer_under_test}.yaml") 94 | config.parent.mkdir(parents=True, exist_ok=True) 95 | 96 | subprocess.check_call(["cscli", "bouncers", "add", "testbouncer", "-k", "123456"]) 97 | 98 | with open(config.with_suffix(".yaml.local"), "w") as f: 99 | f.write('api_key: "123456"') 100 | 101 | p = subprocess.run( 102 | ["dpkg", "--install", deb_package_path.as_posix()], 103 | stdout=subprocess.PIPE, 104 | stderr=subprocess.PIPE, 105 | encoding="utf-8", 106 | ) 107 | assert p.returncode == 0, f"Failed to install {deb_package_path}" 108 | 109 | assert os.path.exists(bouncer_exe) 110 | assert os.path.exists(config) 111 | 112 | with open(config) as f: 113 | cfg = yaml.safe_load(f) 114 | api_key = cfg["api_key"] 115 | # the api key has not been set 116 | assert api_key == "${API_KEY}" 117 | 118 | p = subprocess.check_output([bouncer_exe, "-c", config, "-T"]) 119 | merged_config = yaml.safe_load(p) 120 | assert merged_config["api_key"] == "123456" 121 | 122 | os.unlink(config.with_suffix(".yaml.local")) 123 | 124 | p = subprocess.run( 125 | ["dpkg", "--purge", package_name], 126 | stdout=subprocess.PIPE, 127 | stderr=subprocess.PIPE, 128 | encoding="utf-8", 129 | ) 130 | assert p.returncode == 0, f"Failed to purge {package_name}" 131 | 132 | assert not os.path.exists(bouncer_exe) 133 | assert not os.path.exists(config) 134 | -------------------------------------------------------------------------------- /test/tests/install/with_crowdsec/test_crowdsec_scripts.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | import pexpect 5 | import pytest 6 | import yaml 7 | from pytest_cs.lib import cscli, text 8 | 9 | BOUNCER = "crowdsec-firewall-bouncer" 10 | CONFIG = f"/etc/crowdsec/bouncers/{BOUNCER}.yaml" 11 | 12 | 13 | @pytest.mark.systemd_debug(BOUNCER) 14 | @pytest.mark.dependency 15 | def test_install_crowdsec(project_repo, bouncer_binary, must_be_root): 16 | c = pexpect.spawn("/usr/bin/sh", ["scripts/install.sh"], encoding="utf-8", cwd=project_repo) 17 | 18 | c.expect(f"Installing {BOUNCER}") 19 | c.expect("iptables found") 20 | c.expect("nftables found") 21 | c.expect(re.escape("Found nftables (default) and iptables, which firewall do you want to use (nftables/iptables)")) 22 | c.sendline("fntables") 23 | c.expect("cscli found, generating bouncer api key.") 24 | c.expect("API Key: (.*)") 25 | api_key = text.nocolor(c.match.group(1).strip()) 26 | # XXX: what do we expect here ? 27 | c.wait() 28 | assert c.terminated 29 | # XXX: partial configuration, the service won't start 30 | # assert c.exitstatus == 0 31 | 32 | # installed files 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 | # configuration check 39 | with open(CONFIG) as f: 40 | y = yaml.safe_load(f) 41 | assert y["api_key"] == api_key 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("/usr/bin/sh", ["scripts/install.sh"], encoding="utf-8", cwd=project_repo) 50 | 51 | c.expect(f"ERR:.* /usr/local/bin/{BOUNCER} is already installed. Exiting") 52 | 53 | 54 | @pytest.mark.dependency(depends=["test_install_crowdsec"]) 55 | def test_upgrade_crowdsec(project_repo, must_be_root): 56 | os.remove(f"/usr/local/bin/{BOUNCER}") 57 | 58 | c = pexpect.spawn("/usr/bin/sh", ["scripts/upgrade.sh"], encoding="utf-8", cwd=project_repo) 59 | 60 | c.expect(f"{BOUNCER} upgraded successfully") 61 | c.wait() 62 | assert c.terminated 63 | assert c.exitstatus == 0 64 | 65 | assert os.path.exists(f"/usr/local/bin/{BOUNCER}") 66 | assert os.stat(f"/usr/local/bin/{BOUNCER}").st_mode & 0o777 == 0o755 67 | 68 | 69 | @pytest.mark.dependency(depends=["test_upgrade_crowdsec"]) 70 | def test_uninstall_crowdsec(project_repo, must_be_root): 71 | # the bouncer is registered 72 | with open(f"{CONFIG}.id") as f: 73 | bouncer_name = f.read().strip() 74 | 75 | c = pexpect.spawn("/usr/bin/sh", ["scripts/uninstall.sh"], encoding="utf-8", cwd=project_repo) 76 | 77 | c.expect(f"{BOUNCER} has been successfully uninstalled") 78 | c.wait() 79 | assert c.terminated 80 | assert c.exitstatus == 0 81 | 82 | # installed files 83 | assert not os.path.exists(CONFIG) 84 | assert not os.path.exists(f"/usr/local/bin/{BOUNCER}") 85 | 86 | # the bouncer is unregistered 87 | assert len(list(cscli.get_bouncers(name=bouncer_name))) == 0 88 | -------------------------------------------------------------------------------- /test/tests/pkg/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/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 | --------------------------------------------------------------------------------