Repository: crowdsecurity/cs-firewall-bouncer Branch: main Commit: bcb81cd563e8 Files: 96 Total size: 208.7 KB Directory structure: gitextract_hkmsgkt_/ ├── .envrc ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yaml │ │ ├── config.yml │ │ └── feature_request.yaml │ ├── dependabot.yml │ ├── 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 ├── 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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .envrc ================================================ use flake ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yaml ================================================ name: Bug report description: Report a bug encountered while operating crowdsec labels: kind/bug body: - type: textarea id: problem attributes: label: What happened? description: | Please provide as much info as possible. Not doing so may result in your bug not being addressed in a timely manner. If this matter is security related, please disclose it privately to security@crowdsec.net validations: required: true - type: textarea id: expected attributes: label: What did you expect to happen? validations: required: true - type: textarea id: repro attributes: label: How can we reproduce it (as minimally and precisely as possible)? validations: required: true - type: textarea id: additional attributes: label: Anything else we need to know? - type: textarea id: Version attributes: label: version value: | remediation component version:
```console $ crowdsec-firewall-bouncer --version # paste output here ```
validations: required: true - type: textarea id: CS-Version attributes: label: crowdsec version value: | crowdsec version:
```console $ crowdsec --version # paste output here ```
validations: required: true - type: textarea id: osVersion attributes: label: OS version value: |
```console # On Linux: $ cat /etc/os-release # paste output here $ uname -a # paste output here # On Windows: C:\> wmic os get Caption, Version, BuildNumber, OSArchitecture # paste output here ```
================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ contact_links: - name: Support Request url: https://discourse.crowdsec.net about: Support request or question relating to Crowdsec ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yaml ================================================ name: Feature request description: Suggest an improvement or a new feature body: - type: textarea id: feature attributes: label: What would you like to be added? description: | 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. value: | For feature request please pick a kind label by removing `` that wrap the example lines below validations: required: true - type: textarea id: rationale attributes: label: Why is this needed? validations: required: true ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" labels: - "kind/dependencies" - "github-actions" - package-ecosystem: "gomod" directory: "/" schedule: interval: "daily" labels: - "kind/dependencies" - "go" - package-ecosystem: "uv" directory: "test" schedule: interval: "daily" labels: - "kind/dependencies" - "python" ================================================ FILE: .github/governance.yml ================================================ version: v1 issue: captures: - regex: 'version: v(.+)-' github_release: true ignore_case: true label: 'version/$CAPTURED' labels: - prefix: triage list: ['accepted'] multiple: false author_association: collaborator: true member: true owner: true needs: comment: | @$AUTHOR: Thanks for opening an issue, it is currently awaiting triage. In the meantime, you can: 1. Check [Documentation](https://docs.crowdsec.net/docs/next/bouncers/firewall) to see if your issue can be self resolved. 2. You can also join our [Discord](https://discord.gg/crowdsec) - prefix: kind list: ['feature', 'bug', 'packaging', 'enhancement'] multiple: false author_association: author: true collaborator: true member: true owner: true needs: comment: | @$AUTHOR: There are no 'kind' label on this issue. You need a 'kind' label to start the triage process. * `/kind feature` * `/kind enhancement` * `/kind bug` * `/kind packaging` ================================================ FILE: .github/release-drafter.yml ================================================ template: | ## What’s Changed $CHANGES ================================================ FILE: .github/release.py ================================================ #!/usr/bin/env python3 import argparse import json import os import shutil import subprocess import sys def _goos(): yield 'linux' yield 'freebsd' def _goarch(goos): yield '386' yield 'amd64' yield 'arm' yield 'arm64' if goos == 'linux': yield 'ppc64le' yield 's390x' yield 'riscv64' def _goarm(goarch): if goarch != 'arm': yield '' return yield '6' yield '7' def _build_tarball(os): if os == 'linux': yield True else: yield False def filename_for_entry(prog_name, entry): arch = entry['goarch'] if entry['goarch'] == 'arm': arch += 'v' + entry['goarm'] ret = f'{prog_name}-{entry["goos"]}-{arch}' if entry['build_tarball']: ret += '.tgz' return ret def matrix(prog_name): for goos in _goos(): for goarch in _goarch(goos): if (goos, goarch) == ('freebsd', 'riscv64'): # platform not supported # gopsutil/v4@v4.25.8/cpu/cpu_freebsd.go:85:13: undefined: cpuTimes continue for goarm in _goarm(goarch): for build_tarball in _build_tarball(goos): yield { 'goos': goos, 'goarch': goarch, 'goarm': goarm, 'build_tarball': build_tarball, } def print_matrix(prog_name): j = {'include': list(matrix(prog_name))} if os.isatty(sys.stdout.fileno()): print(json.dumps(j, indent=2)) else: print(json.dumps(j)) default_tarball = { 'goos': 'linux', 'goarch': 'amd64', 'goarm': '', 'build_tarball': True, } default_binary = { 'goos': 'linux', 'goarch': 'amd64', 'goarm': '', 'build_tarball': False, } def run_build(prog_name): # call the makefile for each matrix entry default_tarball_filename = None default_binary_filename = None for entry in matrix(prog_name): env = {'GOOS': entry['goos'], 'GOARCH': entry['goarch']} if entry['goarm']: env['GOARM'] = entry['goarm'] if entry['build_tarball']: target = 'tarball' else: target = 'binary' print(f"Running make {target} for {env}") subprocess.run(['make', target], env=os.environ | env, check=True) want_filename = filename_for_entry(prog_name, entry) if entry['build_tarball']: os.rename(f'{prog_name}.tgz', want_filename) else: os.rename(f'{prog_name}', want_filename) # if this is the default tarball or binary, save the filename # we'll use it later to publish a "default" package if entry == default_tarball: default_tarball_filename = want_filename if entry == default_binary: default_binary_filename = want_filename # Remove the directory to reuse it subprocess.run(['make', 'clean-release-dir'], env=os.environ | env, check=True) # publish the default tarball and binary if default_tarball_filename: shutil.copy(default_tarball_filename, f'{prog_name}.tgz') if default_binary_filename: shutil.copy(default_binary_filename, f'{prog_name}') def main(): parser = argparse.ArgumentParser( description='Build release binaries and tarballs for all supported platforms') parser.add_argument('action', help='Action to perform (ex. run-build, print-matrix)') parser.add_argument('prog_name', help='Name of the program (ex. crowdsec-firewall-bouncer)') args = parser.parse_args() if args.action == 'print-matrix': print_matrix(args.prog_name) if args.action == 'run-build': run_build(args.prog_name) if __name__ == '__main__': main() ================================================ FILE: .github/workflows/build-binary-package.yml ================================================ name: build-binary-package on: release: types: - prereleased permissions: # Use write for: hub release edit contents: write env: PROGRAM_NAME: crowdsec-firewall-bouncer jobs: build: name: Build and upload all platforms runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod - name: Build all platforms run: | # build platform-all first so the .xz vendor file is not removed make platform-all vendor - name: Upload to release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | tag_name="${GITHUB_REF##*/}" # this will upload the $PROGRAM_NAME-vendor.tar.xz file as well gh release upload "$tag_name" $PROGRAM_NAME* vendor.tgz ================================================ FILE: .github/workflows/governance-bot.yaml ================================================ # .github/workflow/governance.yml on: pull_request_target: types: [ synchronize, opened, labeled, unlabeled ] issues: types: [ opened, labeled, unlabeled ] issue_comment: types: [ created ] # You can use permissions to modify the default permissions granted to the GITHUB_TOKEN, # adding or removing access as required, so that you only allow the minimum required access. permissions: contents: read issues: write pull-requests: write statuses: write checks: write jobs: governance: name: Governance runs-on: ubuntu-latest steps: # Semantic versioning, lock to different version: v2, v2.0 or a commit hash. - uses: BirthdayResearch/oss-governance-bot@3abd2d1fd2376ba9990fbc795e7a4c54254e9c61 # v4.0.0 with: # You can use a PAT to post a comment/label/status so that it shows up as a user instead of github-actions github-token: ${{secrets.GITHUB_TOKEN}} # optional, default to '${{ github.token }}' config-path: .github/governance.yml # optional, default to '.github/governance.yml' ================================================ FILE: .github/workflows/lint.yml ================================================ name: Static Analysis on: push: branches: [ main ] pull_request: branches: [ main ] jobs: build: name: "golangci-lint + codeql" runs-on: ubuntu-latest permissions: security-events: write steps: - name: Check out code into the Go module directory uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod - name: Initialize CodeQL uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 with: languages: go, python - name: Build run: | make build - name: golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: version: v2.9 args: --issues-exit-code=1 --timeout 10m only-new-issues: false - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 ================================================ FILE: .github/workflows/release-drafter.yml ================================================ name: Release Drafter on: push: # branches to consider in the event; optional, defaults to all branches: - main permissions: contents: read jobs: update_release_draft: permissions: # write permission is required to create a github release contents: write # write permission is required for autolabeler # otherwise, read permission is required at least pull-requests: read runs-on: ubuntu-latest name: Update the release draft steps: # Drafts your next Release notes as Pull Requests are merged into "main" - uses: release-drafter/release-drafter@139054aeaa9adc52ab36ddf67437541f039b88e2 # v7.1.1 with: config-name: release-drafter.yml # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml # config-name: my-config.yml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/tests.yml ================================================ name: Build + tests on: push: branches: [ main ] pull_request: branches: [ main ] permissions: contents: read jobs: build: name: "Build + tests" runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod - name: Build run: | make build - name: Run unit tests run: | go install github.com/kyoh86/richgo@v0.3.12 set -o pipefail make test | richgo testfilter env: RICHGO_FORCE_COLOR: 1 - name: Install uv uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 with: version: 0.5.24 enable-cache: true cache-dependency-glob: "test/uv.lock" - name: "Set up Python" uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version-file: "test/.python-version" - name: Install the project working-directory: ./test run: uv sync --all-extras --dev - name: Install functional test dependencies run: | sudo apt update sudo apt install -y nftables iptables ipset docker network create net-test - name: Run functional tests env: CROWDSEC_TEST_VERSION: dev CROWDSEC_TEST_FLAVORS: full CROWDSEC_TEST_NETWORK: net-test CROWDSEC_TEST_TIMEOUT: 60 PYTEST_ADDOPTS: --durations=0 -vv --color=yes -m "not (deb or rpm)" working-directory: ./test run: | # everything except for # - install (requires root, ignored by default) # - backends (requires root, ignored by default) # - deb/rpm (on their own workflows) uv run pytest # these need root sudo -E $(which uv) run pytest ./tests/backends sudo -E $(which uv) run pytest ./tests/install/no_crowdsec # these need a running crowdsec docker run -d --name crowdsec -e CI_TESTING=true -e DISABLE_ONLINE_API=true -e CROWDSEC_BYPASS_DB_VOLUME_CHECK=true -ti crowdsecurity/crowdsec cat >/usr/local/bin/cscli <<'EOT' #!/bin/sh docker exec crowdsec cscli "$@" EOT chmod u+x /usr/local/bin/cscli sleep 5 sudo -E $(which uv) run pytest ./tests/install/with_crowdsec - name: Lint working-directory: ./test run: | uv run ruff check uv run basedpyright ================================================ FILE: .github/workflows/tests_deb.yml ================================================ name: Test .deb packaging on: push: branches: [ main ] pull_request: branches: [ main ] permissions: contents: read jobs: build: name: "Test .deb packages" runs-on: ubuntu-latest steps: - name: Check out code into the Go module directory uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod - name: Install uv uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 with: version: 0.5.24 enable-cache: true cache-dependency-glob: "test/uv.lock" - name: "Set up Python" uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version-file: "test/.python-version" - name: Install the project run: uv sync --all-extras --dev working-directory: ./test - name: Install functional test dependencies run: | sudo apt update sudo apt install -y nftables iptables ipset build-essential debhelper devscripts fakeroot lintian docker network create net-test - name: Run functional tests env: CROWDSEC_TEST_VERSION: dev CROWDSEC_TEST_FLAVORS: full CROWDSEC_TEST_NETWORK: net-test CROWDSEC_TEST_TIMEOUT: 60 PYTEST_ADDOPTS: --durations=0 -vv --color=yes working-directory: ./test run: | uv run pytest ./tests/pkg/test_build_deb.py sudo -E $(which uv) run pytest -m deb ./tests/install/no_crowdsec ================================================ FILE: .gitignore ================================================ # Test binary, built with `go test -c` *.test # Output of the go coverage tool, specifically when used with LiteIDE *.out # Dependencies are not vendored by default, but a tarball is created by "make vendor" # and provided in the release. Used by freebsd, gentoo, etc. vendor/ vendor.tgz # Python __pycache__/ *.py[cod] *$py.class venv/ # built by make /crowdsec-firewall-bouncer /crowdsec-firewall-bouncer-* /crowdsec-firewall-bouncer.tgz # built by dpkg-buildpackage /debian/crowdsec-firewall-bouncer-iptables /debian/crowdsec-firewall-bouncer-nftables /debian/files /debian/*.substvars /debian/*.debhelper /debian/*-stamp # built by rpmbuild /rpm/BUILD /rpm/BUILDROOT /rpm/RPMS /rpm/SOURCES/*.tar.gz /rpm/SRPMS # nix generated dirs .direnv .devenv ================================================ FILE: .golangci.yml ================================================ version: "2" linters: default: all disable: - cyclop # revive - funlen # revive - gocognit # revive - gocyclo # revive - lll # revive - wsl # wsl_v5 - depguard - dupl - err113 - exhaustruct - gochecknoglobals - goconst - godox - gosec - ireturn - mnd - nlreturn - paralleltest - tagliatelle - testpackage - unparam - varnamelen - whitespace - wrapcheck - funcorder - wsl_v5 - noinlineerr - noctx - godoclint - prealloc settings: errcheck: check-type-assertions: false gocritic: enable-all: true disabled-checks: - appendCombine - paramTypeCombine - sloppyReassign - unnamedResult - importShadow govet: disable: - fieldalignment enable-all: true maintidx: # raise this after refactoring under: 23 misspell: locale: US modernize: disable: - stringsseq nestif: # lower this after refactoring min-complexity: 13 nlreturn: block-size: 4 nolintlint: require-explanation: false require-specific: false allow-unused: false revive: severity: error enable-all-rules: true rules: - name: add-constant disabled: true - name: cognitive-complexity arguments: # lower this after refactoring - 49 - name: comment-spacings disabled: true - name: confusing-results disabled: true - name: cyclomatic arguments: # lower this after refactoring - 29 - name: enforce-switch-style disabled: true - name: flag-parameter disabled: true - name: function-length arguments: # lower this after refactoring - 74 - 153 - name: identical-switch-branches disabled: true - name: import-alias-naming disabled: true - name: import-shadowing disabled: true - name: line-length-limit disabled: true - name: nested-structs disabled: true - name: exported disabled: true - name: unexported-return disabled: true - name: unhandled-error arguments: - fmt.Print - fmt.Printf - fmt.Println - name: function-result-limit arguments: - 5 - name: var-naming disabled: true staticcheck: checks: - all wsl: allow-trailing-comment: true exclusions: presets: - comments - common-false-positives - legacy - std-error-handling rules: - linters: - govet text: 'shadow: declaration of "(err|ctx)" shadows declaration' - linters: - revive text: 'deep-exit: calls to flag.Parse only in main\(\) or init\(\) functions' paths: - third_party$ - builtin$ - examples$ issues: max-issues-per-linter: 0 max-same-issues: 0 formatters: enable: - gci - gofumpt settings: gci: sections: - standard - default - prefix(github.com/crowdsecurity) - prefix(github.com/crowdsecurity/crowdsec) - prefix(github.com/crowdsecurity/cs-firewall-bouncer) exclusions: paths: - third_party$ - builtin$ - examples$ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020-2021 crowdsecurity Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ GO = go GOBUILD = $(GO) build GOTEST = $(GO) test BINARY_NAME=crowdsec-firewall-bouncer TARBALL_NAME=$(BINARY_NAME).tgz ifdef BUILD_STATIC $(warning WARNING: The BUILD_STATIC variable is deprecated and has no effect. Builds are static by default now.) endif # Versioning information can be overridden in the environment BUILD_VERSION?=$(shell git describe --tags) BUILD_TIMESTAMP?=$(shell date +%F"_"%T) BUILD_TAG?=$(shell git rev-parse HEAD) LD_OPTS_VARS=\ -X 'github.com/crowdsecurity/go-cs-lib/version.Version=$(BUILD_VERSION)' \ -X 'github.com/crowdsecurity/go-cs-lib/version.BuildDate=$(BUILD_TIMESTAMP)' \ -X 'github.com/crowdsecurity/go-cs-lib/version.Tag=$(BUILD_TAG)' ifneq (,$(DOCKER_BUILD)) LD_OPTS_VARS += -X 'github.com/crowdsecurity/go-cs-lib/version.System=docker' endif export CGO_ENABLED=0 export LD_OPTS=-ldflags "-s -extldflags '-static' $(LD_OPTS_VARS)" \ -trimpath -tags netgo .PHONY: all all: build test # same as "$(MAKE) -f debian/rules clean" but without the dependency on debhelper .PHONY: clean-debian clean-debian: @$(RM) -r debian/crowdsec-firewall-bouncer-iptables @$(RM) -r debian/crowdsec-firewall-bouncer-nftables @$(RM) -r debian/files @$(RM) -r debian/.debhelper @$(RM) -r debian/*.substvars @$(RM) -r debian/*-stamp .PHONY: clean-rpm clean-rpm: @$(RM) -r rpm/BUILD @$(RM) -r rpm/BUILDROOT @$(RM) -r rpm/RPMS @$(RM) -r rpm/SOURCES/*.tar.gz @$(RM) -r rpm/SRPMS # Remove everything including all platform binaries and tarballs .PHONY: clean clean: clean-release-dir clean-debian clean-rpm @$(RM) $(BINARY_NAME) @$(RM) $(TARBALL_NAME) @$(RM) -r $(BINARY_NAME)-* # platform binary name and leftover release dir @$(RM) $(BINARY_NAME)-*.tgz # platform release file # # Build binaries # .PHONY: binary binary: $(GOBUILD) $(LD_OPTS) -o $(BINARY_NAME) .PHONY: build build: clean binary # # Unit and integration tests # .PHONY: lint lint: golangci-lint run .PHONY: test test: @$(GOTEST) $(LD_OPTS) ./... .PHONY: func-tests func-tests: build pipenv install --dev pipenv run pytest -v # # Build release tarballs # RELDIR = $(BINARY_NAME)-$(BUILD_VERSION) .PHONY: vendor vendor: vendor-remove $(GO) mod vendor tar czf vendor.tgz vendor tar --create --auto-compress --file=$(RELDIR)-vendor.tar.xz vendor .PHONY: vendor-remove vendor-remove: $(RM) -r vendor vendor.tgz *-vendor.tar.xz # Called during platform-all, to reuse the directory for other platforms .PHONY: clean-release-dir clean-release-dir: @$(RM) -r $(RELDIR) .PHONY: tarball tarball: binary @if [ -z $(BUILD_VERSION) ]; then BUILD_VERSION="local" ; fi @if [ -d $(RELDIR) ]; then echo "$(RELDIR) already exists, please run 'make clean' and retry" ; exit 1 ; fi @echo Building Release to dir $(RELDIR) @mkdir -p $(RELDIR)/scripts @cp $(BINARY_NAME) $(RELDIR)/ @cp -R ./config $(RELDIR)/ @cp ./scripts/install.sh $(RELDIR)/ @cp ./scripts/uninstall.sh $(RELDIR)/ @cp ./scripts/upgrade.sh $(RELDIR)/ @cp ./scripts/_bouncer.sh $(RELDIR)/scripts/ @chmod +x $(RELDIR)/install.sh @chmod +x $(RELDIR)/uninstall.sh @chmod +x $(RELDIR)/upgrade.sh @tar cvzf $(TARBALL_NAME) $(RELDIR) .PHONY: release release: clean tarball # # Build binaries and release tarballs for all platforms # .PHONY: platform-all platform-all: clean python3 .github/release.py run-build $(BINARY_NAME) ================================================ FILE: README.md ================================================

CrowdSec

📚 Documentation 💠 Hub 💬 Discourse

# crowdsec-firewall-bouncer Crowdsec bouncer written in golang for firewalls. crowdsec-firewall-bouncer will fetch new and old decisions from a CrowdSec API to add them in a blocklist used by supported firewalls. Supported firewalls: - iptables (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: ) - nftables (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: ) - ipset only (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: ) - pf (IPV4 :heavy_check_mark: / IPV6 :heavy_check_mark: ) # Installation Please follow the [official documentation](https://doc.crowdsec.net/docs/bouncers/firewall). ================================================ FILE: cmd/root.go ================================================ package cmd import ( "context" "errors" "flag" "fmt" "net" "net/http" "os" "os/signal" "slices" "strings" "syscall" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" log "github.com/sirupsen/logrus" "golang.org/x/sync/errgroup" csbouncer "github.com/crowdsecurity/go-cs-bouncer" "github.com/crowdsecurity/go-cs-lib/csdaemon" "github.com/crowdsecurity/go-cs-lib/csstring" "github.com/crowdsecurity/go-cs-lib/version" "github.com/crowdsecurity/crowdsec/pkg/models" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/backend" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics" ) const bouncerType = "crowdsec-firewall-bouncer" var errSignalShutdown = errors.New("signal shutdown") func backendCleanup(backend *backend.BackendCTX) { log.Info("Shutting down backend") if err := backend.ShutDown(); err != nil { log.Errorf("while shutting down backend: %s", err) } } func HandleSignals(ctx context.Context) error { signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGTERM, os.Interrupt) select { case s := <-signalChan: switch s { case syscall.SIGTERM: return errSignalShutdown case os.Interrupt: // cross-platform SIGINT return errSignalShutdown } case <-ctx.Done(): return ctx.Err() } return nil } func deleteDecisions(backend *backend.BackendCTX, decisions []*models.Decision, config *cfg.BouncerConfig) { nbDeletedDecisions := 0 for _, d := range decisions { if !slices.Contains(config.SupportedDecisionsTypes, strings.ToLower(*d.Type)) { log.Debugf("decisions for ip '%s' will not be deleted because its type is '%s'", *d.Value, *d.Type) continue } if err := backend.Delete(d); err != nil { if !strings.Contains(err.Error(), "netlink receive: no such file or directory") { log.Errorf("unable to delete decision for '%s': %s", *d.Value, err) } continue } log.Debugf("deleted %s", *d.Value) nbDeletedDecisions++ } noun := "decisions" if nbDeletedDecisions == 1 { noun = "decision" } if nbDeletedDecisions > 0 { log.Debug("committing expired decisions") if err := backend.Commit(); err != nil { log.Errorf("unable to commit expired decisions %v", err) return } log.Debug("committed expired decisions") log.Infof("%d %s deleted", nbDeletedDecisions, noun) } } func addDecisions(backend *backend.BackendCTX, decisions []*models.Decision, config *cfg.BouncerConfig) { nbNewDecisions := 0 for _, d := range decisions { if !slices.Contains(config.SupportedDecisionsTypes, strings.ToLower(*d.Type)) { log.Debugf("decisions for ip '%s' will not be added because its type is '%s'", *d.Value, *d.Type) continue } if err := backend.Add(d); err != nil { log.Errorf("unable to insert decision for '%s': %s", *d.Value, err) continue } log.Debugf("Adding '%s' for '%s'", *d.Value, *d.Duration) nbNewDecisions++ } noun := "decisions" if nbNewDecisions == 1 { noun = "decision" } if nbNewDecisions > 0 { log.Debug("committing added decisions") if err := backend.Commit(); err != nil { log.Errorf("unable to commit add decisions %v", err) return } log.Debug("committed added decisions") log.Infof("%d %s added", nbNewDecisions, noun) } } func Execute() error { configPath := flag.String("c", "", "path to crowdsec-firewall-bouncer.yaml") verbose := flag.Bool("v", false, "set verbose mode") bouncerVersion := flag.Bool("V", false, "display version and exit (deprecated)") flag.BoolVar(bouncerVersion, "version", *bouncerVersion, "display version and exit") testConfig := flag.Bool("t", false, "test config and exit") showConfig := flag.Bool("T", false, "show full config (.yaml + .yaml.local) and exit") flag.Parse() if *bouncerVersion { fmt.Fprint(os.Stdout, version.FullString()) return nil } if configPath == nil || *configPath == "" { return errors.New("configuration file is required") } configMerged, err := cfg.MergedConfig(*configPath) if err != nil { return fmt.Errorf("unable to read config file: %w", err) } if *showConfig { fmt.Fprintln(os.Stdout, string(configMerged)) return nil } configExpanded := csstring.StrictExpand(string(configMerged), os.LookupEnv) config, err := cfg.NewConfig(strings.NewReader(configExpanded)) if err != nil { return fmt.Errorf("unable to load configuration: %w", err) } if *verbose && !log.IsLevelEnabled(log.DebugLevel) { log.SetLevel(log.DebugLevel) } log.Infof("Starting %s %s", bouncerType, version.String()) backend, err := backend.NewBackend(config) if err != nil { return err } if err = backend.Init(); err != nil { return err } defer backendCleanup(backend) bouncer := &csbouncer.StreamBouncer{} err = bouncer.ConfigReader(strings.NewReader(configExpanded)) if err != nil { return err } bouncer.UserAgent = fmt.Sprintf("%s/%s", bouncerType, version.String()) if err := bouncer.Init(); err != nil { return fmt.Errorf("unable to configure bouncer: %w", err) } if *testConfig { log.Info("config is valid") return nil } if bouncer.InsecureSkipVerify != nil { log.Debugf("InsecureSkipVerify is set to %t", *bouncer.InsecureSkipVerify) } g, ctx := errgroup.WithContext(context.Background()) g.Go(func() error { return bouncer.Run(ctx) }) mHandler := metrics.Handler{ Backend: backend, } metricsProvider, err := csbouncer.NewMetricsProvider(bouncer.APIClient, bouncerType, mHandler.MetricsUpdater, log.StandardLogger()) if err != nil { return fmt.Errorf("unable to create metrics provider: %w", err) } g.Go(func() error { return metricsProvider.Run(ctx) }) if config.Mode == cfg.IptablesMode || config.Mode == cfg.NftablesMode || config.Mode == cfg.IpsetMode || config.Mode == cfg.PfMode { metrics.Map.MustRegisterAll() } prometheus.MustRegister(csbouncer.TotalLAPICalls, csbouncer.TotalLAPIError) if config.PrometheusConfig.Enabled { go func() { http.Handle("/metrics", mHandler.ComputeMetricsHandler(promhttp.Handler())) listenOn := net.JoinHostPort( config.PrometheusConfig.ListenAddress, config.PrometheusConfig.ListenPort, ) log.Infof("Serving metrics at %s", listenOn+"/metrics") log.Error(http.ListenAndServe(listenOn, nil)) }() } g.Go(func() error { log.Infof("Processing new and deleted decisions . . .") for { select { case <-ctx.Done(): return nil case decisions := <-bouncer.Stream: if decisions == nil { continue } deleteDecisions(backend, decisions.Deleted, config) addDecisions(backend, decisions.New, config) } } }) if config.Daemon != nil { if *config.Daemon { log.Debug("Ignoring deprecated 'daemonize' option") } else { log.Warn("The 'daemonize' config option is deprecated and treated as always true") } } _ = csdaemon.Notify(csdaemon.Ready, log.StandardLogger()) g.Go(func() error { return HandleSignals(ctx) }) if err := g.Wait(); err != nil { if errors.Is(err, errSignalShutdown) { log.Info("Received shutdown signal, exiting gracefully") return nil } return fmt.Errorf("process terminated with error: %w", err) } return nil } ================================================ FILE: config/crowdsec-firewall-bouncer.service ================================================ [Unit] Description=The firewall bouncer for CrowdSec After=syslog.target network.target remote-fs.target nss-lookup.target crowdsec.service [Service] Type=notify ExecStart=${BIN} -c ${CFG}/crowdsec-firewall-bouncer.yaml ExecStartPre=${BIN} -c ${CFG}/crowdsec-firewall-bouncer.yaml -t ExecStartPost=/bin/sleep 0.1 Restart=always RestartSec=10 LimitNOFILE=65536 # don't send a termination signal to the children processes, # because the iptables backend needs to run ipset multiple times to properly shutdown KillMode=mixed [Install] WantedBy=multi-user.target ================================================ FILE: config/crowdsec-firewall-bouncer.yaml ================================================ mode: ${BACKEND} update_frequency: 10s log_mode: file log_dir: /var/log/ log_level: info log_compression: true log_max_size: 100 log_max_backups: 3 log_max_age: 30 api_url: http://127.0.0.1:8080/ api_key: ${API_KEY} ## TLS Authentication # cert_path: /etc/crowdsec/tls/cert.pem # key_path: /etc/crowdsec/tls/key.pem # ca_cert_path: /etc/crowdsec/tls/ca.crt insecure_skip_verify: false disable_ipv6: false deny_action: DROP deny_log: false supported_decisions_types: - ban #to change log prefix #deny_log_prefix: "crowdsec: " #to change the blacklists name blacklists_ipv4: crowdsec-blacklists blacklists_ipv6: crowdsec6-blacklists #type of ipset to use ipset_type: nethash #if present, insert rule in those chains iptables_chains: - INPUT # - FORWARD # - DOCKER-USER iptables_add_rule_comments: true ## nftables nftables: ipv4: enabled: true set-only: false table: crowdsec chain: crowdsec-chain priority: -10 ipv6: enabled: true set-only: false table: crowdsec6 chain: crowdsec6-chain priority: -10 nftables_hooks: - input - forward # packet filter pf: # an empty string disables the anchor anchor_name: "" prometheus: enabled: false listen_addr: 127.0.0.1 listen_port: 60601 ================================================ FILE: debian/changelog ================================================ crowdsec-firewall-bouncer (1.0.12) UNRELEASED; urgency=medium * debian package * pf support -- Manuel Sabban Mon, 08 Feb 2021 09:38:06 +0100 ================================================ FILE: debian/compat ================================================ 11 ================================================ FILE: debian/control ================================================ Source: crowdsec-firewall-bouncer Maintainer: Crowdsec Team Build-Depends: debhelper Section: admin Priority: optional Package: crowdsec-firewall-bouncer-iptables Architecture: any Description: Firewall bouncer for Crowdsec (iptables+ipset) Depends: gettext-base, iptables, ipset Replaces: crowdsec-firewall-bouncer Conflicts: crowdsec-firewall-bouncer-nftables Package: crowdsec-firewall-bouncer-nftables Architecture: any Description: Firewall bouncer for Crowdsec (nftables) Depends: gettext-base, nftables Replaces: crowdsec-firewall-bouncer Conflicts: crowdsec-firewall-bouncer-iptables ================================================ FILE: debian/crowdsec-firewall-bouncer-iptables.postinst ================================================ #!/bin/sh systemctl daemon-reload #shellcheck source=./scripts/_bouncer.sh . "/usr/lib/$DPKG_MAINTSCRIPT_PACKAGE/_bouncer.sh" START=1 if [ "$1" = "configure" ]; then if need_api_key; then if ! set_api_key; then START=0 fi fi fi systemctl --quiet is-enabled "$SERVICE" || systemctl unmask "$SERVICE" && systemctl enable "$SERVICE" set_local_port if [ "$START" -eq 0 ]; then 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 else systemctl start "$SERVICE" fi ================================================ FILE: debian/crowdsec-firewall-bouncer-iptables.postrm ================================================ #!/bin/sh set -eu BOUNCER="crowdsec-firewall-bouncer" CONFIG="/etc/crowdsec/bouncers/$BOUNCER.yaml" if [ "$1" = "purge" ]; then if [ -f "$CONFIG.id" ]; then bouncer_id=$(cat "$CONFIG.id") cscli -oraw bouncers delete "$bouncer_id" 2>/dev/null || true rm -f "$CONFIG.id" fi fi ================================================ FILE: debian/crowdsec-firewall-bouncer-iptables.preinst ================================================ #!/bin/sh set -e # Source debconf library. . /usr/share/debconf/confmodule ================================================ FILE: debian/crowdsec-firewall-bouncer-iptables.prerm ================================================ #!/bin/sh set -eu BOUNCER="crowdsec-firewall-bouncer" systemctl stop "$BOUNCER" || echo "cannot stop service" systemctl disable "$BOUNCER" || echo "cannot disable service" ================================================ FILE: debian/crowdsec-firewall-bouncer-nftables.postinst ================================================ #!/bin/sh systemctl daemon-reload #shellcheck source=./scripts/_bouncer.sh . "/usr/lib/$DPKG_MAINTSCRIPT_PACKAGE/_bouncer.sh" START=1 if [ "$1" = "configure" ]; then if need_api_key; then if ! set_api_key; then START=0 fi fi fi systemctl --quiet is-enabled "$SERVICE" || systemctl unmask "$SERVICE" && systemctl enable "$SERVICE" set_local_port if [ "$START" -eq 0 ]; then 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 else systemctl start "$SERVICE" fi ================================================ FILE: debian/crowdsec-firewall-bouncer-nftables.postrm ================================================ #!/bin/sh set -eu BOUNCER="crowdsec-firewall-bouncer" CONFIG="/etc/crowdsec/bouncers/$BOUNCER.yaml" if [ "$1" = "purge" ]; then if [ -f "$CONFIG.id" ]; then bouncer_id=$(cat "$CONFIG.id") cscli -oraw bouncers delete "$bouncer_id" 2>/dev/null || true rm -f "$CONFIG.id" fi fi ================================================ FILE: debian/crowdsec-firewall-bouncer-nftables.preinst ================================================ #!/bin/sh set -e # Source debconf library. . /usr/share/debconf/confmodule echo "pre-inst (nftables)" ================================================ FILE: debian/crowdsec-firewall-bouncer-nftables.prerm ================================================ #!/bin/sh set -eu BOUNCER="crowdsec-firewall-bouncer" systemctl stop "$BOUNCER" || echo "cannot stop service" systemctl disable "$BOUNCER" || echo "cannot disable service" ================================================ FILE: debian/rules ================================================ #!/usr/bin/make -f export DEB_VERSION=$(shell dpkg-parsechangelog | grep -E '^Version:' | cut -f 2 -d ' ') export BUILD_VERSION=v${DEB_VERSION}-debian-pragmatic %: dh $@ override_dh_systemd_start: echo "Not running dh_systemd_start" override_dh_auto_clean: override_dh_auto_test: override_dh_auto_build: override_dh_auto_install: @make build @BOUNCER=crowdsec-firewall-bouncer; \ for BACKEND in iptables nftables; do \ PKG="$$BOUNCER-$$BACKEND"; \ install -D $$BOUNCER -t "debian/$$PKG/usr/bin/"; \ install -D scripts/_bouncer.sh -t "debian/$$PKG/usr/lib/$$PKG/"; \ mkdir -p "debian/$$PKG/etc/crowdsec/bouncers"; \ (umask 177 && BACKEND=$$BACKEND envsubst '$$BACKEND' < "config/$$BOUNCER.yaml" > "debian/$$PKG/etc/crowdsec/bouncers/$$BOUNCER.yaml"); \ mkdir -p "debian/$$PKG/etc/systemd/system"; \ BIN="/usr/bin/$$BOUNCER" CFG="/etc/crowdsec/bouncers" envsubst '$$BIN $$CFG' < "config/$$BOUNCER.service" > "debian/$$PKG/etc/systemd/system/$$BOUNCER.service"; \ mkdir -p "debian/$$PKG/usr/sbin/"; \ ln -s "/usr/bin/$$BOUNCER" "debian/$$PKG/usr/sbin/$$BOUNCER"; \ done execute_after_dh_fixperms: @BOUNCER=crowdsec-firewall-bouncer; \ for BACKEND in iptables nftables; do \ PKG="$$BOUNCER-$$BACKEND"; \ chmod 0755 "debian/$$PKG/usr/bin/$$BOUNCER"; \ chmod 0600 "debian/$$PKG/usr/lib/$$PKG/_bouncer.sh"; \ chmod 0600 "debian/$$PKG/etc/crowdsec/bouncers/$$BOUNCER.yaml"; \ chmod 0644 "debian/$$PKG/etc/systemd/system/$$BOUNCER.service"; \ done ================================================ FILE: flake.nix ================================================ { description = "A Nix-flake-based Go 1.22 development environment"; inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1.*.tar.gz"; outputs = { self, nixpkgs }: let goVersion = 24; # Change this to update the whole stack supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f { pkgs = import nixpkgs { inherit system; overlays = [ self.overlays.default ]; }; }); in { overlays.default = final: prev: { go = final."go_1_${toString goVersion}"; }; devShells = forEachSupportedSystem ({ pkgs }: { default = pkgs.mkShell { packages = with pkgs; [ # go (version is specified by overlay) go # goimports, godoc, etc. gotools # https://github.com/golangci/golangci-lint golangci-lint golangci-lint-langserver gopls ]; }; }); }; } ================================================ FILE: go.mod ================================================ module github.com/crowdsecurity/cs-firewall-bouncer go 1.25.2 require ( github.com/crowdsecurity/crowdsec v1.7.7 github.com/crowdsecurity/go-cs-bouncer v0.0.21 github.com/crowdsecurity/go-cs-lib v0.0.25 github.com/google/nftables v0.3.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 github.com/sirupsen/logrus v1.9.4 github.com/stretchr/testify v1.11.1 golang.org/x/sync v0.20.0 golang.org/x/sys v0.42.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v2 v2.4.0 ) require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/ebitengine/purego v0.8.4 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/analysis v0.23.0 // indirect github.com/go-openapi/errors v0.22.2 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/loads v0.22.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/strfmt v0.23.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/validate v0.24.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/shirou/gopsutil/v4 v4.25.8 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/net v0.48.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/crowdsecurity/crowdsec v1.7.7 h1:sduZN763iXsrZodocWDrsR//7nLeffGu+RVkkIsbQkE= github.com/crowdsecurity/crowdsec v1.7.7/go.mod h1:L1HLGPDnBYCcY+yfSFnuBbQ1G9DHEJN9c+Kevv9F+4Q= github.com/crowdsecurity/go-cs-bouncer v0.0.21 h1:arPz0VtdVSaz+auOSfHythzkZVLyy18CzYvYab8UJDU= github.com/crowdsecurity/go-cs-bouncer v0.0.21/go.mod h1:4JiH0XXA4KKnnWThItUpe5+heJHWzsLOSA2IWJqUDBA= github.com/crowdsecurity/go-cs-lib v0.0.25 h1:Ov6VPW9yV+OPsbAIQk1iTkEWhwkpaG0v3lrBzeqjzj4= github.com/crowdsecurity/go-cs-lib v0.0.25/go.mod h1:X0GMJY2CxdA1S09SpuqIKaWQsvRGxXmecUp9cP599dE= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU= github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo= github.com/go-openapi/errors v0.22.2 h1:rdxhzcBUazEcGccKqbY1Y7NS8FDcMyIRr0934jrYnZg= github.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg= github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: main.go ================================================ package main import ( log "github.com/sirupsen/logrus" "github.com/crowdsecurity/cs-firewall-bouncer/cmd" ) func main() { err := cmd.Execute() if err != nil { log.Fatal(err) } } ================================================ FILE: pkg/backend/backend.go ================================================ package backend import ( "errors" "fmt" "runtime" log "github.com/sirupsen/logrus" "github.com/crowdsecurity/crowdsec/pkg/models" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/dryrun" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/iptables" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/nftables" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/pf" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types" ) type BackendCTX struct { firewall types.Backend } func (b *BackendCTX) Init() error { return b.firewall.Init() } func (b *BackendCTX) Commit() error { return b.firewall.Commit() } func (b *BackendCTX) ShutDown() error { return b.firewall.ShutDown() } func (b *BackendCTX) Add(decision *models.Decision) error { return b.firewall.Add(decision) } func (b *BackendCTX) Delete(decision *models.Decision) error { return b.firewall.Delete(decision) } func (b *BackendCTX) CollectMetrics() { log.Trace("Collecting backend-specific metrics") b.firewall.CollectMetrics() } func isPFSupported(runtimeOS string) bool { var supported bool switch runtimeOS { case "openbsd", "freebsd": supported = true default: supported = false } return supported } func NewBackend(config *cfg.BouncerConfig) (*BackendCTX, error) { var err error b := &BackendCTX{} log.Infof("backend type: %s", config.Mode) if config.DisableIPV6 { log.Info("IPV6 is disabled") } if config.DisableIPV4 { log.Info("IPV4 is disabled") } switch config.Mode { case cfg.IptablesMode, cfg.IpsetMode: if runtime.GOOS != "linux" { return nil, errors.New("iptables and ipset is linux only") } b.firewall, err = iptables.NewIPTables(config) if err != nil { return nil, err } case cfg.NftablesMode: if runtime.GOOS != "linux" { return nil, errors.New("nftables is linux only") } b.firewall, err = nftables.NewNFTables(config) if err != nil { return nil, err } case "pf": if !isPFSupported(runtime.GOOS) { log.Warning("pf mode can only work with openbsd and freebsd. It is available on other platforms only for testing purposes") } b.firewall, err = pf.NewPF(config) if err != nil { return nil, err } case "dry-run": b.firewall, err = dryrun.NewDryRun(config) if err != nil { return nil, err } default: return b, fmt.Errorf("firewall '%s' is not supported", config.Mode) } return b, nil } ================================================ FILE: pkg/cfg/config.go ================================================ package cfg import ( "errors" "fmt" "io" log "github.com/sirupsen/logrus" "gopkg.in/yaml.v2" "github.com/crowdsecurity/go-cs-lib/csyaml" "github.com/crowdsecurity/go-cs-lib/ptr" ) type PrometheusConfig struct { Enabled bool `yaml:"enabled"` ListenAddress string `yaml:"listen_addr"` ListenPort string `yaml:"listen_port"` } type nftablesFamilyConfig struct { Enabled *bool `yaml:"enabled"` SetOnly bool `yaml:"set-only"` Table string `yaml:"table"` Chain string `yaml:"chain"` Priority int `yaml:"priority"` } const ( IpsetMode = "ipset" IptablesMode = "iptables" NftablesMode = "nftables" PfMode = "pf" DryRunMode = "dry-run" ) type BouncerConfig struct { Mode string `yaml:"mode"` // ipset,iptables,tc PidDir string `yaml:"pid_dir"` // unused UpdateFrequency string `yaml:"update_frequency"` Daemon *bool `yaml:"daemonize"` // unused Logging LoggingConfig `yaml:",inline"` DisableIPV6 bool `yaml:"disable_ipv6"` DisableIPV4 bool `yaml:"disable_ipv4"` DenyAction string `yaml:"deny_action"` DenyLog bool `yaml:"deny_log"` DenyLogPrefix string `yaml:"deny_log_prefix"` BlacklistsIpv4 string `yaml:"blacklists_ipv4"` BlacklistsIpv6 string `yaml:"blacklists_ipv6"` SetType string `yaml:"ipset_type"` SetSize int `yaml:"ipset_size"` SetDisableTimeouts bool `yaml:"ipset_disable_timeouts"` // specific to iptables, following https://github.com/crowdsecurity/cs-firewall-bouncer/issues/19 IptablesChains []string `yaml:"iptables_chains"` IptablesV4Chains []string `yaml:"iptables_v4_chains"` IptablesV6Chains []string `yaml:"iptables_v6_chains"` IptablesAddRuleComments bool `yaml:"iptables_add_rule_comments"` SupportedDecisionsTypes []string `yaml:"supported_decisions_types"` // specific to nftables, following https://github.com/crowdsecurity/cs-firewall-bouncer/issues/74 Nftables struct { Ipv4 nftablesFamilyConfig `yaml:"ipv4"` Ipv6 nftablesFamilyConfig `yaml:"ipv6"` } `yaml:"nftables"` NftablesHooks []string `yaml:"nftables_hooks"` PF struct { AnchorName string `yaml:"anchor_name"` BatchSize int `yaml:"batch_size"` } `yaml:"pf"` PrometheusConfig PrometheusConfig `yaml:"prometheus"` } // MergedConfig() returns the byte content of the patched configuration file (with .yaml.local). func MergedConfig(configPath string) ([]byte, error) { patcher := csyaml.NewPatcher(configPath, ".local") data, err := patcher.MergedPatchContent() if err != nil { return nil, err } return data, nil } func NewConfig(reader io.Reader) (*BouncerConfig, error) { config := &BouncerConfig{ IptablesAddRuleComments: true, } fcontent, err := io.ReadAll(reader) if err != nil { return nil, err } err = yaml.Unmarshal(fcontent, &config) if err != nil { return nil, fmt.Errorf("failed to unmarshal: %w", err) } if err = config.Logging.setup("crowdsec-firewall-bouncer.log"); err != nil { return nil, fmt.Errorf("failed to setup logging: %w", err) } if config.Mode == "" { return nil, errors.New("config does not contain 'mode'") } if len(config.SupportedDecisionsTypes) == 0 { config.SupportedDecisionsTypes = []string{"ban"} } if config.PidDir != "" { log.Debug("Ignoring deprecated 'pid_dir' option") } if config.DenyLog && config.DenyLogPrefix == "" { config.DenyLogPrefix = "crowdsec drop: " } // for config file backward compatibility if config.BlacklistsIpv4 == "" { config.BlacklistsIpv4 = "crowdsec-blacklists" } if config.BlacklistsIpv6 == "" { config.BlacklistsIpv6 = "crowdsec6-blacklists" } if config.SetType == "" { config.SetType = "nethash" } if config.SetSize == 0 { config.SetSize = 131072 } if config.DisableIPV4 && config.DisableIPV6 && config.Mode != NftablesMode { // we return an error for pf or iptables because nftables has it own way to handle this return nil, errors.New("both IPv4 and IPv6 disabled, doing nothing") } switch config.Mode { case NftablesMode: err := nftablesConfig(config) if err != nil { return nil, err } case IpsetMode, IptablesMode: // nothing specific to do case PfMode: err := pfConfig(config) if err != nil { return nil, err } case DryRunMode: // nothing specific to do default: log.Warningf("unexpected %s mode", config.Mode) } return config, nil } func pfConfig(config *BouncerConfig) error { if config.PF.BatchSize != 0 { log.Warning("Option pf.batch_size is deprecated and ignored, all IPs are loaded at once") } return nil } func nftablesConfig(config *BouncerConfig) error { // deal with defaults in a backward compatible way if config.Nftables.Ipv4.Enabled == nil { config.Nftables.Ipv4.Enabled = ptr.Of(!config.DisableIPV4) } if config.Nftables.Ipv6.Enabled == nil { config.Nftables.Ipv6.Enabled = ptr.Of(!config.DisableIPV6) } if *config.Nftables.Ipv4.Enabled { if config.Nftables.Ipv4.Table == "" { config.Nftables.Ipv4.Table = "crowdsec" } if config.Nftables.Ipv4.Chain == "" { config.Nftables.Ipv4.Chain = "crowdsec-chain" } } if *config.Nftables.Ipv6.Enabled { if config.Nftables.Ipv6.Table == "" { config.Nftables.Ipv6.Table = "crowdsec6" } if config.Nftables.Ipv6.Chain == "" { config.Nftables.Ipv6.Chain = "crowdsec6-chain" } } if !*config.Nftables.Ipv4.Enabled && !*config.Nftables.Ipv6.Enabled { return errors.New("both IPv4 and IPv6 disabled, doing nothing") } if len(config.NftablesHooks) == 0 { config.NftablesHooks = []string{"input"} } return nil } ================================================ FILE: pkg/cfg/logging.go ================================================ package cfg import ( "errors" "io" "os" "path/filepath" "time" log "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/writer" "gopkg.in/natefinch/lumberjack.v2" "github.com/crowdsecurity/go-cs-lib/ptr" ) type LoggingConfig struct { LogLevel *log.Level `yaml:"log_level"` LogMode string `yaml:"log_mode"` LogDir string `yaml:"log_dir"` LogMaxSize int `yaml:"log_max_size,omitempty"` LogMaxFiles int `yaml:"log_max_files,omitempty"` LogMaxAge int `yaml:"log_max_age,omitempty"` CompressLogs *bool `yaml:"compress_logs,omitempty"` } func (c *LoggingConfig) LoggerForFile(fileName string) (io.Writer, error) { if c.LogMode == "stdout" { return os.Stderr, nil } // default permissions will be 0600 from lumberjack // and are preserved if the file already exists l := &lumberjack.Logger{ Filename: filepath.Join(c.LogDir, fileName), MaxSize: c.LogMaxSize, MaxBackups: c.LogMaxFiles, MaxAge: c.LogMaxAge, Compress: *c.CompressLogs, } return l, nil } func (c *LoggingConfig) setDefaults() { if c.LogMode == "" { c.LogMode = "stdout" } if c.LogDir == "" { c.LogDir = "/var/log/" } if c.LogLevel == nil { c.LogLevel = ptr.Of(log.InfoLevel) } if c.LogMaxSize == 0 { c.LogMaxSize = 500 } if c.LogMaxFiles == 0 { c.LogMaxFiles = 3 } if c.LogMaxAge == 0 { c.LogMaxAge = 30 } if c.CompressLogs == nil { c.CompressLogs = ptr.Of(true) } } func (c *LoggingConfig) validate() error { if c.LogMode != "stdout" && c.LogMode != "file" { return errors.New("log_mode should be either 'stdout' or 'file'") } return nil } func (c *LoggingConfig) setup(fileName string) error { c.setDefaults() if err := c.validate(); err != nil { return err } log.SetLevel(*c.LogLevel) if c.LogMode == "stdout" { return nil } log.SetFormatter(&log.TextFormatter{TimestampFormat: time.RFC3339, FullTimestamp: true}) logger, err := c.LoggerForFile(fileName) if err != nil { return err } log.SetOutput(logger) // keep stderr for panic/fatal, otherwise process failures // won't be visible enough log.AddHook(&writer.Hook{ Writer: os.Stderr, LogLevels: []log.Level{ log.PanicLevel, log.FatalLevel, }, }) return nil } ================================================ FILE: pkg/dryrun/dryrun.go ================================================ package dryrun import ( log "github.com/sirupsen/logrus" "github.com/crowdsecurity/crowdsec/pkg/models" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types" ) type dryRun struct{} func NewDryRun(_ *cfg.BouncerConfig) (types.Backend, error) { return &dryRun{}, nil } func (*dryRun) Init() error { log.Infof("backend.Init() called") return nil } func (*dryRun) Commit() error { log.Infof("backend.Commit() called") return nil } func (*dryRun) Add(decision *models.Decision) error { log.Infof("backend.Add() called with %s", *decision.Value) return nil } func (*dryRun) CollectMetrics() { log.Infof("backend.CollectMetrics() called") } func (*dryRun) Delete(decision *models.Decision) error { log.Infof("backend.Delete() called with %s", *decision.Value) return nil } func (*dryRun) ShutDown() error { log.Infof("backend.ShutDown() called") return nil } ================================================ FILE: pkg/ipsetcmd/ipset.go ================================================ package ipsetcmd import ( "errors" "fmt" "os/exec" "strconv" "strings" log "github.com/sirupsen/logrus" ) type IPSet struct { binaryPath string setName string } type CreateOptions struct { Timeout string MaxElem string Family string Type string DisableTimeouts bool } const ipsetBinary = "ipset" func NewIPSet(setName string) (*IPSet, error) { ipsetBin, err := exec.LookPath(ipsetBinary) if err != nil { return nil, errors.New("unable to find ipset") } return &IPSet{ binaryPath: ipsetBin, setName: setName, }, nil } // Wraps all the ipset commands. func (i *IPSet) Create(opts CreateOptions) error { cmdArgs := []string{"create", i.setName} if opts.Type != "" { cmdArgs = append(cmdArgs, opts.Type) } if opts.Timeout != "" && !opts.DisableTimeouts { cmdArgs = append(cmdArgs, "timeout", opts.Timeout) } if opts.MaxElem != "" { cmdArgs = append(cmdArgs, "maxelem", opts.MaxElem) } if opts.Family != "" { cmdArgs = append(cmdArgs, "family", opts.Family) } cmd := exec.Command(i.binaryPath, cmdArgs...) log.Debugf("ipset create command: %v", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("error creating ipset: %s", out) } return nil } func (i *IPSet) Add(entry string) error { cmd := exec.Command(i.binaryPath, "add", i.setName, entry) log.Debugf("ipset add command: %v", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("error creating ipset: %s", out) } return nil } func (i *IPSet) DeleteEntry(entry string) error { cmd := exec.Command(i.binaryPath, "del", i.setName, entry) log.Debugf("ipset delete entry command: %v", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("error creating ipset: %s", out) } return nil } func (i *IPSet) List() ([]string, error) { cmd := exec.Command(i.binaryPath, "list", i.setName) log.Debugf("ipset list command: %v", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("error listing ipset: %s", out) } return strings.Split(string(out), "\n"), nil } func (i *IPSet) Flush() error { cmd := exec.Command(i.binaryPath, "flush", i.setName) log.Debugf("ipset flush command: %v", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("error flushing ipset: %s", out) } return nil } func (i *IPSet) Destroy() error { cmd := exec.Command(i.binaryPath, "destroy", i.setName) log.Debugf("ipset destroy command: %v", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("error destroying ipset: %s", out) } return nil } func (i *IPSet) Rename(toSetName string) error { cmd := exec.Command(i.binaryPath, "rename", i.setName, toSetName) log.Debugf("ipset rename command: %v", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("error renaming ipset: %s", out) } i.setName = toSetName return nil } func (i *IPSet) Test(entry string) error { cmd := exec.Command(i.binaryPath, "test", i.setName, entry) log.Debugf("ipset test command: %v", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("error testing ipset: %s", out) } return nil } func (i *IPSet) Save() ([]string, error) { cmd := exec.Command(i.binaryPath, "save", i.setName) log.Debugf("ipset save command: %v", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("error saving ipset: %s", out) } return strings.Split(string(out), "\n"), nil } func (i *IPSet) Restore(filename string) error { cmd := exec.Command(i.binaryPath, "restore", "-file", filename) log.Debugf("ipset restore command: %v", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("error restoring ipset: %s", out) } return nil } func (i *IPSet) Swap(toSetName string) error { cmd := exec.Command(i.binaryPath, "swap", i.setName, toSetName) log.Debugf("ipset swap command: %v", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("error swapping ipset: %s", out) } i.setName = toSetName return nil } func (i *IPSet) Name() string { return i.setName } func (i *IPSet) Exists() bool { cmd := exec.Command(i.binaryPath, "list", i.setName) err := cmd.Run() return err == nil } func (i *IPSet) Len() int { cmd := exec.Command(i.binaryPath, "list", i.setName) log.Debugf("ipset list command: %v", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { return 0 } for _, line := range strings.Split(string(out), "\n") { if !strings.Contains(strings.ToLower(line), "number of entries:") { continue } fields := strings.Split(line, ":") if len(fields) != 2 { continue } count, err := strconv.Atoi(strings.TrimSpace(fields[1])) if err != nil { return 0 } return count } return 0 } // Helpers. func GetSetsStartingWith(name string) (map[string]*IPSet, error) { cmd := exec.Command(ipsetBinary, "list", "-name") log.Debugf("ipset list command: %v", cmd.String()) out, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("error listing ipset: %s", out) } sets := make(map[string]*IPSet, 0) for _, line := range strings.Split(string(out), "\n") { if !strings.HasPrefix(line, name) { continue } fields := strings.Fields(line) if len(fields) != 1 { continue } set, err := NewIPSet(fields[0]) if err != nil { return nil, err } sets[fields[0]] = set } return sets, nil } ================================================ FILE: pkg/iptables/iptables.go ================================================ //go:build linux package iptables import ( "errors" "fmt" "os/exec" "slices" "strings" log "github.com/sirupsen/logrus" "github.com/crowdsecurity/crowdsec/pkg/models" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/ipsetcmd" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types" ) const ( IPTablesDroppedPacketIdx = 0 IPTablesDroppedByteIdx = 1 ) type iptables struct { v4 *ipTablesContext v6 *ipTablesContext } func NewIPTables(config *cfg.BouncerConfig) (types.Backend, error) { var err error ret := &iptables{} defaultSet, err := ipsetcmd.NewIPSet("") if err != nil { return nil, err } allowedActions := []string{"DROP", "REJECT", "TARPIT", "LOG"} target := strings.ToUpper(config.DenyAction) if target == "" { target = "DROP" } log.Infof("using '%s' as deny_action", target) if !slices.Contains(allowedActions, target) { return nil, fmt.Errorf("invalid deny_action '%s', must be one of %s", config.DenyAction, strings.Join(allowedActions, ", ")) } v4Sets := make(map[string]*ipsetcmd.IPSet) v6Sets := make(map[string]*ipsetcmd.IPSet) ipv4Ctx := &ipTablesContext{ version: "v4", SetName: config.BlacklistsIpv4, SetType: config.SetType, SetSize: config.SetSize, ipsetDisableTimeouts: config.SetDisableTimeouts, Chains: []string{}, defaultSet: defaultSet, target: target, loggingEnabled: config.DenyLog, loggingPrefix: config.DenyLogPrefix, addRuleComments: config.IptablesAddRuleComments, } ipv6Ctx := &ipTablesContext{ version: "v6", SetName: config.BlacklistsIpv6, SetType: config.SetType, SetSize: config.SetSize, ipsetDisableTimeouts: config.SetDisableTimeouts, Chains: []string{}, defaultSet: defaultSet, target: target, loggingEnabled: config.DenyLog, loggingPrefix: config.DenyLogPrefix, addRuleComments: config.IptablesAddRuleComments, } if !config.DisableIPV4 { ipv4Ctx.iptablesSaveBin, err = exec.LookPath("iptables-save") if err != nil { return nil, errors.New("unable to find iptables-save") } if config.Mode == cfg.IpsetMode { ipv4Ctx.ipsetContentOnly = true set, err := ipsetcmd.NewIPSet(config.BlacklistsIpv4) if err != nil { return nil, err } v4Sets["ipset"] = set } else { ipv4Ctx.iptablesBin, err = exec.LookPath("iptables") if err != nil { return nil, errors.New("unable to find iptables") } // Try to "adopt" any leftover sets from a previous run if we crashed // They will get flushed/deleted just after v4Sets, _ = ipsetcmd.GetSetsStartingWith(config.BlacklistsIpv4) config.IptablesV4Chains = append(config.IptablesV4Chains, config.IptablesChains...) ipv4Ctx.Chains = config.IptablesV4Chains } ipv4Ctx.ipsets = v4Sets ret.v4 = ipv4Ctx } if !config.DisableIPV6 { ipv6Ctx.iptablesSaveBin, err = exec.LookPath("ip6tables-save") if err != nil { return nil, errors.New("unable to find ip6tables-save") } if config.Mode == cfg.IpsetMode { ipv6Ctx.ipsetContentOnly = true set, err := ipsetcmd.NewIPSet(config.BlacklistsIpv6) if err != nil { return nil, err } v6Sets["ipset"] = set } else { ipv6Ctx.iptablesBin, err = exec.LookPath("ip6tables") if err != nil { return nil, errors.New("unable to find ip6tables") } v6Sets, _ = ipsetcmd.GetSetsStartingWith(config.BlacklistsIpv6) config.IptablesV6Chains = append(config.IptablesV6Chains, config.IptablesChains...) ipv6Ctx.Chains = config.IptablesV6Chains } ipv6Ctx.ipsets = v6Sets ret.v6 = ipv6Ctx } return ret, nil } func (ipt *iptables) Init() error { if ipt.v4 != nil { log.Info("iptables for ipv4 initiated") // flush before init if err := ipt.v4.shutDown(); err != nil { return fmt.Errorf("iptables shutdown failed: %w", err) } if !ipt.v4.ipsetContentOnly { ipt.v4.setupChain() } } if ipt.v6 != nil { log.Info("iptables for ipv6 initiated") if err := ipt.v6.shutDown(); err != nil { return fmt.Errorf("iptables shutdown failed: %w", err) } if !ipt.v6.ipsetContentOnly { ipt.v6.setupChain() } } return nil } func (ipt *iptables) Commit() error { if ipt.v4 != nil { err := ipt.v4.commit() if err != nil { return fmt.Errorf("ipset for ipv4 commit failed: %w", err) } } if ipt.v6 != nil { err := ipt.v6.commit() if err != nil { return fmt.Errorf("ipset for ipv6 commit failed: %w", err) } } return nil } func (ipt *iptables) Add(decision *models.Decision) error { if strings.HasPrefix(*decision.Type, "simulation:") { log.Debugf("measure against '%s' is in simulation mode, skipping it", *decision.Value) return nil } if strings.Contains(*decision.Value, ":") { if ipt.v6 == nil { log.Debugf("not adding '%s' because ipv6 is disabled", *decision.Value) return nil } ipt.v6.add(decision) } else { if ipt.v4 == nil { log.Debugf("not adding '%s' because ipv4 is disabled", *decision.Value) return nil } ipt.v4.add(decision) } return nil } func (ipt *iptables) ShutDown() error { if ipt.v4 != nil { if err := ipt.v4.shutDown(); err != nil { return fmt.Errorf("iptables for ipv4 shutdown failed: %w", err) } } if ipt.v6 != nil { if err := ipt.v6.shutDown(); err != nil { return fmt.Errorf("iptables for ipv6 shutdown failed: %w", err) } } return nil } func (ipt *iptables) Delete(decision *models.Decision) error { done := false if strings.Contains(*decision.Value, ":") { if ipt.v6 == nil { log.Debugf("not deleting '%s' because ipv6 is disabled", *decision.Value) return nil } if err := ipt.v6.delete(decision); err != nil { return errors.New("failed deleting ban") } done = true } if strings.Contains(*decision.Value, ".") { if ipt.v4 == nil { log.Debugf("not deleting '%s' because ipv4 is disabled", *decision.Value) return nil } if err := ipt.v4.delete(decision); err != nil { return errors.New("failed deleting ban") } done = true } if !done { return fmt.Errorf("failed deleting ban: ip %s was not recognized", *decision.Value) } return nil } ================================================ FILE: pkg/iptables/iptables_context.go ================================================ //go:build linux package iptables import ( "fmt" "os" "os/exec" "slices" "strconv" "strings" "time" log "github.com/sirupsen/logrus" "github.com/crowdsecurity/crowdsec/pkg/models" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/ipsetcmd" ) const ( chainName = "CROWDSEC_CHAIN" loggingChainName = "CROWDSEC_LOG" dockerUserChainName = "DOCKER-USER" maxBanSeconds = 2147483 defaultTimeout = "300" ) type ipTablesContext struct { version string iptablesBin string iptablesSaveBin string SetName string // crowdsec-netfilter SetType string SetSize int ipsetContentOnly bool ipsetDisableTimeouts bool Chains []string target string ipsets map[string]*ipsetcmd.IPSet 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 toAdd []*models.Decision toDel []*models.Decision // To avoid issues with set name length (ipsest name length is limited to 31 characters) // Store the origin of the decisions, and use the index in the slice as the name // This is not stable (ie, between two runs, the index of a set can change), but it's (probably) not an issue originSetMapping []string loggingEnabled bool loggingPrefix string addRuleComments bool } func (ctx *ipTablesContext) chainExist(chainName string) bool { cmd := []string{"-L", chainName, "-t", "filter"} c := exec.Command(ctx.iptablesBin, cmd...) if _, err := c.CombinedOutput(); err != nil { return false } return true } func (ctx *ipTablesContext) setupChain() { cmd := []string{"-N", chainName, "-t", "filter"} c := exec.Command(ctx.iptablesBin, cmd...) log.Infof("Creating chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) if out, err := c.CombinedOutput(); err != nil { log.Errorf("error while creating chain : %v --> %s", err, string(out)) return } for _, chain := range ctx.Chains { cmd = []string{"-I", chain, "-j", chainName} c = exec.Command(ctx.iptablesBin, cmd...) log.Infof("Adding rule : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) if out, err := c.CombinedOutput(); err != nil { log.Errorf("error while adding rule : %v --> %s", err, string(out)) continue } } if ctx.loggingEnabled { // Create the logging chain cmd = []string{"-N", loggingChainName, "-t", "filter"} c = exec.Command(ctx.iptablesBin, cmd...) log.Infof("Creating logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) if out, err := c.CombinedOutput(); err != nil { log.Errorf("error while creating logging chain : %v --> %s", err, string(out)) return } // Insert the logging rule cmd = []string{"-I", loggingChainName, "-j", "LOG", "--log-prefix", ctx.loggingPrefix} c = exec.Command(ctx.iptablesBin, cmd...) log.Infof("Adding logging rule : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) if out, err := c.CombinedOutput(); err != nil { log.Errorf("error while adding logging rule : %v --> %s", err, string(out)) } // Add the desired target to the logging chain cmd = []string{"-A", loggingChainName, "-j", ctx.target} c = exec.Command(ctx.iptablesBin, cmd...) log.Infof("Adding target rule to logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) if out, err := c.CombinedOutput(); err != nil { log.Errorf("error while setting logging chain policy : %v --> %s", err, string(out)) } } if ctx.chainExist(dockerUserChainName) && !slices.Contains(ctx.Chains, dockerUserChainName) { // if the DOCKER-USER chain exists, but is not configured by the user, warn them as their containers will not be protected log.Warnf("The %s chain exists, but is not configured for use by the bouncer. The bouncer will not block traffic destined for your containers", dockerUserChainName) } } func (ctx *ipTablesContext) deleteChain() { for _, chain := range ctx.Chains { cmd := []string{"-D", chain, "-j", chainName} c := exec.Command(ctx.iptablesBin, cmd...) log.Infof("Deleting rule : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) if out, err := c.CombinedOutput(); err != nil { log.Errorf("error while removing rule : %v --> %s", err, string(out)) } } cmd := []string{"-F", chainName} c := exec.Command(ctx.iptablesBin, cmd...) log.Infof("Flushing chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) if out, err := c.CombinedOutput(); err != nil { log.Errorf("error while flushing chain : %v --> %s", err, string(out)) } cmd = []string{"-X", chainName} c = exec.Command(ctx.iptablesBin, cmd...) log.Infof("Deleting chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) if out, err := c.CombinedOutput(); err != nil { log.Errorf("error while deleting chain : %v --> %s", err, string(out)) } if ctx.loggingEnabled { cmd = []string{"-F", loggingChainName} c = exec.Command(ctx.iptablesBin, cmd...) log.Infof("Flushing logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) if out, err := c.CombinedOutput(); err != nil { log.Errorf("error while flushing logging chain : %v --> %s", err, string(out)) } cmd = []string{"-X", loggingChainName} c = exec.Command(ctx.iptablesBin, cmd...) log.Infof("Deleting logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) if out, err := c.CombinedOutput(); err != nil { log.Errorf("error while deleting logging chain : %v --> %s", err, string(out)) } } } func (ctx *ipTablesContext) createRule(setName string, origin string) { target := ctx.target if ctx.loggingEnabled { target = loggingChainName } cmd := []string{"-I", chainName, "-m", "set", "--match-set", setName, "src", "-j", target} if ctx.addRuleComments { cmd = append(cmd, "-m", "comment", "--comment", "CrowdSec: "+origin) } c := exec.Command(ctx.iptablesBin, cmd...) log.Infof("Creating rule : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) if out, err := c.CombinedOutput(); err != nil { log.Errorf("error while inserting set entry in iptables : %v --> %s", err, string(out)) } } func (ctx *ipTablesContext) commit() error { tmpFile, err := os.CreateTemp("", "cs-firewall-bouncer-ipset-") if err != nil { return err } defer func() { tmpFile.Close() os.Remove(tmpFile.Name()) ctx.toAdd = nil ctx.toDel = nil }() for _, decision := range ctx.toDel { var ( set *ipsetcmd.IPSet ok bool ) // Decisions coming from lists will have "lists" as origin, and the scenario will be the list name // We use those to build a custom origin because we want to track metrics per list // In case of other origin (crowdsec, cscli, ...), we do not really care about the scenario, it would be too noisy origin := *decision.Origin if origin == "lists" { origin = origin + ":" + *decision.Scenario } if ctx.ipsetContentOnly { set = ctx.ipsets["ipset"] } else { set, ok = ctx.ipsets[origin] if !ok { // No set for this origin, skip, as there's nothing to delete continue } } delCmd := fmt.Sprintf("del %s %s -exist\n", set.Name(), *decision.Value) log.Debugf("%s", delCmd) _, err = tmpFile.WriteString(delCmd) if err != nil { log.Errorf("error while writing to temp file : %s", err) continue } } for _, decision := range ctx.toAdd { banDuration, err := time.ParseDuration(*decision.Duration) if err != nil { log.Errorf("error while parsing ban duration : %s", err) continue } var ( set *ipsetcmd.IPSet ok bool ) if banDuration.Seconds() > maxBanSeconds { log.Warnf("Ban duration too long (%d seconds), maximum for ipset is %d, setting duration to %d", int(banDuration.Seconds()), maxBanSeconds, maxBanSeconds-1) banDuration = time.Duration(maxBanSeconds-1) * time.Second } origin := *decision.Origin if origin == "lists" { origin = origin + ":" + *decision.Scenario } if ctx.ipsetContentOnly { set = ctx.ipsets["ipset"] } else { set, ok = ctx.ipsets[origin] if !ok { idx := slices.Index(ctx.originSetMapping, origin) if idx == -1 { ctx.originSetMapping = append(ctx.originSetMapping, origin) idx = len(ctx.originSetMapping) - 1 } setName := fmt.Sprintf("%s-%d", ctx.SetName, idx) log.Infof("Using %s as set for origin %s", setName, origin) set, err = ipsetcmd.NewIPSet(setName) if err != nil { log.Errorf("error while creating ipset : %s", err) continue } family := "inet" if ctx.version == "v6" { family = "inet6" } err = set.Create(ipsetcmd.CreateOptions{ Family: family, Timeout: defaultTimeout, MaxElem: strconv.Itoa(ctx.SetSize), Type: ctx.SetType, DisableTimeouts: ctx.ipsetDisableTimeouts, }) // Ignore errors if the set already exists if err != nil { log.Errorf("error while creating ipset : %s", err) continue } ctx.ipsets[origin] = set if !ctx.ipsetContentOnly { // Create the rule to use the set ctx.createRule(set.Name(), origin) } } } var addCmd string if ctx.ipsetDisableTimeouts { addCmd = fmt.Sprintf("add %s %s -exist\n", set.Name(), *decision.Value) } else { addCmd = fmt.Sprintf("add %s %s timeout %d -exist\n", set.Name(), *decision.Value, int(banDuration.Seconds())) } log.Debugf("%s", addCmd) _, err = tmpFile.WriteString(addCmd) if err != nil { log.Errorf("error while writing to temp file : %s", err) continue } } if len(ctx.toAdd) == 0 && len(ctx.toDel) == 0 { return nil } return ctx.defaultSet.Restore(tmpFile.Name()) } func (ctx *ipTablesContext) add(decision *models.Decision) { ctx.toAdd = append(ctx.toAdd, decision) } func (ctx *ipTablesContext) shutDown() error { // Remove rules if !ctx.ipsetContentOnly { ctx.deleteChain() } time.Sleep(1 * time.Second) // Clean sets for _, set := range ctx.ipsets { if ctx.ipsetContentOnly { err := set.Flush() if err != nil { log.Errorf("error while flushing ipset : %s", err) } } else { err := set.Destroy() if err != nil { log.Errorf("error while destroying set %s : %s", set.Name(), err) } } } if !ctx.ipsetContentOnly { // In case we are starting, just reset the map ctx.ipsets = make(map[string]*ipsetcmd.IPSet) } return nil } func (ctx *ipTablesContext) delete(decision *models.Decision) error { ctx.toDel = append(ctx.toDel, decision) return nil } ================================================ FILE: pkg/iptables/iptables_stub.go ================================================ //go:build !linux package iptables import ( "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types" ) func NewIPTables(config *cfg.BouncerConfig) (types.Backend, error) { return nil, nil } ================================================ FILE: pkg/iptables/metrics.go ================================================ //go:build linux package iptables import ( "bufio" "os/exec" "regexp" "strconv" "strings" "github.com/prometheus/client_golang/prometheus" log "github.com/sirupsen/logrus" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics" ) // iptables does not provide a "nice" way to get the counters for a rule, so we have to parse the output of iptables-save // 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 // ruleRegexp is used to get the counters for the rules we have added that will actually block the traffic // Example output of iptables-save : // [2080:13210403] -A INPUT -j CROWDSEC_CHAIN // ... // [0:0] -A CROWDSEC_CHAIN -m set --match-set test-set-ipset-mode-0 src -j DROP // First number is the number of packets, second is the number of bytes // 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) // 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). var ( chainRegexp = regexp.MustCompile(`^\[(\d+):(\d+)\]`) ruleRegexp = regexp.MustCompile(`^\[(\d+):(\d+)\] -A [0-9A-Za-z_-]+ -m set --match-set (.*) src .*-j \w+`) ) // In ipset mode, we have to track the numbers of processed bytes/packets at the chain level // This is not really accurate, as a rule *before* the crowdsec rule could impact the numbers, but we don't have any other way. var ( ipsetChainDeclaration = regexp.MustCompile(`^:([0-9A-Za-z_-]+) ([0-9A-Za-z_-]+) \[(\d+):(\d+)\]`) ipsetRule = regexp.MustCompile(`^\[(\d+):(\d+)\] -A ([0-9A-Za-z_-]+)`) ) func (ctx *ipTablesContext) collectMetricsIptables(scanner *bufio.Scanner) (map[string]uint64, map[string]uint64, uint64, uint64) { processedBytes := uint64(0) processedPackets := uint64(0) droppedBytes := make(map[string]uint64) droppedPackets := make(map[string]uint64) for scanner.Scan() { line := scanner.Text() if line == "" { continue } // Ignore chain declaration if line[0] == ':' { continue } // Jump to our chain, we can get the processed packets and bytes if strings.Contains(line, "-j "+chainName) { matches := chainRegexp.FindStringSubmatch(line) if len(matches) != 3 { log.Errorf("error while parsing counters : %s | not enough matches", line) continue } val, err := strconv.ParseUint(matches[1], 10, 64) if err != nil { log.Errorf("error while parsing counters : %s | %s", line, err) continue } processedPackets += val val, err = strconv.ParseUint(matches[2], 10, 64) if err != nil { log.Errorf("error while parsing counters : %s | %s", line, err) continue } processedBytes += val continue } // This is a rule if strings.Contains(line, "-A "+chainName) { matches := ruleRegexp.FindStringSubmatch(line) if len(matches) != 4 { log.Errorf("error while parsing counters : %s | not enough matches", line) continue } originIDStr, found := strings.CutPrefix(matches[3], ctx.SetName+"-") if !found { log.Errorf("error while parsing counters : %s | no origin found", line) continue } originID, err := strconv.Atoi(originIDStr) if err != nil { log.Errorf("error while parsing counters : %s | %s", line, err) continue } if len(ctx.originSetMapping) <= originID { log.Errorf("Found unknown origin id : %d", originID) continue } origin := ctx.originSetMapping[originID] val, err := strconv.ParseUint(matches[1], 10, 64) if err != nil { log.Errorf("error while parsing counters : %s | %s", line, err) continue } droppedPackets[origin] += val val, err = strconv.ParseUint(matches[2], 10, 64) if err != nil { log.Errorf("error while parsing counters : %s | %s", line, err) continue } droppedBytes[origin] += val } } return droppedPackets, droppedBytes, processedPackets, processedBytes } type chainCounters struct { bytes uint64 packets uint64 } // In ipset mode, we only get dropped packets and bytes by matching on the set name in the rule // It's probably not perfect, but good enough for most users. func (ctx *ipTablesContext) collectMetricsIpset(scanner *bufio.Scanner) (map[string]uint64, map[string]uint64, uint64, uint64) { processedBytes := uint64(0) processedPackets := uint64(0) droppedBytes := make(map[string]uint64) droppedPackets := make(map[string]uint64) // We need to store the counters for all chains // As we don't know in which chain the user has setup the rules // We'll resolve the value laters. chainsCounter := make(map[string]chainCounters) // Hardcode the origin to ipset as we cannot know it based on the rule. droppedBytes["ipset"] = 0 droppedPackets["ipset"] = 0 for scanner.Scan() { line := scanner.Text() if line == "" { continue } // Chain declaration if line[0] == ':' { matches := ipsetChainDeclaration.FindStringSubmatch(line) if len(matches) != 5 { log.Errorf("error while parsing counters : %s | not enough matches", line) continue } log.Debugf("Found chain %s with matches %+v", matches[1], matches) c, ok := chainsCounter[matches[1]] if !ok { c = chainCounters{} } val, err := strconv.ParseUint(matches[3], 10, 64) if err != nil { log.Errorf("error while parsing counters : %s", line) continue } c.packets += val val, err = strconv.ParseUint(matches[4], 10, 64) if err != nil { log.Errorf("error while parsing counters : %s", line) continue } c.bytes += val chainsCounter[matches[1]] = c continue } // Assume that if a line contains the set name, it's a rule we are interested in. if strings.Contains(line, ctx.SetName) { matches := ipsetRule.FindStringSubmatch(line) if len(matches) != 4 { log.Errorf("error while parsing counters : %s | not enough matches", line) continue } val, err := strconv.ParseUint(matches[1], 10, 64) if err != nil { log.Errorf("error while parsing counters : %s", line) continue } droppedPackets["ipset"] += val val, err = strconv.ParseUint(matches[2], 10, 64) if err != nil { log.Errorf("error while parsing counters : %s", line) continue } droppedBytes["ipset"] += val // Resolve the chain counters c, ok := chainsCounter[matches[3]] if !ok { log.Errorf("error while parsing counters : %s | chain not found", line) continue } processedPackets += c.packets processedBytes += c.bytes } } return droppedPackets, droppedBytes, processedPackets, processedBytes } func (ctx *ipTablesContext) collectMetrics() (map[string]uint64, map[string]uint64, uint64, uint64, error) { //-c is required to get the counters cmd := []string{ctx.iptablesSaveBin, "-c", "-t", "filter"} saveCmd := exec.Command(cmd[0], cmd[1:]...) out, err := saveCmd.CombinedOutput() if err != nil { log.Errorf("error while getting iptables rules with cmd %+v : %v --> %s", cmd, err, string(out)) return nil, nil, 0, 0, err } var ( processedBytes uint64 processedPackets uint64 droppedBytes map[string]uint64 droppedPackets map[string]uint64 ) scanner := bufio.NewScanner(strings.NewReader(string(out))) if !ctx.ipsetContentOnly { droppedPackets, droppedBytes, processedPackets, processedBytes = ctx.collectMetricsIptables(scanner) } else { droppedPackets, droppedBytes, processedPackets, processedBytes = ctx.collectMetricsIpset(scanner) } log.Debugf("Processed %d packets and %d bytes", processedPackets, processedBytes) log.Debugf("Dropped packets : %v", droppedPackets) log.Debugf("Dropped bytes : %v", droppedBytes) return droppedPackets, droppedBytes, processedPackets, processedBytes, nil } func (ipt *iptables) CollectMetrics() { if ipt.v4 != nil { for origin, set := range ipt.v4.ipsets { metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": origin}).Set(float64(set.Len())) } ipv4DroppedPackets, ipv4DroppedBytes, ipv4ProcessedPackets, ipv4ProcessedBytes, err := ipt.v4.collectMetrics() if err != nil { log.Errorf("can't collect dropped packets for ipv4 from iptables: %s", err) } else { metrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv4"}).Set(float64(ipv4ProcessedPackets)) metrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv4"}).Set(float64(ipv4ProcessedBytes)) for origin, count := range ipv4DroppedPackets { metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": origin}).Set(float64(count)) } for origin, count := range ipv4DroppedBytes { metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": origin}).Set(float64(count)) } } } if ipt.v6 != nil { for origin, set := range ipt.v6.ipsets { metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": origin}).Set(float64(set.Len())) } ipv6DroppedPackets, ipv6DroppedBytes, ipv6ProcessedPackets, ipv6ProcessedBytes, err := ipt.v6.collectMetrics() if err != nil { log.Errorf("can't collect dropped packets for ipv6 from iptables: %s", err) } else { metrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv6"}).Set(float64(ipv6ProcessedPackets)) metrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv6"}).Set(float64(ipv6ProcessedBytes)) for origin, count := range ipv6DroppedPackets { metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": origin}).Set(float64(count)) } for origin, count := range ipv6DroppedBytes { metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": origin}).Set(float64(count)) } } } } ================================================ FILE: pkg/metrics/metrics.go ================================================ package metrics import ( "net/http" "time" "github.com/prometheus/client_golang/prometheus" io_prometheus_client "github.com/prometheus/client_model/go" log "github.com/sirupsen/logrus" "github.com/crowdsecurity/go-cs-lib/ptr" "github.com/crowdsecurity/crowdsec/pkg/models" ) const CollectionInterval = time.Second * 10 type metricName string const ( DroppedPackets metricName = "fw_bouncer_dropped_packets" DroppedBytes metricName = "fw_bouncer_dropped_bytes" ProcessedPackets metricName = "fw_bouncer_processed_packets" ProcessedBytes metricName = "fw_bouncer_processed_bytes" ActiveBannedIPs metricName = "fw_bouncer_banned_ips" ) type backendCollector interface { CollectMetrics() } type Handler struct { Backend backendCollector } type metricConfig struct { Name string Unit string Gauge *prometheus.GaugeVec LabelKeys []string LastValueMap map[string]float64 // keep last value to send deltas -- nil if absolute KeyFunc func(labels []*io_prometheus_client.LabelPair) string } type metricMap map[metricName]*metricConfig func (m metricMap) MustRegisterAll() { for _, met := range m { prometheus.MustRegister(met.Gauge) } } var Map = metricMap{ ActiveBannedIPs: { Name: "active_decisions", Unit: "ip", Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: string(ActiveBannedIPs), Help: "Denotes the number of IPs which are currently banned", }, []string{"origin", "ip_type"}), LabelKeys: []string{"origin", "ip_type"}, LastValueMap: nil, KeyFunc: func([]*io_prometheus_client.LabelPair) string { return "" }, }, DroppedBytes: { Name: "dropped", Unit: "byte", Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: string(DroppedBytes), Help: "Denotes the number of total dropped bytes because of rule(s) created by crowdsec", }, []string{"origin", "ip_type"}), LabelKeys: []string{"origin", "ip_type"}, LastValueMap: make(map[string]float64), KeyFunc: func(labels []*io_prometheus_client.LabelPair) string { return getLabelValue(labels, "origin") + getLabelValue(labels, "ip_type") }, }, DroppedPackets: { Name: "dropped", Unit: "packet", Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: string(DroppedPackets), Help: "Denotes the number of total dropped packets because of rule(s) created by crowdsec", }, []string{"origin", "ip_type"}), LabelKeys: []string{"origin", "ip_type"}, LastValueMap: make(map[string]float64), KeyFunc: func(labels []*io_prometheus_client.LabelPair) string { return getLabelValue(labels, "origin") + getLabelValue(labels, "ip_type") }, }, ProcessedBytes: { Name: "processed", Unit: "byte", Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: string(ProcessedBytes), Help: "Denotes the number of total processed bytes by the rules created by crowdsec", }, []string{"ip_type"}), LabelKeys: []string{"ip_type"}, LastValueMap: make(map[string]float64), KeyFunc: func(labels []*io_prometheus_client.LabelPair) string { return getLabelValue(labels, "ip_type") }, }, ProcessedPackets: { Name: "processed", Unit: "packet", Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: string(ProcessedPackets), Help: "Denotes the number of total processed packets by the rules created by crowdsec", }, []string{"ip_type"}), LabelKeys: []string{"ip_type"}, LastValueMap: make(map[string]float64), KeyFunc: func(labels []*io_prometheus_client.LabelPair) string { return getLabelValue(labels, "ip_type") }, }, } func getLabelValue(labels []*io_prometheus_client.LabelPair, key string) string { for _, label := range labels { if label.GetName() == key { return label.GetValue() } } return "" } // MetricsUpdater receives a metrics struct with basic data and populates it with the current metrics. func (m Handler) MetricsUpdater(met *models.RemediationComponentsMetrics, updateInterval time.Duration) { log.Debugf("Updating metrics") m.Backend.CollectMetrics() // Most of the common fields are set automatically by the metrics provider // We only need to care about the metrics themselves promMetrics, err := prometheus.DefaultGatherer.Gather() if err != nil { log.Errorf("unable to gather prometheus metrics: %s", err) return } met.Metrics = append(met.Metrics, &models.DetailedMetrics{ Meta: &models.MetricsMeta{ UtcNowTimestamp: ptr.Of(time.Now().Unix()), WindowSizeSeconds: ptr.Of(int64(updateInterval.Seconds())), }, Items: make([]*models.MetricsDetailItem, 0), }) for _, metricFamily := range promMetrics { cfg, ok := Map[metricName(metricFamily.GetName())] if !ok { continue } for _, metric := range metricFamily.GetMetric() { labels := metric.GetLabel() value := metric.GetGauge().GetValue() labelMap := make(map[string]string) for _, key := range cfg.LabelKeys { labelMap[key] = getLabelValue(labels, key) } finalValue := value if cfg.LastValueMap == nil { // always send absolute values log.Debugf("Sending %s for %+v %f", cfg.Name, labelMap, finalValue) } else { // the final value to send must be relative, and never negative // because the firewall counter may have been reset since last collection. key := cfg.KeyFunc(labels) // no need to guard access to LastValueMap, as we are in the main thread -- it's // the gauge that is updated by the requests finalValue = value - cfg.LastValueMap[key] if finalValue < 0 { finalValue = -finalValue log.Warningf("metric value for %s %+v is negative, assuming external counter was reset", cfg.Name, labelMap) } cfg.LastValueMap[key] = value log.Debugf("Sending %s for %+v %f | current value: %f | previous value: %f", cfg.Name, labelMap, finalValue, value, cfg.LastValueMap[key]) } met.Metrics[0].Items = append(met.Metrics[0].Items, &models.MetricsDetailItem{ Name: ptr.Of(cfg.Name), Value: &finalValue, Labels: labelMap, Unit: ptr.Of(cfg.Unit), }) } } } func (m Handler) ComputeMetricsHandler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { m.Backend.CollectMetrics() next.ServeHTTP(w, r) }) } ================================================ FILE: pkg/nftables/metrics.go ================================================ //go:build linux package nftables import ( "fmt" "strings" "time" "github.com/google/nftables" "github.com/google/nftables/expr" "github.com/prometheus/client_golang/prometheus" log "github.com/sirupsen/logrus" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics" ) func (c *nftContext) collectDroppedPackets() (map[string]uint64, map[string]uint64, uint64, uint64, error) { droppedPackets := make(map[string]uint64) droppedBytes := make(map[string]uint64) processedPackets := uint64(0) processedBytes := uint64(0) objs, err := c.conn.GetNamedObjects(c.table) if err != nil { return nil, nil, 0, 0, fmt.Errorf("can't get named objects for table %s: %w", c.table.Name, err) } for _, obj := range objs { o, ok := obj.(*nftables.NamedObj) if !ok { continue } if o.Type != nftables.ObjTypeCounter { continue } counterObj, ok := o.Obj.(*expr.Counter) if !ok { continue } if o.Name == "processed" { processedPackets = counterObj.Packets processedBytes = counterObj.Bytes continue } origin, found := strings.CutPrefix(o.Name, c.blacklists+"-") if !found || origin == "" { continue } droppedPackets[origin] += counterObj.Packets droppedBytes[origin] += counterObj.Bytes } return droppedPackets, droppedBytes, processedPackets, processedBytes, nil } func (c *nftContext) collectActiveBannedIPs() (map[string]int, error) { // Find the size of the set we have created ret := make(map[string]int) for origin, set := range c.sets { setContent, err := c.conn.GetSetElements(set) if err != nil { return nil, fmt.Errorf("can't get set elements for %s: %w", set.Name, err) } if c.setOnly { ret[c.blacklists] = len(setContent) } else { ret[origin] = len(setContent) } return ret, nil } return ret, nil } func (c *nftContext) collectDropped() (map[string]uint64, map[string]uint64, uint64, uint64, map[string]int) { if c.conn == nil { return nil, nil, 0, 0, nil } droppedPackets, droppedBytes, processedPackets, processedBytes, err := c.collectDroppedPackets() if err != nil { log.Errorf("can't collect dropped packets for ip%s from nft: %s", c.version, err) } banned, err := c.collectActiveBannedIPs() if err != nil { log.Errorf("can't collect total banned IPs for ip%s from nft: %s", c.version, err) } return droppedPackets, droppedBytes, processedPackets, processedBytes, banned } func getOriginForList(origin string) string { if !strings.HasPrefix(origin, "lists-") { return origin } return strings.Replace(origin, "-", ":", 1) } func (n *nft) CollectMetrics() { startTime := time.Now() ip4DroppedPackets, ip4DroppedBytes, ip4ProcessedPackets, ip4ProcessedBytes, bannedIP4 := n.v4.collectDropped() ip6DroppedPackets, ip6DroppedBytes, ip6ProcessedPackets, ip6ProcessedBytes, bannedIP6 := n.v6.collectDropped() log.Debugf("metrics collection took %s", time.Since(startTime)) log.Debugf("ip4: dropped packets: %+v, dropped bytes: %+v, banned IPs: %+v, proccessed packets: %d, processed bytes: %d", ip4DroppedPackets, ip4DroppedBytes, bannedIP4, ip4ProcessedPackets, ip4ProcessedBytes) log.Debugf("ip6: dropped packets: %+v, dropped bytes: %+v, banned IPs: %+v, proccessed packets: %d, processed bytes: %d", ip6DroppedPackets, ip6DroppedBytes, bannedIP6, ip6ProcessedPackets, ip6ProcessedBytes) metrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv4"}).Set(float64(ip4ProcessedPackets)) metrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv4"}).Set(float64(ip4ProcessedBytes)) metrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv6"}).Set(float64(ip6ProcessedPackets)) metrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv6"}).Set(float64(ip6ProcessedBytes)) for origin, count := range bannedIP4 { origin = getOriginForList(origin) metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv4"}).Set(float64(count)) } for origin, count := range bannedIP6 { origin = getOriginForList(origin) metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv6"}).Set(float64(count)) } for origin, count := range ip4DroppedPackets { origin = getOriginForList(origin) metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv4"}).Set(float64(count)) } for origin, count := range ip6DroppedPackets { origin = getOriginForList(origin) metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv6"}).Set(float64(count)) } for origin, count := range ip4DroppedBytes { origin = getOriginForList(origin) metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv4"}).Set(float64(count)) } for origin, count := range ip6DroppedBytes { origin = getOriginForList(origin) metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv6"}).Set(float64(count)) } } ================================================ FILE: pkg/nftables/nftables.go ================================================ //go:build linux package nftables import ( "fmt" "net" "strings" "time" "github.com/google/nftables" "github.com/google/nftables/binaryutil" log "github.com/sirupsen/logrus" "github.com/crowdsecurity/crowdsec/pkg/models" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" ) const ( chunkSize = 200 defaultTimeout = "4h" ) type nft struct { v4 *nftContext v6 *nftContext decisionsToAdd []*models.Decision decisionsToDelete []*models.Decision DenyAction string DenyLog bool DenyLogPrefix string Hooks []string } func NewNFTables(config *cfg.BouncerConfig) (*nft, error) { ret := &nft{ v4: NewNFTV4Context(config), v6: NewNFTV6Context(config), DenyAction: config.DenyAction, DenyLog: config.DenyLog, DenyLogPrefix: config.DenyLogPrefix, Hooks: config.NftablesHooks, } return ret, nil } func (n *nft) Init() error { log.Debug("nftables: Init()") if err := n.v4.init(n.Hooks); err != nil { return err } if err := n.v6.init(n.Hooks); err != nil { return err } log.Infof("nftables initiated") return nil } func (n *nft) Add(decision *models.Decision) error { n.decisionsToAdd = append(n.decisionsToAdd, decision) return nil } func (n *nft) getBannedState() (map[string]struct{}, error) { banned := make(map[string]struct{}) if err := n.v4.setBanned(banned); err != nil { return nil, err } if err := n.v6.setBanned(banned); err != nil { return nil, err } return banned, nil } func (n *nft) reset() { n.decisionsToAdd = make([]*models.Decision, 0) n.decisionsToDelete = make([]*models.Decision, 0) } func (n *nft) commitDeletedDecisions() error { banned, err := n.getBannedState() if err != nil { return fmt.Errorf("failed to get current state: %w", err) } ip4 := []nftables.SetElement{} ip6 := []nftables.SetElement{} n.decisionsToDelete = normalizedDecisions(n.decisionsToDelete) for _, decision := range n.decisionsToDelete { ip := net.ParseIP(*decision.Value) if _, ok := banned[ip.String()]; !ok { log.Debugf("not deleting %s since it's not in the set", ip) continue } if strings.Contains(ip.String(), ":") { if n.v6.conn != nil { log.Tracef("adding %s to buffer", ip) ip6 = append(ip6, nftables.SetElement{Key: ip.To16()}) } continue } if n.v4.conn != nil { log.Tracef("adding %s to buffer", ip) ip4 = append(ip4, nftables.SetElement{Key: ip.To4()}) } } if len(ip4) > 0 { log.Debugf("removing %d ip%s elements from set", len(ip4), n.v4.version) if err := n.v4.deleteElements(ip4); err != nil { return err } } if len(ip6) > 0 { log.Debugf("removing %d ip%s elements from set", len(ip6), n.v6.version) if err := n.v6.deleteElements(ip6); err != nil { return err } } return nil } func (n *nft) createSetAndRuleForOrigin(ctx *nftContext, origin string) error { if _, ok := ctx.sets[origin]; !ok { // First time we see this origin, create the rule/set for all hooks set := &nftables.Set{ Name: fmt.Sprintf("%s-%s", ctx.blacklists, origin), Table: ctx.table, KeyType: ctx.typeIPAddr, KeyByteOrder: binaryutil.BigEndian, HasTimeout: true, } ctx.sets[origin] = set if err := ctx.conn.AddSet(set, []nftables.SetElement{}); err != nil { return err } for _, chain := range ctx.chains { rule, err := ctx.createRule(chain, set, n.DenyLog, n.DenyLogPrefix, n.DenyAction) if err != nil { return err } ctx.conn.AddRule(rule) log.Infof("Created set and rule for origin %s and type %s in chain %s", origin, ctx.typeIPAddr.Name, chain.Name) } } return nil } func (n *nft) commitAddedDecisions() error { banned, err := n.getBannedState() if err != nil { return fmt.Errorf("failed to get current state: %w", err) } ip4 := make(map[string][]nftables.SetElement, 0) ip6 := make(map[string][]nftables.SetElement, 0) n.decisionsToAdd = normalizedDecisions(n.decisionsToAdd) for _, decision := range n.decisionsToAdd { ip := net.ParseIP(*decision.Value) if _, ok := banned[ip.String()]; ok { log.Debugf("not adding %s since it's already in the set", ip) continue } t, _ := time.ParseDuration(*decision.Duration) origin := *decision.Origin if origin == "lists" { origin = origin + "-" + *decision.Scenario } if strings.Contains(ip.String(), ":") { if n.v6.conn != nil { if n.v6.setOnly { origin = n.v6.blacklists } log.Tracef("adding %s to buffer", ip) if _, ok := ip6[origin]; !ok { ip6[origin] = make([]nftables.SetElement, 0) } ip6[origin] = append(ip6[origin], nftables.SetElement{Timeout: t, Key: ip.To16()}) if !n.v6.setOnly { err := n.createSetAndRuleForOrigin(n.v6, origin) if err != nil { return err } } } continue } if n.v4.conn != nil { if n.v4.setOnly { origin = n.v4.blacklists } log.Tracef("adding %s to buffer", ip) if _, ok := ip4[origin]; !ok { ip4[origin] = make([]nftables.SetElement, 0) } ip4[origin] = append(ip4[origin], nftables.SetElement{Timeout: t, Key: ip.To4()}) if !n.v4.setOnly { err := n.createSetAndRuleForOrigin(n.v4, origin) if err != nil { return err } } } } if err := n.v4.addElements(ip4); err != nil { return err } return n.v6.addElements(ip6) } func (n *nft) Commit() error { defer n.reset() if err := n.commitDeletedDecisions(); err != nil { return err } return n.commitAddedDecisions() } type tmpDecisions struct { duration time.Duration origin string scenario string } // remove duplicates, normalize decision timeouts, keep the longest decision when dups are present. func normalizedDecisions(decisions []*models.Decision) []*models.Decision { vals := make(map[string]tmpDecisions) finalDecisions := make([]*models.Decision, 0) for _, d := range decisions { t, err := time.ParseDuration(*d.Duration) if err != nil { t, _ = time.ParseDuration(defaultTimeout) } *d.Value = strings.Split(*d.Value, "/")[0] if longest, ok := vals[*d.Value]; !ok || t > longest.duration { vals[*d.Value] = tmpDecisions{ duration: t, origin: *d.Origin, scenario: *d.Scenario, } } } for ip, decision := range vals { d := decision.duration.String() i := ip // copy it because we don't same value for all decisions as `ip` is same pointer :) origin := decision.origin scenario := decision.scenario finalDecisions = append(finalDecisions, &models.Decision{ Duration: &d, Value: &i, Origin: &origin, Scenario: &scenario, }) } return finalDecisions } func (n *nft) Delete(decision *models.Decision) error { n.decisionsToDelete = append(n.decisionsToDelete, decision) return nil } func (n *nft) ShutDown() error { if err := n.v4.shutDown(); err != nil { return err } return n.v6.shutDown() } ================================================ FILE: pkg/nftables/nftables_context.go ================================================ //go:build linux package nftables import ( "fmt" "net" "strings" "github.com/google/nftables" "github.com/google/nftables/binaryutil" "github.com/google/nftables/expr" log "github.com/sirupsen/logrus" "golang.org/x/sys/unix" "github.com/crowdsecurity/go-cs-lib/slicetools" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" ) var HookNameToHookID = map[string]nftables.ChainHook{ "prerouting": *nftables.ChainHookPrerouting, "input": *nftables.ChainHookInput, "forward": *nftables.ChainHookForward, "output": *nftables.ChainHookOutput, "postrouting": *nftables.ChainHookPostrouting, "ingress": *nftables.ChainHookIngress, } type nftContext struct { chains map[string]*nftables.Chain conn *nftables.Conn sets map[string]*nftables.Set table *nftables.Table tableFamily nftables.TableFamily typeIPAddr nftables.SetDatatype version string payloadOffset uint32 payloadLength uint32 priority int blacklists string chainName string tableName string setOnly bool } // convert a binary representation of an IP (4 or 16 bytes) to a string. func reprIP(ip []byte) string { return net.IP(ip).String() } func NewNFTV4Context(config *cfg.BouncerConfig) *nftContext { if !*config.Nftables.Ipv4.Enabled { log.Debug("nftables: ipv4 disabled") return &nftContext{} } log.Debug("nftables: ipv4 enabled") ret := &nftContext{ conn: &nftables.Conn{}, version: "v4", tableFamily: nftables.TableFamilyIPv4, typeIPAddr: nftables.TypeIPAddr, payloadOffset: 12, payloadLength: 4, tableName: config.Nftables.Ipv4.Table, chainName: config.Nftables.Ipv4.Chain, blacklists: config.BlacklistsIpv4, setOnly: config.Nftables.Ipv4.SetOnly, priority: config.Nftables.Ipv4.Priority, sets: make(map[string]*nftables.Set), } log.Debugf("nftables: ipv4: %t, table: %s, chain: %s, blacklist: %s, set-only: %t", *config.Nftables.Ipv4.Enabled, ret.tableName, ret.chainName, ret.blacklists, ret.setOnly) return ret } func NewNFTV6Context(config *cfg.BouncerConfig) *nftContext { if !*config.Nftables.Ipv6.Enabled { log.Debug("nftables: ipv6 disabled") return &nftContext{} } log.Debug("nftables: ipv6 enabled") ret := &nftContext{ conn: &nftables.Conn{}, version: "v6", tableFamily: nftables.TableFamilyIPv6, typeIPAddr: nftables.TypeIP6Addr, payloadOffset: 8, payloadLength: 16, tableName: config.Nftables.Ipv6.Table, chainName: config.Nftables.Ipv6.Chain, blacklists: config.BlacklistsIpv6, setOnly: config.Nftables.Ipv6.SetOnly, priority: config.Nftables.Ipv6.Priority, sets: make(map[string]*nftables.Set), } log.Debugf("nftables: ipv6: %t, table6: %s, chain6: %s, blacklist: %s, set-only6: %t", *config.Nftables.Ipv6.Enabled, ret.tableName, ret.chainName, ret.blacklists, ret.setOnly) return ret } // setBanned retrieves the list of banned IPs from the nftables set and adds them to the banned map. func (c *nftContext) setBanned(banned map[string]struct{}) error { if c.conn == nil { return nil } for _, set := range c.sets { elements, err := c.conn.GetSetElements(set) if err != nil { return err } for i := range elements { banned[net.IP(elements[i].Key).String()] = struct{}{} } } return nil } func (c *nftContext) initSetOnly() error { var err error // Use existing nftables configuration log.Debugf("nftables: ip%s set-only", c.version) c.table, err = c.lookupTable() if err != nil { return err } set, err := c.conn.GetSetByName(c.table, c.blacklists) if err != nil { log.Debugf("nftables: could not find ip%s blacklist '%s' in table '%s': creating...", c.version, c.blacklists, c.tableName) set = &nftables.Set{ Name: c.blacklists, Table: c.table, KeyType: c.typeIPAddr, KeyByteOrder: binaryutil.BigEndian, HasTimeout: true, } if err := c.conn.AddSet(set, []nftables.SetElement{}); err != nil { return err } if err := c.conn.Flush(); err != nil { return err } } c.sets[c.blacklists] = set log.Debugf("nftables: ip%s set '%s' configured", c.version, c.blacklists) return nil } func (c *nftContext) initOwnTable(hooks []string) error { log.Debugf("nftables: ip%s own table", c.version) c.table = c.conn.AddTable(&nftables.Table{ Family: c.tableFamily, Name: c.tableName, }) for _, hook := range hooks { hooknum := HookNameToHookID[hook] priority := nftables.ChainPriority(c.priority) chain := c.conn.AddChain(&nftables.Chain{ Name: c.chainName + "-" + hook, Table: c.table, Type: nftables.ChainTypeFilter, Hooknum: &hooknum, Priority: &priority, }) namedCounter := nftables.NamedObj{ Table: c.table, Name: "processed", Type: nftables.ObjTypeCounter, Obj: &expr.Counter{}, } c.conn.AddObject(&namedCounter) // We flush here because we need to create a reference to the counter in the rule err := c.conn.Flush() if err != nil { return fmt.Errorf("nftables: failed to flush conn: %w", err) } r := &nftables.Rule{ Table: c.table, Chain: chain, Exprs: []expr.Any{ &expr.Objref{ Type: int(nftables.ObjTypeCounter), // The nftables library does use the ObjType enum here, just cast it Name: namedCounter.Name, }, }, } c.conn.AddRule(r) c.chains[hook] = chain log.Debugf("nftables: ip%s chain '%s' created", c.version, chain.Name) // Rules and sets are created on the fly when we detect a new origin } if err := c.conn.Flush(); err != nil { return err } log.Debugf("nftables: ip%s table created", c.version) return nil } func (c *nftContext) init(hooks []string) error { if c.conn == nil { return nil } if c.chains == nil { c.chains = make(map[string]*nftables.Chain) } log.Debugf("nftables: ip%s init starting", c.version) var err error if c.setOnly { err = c.initSetOnly() } else { err = c.initOwnTable(hooks) } if err != nil && strings.Contains(err.Error(), "out of range") { return fmt.Errorf("nftables: %w. Please check the name length of tables, sets and chains. "+ "Some legacy systems have 32 or 15 character limits. "+ "For example, use 'crowdsec-set' instead of 'crowdsec-blacklists'", err) } return err } func (c *nftContext) lookupTable() (*nftables.Table, error) { tables, err := c.conn.ListTables() if err != nil { return nil, err } for _, t := range tables { if t.Name == c.tableName { return t, nil } } return nil, fmt.Errorf("nftables: could not find table '%s'", c.tableName) } func (c *nftContext) createRule(chain *nftables.Chain, set *nftables.Set, denyLog bool, denyLogPrefix string, denyAction string, ) (*nftables.Rule, error) { namedCounter := nftables.NamedObj{ Table: c.table, Name: set.Name, Type: nftables.ObjTypeCounter, Obj: &expr.Counter{}, } c.conn.AddObject(&namedCounter) // We flush here because we need to create a reference to the counter in the rule if err := c.conn.Flush(); err != nil { return nil, fmt.Errorf("nftables: failed to flush conn: %w", err) } r := &nftables.Rule{ Table: c.table, Chain: chain, Exprs: []expr.Any{}, UserData: []byte(set.Name), } // [ payload load 4b @ network header + 16 => reg 1 ] r.Exprs = append(r.Exprs, &expr.Payload{ DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: c.payloadOffset, Len: c.payloadLength, }) // [ lookup reg 1 set whitelist ] r.Exprs = append(r.Exprs, &expr.Lookup{ SourceRegister: 1, SetName: set.Name, SetID: set.ID, }) r.Exprs = append(r.Exprs, &expr.Objref{ Type: int(nftables.ObjTypeCounter), // The nftables library does use the ObjType enum here, just cast it Name: namedCounter.Name, }) if denyLog { r.Exprs = append(r.Exprs, &expr.Log{ Key: 1 << unix.NFTA_LOG_PREFIX, Data: []byte(denyLogPrefix), }) } action := strings.ToUpper(denyAction) if action == "" { action = "DROP" } switch action { case "DROP": r.Exprs = append(r.Exprs, &expr.Verdict{ Kind: expr.VerdictDrop, }) case "REJECT": r.Exprs = append(r.Exprs, &expr.Reject{ Type: unix.NFT_REJECT_ICMP_UNREACH, Code: unix.NFT_REJECT_ICMPX_ADMIN_PROHIBITED, }) default: return nil, fmt.Errorf("invalid deny_action '%s', must be one of DROP, REJECT", action) } log.Tracef("using '%s' as deny_action", action) return r, nil } func (c *nftContext) deleteElementChunk(els []nftables.SetElement) error { // FIXME: only delete IPs from the set they are in // But this could lead to strange behavior if we have duplicate decisions with different origins for _, set := range c.sets { log.Debugf("removing %d ip%s elements from set %s", len(els), c.version, set.Name) if err := c.conn.SetDeleteElements(set, els); err != nil { return fmt.Errorf("failed to remove ip%s elements from set: %w", c.version, err) } if err := c.conn.Flush(); err != nil { if len(els) == 1 { log.Debugf("deleting %s, failed to flush: %s", reprIP(els[0].Key), err) continue } log.Debugf("failed to flush chunk of %d elements, will retry each one: %s", len(els), err) for i := range els { if err := c.deleteElementChunk([]nftables.SetElement{els[i]}); err != nil { return err } } } } return nil } func (c *nftContext) deleteElements(els []nftables.SetElement) error { if len(els) <= chunkSize { return c.deleteElementChunk(els) } log.Debugf("splitting %d elements into chunks of %d", len(els), chunkSize) for _, chunk := range slicetools.Chunks(els, chunkSize) { if err := c.deleteElementChunk(chunk); err != nil { return err } } return nil } func (c *nftContext) addElements(els map[string][]nftables.SetElement) error { var setName string for origin, set := range c.sets { if c.setOnly { setName = c.blacklists } else { setName = fmt.Sprintf("%s-%s", c.blacklists, origin) } log.Debugf("Using %s as origin | len of IPs: %d | set name is %s", origin, len(els[origin]), setName) for _, chunk := range slicetools.Chunks(els[origin], chunkSize) { log.Debugf("adding %d ip%s elements to set %s", len(chunk), c.version, setName) if err := c.conn.SetAddElements(set, chunk); err != nil { return fmt.Errorf("failed to add ip%s elements to set: %w", c.version, err) } if err := c.conn.Flush(); err != nil { return fmt.Errorf("failed to flush ip%s conn: %w", c.version, err) } } } return nil } func (c *nftContext) shutDown() error { if c.conn == nil { return nil } if c.setOnly { // Flush blacklist4 set empty log.Infof("flushing '%s' set in '%s' table", c.sets[c.blacklists].Name, c.table.Name) c.conn.FlushSet(c.sets[c.blacklists]) } else { log.Infof("removing '%s' table", c.table.Name) c.conn.DelTable(c.table) } return c.conn.Flush() } ================================================ FILE: pkg/nftables/nftables_stub.go ================================================ //go:build !linux package nftables import ( "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types" ) func NewNFTables(config *cfg.BouncerConfig) (types.Backend, error) { return nil, nil } ================================================ FILE: pkg/pf/metrics.go ================================================ package pf import ( "bufio" "regexp" "slices" "strconv" "strings" "github.com/prometheus/client_golang/prometheus" log "github.com/sirupsen/logrus" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics" ) type counter struct { packets uint64 bytes uint64 } var ( // table names can contain _ or - characters. rexpTable = regexp.MustCompile(`^block .* from <(?P[^ ]+)> .*"$`) rexpMetrics = regexp.MustCompile(`^\s+\[.*Packets: (?P\d+)\s+Bytes: (?P\d+).*\]$`) ) func parseMetrics(reader *strings.Reader, tables []string) map[string]counter { ret := make(map[string]counter) // scan until we find a table name between <> scanner := bufio.NewScanner(reader) for scanner.Scan() { line := scanner.Text() // parse the line and extract the table name match := rexpTable.FindStringSubmatch(line) if len(match) == 0 { continue } table := match[1] // if the table is not in the list of tables we want to parse, skip it if !slices.Contains(tables, table) { continue } // parse the line with the actual metrics if !scanner.Scan() { break } line = scanner.Text() match = rexpMetrics.FindStringSubmatch(line) if len(match) == 0 { log.Errorf("failed to parse metrics: %s", line) continue } packets, err := strconv.ParseUint(match[1], 10, 64) if err != nil { log.Errorf("failed to parse metrics - dropped packets: %s", err) packets = 0 } bytes, err := strconv.ParseUint(match[2], 10, 64) if err != nil { log.Errorf("failed to parse metrics - dropped bytes: %s", err) bytes = 0 } ret[table] = counter{ packets: packets, bytes: bytes, } } return ret } // countIPs returns the number of IPs in a table. func countIPs(table string) int { cmd := execPfctl("", "-T", "show", "-t", table) out, err := cmd.Output() if err != nil { log.Errorf("failed to run 'pfctl -T show -t %s': %s", table, err) return 0 } // one IP per line return strings.Count(string(out), "\n") } // CollectMetrics collects metrics from pfctl. // In pf mode the firewall rules are not controlled by the bouncer, so we can only // trust they are set up correctly, and retrieve stats from the pfctl tables. func (pf *pf) CollectMetrics() { tables := []string{} if pf.inet != nil { tables = append(tables, pf.inet.table) } if pf.inet6 != nil { tables = append(tables, pf.inet6.table) } cmd := execPfctl("", "-v", "-sr") out, err := cmd.Output() if err != nil { log.Errorf("failed to run 'pfctl -v -sr': %s", err) return } reader := strings.NewReader(string(out)) stats := parseMetrics(reader, tables) for _, table := range tables { st, ok := stats[table] if !ok { continue } droppedPackets := float64(st.packets) droppedBytes := float64(st.bytes) bannedIPs := countIPs(table) if pf.inet != nil && table == pf.inet.table { metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": ""}).Set(droppedPackets) metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": ""}).Set(droppedBytes) metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": ""}).Set(float64(bannedIPs)) } else if pf.inet6 != nil && table == pf.inet6.table { metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": ""}).Set(droppedPackets) metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": ""}).Set(droppedBytes) metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": ""}).Set(float64(bannedIPs)) } } } ================================================ FILE: pkg/pf/metrics_test.go ================================================ package pf import ( "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestParseMetrics(t *testing.T) { metricsInput := `block drop in quick inet from to any label "CrowdSec IPv4" [ Evaluations: 1519 Packets: 16 Bytes: 4096 States: 0 ] [ Inserted: uid 0 pid 14219 State Creations: 0 ] block drop in quick inet6 from to any label "CrowdSec IPv6" [ Evaluations: 914 Packets: 8 Bytes: 2048 States: 0 ] [ Inserted: uid 0 pid 14219 State Creations: 0 ]` reader := strings.NewReader(metricsInput) tables := []string{"crowdsec_blacklists", "crowdsec6_blacklists"} metrics := parseMetrics(reader, tables) require.Contains(t, metrics, "crowdsec_blacklists") require.Contains(t, metrics, "crowdsec6_blacklists") ip4Metrics := metrics["crowdsec_blacklists"] assert.Equal(t, uint64(16), ip4Metrics.packets) assert.Equal(t, uint64(4096), ip4Metrics.bytes) ip6Metrics := metrics["crowdsec6_blacklists"] assert.Equal(t, uint64(8), ip6Metrics.packets) assert.Equal(t, uint64(2048), ip6Metrics.bytes) } ================================================ FILE: pkg/pf/pf.go ================================================ package pf import ( "fmt" "os" "os/exec" "strings" log "github.com/sirupsen/logrus" "github.com/crowdsecurity/crowdsec/pkg/models" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types" ) type pf struct { inet *pfContext inet6 *pfContext decisionsToAdd []*models.Decision decisionsToDelete []*models.Decision } const ( pfctlCmd = "/sbin/pfctl" pfDevice = "/dev/pf" ) func NewPF(config *cfg.BouncerConfig) (types.Backend, error) { ret := &pf{} inetCtx := &pfContext{ table: config.BlacklistsIpv4, proto: "inet", anchor: config.PF.AnchorName, version: "ipv4", } inet6Ctx := &pfContext{ table: config.BlacklistsIpv6, proto: "inet6", anchor: config.PF.AnchorName, version: "ipv6", } if !config.DisableIPV4 { ret.inet = inetCtx } if !config.DisableIPV6 { ret.inet6 = inet6Ctx } return ret, nil } // execPfctl runs a pfctl command by prepending the anchor name if needed. // Some commands return an error if an anchor is specified. func execPfctl(anchor string, arg ...string) *exec.Cmd { if anchor != "" { arg = append([]string{"-a", anchor}, arg...) } log.Debugf("Running: %s %s", pfctlCmd, arg) return exec.Command(pfctlCmd, arg...) } func (pf *pf) Init() error { if _, err := os.Stat(pfDevice); err != nil { return fmt.Errorf("%s device not found: %w", pfDevice, err) } if _, err := exec.LookPath(pfctlCmd); err != nil { return fmt.Errorf("%s command not found: %w", pfctlCmd, err) } if pf.inet != nil { if err := pf.inet.init(); err != nil { return err } } if pf.inet6 != nil { if err := pf.inet6.init(); err != nil { return err } } return nil } func (pf *pf) Commit() error { defer pf.reset() if err := pf.commitDeletedDecisions(); err != nil { return err } return pf.commitAddedDecisions() } func (pf *pf) Add(decision *models.Decision) error { pf.decisionsToAdd = append(pf.decisionsToAdd, decision) return nil } func (pf *pf) reset() { pf.decisionsToAdd = make([]*models.Decision, 0) pf.decisionsToDelete = make([]*models.Decision, 0) } func (pf *pf) commitDeletedDecisions() error { ipv4decisions := make([]*models.Decision, 0) ipv6decisions := make([]*models.Decision, 0) for _, d := range pf.decisionsToDelete { if strings.Contains(*d.Value, ":") && pf.inet6 != nil { ipv6decisions = append(ipv6decisions, d) } else if pf.inet != nil { ipv4decisions = append(ipv4decisions, d) } } if len(ipv6decisions) > 0 { if pf.inet6 == nil { log.Debugf("not removing '%d' decisions because ipv6 is disabled", len(ipv6decisions)) } else if err := pf.inet6.delete(ipv6decisions); err != nil { return err } } if len(ipv4decisions) > 0 { if pf.inet == nil { log.Debugf("not removing '%d' decisions because ipv4 is disabled", len(ipv4decisions)) } else if err := pf.inet.delete(ipv4decisions); err != nil { return err } } return nil } func (pf *pf) commitAddedDecisions() error { ipv4decisions := make([]*models.Decision, 0) ipv6decisions := make([]*models.Decision, 0) for _, d := range pf.decisionsToAdd { if strings.Contains(*d.Value, ":") && pf.inet6 != nil { ipv6decisions = append(ipv6decisions, d) } else if pf.inet != nil { ipv4decisions = append(ipv4decisions, d) } } if len(ipv6decisions) > 0 { if pf.inet6 == nil { log.Debugf("not adding '%d' decisions because ipv6 is disabled", len(ipv6decisions)) } else if err := pf.inet6.add(ipv6decisions); err != nil { return err } } if len(ipv4decisions) > 0 { if pf.inet == nil { log.Debugf("not adding '%d' decisions because ipv4 is disabled", len(ipv4decisions)) } else if err := pf.inet.add(ipv4decisions); err != nil { return err } } return nil } func (pf *pf) Delete(decision *models.Decision) error { pf.decisionsToDelete = append(pf.decisionsToDelete, decision) return nil } func (pf *pf) ShutDown() error { log.Infof("flushing 'crowdsec' table(s)") if pf.inet != nil { if err := pf.inet.shutDown(); err != nil { return fmt.Errorf("unable to flush %s table (%s): ", pf.inet.version, pf.inet.table) } } if pf.inet6 != nil { if err := pf.inet6.shutDown(); err != nil { return fmt.Errorf("unable to flush %s table (%s): ", pf.inet6.version, pf.inet6.table) } } return nil } ================================================ FILE: pkg/pf/pf_context.go ================================================ package pf import ( "bufio" "fmt" "maps" "os" "os/exec" "slices" "strings" log "github.com/sirupsen/logrus" "github.com/crowdsecurity/crowdsec/pkg/models" ) type pfContext struct { proto string anchor string table string version string } const backendName = "pf" func decisionsToIPs(decisions []*models.Decision) []string { ips := make([]string, 0, len(decisions)) for i, d := range decisions { if d == nil || d.Value == nil { continue } ips[i] = *d.Value } return ips } func writeIPsToFile(ips []string) (string, error) { f, err := os.CreateTemp("", "crowdsec-ips-*.txt") if err != nil { return "", err } name := f.Name() done := false defer func() { if !done { _ = f.Close() _ = os.Remove(name) } }() w := bufio.NewWriter(f) for _, ip := range ips { if _, err = w.WriteString(ip + "\n"); err != nil { return "", err } } if err = w.Flush(); err != nil { return "", err } if err = f.Close(); err != nil { return "", err } done = true return name, nil } func (ctx *pfContext) checkTable() error { log.Infof("Checking pf table: %s", ctx.table) cmd := execPfctl(ctx.anchor, "-s", "Tables") out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("pfctl error: %s - %w", out, err) } if !strings.Contains(string(out), ctx.table) { if ctx.anchor != "" { return fmt.Errorf("table %s in anchor %s doesn't exist", ctx.table, ctx.anchor) } return fmt.Errorf("table %s doesn't exist", ctx.table) } return nil } func (ctx *pfContext) shutDown() error { cmd := execPfctl(ctx.anchor, "-t", ctx.table, "-T", "flush") log.Infof("pf table clean-up: %s", cmd) if out, err := cmd.CombinedOutput(); err != nil { log.Errorf("Error while flushing table (%s): %v --> %s", cmd, err, out) } return nil } // getStateIPs returns a list of IPs that are currently in the state table. func getStateIPs() (map[string]bool, error) { ret := make(map[string]bool) cmd := exec.Command(pfctlCmd, "-s", "states") out, err := cmd.Output() if err != nil { return nil, err } scanner := bufio.NewScanner(strings.NewReader(string(out))) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) < 6 { continue } // don't bother to parse the direction, we'll block both anyway // right side ip := fields[4] if strings.Contains(ip, ":") { ip = strings.Split(ip, ":")[0] } ret[ip] = true // left side ip = fields[2] if strings.Contains(ip, ":") { ip = strings.Split(ip, ":")[0] } ret[ip] = true } log.Debugf("Found IPs in state table: %v", len(ret)) return ret, nil } func (ctx *pfContext) add(decisions []*models.Decision) error { log.Debugf("Adding %d decisions", len(decisions)) ips := decisionsToIPs(decisions) file, err := writeIPsToFile(ips) if err != nil { return fmt.Errorf("writing decisions to temp file: %w", err) } defer os.Remove(file) cmd := execPfctl(ctx.anchor, "-t", ctx.table, "-T", "add", "-f", file) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("error while adding to table (%s): %w --> %s", cmd, err, out) } bannedIPs := make(map[string]bool, len(ips)) for _, ip := range ips { bannedIPs[ip] = true } if len(bannedIPs) == 0 { log.Debugf("No new banned IPs") return nil } if log.IsLevelEnabled(log.DebugLevel) { keys := slices.Collect(maps.Keys(bannedIPs)) slices.Sort(keys) log.Debugf("New banned IPs: %v", keys) } stateIPs, err := getStateIPs() if err != nil { return fmt.Errorf("error while getting state IPs: %w", err) } // Reset the states of connections coming from or going to an IP if it's both in stateIPs and bannedIPs for ip := range bannedIPs { if stateIPs[ip] { // incoming cmd := execPfctl("", "-k", ip) if out, err := cmd.CombinedOutput(); err != nil { log.Errorf("Error while flushing state (%s): %v --> %s", cmd, err, out) } // outgoing cmd = execPfctl("", "-k", "0.0.0.0/0", "-k", ip) if out, err := cmd.CombinedOutput(); err != nil { log.Errorf("Error while flushing state (%s): %v --> %s", cmd, err, out) } } } return nil } func (ctx *pfContext) delete(decisions []*models.Decision) error { log.Debugf("Removing %d decisions", len(decisions)) ips := decisionsToIPs(decisions) file, err := writeIPsToFile(ips) if err != nil { return fmt.Errorf("writing decisions to temp file: %w", err) } defer os.Remove(file) cmd := execPfctl(ctx.anchor, "-t", ctx.table, "-T", "delete", "-f", file) if out, err := cmd.CombinedOutput(); err != nil { log.Infof("Error while deleting from table (%s): %v --> %s", cmd, err, out) } return nil } func (ctx *pfContext) init() error { if err := ctx.shutDown(); err != nil { return fmt.Errorf("pf table flush failed: %w", err) } if err := ctx.checkTable(); err != nil { return fmt.Errorf("pf init failed: %w", err) } log.Infof("%s initiated for %s", backendName, ctx.version) return nil } ================================================ FILE: pkg/types/types.go ================================================ package types import ( "github.com/crowdsecurity/crowdsec/pkg/models" ) type Backend interface { Init() error ShutDown() error Add(decision *models.Decision) error Delete(decision *models.Decision) error Commit() error CollectMetrics() } ================================================ FILE: rpm/SOURCES/80-crowdsec-firewall-bouncer.preset ================================================ # This file is part of crowdsec-firewall-bouncer enable crowdsec-firewall-bouncer.service ================================================ FILE: rpm/SPECS/crowdsec-firewall-bouncer.spec ================================================ Name: crowdsec-firewall-bouncer-iptables Version: %(echo $VERSION) Release: %(echo $PACKAGE_NUMBER)%{?dist} Summary: Firewall bouncer for Crowdsec (iptables+ipset configuration) License: MIT URL: https://crowdsec.net Source0: https://github.com/crowdsecurity/%{name}/archive/v%(echo $VERSION).tar.gz Source1: 80-crowdsec-firewall-bouncer.preset BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) BuildRequires: make %{?fc33:BuildRequires: systemd-rpm-macros} Requires: gettext,iptables,ipset,ipset-libs %define debug_package %{nil} %define version_number %(echo $VERSION) %define releasever %(echo $RELEASEVER) %global local_version v%{version_number}-%{releasever}-rpm %global name crowdsec-firewall-bouncer %global __mangle_shebangs_exclude_from /usr/bin/env %prep %setup -q -T -b 0 -n %{name}-%{version_number} %build BUILD_VERSION=%{local_version} make %install rm -rf %{buildroot} install -m 755 -D %{name} %{buildroot}%{_bindir}/%{name} install -m 600 -D config/%{name}.yaml %{buildroot}/etc/crowdsec/bouncers/%{name}.yaml install -m 600 -D scripts/_bouncer.sh %{buildroot}/usr/lib/%{name}/_bouncer.sh mkdir -p %{buildroot}%{_unitdir} BIN=%{_bindir}/%{name} CFG=/etc/crowdsec/bouncers envsubst '$BIN $CFG' < config/%{name}.service > %{buildroot}%{_unitdir}/%{name}.service install -D -m 644 %{SOURCE1} %{buildroot}%{_presetdir}/80-crowdsec-firewall-bouncer.preset %clean rm -rf %{buildroot} %changelog * Tue Feb 16 2021 Manuel Sabban - First initial packaging # ------------------------------------ # iptables # ------------------------------------ %description -n %{name}-iptables %files -n %{name}-iptables %defattr(-,root,root,-) %{_bindir}/%{name} /usr/lib/%{name}/_bouncer.sh %{_unitdir}/%{name}.service %config(noreplace) /etc/crowdsec/bouncers/%{name}.yaml %config(noreplace) %{_presetdir}/80-crowdsec-firewall-bouncer.preset %post -n %{name}-iptables systemctl daemon-reload . /usr/lib/%{name}/_bouncer.sh START=1 if grep -q '${BACKEND}' "$CONFIG"; then newconfig=$(BACKEND="iptables" envsubst '$BACKEND' < "$CONFIG") (umask 177 && echo "$newconfig" > "$CONFIG") fi if [ "$1" = "1" ]; then if need_api_key; then if ! set_api_key; then START=0 fi fi fi set_local_port if [ ! -e /usr/sbin/crowdsec-firewall-bouncer ]; then if [ ! -L /usr/sbin ]; then ln -s ../bin/crowdsec-firewall-bouncer /usr/sbin/crowdsec-firewall-bouncer fi fi %systemd_post %{name}.service if [ "$START" -eq 0 ]; then 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 else %if 0%{?fc35} systemctl enable "$SERVICE" %endif systemctl start "$SERVICE" fi echo "$BOUNCER has been successfully installed" %preun -n %{name}-iptables . /usr/lib/%{name}/_bouncer.sh if [ "$1" = "0" ]; then systemctl stop "$SERVICE" || echo "cannot stop service" systemctl disable "$SERVICE" || echo "cannot disable service" delete_bouncer fi %postun -n %{name}-iptables if [ "$1" = "1" ]; then systemctl restart %{name} || echo "cannot restart service" fi if [ -L /usr/sbin/crowdsec-firewall-bouncer ]; then rm -f /usr/sbin/crowdsec-firewall-bouncer fi # ------------------------------------ # nftables # ------------------------------------ %package -n %{name}-nftables Summary: Firewall bouncer for Crowdsec (nftables configuration) Requires: nftables,gettext %description -n %{name}-nftables %files -n %{name}-nftables %defattr(-,root,root,-) %{_bindir}/%{name} /usr/lib/%{name}/_bouncer.sh %{_unitdir}/%{name}.service %config(noreplace) /etc/crowdsec/bouncers/%{name}.yaml %config(noreplace) %{_presetdir}/80-crowdsec-firewall-bouncer.preset %post -n %{name}-nftables systemctl daemon-reload . /usr/lib/%{name}/_bouncer.sh START=1 if grep -q '${BACKEND}' "$CONFIG"; then newconfig=$(BACKEND="nftables" envsubst '$BACKEND' < "$CONFIG") (umask 177 && echo "$newconfig" > "$CONFIG") fi if [ "$1" = "1" ]; then if need_api_key; then if ! set_api_key; then START=0 fi fi fi set_local_port if [ ! -e /usr/sbin/crowdsec-firewall-bouncer ]; then if [ ! -L /usr/sbin ]; then ln -s ../bin/crowdsec-firewall-bouncer /usr/sbin/crowdsec-firewall-bouncer fi fi %systemd_post %{name}.service if [ "$START" -eq 0 ]; then 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 else %if 0%{?fc35} systemctl enable "$SERVICE" %endif systemctl start "$SERVICE" fi echo "$BOUNCER has been successfully installed" %preun -n %{name}-nftables . /usr/lib/%{name}/_bouncer.sh if [ "$1" = "0" ]; then systemctl stop "$SERVICE" || echo "cannot stop service" systemctl disable "$SERVICE" || echo "cannot disable service" delete_bouncer fi %postun -n %{name}-nftables if [ "$1" = "1" ]; then systemctl restart %{name} || echo "cannot restart service" fi if [ -L /usr/sbin/crowdsec-firewall-bouncer ]; then rm -f /usr/sbin/crowdsec-firewall-bouncer fi ================================================ FILE: scripts/_bouncer.sh ================================================ #!/bin/sh #shellcheck disable=SC3043 set -eu BOUNCER="crowdsec-firewall-bouncer" BOUNCER_PREFIX=$(echo "$BOUNCER" | sed 's/crowdsec-/cs-/g') # This is a library of functions that can be sourced by other scripts # to install and configure bouncers. # # While not requiring bash, it is not strictly POSIX-compliant because # it uses local variables, but it should work with every modern shell. # # Since passing/parsing arguments in posix sh is tricky, we share # some environment variables with the functions. It's a matter of # readability balance between shorter vs cleaner code. if [ -n "${NO_COLOR-}" ] || [ ! -t 1 ]; then # terminal is not interactive; no colors FG_RED="" FG_GREEN="" FG_YELLOW="" FG_CYAN="" RESET="" elif [ -n "${TERM-}" ] && tput sgr0 >/dev/null 2>&1; then # terminfo FG_RED=$(tput setaf 1) FG_GREEN=$(tput setaf 2) FG_YELLOW=$(tput setaf 3) FG_CYAN=$(tput setaf 6) RESET=$(tput sgr0) else FG_RED=$(printf '%b' '\033[31m') FG_GREEN=$(printf '%b' '\033[32m') FG_YELLOW=$(printf '%b' '\033[33m') FG_CYAN=$(printf '%b' '\033[36m') RESET=$(printf '%b' '\033[0m') fi msg() { case "$1" in info) echo "${FG_CYAN}$2${RESET}" >&2 ;; warn) echo "${FG_YELLOW}WARN:${RESET} $2" >&2 ;; err) echo "${FG_RED}ERR:${RESET} $2" >&2 ;; succ) echo "${FG_GREEN}$2${RESET}" >&2 ;; *) echo "$1" >&2 ;; esac } require() { set | grep -q "^$1=" || { msg err "missing required variable \$$1"; exit 1; } shift [ "$#" -eq 0 ] || require "$@" } # shellcheck disable=SC2034 { SERVICE="$BOUNCER.service" BIN_PATH_INSTALLED="/usr/local/bin/$BOUNCER" BIN_PATH="./$BOUNCER" CONFIG_DIR="/etc/crowdsec/bouncers" CONFIG_FILE="$BOUNCER.yaml" CONFIG="$CONFIG_DIR/$CONFIG_FILE" SYSTEMD_PATH_FILE="/etc/systemd/system/$SERVICE" } assert_root() { #shellcheck disable=SC2312 if [ "$(id -u)" -ne 0 ]; then msg err "This script must be run as root" exit 1 fi } # Check if the configuration file contains a variable # which has not yet been interpolated, like "$API_KEY", # and return true if it does. config_not_set() { require 'CONFIG' local varname before after varname=$1 if [ "$varname" = "" ]; then msg err "missing required variable name" exit 1 fi before=$("$BOUNCER" -c "$CONFIG" -T) # shellcheck disable=SC2016 after=$(echo "$before" | envsubst "\$$varname") if [ "$before" = "$after" ]; then return 1 fi return 0 } need_api_key() { if config_not_set 'API_KEY'; then return 0 fi return 1 } # Interpolate a variable in the config file with a value. set_config_var_value() { require 'CONFIG' local varname value before varname=$1 if [ "$varname" = "" ]; then msg err "missing required variable name" exit 1 fi value=$2 if [ "$value" = "" ]; then msg err "missing required variable value" exit 1 fi before=$(cat "$CONFIG") (umask 177 && echo "$before" | \ env "$varname=$value" envsubst "\$$varname" >"$CONFIG") } set_api_key() { require 'CONFIG' 'BOUNCER_PREFIX' local api_key ret bouncer_id before # if we can't set the key, the user will take care of it ret=0 if command -v cscli >/dev/null; then echo "cscli/crowdsec is present, generating API key" >&2 bouncer_id="$BOUNCER_PREFIX-$(date +%s)" api_key=$(cscli -oraw bouncers add "$bouncer_id" || true) if [ "$api_key" = "" ]; then echo "failed to create API key" >&2 api_key="" ret=1 else echo "API Key successfully created" >&2 echo "$bouncer_id" > "$CONFIG.id" fi else echo "cscli/crowdsec is not present, please set the API key manually" >&2 api_key="" ret=1 fi if [ "$api_key" != "" ]; then set_config_var_value 'API_KEY' "$api_key" fi return "$ret" } set_local_port() { require 'CONFIG' local port command -v cscli >/dev/null || return 0 # the following will fail with a non-LAPI local crowdsec, leaving empty port port=$(cscli config show -oraw --key "Config.API.Server.ListenURI" 2>/dev/null | cut -d ":" -f2 || true) if [ "$port" != "" ]; then sed -i "s/localhost:8080/127.0.0.1:$port/g" "$CONFIG" sed -i "s/127.0.0.1:8080/127.0.0.1:$port/g" "$CONFIG" fi } set_local_lapi_url() { require 'CONFIG' local port before varname # $varname is the name of the variable to interpolate # in the config file with the URL of the LAPI server, # assuming it is running on the same host as the # bouncer. varname=$1 if [ "$varname" = "" ]; then msg err "missing required variable name" exit 1 fi command -v cscli >/dev/null || return 0 port=$(cscli config show -oraw --key "Config.API.Server.ListenURI" 2>/dev/null | cut -d ":" -f2 || true) if [ "$port" = "" ]; then port=8080 fi set_config_var_value "$varname" "http://127.0.0.1:$port" } delete_bouncer() { require 'CONFIG' local bouncer_id if [ -f "$CONFIG.id" ]; then bouncer_id=$(cat "$CONFIG.id") cscli -oraw bouncers delete "$bouncer_id" 2>/dev/null || true rm -f "$CONFIG.id" fi } upgrade_bin() { require 'BIN_PATH' 'BIN_PATH_INSTALLED' rm "$BIN_PATH_INSTALLED" install -v -m 0755 -D "$BIN_PATH" "$BIN_PATH_INSTALLED" } ================================================ FILE: scripts/install.sh ================================================ #!/bin/sh set -eu . ./scripts/_bouncer.sh assert_root # --------------------------------- # API_KEY="" install_pkg() { pkg="$1" if [ -f /etc/redhat-release ]; then yum install -y "$pkg" elif grep -q "Amazon Linux release 2 (Karoo)" /etc/system-release 2>/dev/null; then yum install -y "$pkg" elif grep -q "suse" /etc/os-release 2>/dev/null; then zypper install -y "$pkg" elif [ -f /etc/debian_version ]; then apt install -y "$pkg" else msg warn "This distribution is not supported" return 1 fi msg succ "$pkg successfully installed" return 0 } check_firewall() { # Default firewall backend is nftables FW_BACKEND="nftables" iptables="true" if command -v iptables >/dev/null; then FW_BACKEND="iptables" msg info "iptables found" else msg warn "iptables not found" iptables="false" fi nftables="true" if command -v nft >/dev/null; then FW_BACKEND="nftables" msg info "nftables found" else msg warn "nftables not found" nftables="false" fi if [ "$nftables" = "false" ] && [ "$iptables" = "false" ]; then printf '%s ' "No firewall found, do you want to install nftables (Y/n) ?" read -r answer if echo "$answer" | grep -iq '^n'; then msg err "unable to continue without nftables. Please install nftables or iptables to use this bouncer." exit 1 fi # shellcheck disable=SC2310 install_pkg nftables || ( msg err "Cannot install nftables, please install it manually"; exit 1 ) fi if [ "$nftables" = "true" ] && [ "$iptables" = "true" ]; then printf '%s ' "Found nftables (default) and iptables, which firewall do you want to use (nftables/iptables) ?" read -r answer if [ "$answer" = "iptables" ]; then FW_BACKEND="iptables" fi fi if [ "$FW_BACKEND" = "iptables" ]; then check_ipset fi } check_ipset() { if ! command -v ipset >/dev/null; then printf '%s ' "ipset not found, do you want to install it (Y/n) ?" read -r answer if echo "$answer" | grep -iq '^n'; then msg err "unable to continue without ipset. Exiting" exit 1 fi # shellcheck disable=SC2310 install_pkg ipset || ( msg err "Cannot install ipset, please install it manually"; exit 1 ) fi } gen_apikey() { if command -v cscli >/dev/null; then msg succ "cscli found, generating bouncer api key." bouncer_id="$BOUNCER_PREFIX-$(date +%s)" API_KEY=$(cscli -oraw bouncers add "$bouncer_id") echo "$bouncer_id" > "$CONFIG.id" msg info "API Key: $API_KEY" READY="yes" else msg warn "cscli not found, you will need to generate an api key." READY="no" fi } gen_config_file() { # shellcheck disable=SC2016 (umask 177 && API_KEY="$API_KEY" BACKEND="$FW_BACKEND" envsubst '$API_KEY $BACKEND' <"./config/$CONFIG_FILE" > "$CONFIG") } install_bouncer() { if [ ! -f "$BIN_PATH" ]; then msg err "$BIN_PATH not found, exiting." exit 1 fi if [ -e "$BIN_PATH_INSTALLED" ]; then msg err "$BIN_PATH_INSTALLED is already installed. Exiting" exit 1 fi msg info "Installing $BOUNCER" check_firewall install -v -m 0755 -D "$BIN_PATH" "$BIN_PATH_INSTALLED" mkdir -p "$(dirname "$CONFIG")" # shellcheck disable=SC2016 CFG=${CONFIG_DIR} BIN=${BIN_PATH_INSTALLED} envsubst '$CFG $BIN' <"./config/$SERVICE" >"$SYSTEMD_PATH_FILE" systemctl daemon-reload gen_apikey gen_config_file set_local_port } # --------------------------------- # install_bouncer systemctl enable "$SERVICE" if [ "$READY" = "yes" ]; then systemctl start "$SERVICE" else msg warn "service not started. You need to get an API key and configure it in $CONFIG" fi msg succ "The $BOUNCER service has been installed." exit 0 ================================================ FILE: scripts/uninstall.sh ================================================ #!/bin/sh set -eu . ./scripts/_bouncer.sh assert_root # --------------------------------- # uninstall() { systemctl stop "$SERVICE" || true delete_bouncer rm -f "$CONFIG" rm -f "$SYSTEMD_PATH_FILE" rm -f "$BIN_PATH_INSTALLED" rm -f "/var/log/$BOUNCER.log" } uninstall msg succ "$BOUNCER has been successfully uninstalled" exit 0 ================================================ FILE: scripts/upgrade.sh ================================================ #!/bin/sh set -eu . ./scripts/_bouncer.sh assert_root # --------------------------------- # systemctl stop "$SERVICE" if ! upgrade_bin; then msg err "failed to upgrade $BOUNCER" exit 1 fi systemctl start "$SERVICE" || msg warn "$SERVICE failed to start, please check the systemd logs" msg succ "$BOUNCER upgraded successfully." exit 0 ================================================ FILE: test/.python-version ================================================ 3.12 ================================================ FILE: test/README.md ================================================ ================================================ FILE: test/default.env ================================================ CROWDSEC_TEST_VERSION="dev" CROWDSEC_TEST_FLAVORS="full" CROWDSEC_TEST_NETWORK="net-test" ================================================ FILE: test/pyproject.toml ================================================ [project] name = "cs-firewall-bouncer-tests" version = "0.1.0" description = "Tests for cs-firewall-bouncer" readme = "README.md" requires-python = ">=3.12" dependencies = [ "flask>=3.1.0", "pexpect>=4.9.0", "psutil>=6.1.1", "pytest>=8.3.4", "pytest-cs>=0.7.24", "pytest-dependency>=0.6.0", "pytest-dotenv>=0.5.2", "pytimeparse>=1.1.8", "zxcvbn>=4.4.28", ] [tool.uv.sources] pytest-cs = { git = "https://github.com/crowdsecurity/pytest-cs" } [dependency-groups] dev = [ "basedpyright>=1.26.0", "ipdb>=0.13.13", "ruff>=0.9.4", ] #[tool.uv.sources] #pytest-cs = { path = "../../../pytest-cs", editable = true } [tool.ruff] line-length = 120 [tool.ruff.lint] select = [ "ALL" ] ignore = [ "ANN", # Missing type annotations "A002", # Function argument `id` is shadowing a Python builtin "ARG001", # Unused function argument: `...` "COM812", # Trailing comma missing "D100", # Missing docstring in public module "D101", # Missing docstring in public class "D102", # Missing docstring in public method "D103", # Missing docstring in public function "D104", # Missing docstring in public package "D107", # Missing docstring in __init__ "D203", # incorrect-blank-line-before-class "D212", # Multi-line docstring summary should start at the first line "D212", # Multi-line docstring summary should start at the first line "D400", # First line should end with a period "D415", # First line should end with a period, question mark, or exclamation point "DTZ005", # `datetime.datetime.now()` called without a `tz` argument "DTZ901", # Use of `datetime.datetime.min` without timezone information "EM102", # Exception must not use an f-string literal, assign to variable first "ERA001", # Found commented-out code "FBT002", # Boolean default positional argument in function definition "FIX002", # Line contains TODO, consider resolving the issue "FIX003", # Line contains XXX, consider resolving the issue "N802", # Function name `testLogging` should be lowercase "PLW1510", # `subprocess.run` without explicit `check` argument "S101", # Use of 'assert' detected "S104", # Possible binding to all interfaces "S314", # Using `xml` to parse untrusted data is known to be vulnerable to XML attacks; use `defusedxml` equivalents "S603", # `subprocess` call: check for execution of untrusted input "S604", # Function call with `shell=True` parameter identified, security issue "S607", # Starting a process with a partial executable path "SIM108", # Use ternary operator `...` instead of `if`-`else`-block "TD001", # Invalid TODO tag: `XXX` "TD002", # Missing author in TODO "TD003", # Missing issue link for this TODO "TRY003", # Avoid specifying long messages outside the exception class "PLR2004", # Magic value used in comparison, consider replacing `...` with a constant variable "PLR0913", # Too many arguments in function definition (6 > 5) "PTH107", # `os.remove()` should be replaced by `Path.unlink()` "PTH108", # `os.unlink()` should be replaced by `Path.unlink()` "PTH110", # `os.path.exists()` should be replaced by `Path.exists()` "PTH116", # `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()` "PTH120", # `os.path.dirname()` should be replaced by `Path.parent` "PTH123", # `open()` should be replaced by `Path.open()` "PT009", # Use a regular `assert` instead of unittest-style `assertEqual` "PT022", # No teardown in fixture `fw_cfg_factory`, use `return` instead of `yield` "TID252", # Prefer absolute imports over relative imports from parent modules "UP022", # Prefer `capture_output` over sending `stdout` and `stderr` to `PIPE` ] [tool.basedpyright] reportAny = "none" reportArgumentType = "none" reportAttributeAccessIssue = "none" reportImplicitOverride = "none" reportImplicitStringConcatenation = "none" reportMissingParameterType = "none" reportMissingTypeStubs = "none" reportOptionalMemberAccess = "none" reportUnannotatedClassAttribute = "none" reportUninitializedInstanceVariable = "none" reportUnknownArgumentType = "none" reportUnknownMemberType = "none" reportUnknownParameterType = "none" reportUnknownVariableType = "none" reportUnusedCallResult = "none" reportUnusedParameter = "none" ================================================ FILE: test/pytest.ini ================================================ [pytest] addopts = --pdbcls=IPython.terminal.debugger:Pdb --ignore=tests/install --ignore=tests/backends --strict-markers markers: deb: mark tests related to deb packaging rpm: mark tests related to rpm packaging systemd_debug: dump systemd status and journal on test failure env_files = .env default.env ================================================ FILE: test/tests/__init__.py ================================================ ================================================ FILE: test/tests/backends/__init__.py ================================================ ================================================ FILE: test/tests/backends/iptables/__init__.py ================================================ ================================================ FILE: test/tests/backends/iptables/crowdsec-firewall-bouncer-logging.yaml ================================================ mode: iptables update_frequency: 0.1s log_mode: stdout log_dir: ./ log_level: info api_url: http://127.0.0.1:8081/ api_key: 1237adaf7a1724ac68a3288828820a67 disable_ipv6: false deny_action: DROP deny_log: true deny_log_prefix: "blocked by crowdsec" supported_decisions_types: - ban iptables_chains: - INPUT ================================================ FILE: test/tests/backends/iptables/crowdsec-firewall-bouncer.yaml ================================================ mode: iptables update_frequency: 0.1s log_mode: stdout log_dir: ./ log_level: info api_url: http://127.0.0.1:8081/ api_key: 1237adaf7a1724ac68a3288828820a67 disable_ipv6: false deny_action: DROP deny_log: false supported_decisions_types: - ban iptables_chains: - INPUT ================================================ FILE: test/tests/backends/iptables/test_iptables.py ================================================ import os import subprocess import unittest import xml.etree.ElementTree as ET from ipaddress import ip_address from pathlib import Path from time import sleep from ..mock_lapi import MockLAPI from ..utils import generate_n_decisions, new_decision, run_cmd SCRIPT_DIR = Path(os.path.dirname(os.path.realpath(__file__))) PROJECT_ROOT = SCRIPT_DIR.parent.parent.parent.parent BINARY_PATH = PROJECT_ROOT.joinpath("crowdsec-firewall-bouncer") CONFIG_PATH = SCRIPT_DIR.joinpath("crowdsec-firewall-bouncer.yaml") CONFIG_PATH_LOGGING = SCRIPT_DIR.joinpath("crowdsec-firewall-bouncer-logging.yaml") SET_NAME_IPV4 = "crowdsec-blacklists-0" SET_NAME_IPV6 = "crowdsec6-blacklists-0" RULES_CHAIN_NAME = "CROWDSEC_CHAIN" LOGGING_CHAIN_NAME = "CROWDSEC_LOG" CHAIN_NAME = "INPUT" class TestIPTables(unittest.TestCase): def setUp(self): self.fb = subprocess.Popen([BINARY_PATH, "-c", CONFIG_PATH]) self.lapi = MockLAPI() self.lapi.start() return super().setUp() def tearDown(self): self.fb.kill() self.fb.wait() self.lapi.stop() def test_table_rule_set_are_created(self): d1 = generate_n_decisions(3) d2 = generate_n_decisions(1, ipv4=False) self.lapi.ds.insert_decisions(d1 + d2) sleep(3) # IPV4 Chain # Check the rules with the sets output = run_cmd("iptables", "-L", RULES_CHAIN_NAME) rules = [line for line in output.split("\n") if SET_NAME_IPV4 in line] self.assertEqual(len(rules), 1) assert f"match-set {SET_NAME_IPV4} src" in rules[0] # Check the JUMP to CROWDSEC_CHAIN output = run_cmd("iptables", "-L", CHAIN_NAME) rules = [line for line in output.split("\n") if RULES_CHAIN_NAME in line] self.assertEqual(len(rules), 1) assert f"{RULES_CHAIN_NAME}" in rules[0] # IPV6 Chain output = run_cmd("ip6tables", "-L", RULES_CHAIN_NAME) rules = [line for line in output.split("\n") if SET_NAME_IPV6 in line] self.assertEqual(len(rules), 1) assert f"match-set {SET_NAME_IPV6} src" in rules[0] # Check the JUMP to CROWDSEC_CHAIN output = run_cmd("ip6tables", "-L", CHAIN_NAME) rules = [line for line in output.split("\n") if RULES_CHAIN_NAME in line] self.assertEqual(len(rules), 1) assert f"{RULES_CHAIN_NAME}" in rules[0] output = run_cmd("ipset", "list") assert SET_NAME_IPV6 in output assert SET_NAME_IPV4 in output def test_duplicate_decisions_across_decision_stream(self): d1, d2, d3 = generate_n_decisions(3, dup_count=1) self.lapi.ds.insert_decisions([d1]) sleep(3) res = get_set_elements(SET_NAME_IPV4) self.assertEqual(res, {"0.0.0.0"}) self.lapi.ds.insert_decisions([d2, d3]) sleep(3) assert self.fb.poll() is None self.assertEqual(get_set_elements(SET_NAME_IPV4), {"0.0.0.0", "0.0.0.1"}) self.lapi.ds.delete_decision_by_id(d1["id"]) self.lapi.ds.delete_decision_by_id(d2["id"]) sleep(3) self.assertEqual(get_set_elements(SET_NAME_IPV4), set()) assert self.fb.poll() is None self.lapi.ds.delete_decision_by_id(d3["id"]) sleep(3) self.assertEqual(get_set_elements(SET_NAME_IPV6), set()) assert self.fb.poll() is None def test_decision_insertion_deletion_ipv4(self): total_decisions, duplicate_decisions = 100, 23 decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions) self.lapi.ds.insert_decisions(decisions) sleep(3) # let the bouncer insert the decisions set_elements = get_set_elements(SET_NAME_IPV4) self.assertEqual(len(set_elements), total_decisions - duplicate_decisions) self.assertEqual({i["value"] for i in decisions}, set_elements) self.assertIn("0.0.0.0", set_elements) self.lapi.ds.delete_decisions_by_ip("0.0.0.0") sleep(3) set_elements = get_set_elements(SET_NAME_IPV4) self.assertEqual({i["value"] for i in decisions if i["value"] != "0.0.0.0"}, set_elements) self.assertEqual(len(set_elements), total_decisions - duplicate_decisions - 1) self.assertNotIn("0.0.0.0", set_elements) def test_decision_insertion_deletion_ipv6(self): total_decisions, duplicate_decisions = 100, 23 decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions, ipv4=False) self.lapi.ds.insert_decisions(decisions) sleep(3) set_elements = get_set_elements(SET_NAME_IPV6) set_elements = set(map(ip_address, set_elements)) self.assertEqual(len(set_elements), total_decisions - duplicate_decisions) self.assertEqual({ip_address(i["value"]) for i in decisions}, set_elements) self.assertIn(ip_address("::1:0:3"), set_elements) self.lapi.ds.delete_decisions_by_ip("::1:0:3") sleep(3) set_elements = get_set_elements(SET_NAME_IPV6) set_elements = set(map(ip_address, set_elements)) self.assertEqual(len(set_elements), total_decisions - duplicate_decisions - 1) self.assertEqual( {ip_address(i["value"]) for i in decisions if ip_address(i["value"]) != ip_address("::1:0:3")}, set_elements, ) self.assertNotIn(ip_address("::1:0:3"), set_elements) def test_longest_decision_insertion(self): decisions = [ { "value": "123.45.67.12", "scope": "ip", "type": "ban", "origin": "script", "duration": f"{i}h", "reason": "for testing", } for i in range(1, 201) ] self.lapi.ds.insert_decisions(decisions) sleep(3) elems = get_set_elements(SET_NAME_IPV4, with_timeout=True) self.assertEqual(len(elems), 1) elems = list(elems) self.assertEqual(elems[0][0], "123.45.67.12") self.assertLessEqual(abs(elems[0][1] - 200 * 60 * 60), 15) def get_set_elements(set_name, with_timeout=False): output = run_cmd("ipset", "list", "-o", "xml") root = ET.fromstring(output) elements = set() for member in root.findall(f"ipset[@name='{set_name}']/members/member"): if with_timeout: to_add = (member.find("elem").text, int(member.find("timeout").text)) else: to_add = member.find("elem").text elements.add(to_add) return elements class TestIPTablesLogging(unittest.TestCase): def setUp(self): self.fb = subprocess.Popen([BINARY_PATH, "-c", CONFIG_PATH_LOGGING]) self.lapi = MockLAPI() self.lapi.start() return super().setUp() def tearDown(self): self.fb.kill() self.fb.wait() self.lapi.stop() def testLogging(self): # We use 1.1.1.1 because we want to see some dropped packets in the logs # We know this IP responds to ping, and the response will be dropped by the firewall d = new_decision("1.1.1.1") self.lapi.ds.insert_decisions([d]) sleep(3) # Check if our logging chain is in place output = run_cmd("iptables", "-L", LOGGING_CHAIN_NAME) rules = [line for line in output.split("\n") if "anywhere" in line] # 2 rules: one logging, one generic drop self.assertEqual(len(rules), 2) # Check if the logging chain is called from the main chain output = run_cmd("iptables", "-L", CHAIN_NAME) rules = [line for line in output.split("\n") if RULES_CHAIN_NAME in line] self.assertEqual(len(rules), 1) # Check if logging/drop chain is called from the rules chain output = run_cmd("iptables", "-L", RULES_CHAIN_NAME) rules = [line for line in output.split("\n") if LOGGING_CHAIN_NAME in line] self.assertEqual(len(rules), 1) # Now, try to ping the IP output = run_cmd( "curl", "--connect-timeout", "1", "1.1.1.1", ignore_error=True ) # We don't care about the output, we just want to trigger the rule # Check if the firewall has logged the dropped response output = run_cmd("dmesg | tail -n 10", shell=True) assert "blocked by crowdsec" in output ================================================ FILE: test/tests/backends/mock_lapi.py ================================================ import datetime import logging from datetime import timedelta from ipaddress import ip_address from threading import Thread from time import sleep from flask import Flask, abort, request from pytimeparse.timeparse import timeparse from werkzeug.serving import make_server # This is the "database" of our dummy LAPI class DataStore: def __init__(self) -> None: self.id = 0 self.decisions = [] self.bouncer_lastpull_by_api_key = {} def insert_decisions(self, decisions): for i, _ in enumerate(decisions): decisions[i]["created_at"] = datetime.datetime.now() decisions[i]["deleted_at"] = self.get_decision_expiry_time(decisions[i]) decisions[i]["id"] = self.id self.id += 1 self.decisions.extend(decisions) # This methods can be made more generic by taking lambda expr as input for filtering # decisions to delete def delete_decisions_by_ip(self, ip): for i, decision in enumerate(self.decisions): if ip_address(decision["value"]) == ip_address(ip): self.decisions[i]["deleted_at"] = datetime.datetime.now() def delete_decision_by_id(self, id): for i, decision in enumerate(self.decisions): if decision["id"] == id: self.decisions[i]["deleted_at"] = datetime.datetime.now() break def update_bouncer_pull(self, api_key): self.bouncer_lastpull_by_api_key[api_key] = datetime.datetime.now() def get_active_and_expired_decisions_since(self, since): expired_decisions = [] active_decisions = [] for decision in self.decisions: # decision["deleted_at"] > datetime.datetime.now() means that decision hasn't yet expired if decision["deleted_at"] > since and decision["deleted_at"] < datetime.datetime.now(): expired_decisions.append(decision) elif decision["created_at"] > since: active_decisions.append(decision) return active_decisions, expired_decisions def get_decisions_for_bouncer(self, api_key, startup=False): if startup or api_key not in self.bouncer_lastpull_by_api_key: since = datetime.datetime.min self.bouncer_lastpull_by_api_key[api_key] = since else: since = self.bouncer_lastpull_by_api_key[api_key] self.update_bouncer_pull(api_key) return self.get_active_and_expired_decisions_since(since) @staticmethod def get_decision_expiry_time(decision): return decision["created_at"] + timedelta(seconds=timeparse(decision["duration"])) class MockLAPI: def __init__(self) -> None: self.app = Flask(__name__) self.app.add_url_rule("/v1/decisions/stream", view_func=self.decisions) log = logging.getLogger("werkzeug") log.setLevel(logging.ERROR) self.app.logger.disabled = True log.disabled = True self.ds = DataStore() def decisions(self): api_key = request.headers.get("x-api-key") if not api_key: abort(404) startup = request.args.get("startup") == "true" active_decisions, expired_decisions = self.ds.get_decisions_for_bouncer(api_key, startup) return { "new": formatted_decisions(active_decisions), "deleted": formatted_decisions(expired_decisions), } def start(self, port=8081): self.server_thread = ServerThread(self.app, port=port) self.server_thread.start() def stop(self): self.server_thread.shutdown() def formatted_decisions(decisions): formatted_decisions = [] for decision in decisions: expiry_time = decision["created_at"] + timedelta(seconds=timeparse(decision["duration"])) duration = expiry_time - datetime.datetime.now() formatted_decisions.append( { "duration": f"{duration.total_seconds()}s", "id": decision["id"], "origin": decision["origin"], "scenario": "cscli", "scope": decision["scope"], "type": decision["type"], "value": decision["value"], } ) return formatted_decisions # Copied from https://stackoverflow.com/a/45017691 . # We run server inside thread instead of process to avoid # huge complexity of sharing objects class ServerThread(Thread): def __init__(self, app, port=8081): Thread.__init__(self) self.server = make_server("127.0.0.1", port, app) self.ctx = app.app_context() self.ctx.push() def run(self): self.server.serve_forever() def shutdown(self): self.server.shutdown() if __name__ == "__main__": MockLAPI().start() sleep(100) ================================================ FILE: test/tests/backends/nftables/__init__.py ================================================ ================================================ FILE: test/tests/backends/nftables/crowdsec-firewall-bouncer.yaml ================================================ mode: nftables update_frequency: 0.01s log_mode: stdout log_dir: ./ log_level: info api_url: http://127.0.0.1:8081/ api_key: 1237adaf7a1724ac68a3288828820a67 disable_ipv6: false deny_action: DROP deny_log: false supported_decisions_types: - ban iptables_chains: - INPUT nftables_hooks: - input - forward nftables: ipv4: enabled: true set-only: false table: crowdsec chain: crowdsec-chain ipv6: enabled: true set-only: false table: crowdsec6 chain: crowdsec6-chain ================================================ FILE: test/tests/backends/nftables/test_nftables.py ================================================ import json import os import subprocess import unittest from ipaddress import ip_address from pathlib import Path from time import sleep from ..mock_lapi import MockLAPI from ..utils import generate_n_decisions, run_cmd SCRIPT_DIR = Path(os.path.dirname(os.path.realpath(__file__))) PROJECT_ROOT = SCRIPT_DIR.parent.parent.parent.parent BINARY_PATH = PROJECT_ROOT.joinpath("crowdsec-firewall-bouncer") CONFIG_PATH = SCRIPT_DIR.joinpath("crowdsec-firewall-bouncer.yaml") class TestNFTables(unittest.TestCase): def setUp(self): self.fb = subprocess.Popen([BINARY_PATH, "-c", CONFIG_PATH]) self.lapi = MockLAPI() self.lapi.start() return super().setUp() def tearDown(self): self.fb.kill() self.fb.wait() self.lapi.stop() run_cmd("nft", "delete", "table", "ip", "crowdsec", ignore_error=True) run_cmd("nft", "delete", "table", "ip6", "crowdsec6", ignore_error=True) def test_table_rule_set_are_created(self): d1 = generate_n_decisions(3) d2 = generate_n_decisions(1, ipv4=False) self.lapi.ds.insert_decisions(d1 + d2) sleep(1) output = json.loads(run_cmd("nft", "-j", "list", "tables")) tables = {(node["table"]["family"], node["table"]["name"]) for node in output["nftables"] if "table" in node} assert ("ip6", "crowdsec6") in tables assert ("ip", "crowdsec") in tables # IPV4 output = json.loads(run_cmd("nft", "-j", "list", "table", "ip", "crowdsec")) sets = { (node["set"]["family"], node["set"]["name"], node["set"]["type"]) for node in output["nftables"] if "set" in node } assert ("ip", "crowdsec-blacklists-script", "ipv4_addr") in sets rules = {node["rule"]["chain"] for node in output["nftables"] if "rule" in node} # maybe stricter check ? assert "crowdsec-chain-forward" in rules assert "crowdsec-chain-input" in rules # IPV6 output = json.loads(run_cmd("nft", "-j", "list", "table", "ip6", "crowdsec6")) sets = { (node["set"]["family"], node["set"]["name"], node["set"]["type"]) for node in output["nftables"] if "set" in node } assert ("ip6", "crowdsec6-blacklists-script", "ipv6_addr") in sets rules = {node["rule"]["chain"] for node in output["nftables"] if "rule" in node} # maybe stricter check ? assert "crowdsec6-chain-input" in rules assert "crowdsec6-chain-forward" in rules def test_duplicate_decisions_across_decision_stream(self): d1, d2, d3 = generate_n_decisions(3, dup_count=1) self.lapi.ds.insert_decisions([d1]) sleep(1) self.assertEqual( get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script"), {"0.0.0.0"}, ) self.lapi.ds.insert_decisions([d2, d3]) sleep(1) assert self.fb.poll() is None self.assertEqual( get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script"), {"0.0.0.0", "0.0.0.1"}, ) self.lapi.ds.delete_decision_by_id(d1["id"]) self.lapi.ds.delete_decision_by_id(d2["id"]) sleep(1) self.assertEqual(get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script"), set()) assert self.fb.poll() is None self.lapi.ds.delete_decision_by_id(d3["id"]) sleep(1) self.assertEqual(get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script"), set()) assert self.fb.poll() is None def test_decision_insertion_deletion_ipv4(self): total_decisions, duplicate_decisions = 100, 23 decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions) self.lapi.ds.insert_decisions(decisions) sleep(1) # let the bouncer insert the decisions set_elements = get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script") self.assertEqual(len(set_elements), total_decisions - duplicate_decisions) assert {i["value"] for i in decisions} == set_elements assert "0.0.0.0" in set_elements self.lapi.ds.delete_decisions_by_ip("0.0.0.0") sleep(1) set_elements = get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script") assert {i["value"] for i in decisions if i["value"] != "0.0.0.0"} == set_elements assert len(set_elements) == total_decisions - duplicate_decisions - 1 assert "0.0.0.0" not in set_elements def test_decision_insertion_deletion_ipv6(self): total_decisions, duplicate_decisions = 100, 23 decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions, ipv4=False) self.lapi.ds.insert_decisions(decisions) sleep(1) set_elements = get_set_elements("ip6", "crowdsec6", "crowdsec6-blacklists-script") set_elements = set(map(ip_address, set_elements)) assert len(set_elements) == total_decisions - duplicate_decisions assert {ip_address(i["value"]) for i in decisions} == set_elements assert ip_address("::1:0:3") in set_elements self.lapi.ds.delete_decisions_by_ip("::1:0:3") sleep(1) set_elements = get_set_elements("ip6", "crowdsec6", "crowdsec6-blacklists-script") set_elements = set(map(ip_address, set_elements)) self.assertEqual(len(set_elements), total_decisions - duplicate_decisions - 1) assert ( {ip_address(i["value"]) for i in decisions if ip_address(i["value"]) != ip_address("::1:0:3")} ) == set_elements assert ip_address("::1:0:3") not in set_elements def test_longest_decision_insertion(self): decisions = [ { "value": "123.45.67.12", "scope": "ip", "type": "ban", "origin": "script", "duration": f"{i}h", "reason": "for testing", } for i in range(1, 201) ] self.lapi.ds.insert_decisions(decisions) sleep(1) elems = get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script", with_timeout=True) assert len(elems) == 1 elems = list(elems) assert elems[0][0] == "123.45.67.12" assert abs(elems[0][1] - 200 * 60 * 60) <= 3 def get_set_elements(family, table_name, set_name, with_timeout=False): output = json.loads(run_cmd("nft", "-j", "list", "set", family, table_name, set_name)) for node in output["nftables"]: if "set" not in node or "elem" not in node["set"]: continue if not isinstance(node["set"]["elem"][0], dict): return set(node["set"]["elem"]) if not with_timeout: return {elem["elem"]["val"] for elem in node["set"]["elem"]} return {(elem["elem"]["val"], elem["elem"]["timeout"]) for elem in node["set"]["elem"]} return set() ================================================ FILE: test/tests/backends/utils.py ================================================ import subprocess from ipaddress import ip_address def run_cmd(*cmd, ignore_error=False, shell=False): p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, shell=shell) if not ignore_error and p.returncode: raise SystemExit(f"{cmd} exited with non-zero code with following logs:\n {p.stdout}") return p.stdout def generate_n_decisions(n: int, action="ban", dup_count=0, ipv4=True, duration="4h"): if dup_count >= n: raise SystemExit(f"generate_n_decisions got dup_count={dup_count} which is >=n") unique_decision_count = n - dup_count decisions = [] for i in range(unique_decision_count): if ipv4: ip = ip_address(i) else: ip = ip_address(2**32 + i) decisions.append( { "value": ip.__str__(), "scope": "ip", "type": action, "origin": "script", "duration": duration, "reason": "for testing", } ) decisions += decisions[: n % unique_decision_count] decisions *= n // unique_decision_count return decisions def new_decision(ip: str): return { "value": ip, "scope": "ip", "type": "ban", "origin": "script", "duration": "4h", "reason": "for testing", } ================================================ FILE: test/tests/bouncer/__init__.py ================================================ ================================================ FILE: test/tests/bouncer/test_firewall_bouncer.py ================================================ import json def test_backend_mode(bouncer, fw_cfg_factory): cfg = fw_cfg_factory() del cfg["mode"] with bouncer(cfg) as fw: fw.wait_for_lines_fnmatch( [ "*unable to load configuration: config does not contain 'mode'*", ] ) fw.proc.wait(timeout=0.2) assert not fw.proc.is_running() cfg["mode"] = "whatever" with bouncer(cfg) as fw: fw.wait_for_lines_fnmatch( [ "*firewall 'whatever' is not supported*", ] ) fw.proc.wait(timeout=0.2) assert not fw.proc.is_running() cfg["mode"] = "dry-run" with bouncer(cfg) as fw: fw.wait_for_lines_fnmatch( [ "*Starting crowdsec-firewall-bouncer*", "*backend type: dry-run*", "*backend.Init() called*", "*unable to configure bouncer: config does not contain LAPI url*", ] ) fw.proc.wait(timeout=0.2) assert not fw.proc.is_running() def test_api_url(crowdsec, bouncer, fw_cfg_factory): cfg = fw_cfg_factory() with bouncer(cfg) as fw: fw.wait_for_lines_fnmatch( [ "*unable to configure bouncer: config does not contain LAPI url*", ] ) fw.proc.wait() assert not fw.proc.is_running() cfg["api_url"] = "" with bouncer(cfg) as fw: fw.wait_for_lines_fnmatch( [ "*unable to configure bouncer: config does not contain LAPI url*", ] ) fw.proc.wait() assert not fw.proc.is_running() def test_api_key(crowdsec, bouncer, fw_cfg_factory, api_key_factory, bouncer_under_test): api_key = api_key_factory() env = {"BOUNCER_KEY_bouncer": api_key} with crowdsec(environment=env) as lapi: lapi.wait_for_http(8080, "/health") port = lapi.probe.get_bound_port("8080") cfg = fw_cfg_factory() cfg["api_url"] = f"http://localhost:{port}" with bouncer(cfg) as fw: fw.wait_for_lines_fnmatch( [ "*unable to configure bouncer: config does not contain LAPI key or certificate*", ] ) fw.proc.wait() assert not fw.proc.is_running() cfg["api_key"] = "badkey" with bouncer(cfg) as fw: fw.wait_for_lines_fnmatch( [ "*Using API key auth*", "*process terminated with error: API error: access forbidden*", ] ) fw.proc.wait() assert not fw.proc.is_running() cfg["api_key"] = api_key with bouncer(cfg) as fw: fw.wait_for_lines_fnmatch( [ "*Using API key auth*", "*Processing new and deleted decisions*", ] ) assert fw.proc.is_running() # check that the bouncer is registered res = lapi.cont.exec_run("cscli bouncers list -o json") assert res.exit_code == 0 bouncers = json.loads(res.output) assert len(bouncers) == 1 assert bouncers[0]["name"] == "bouncer" assert bouncers[0]["auth_type"] == "api-key" assert bouncers[0]["type"] == bouncer_under_test ================================================ FILE: test/tests/bouncer/test_iptables_deny_action.py ================================================ def test_iptables_deny_action(bouncer, fw_cfg_factory): cfg = fw_cfg_factory() cfg["log_level"] = "trace" cfg["mode"] = "iptables" with bouncer(cfg) as fw: fw.wait_for_lines_fnmatch( [ "*using 'DROP' as deny_action*", ] ) fw.proc.wait(timeout=5) assert not fw.proc.is_running() cfg["deny_action"] = "drop" with bouncer(cfg) as fw: fw.wait_for_lines_fnmatch( [ "*using 'DROP' as deny_action*", ] ) fw.proc.wait(timeout=5) assert not fw.proc.is_running() cfg["deny_action"] = "reject" with bouncer(cfg) as fw: fw.wait_for_lines_fnmatch( [ "*using 'REJECT' as deny_action*", ] ) fw.proc.wait(timeout=5) assert not fw.proc.is_running() cfg["deny_action"] = "tarpit" with bouncer(cfg) as fw: fw.wait_for_lines_fnmatch( [ "*using 'TARPIT' as deny_action*", ] ) fw.proc.wait(timeout=5) assert not fw.proc.is_running() cfg["deny_action"] = "somethingelse" with bouncer(cfg) as fw: fw.wait_for_lines_fnmatch( [ "*invalid deny_action 'somethingelse', must be one of DROP, REJECT, TARPIT*", ] ) fw.proc.wait(timeout=5) assert not fw.proc.is_running() ================================================ FILE: test/tests/bouncer/test_tls.py ================================================ import json def test_tls_server(crowdsec, certs_dir, api_key_factory, bouncer, fw_cfg_factory): """TLS with server-only certificate""" api_key = api_key_factory() lapi_env = { "CACERT_FILE": "/etc/ssl/crowdsec/ca.crt", "LAPI_CERT_FILE": "/etc/ssl/crowdsec/lapi.crt", "LAPI_KEY_FILE": "/etc/ssl/crowdsec/lapi.key", "USE_TLS": "true", "LOCAL_API_URL": "https://localhost:8080", "BOUNCER_KEY_bouncer": api_key, } certs = certs_dir(lapi_hostname="lapi") volumes = { certs: {"bind": "/etc/ssl/crowdsec", "mode": "ro"}, } with crowdsec(environment=lapi_env, volumes=volumes) as cs: cs.wait_for_log("*CrowdSec Local API listening*") # TODO: wait_for_https cs.wait_for_http(8080, "/health", want_status=None) port = cs.probe.get_bound_port("8080") cfg = fw_cfg_factory() cfg["api_url"] = f"https://localhost:{port}" cfg["api_key"] = api_key with bouncer(cfg) as cb: cb.wait_for_lines_fnmatch( [ "*backend type: dry-run*", "*Using API key auth*", "*auth-api: auth with api key failed*", "*tls: failed to verify certificate: x509: certificate signed by unknown authority*", ] ) cfg["ca_cert_path"] = (certs / "ca.crt").as_posix() with bouncer(cfg) as cb: cb.wait_for_lines_fnmatch( [ "*backend type: dry-run*", "*Using CA cert *ca.crt*", "*Using API key auth*", "*Processing new and deleted decisions*", ] ) def test_tls_mutual(crowdsec, certs_dir, api_key_factory, bouncer, fw_cfg_factory, bouncer_under_test): """TLS with two-way bouncer/lapi authentication""" lapi_env = { "CACERT_FILE": "/etc/ssl/crowdsec/ca.crt", "LAPI_CERT_FILE": "/etc/ssl/crowdsec/lapi.crt", "LAPI_KEY_FILE": "/etc/ssl/crowdsec/lapi.key", "USE_TLS": "true", "LOCAL_API_URL": "https://localhost:8080", } certs = certs_dir(lapi_hostname="lapi") volumes = { certs: {"bind": "/etc/ssl/crowdsec", "mode": "ro"}, } with crowdsec(environment=lapi_env, volumes=volumes) as cs: cs.wait_for_log("*CrowdSec Local API listening*") # TODO: wait_for_https cs.wait_for_http(8080, "/health", want_status=None) port = cs.probe.get_bound_port("8080") cfg = fw_cfg_factory() cfg["api_url"] = f"https://localhost:{port}" cfg["ca_cert_path"] = (certs / "ca.crt").as_posix() cfg["cert_path"] = (certs / "agent.crt").as_posix() cfg["key_path"] = (certs / "agent.key").as_posix() with bouncer(cfg) as cb: cb.wait_for_lines_fnmatch( [ "*Starting crowdsec-firewall-bouncer*", "*Using CA cert*", "*Using cert auth with cert * and key *", "*API error: access forbidden*", ] ) cs.wait_for_log("*client certificate OU ?agent-ou? doesn't match expected OU ?bouncer-ou?*") cfg["cert_path"] = (certs / "bouncer.crt").as_posix() cfg["key_path"] = (certs / "bouncer.key").as_posix() with bouncer(cfg) as cb: cb.wait_for_lines_fnmatch( [ "*backend type: dry-run*", "*Using CA cert*", "*Using cert auth with cert * and key *", "*Processing new and deleted decisions . . .*", ] ) # check that the bouncer is registered res = cs.cont.exec_run("cscli bouncers list -o json") assert res.exit_code == 0 bouncers = json.loads(res.output) assert len(bouncers) == 1 assert bouncers[0]["name"].startswith("@") assert bouncers[0]["auth_type"] == "tls" assert bouncers[0]["type"] == bouncer_under_test def test_api_key_and_cert(crowdsec, certs_dir, api_key_factory, bouncer, fw_cfg_factory): """Attempt to send an api key and a certificate too""" api_key = api_key_factory() lapi_env = { "CACERT_FILE": "/etc/ssl/crowdsec/ca.crt", "LAPI_CERT_FILE": "/etc/ssl/crowdsec/lapi.crt", "LAPI_KEY_FILE": "/etc/ssl/crowdsec/lapi.key", "USE_TLS": "true", "LOCAL_API_URL": "https://localhost:8080", "BOUNCER_KEY_bouncer": api_key, } certs = certs_dir(lapi_hostname="lapi") volumes = { certs: {"bind": "/etc/ssl/crowdsec", "mode": "ro"}, } with crowdsec(environment=lapi_env, volumes=volumes) as cs: cs.wait_for_log("*CrowdSec Local API listening*") cs.wait_for_http(8080, "/health", want_status=None) port = cs.probe.get_bound_port("8080") cfg = fw_cfg_factory() cfg["api_url"] = f"https://localhost:{port}" cfg["ca_cert_path"] = (certs / "ca.crt").as_posix() cfg["api_key"] = api_key cfg["cert_path"] = (certs / "bouncer.crt").as_posix() cfg["key_path"] = (certs / "bouncer.key").as_posix() cs.wait_for_log("*Starting processing data*") with bouncer(cfg) as cb: cb.wait_for_lines_fnmatch( [ "*Starting crowdsec-firewall-bouncer*", "*unable to configure bouncer: api client init: cannot use both API key and certificate auth*", ] ) ================================================ FILE: test/tests/bouncer/test_yaml_local.py ================================================ import os def test_yaml_local(bouncer, fw_cfg_factory): cfg = fw_cfg_factory() cfg.pop("mode") with bouncer(cfg) as fw: fw.wait_for_lines_fnmatch( [ "*unable to load configuration: config does not contain 'mode'*", ] ) fw.proc.wait(timeout=0.2) assert not fw.proc.is_running() config_local = {"mode": "whatever"} with bouncer(cfg, config_local=config_local) as fw: fw.wait_for_lines_fnmatch( [ "*firewall 'whatever' is not supported*", ] ) fw.proc.wait(timeout=0.2) assert not fw.proc.is_running() # variable expansion config_local = {"mode": "$BOUNCER_MODE"} os.environ["BOUNCER_MODE"] = "fromenv" with bouncer(cfg, config_local=config_local) as fw: fw.wait_for_lines_fnmatch( [ "*firewall 'fromenv' is not supported*", ] ) fw.proc.wait(timeout=0.2) assert not fw.proc.is_running() ================================================ FILE: test/tests/conftest.py ================================================ import contextlib import pytest from pytest_cs import plugin # pytest_exception_interact = plugin.pytest_exception_interact # provide the name of the bouncer binary to test @pytest.fixture(scope="session") def bouncer_under_test(): return "crowdsec-firewall-bouncer" # Create a lapi container, register a bouncer and run it with the updated config. # - Return context manager that yields a tuple of (bouncer, lapi) @pytest.fixture(scope="session") def bouncer_with_lapi(bouncer, crowdsec, fw_cfg_factory, api_key_factory: plugin.ApiKeyFactoryType): @contextlib.contextmanager def closure(config_lapi=None, config_bouncer=None, api_key=None): if config_bouncer is None: config_bouncer = {} if config_lapi is None: config_lapi = {} # can be overridden by config_lapi + config_bouncer api_key = api_key_factory() env = { "BOUNCER_KEY_custom": api_key, } try: env.update(config_lapi) with crowdsec(environment=env) as lapi: lapi.wait_for_http(8080, "/health") port = lapi.probe.get_bound_port("8080") cfg = fw_cfg_factory() cfg["api_url"] = f"http://localhost:{port}/" cfg["api_key"] = api_key cfg.update(config_bouncer) with bouncer(cfg) as cb: yield cb, lapi finally: pass yield closure _default_config = { "mode": "dry-run", "log_level": "info", } @pytest.fixture(scope="session") def fw_cfg_factory(): def closure(**kw): cfg = _default_config.copy() cfg |= kw return cfg | kw yield closure ================================================ FILE: test/tests/install/__init__.py ================================================ ================================================ FILE: test/tests/install/no_crowdsec/__init__.py ================================================ ================================================ FILE: test/tests/install/no_crowdsec/test_no_crowdsec_deb.py ================================================ import os import subprocess import pytest pytestmark = pytest.mark.deb def test_deb_install_purge(deb_package_path, bouncer_under_test, must_be_root): # test the full install-purge cycle, doing that in separate tests would # be a bit too much # TODO: remove and reinstall # use the package built as non-root by test_deb_build() assert deb_package_path.exists(), f"This test requires {deb_package_path}" bouncer_exe = f"/usr/bin/{bouncer_under_test}" assert not os.path.exists(bouncer_exe) config = f"/etc/crowdsec/bouncers/{bouncer_under_test}.yaml" assert not os.path.exists(config) # install the package p = subprocess.run( ["dpkg", "--install", deb_package_path.as_posix()], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", ) assert p.returncode == 0, f"Failed to install {deb_package_path}" assert os.path.exists(bouncer_exe) assert os.stat(bouncer_exe).st_mode & 0o777 == 0o755 assert os.path.exists(config) assert os.stat(config).st_mode & 0o777 == 0o600 p = subprocess.check_output(["dpkg-deb", "-f", deb_package_path.as_posix(), "Package"], encoding="utf-8") package_name = p.strip() p = subprocess.run( ["dpkg", "--purge", package_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", ) assert p.returncode == 0, f"Failed to purge {package_name}" assert not os.path.exists(bouncer_exe) assert not os.path.exists(config) ================================================ FILE: test/tests/install/no_crowdsec/test_no_crowdsec_scripts.py ================================================ import os import re import pexpect import pytest import yaml BOUNCER = "crowdsec-firewall-bouncer" CONFIG = f"/etc/crowdsec/bouncers/{BOUNCER}.yaml" @pytest.mark.dependency def test_install_no_crowdsec(project_repo, bouncer_binary, must_be_root): c = pexpect.spawn("/usr/bin/sh", ["scripts/install.sh"], cwd=project_repo) c.expect(f"Installing {BOUNCER}") c.expect("iptables found") c.expect("nftables found") c.expect(re.escape("Found nftables (default) and iptables, which firewall do you want to use (nftables/iptables)")) c.sendline("nftables") c.expect("WARN.* cscli not found, you will need to generate an api key.") c.expect(f"WARN.* service not started. You need to get an API key and configure it in {CONFIG}") c.expect(f"The {BOUNCER} service has been installed.") c.wait() assert c.terminated assert c.exitstatus == 0 with open(CONFIG) as f: y = yaml.safe_load(f) assert y["api_key"] == "" assert y["mode"] == "nftables" assert os.path.exists(CONFIG) assert os.stat(CONFIG).st_mode & 0o777 == 0o600 assert os.path.exists(f"/usr/local/bin/{BOUNCER}") assert os.stat(f"/usr/local/bin/{BOUNCER}").st_mode & 0o777 == 0o755 c = pexpect.spawn("/usr/bin/sh", ["scripts/install.sh"], cwd=project_repo) c.expect(f"ERR.* /usr/local/bin/{BOUNCER} is already installed. Exiting") @pytest.mark.dependency(depends=["test_install_no_crowdsec"]) def test_upgrade_no_crowdsec(project_repo, must_be_root): os.remove(f"/usr/local/bin/{BOUNCER}") c = pexpect.spawn("/usr/bin/sh", ["scripts/upgrade.sh"], cwd=project_repo) c.expect(f"{BOUNCER} upgraded successfully") c.wait() assert c.terminated assert c.exitstatus == 0 assert os.path.exists(f"/usr/local/bin/{BOUNCER}") assert os.stat(f"/usr/local/bin/{BOUNCER}").st_mode & 0o777 == 0o755 @pytest.mark.dependency(depends=["test_upgrade_no_crowdsec"]) def test_uninstall_no_crowdsec(project_repo, must_be_root): c = pexpect.spawn("/usr/bin/sh", ["scripts/uninstall.sh"], cwd=project_repo) c.expect(f"{BOUNCER} has been successfully uninstalled") c.wait() assert c.terminated assert c.exitstatus == 0 assert not os.path.exists(CONFIG) assert not os.path.exists(f"/usr/local/bin/{BOUNCER}") ================================================ FILE: test/tests/install/with_crowdsec/__init__.py ================================================ ================================================ FILE: test/tests/install/with_crowdsec/test_crowdsec_deb.py ================================================ import os import subprocess from pathlib import Path import pytest import yaml from zxcvbn import zxcvbn pytestmark = pytest.mark.deb # TODO: use fixtures to install/purge and register/unregister bouncers def test_deb_install_purge(deb_package_path, bouncer_under_test, must_be_root): # test the full install-purge cycle, doing that in separate tests would # be a bit too much # TODO: remove and reinstall # use the package built as non-root by test_deb_build() assert deb_package_path.exists(), f"This test requires {deb_package_path}" p = subprocess.check_output(["dpkg-deb", "-f", deb_package_path.as_posix(), "Package"], encoding="utf-8") package_name = p.strip() subprocess.check_call(["dpkg", "--purge", package_name]) bouncer_exe = f"/usr/bin/{bouncer_under_test}" assert not os.path.exists(bouncer_exe) config = f"/etc/crowdsec/bouncers/{bouncer_under_test}.yaml" assert not os.path.exists(config) # install the package p = subprocess.run( ["dpkg", "--install", deb_package_path.as_posix()], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", ) assert p.returncode == 0, f"Failed to install {deb_package_path}" assert os.path.exists(bouncer_exe) assert os.stat(bouncer_exe).st_mode & 0o777 == 0o755 assert os.path.exists(config) assert os.stat(config).st_mode & 0o777 == 0o600 with open(config) as f: cfg = yaml.safe_load(f) api_key = cfg["api_key"] # the api key has been set to a random value assert zxcvbn(api_key)["score"] == 4, f"weak api_key: '{api_key}'" with open(config + ".id") as f: bouncer_name = f.read().strip() p = subprocess.check_output(["cscli", "bouncers", "list", "-o", "json"]) bouncers = yaml.safe_load(p) assert len([b for b in bouncers if b["name"] == bouncer_name]) == 1 p = subprocess.run( ["dpkg", "--purge", package_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", ) assert p.returncode == 0, f"Failed to purge {package_name}" assert not os.path.exists(bouncer_exe) assert not os.path.exists(config) def test_deb_install_purge_yaml_local(deb_package_path, bouncer_under_test, must_be_root): """ Check .deb package installation with: - a pre-existing .yaml.local file with an api key - a pre-registered bouncer => the configuration files are not touched (no new api key) """ assert deb_package_path.exists(), f"This test requires {deb_package_path}" p = subprocess.check_output(["dpkg-deb", "-f", deb_package_path.as_posix(), "Package"], encoding="utf-8") package_name = p.strip() subprocess.check_call(["dpkg", "--purge", package_name]) subprocess.run(["cscli", "bouncers", "delete", "testbouncer"]) bouncer_exe = f"/usr/bin/{bouncer_under_test}" config = Path(f"/etc/crowdsec/bouncers/{bouncer_under_test}.yaml") config.parent.mkdir(parents=True, exist_ok=True) subprocess.check_call(["cscli", "bouncers", "add", "testbouncer", "-k", "123456"]) with open(config.with_suffix(".yaml.local"), "w") as f: f.write('api_key: "123456"') p = subprocess.run( ["dpkg", "--install", deb_package_path.as_posix()], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", ) assert p.returncode == 0, f"Failed to install {deb_package_path}" assert os.path.exists(bouncer_exe) assert os.path.exists(config) with open(config) as f: cfg = yaml.safe_load(f) api_key = cfg["api_key"] # the api key has not been set assert api_key == "${API_KEY}" p = subprocess.check_output([bouncer_exe, "-c", config, "-T"]) merged_config = yaml.safe_load(p) assert merged_config["api_key"] == "123456" os.unlink(config.with_suffix(".yaml.local")) p = subprocess.run( ["dpkg", "--purge", package_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8", ) assert p.returncode == 0, f"Failed to purge {package_name}" assert not os.path.exists(bouncer_exe) assert not os.path.exists(config) ================================================ FILE: test/tests/install/with_crowdsec/test_crowdsec_scripts.py ================================================ import os import re import pexpect import pytest import yaml from pytest_cs.lib import cscli, text BOUNCER = "crowdsec-firewall-bouncer" CONFIG = f"/etc/crowdsec/bouncers/{BOUNCER}.yaml" @pytest.mark.systemd_debug(BOUNCER) @pytest.mark.dependency def test_install_crowdsec(project_repo, bouncer_binary, must_be_root): c = pexpect.spawn("/usr/bin/sh", ["scripts/install.sh"], encoding="utf-8", cwd=project_repo, env={"NO_COLOR": "1"}) c.expect(f"Installing {BOUNCER}") c.expect("iptables found") c.expect("nftables found") c.expect(re.escape("Found nftables (default) and iptables, which firewall do you want to use (nftables/iptables)")) c.sendline("nftables") c.expect("cscli found, generating bouncer api key.") c.expect("API Key: (.*)") api_key = text.nocolor(c.match.group(1).strip()) # XXX: what do we expect here ? c.wait() assert c.terminated # XXX: partial configuration, the service won't start # assert c.exitstatus == 0 # installed files assert os.path.exists(CONFIG) assert os.stat(CONFIG).st_mode & 0o777 == 0o600 assert os.path.exists(f"/usr/local/bin/{BOUNCER}") assert os.stat(f"/usr/local/bin/{BOUNCER}").st_mode & 0o777 == 0o755 # configuration check with open(CONFIG) as f: y = yaml.safe_load(f) assert y["api_key"] == api_key # the bouncer is registered with open(f"{CONFIG}.id") as f: bouncer_name = f.read().strip() assert len(list(cscli.get_bouncers(name=bouncer_name))) == 1 c = pexpect.spawn("/usr/bin/sh", ["scripts/install.sh"], encoding="utf-8", cwd=project_repo) c.expect(f"ERR:.* /usr/local/bin/{BOUNCER} is already installed. Exiting") @pytest.mark.dependency(depends=["test_install_crowdsec"]) def test_upgrade_crowdsec(project_repo, must_be_root): os.remove(f"/usr/local/bin/{BOUNCER}") c = pexpect.spawn("/usr/bin/sh", ["scripts/upgrade.sh"], encoding="utf-8", cwd=project_repo) c.expect(f"{BOUNCER} upgraded successfully") c.wait() assert c.terminated assert c.exitstatus == 0 assert os.path.exists(f"/usr/local/bin/{BOUNCER}") assert os.stat(f"/usr/local/bin/{BOUNCER}").st_mode & 0o777 == 0o755 @pytest.mark.dependency(depends=["test_upgrade_crowdsec"]) def test_uninstall_crowdsec(project_repo, must_be_root): # the bouncer is registered with open(f"{CONFIG}.id") as f: bouncer_name = f.read().strip() c = pexpect.spawn("/usr/bin/sh", ["scripts/uninstall.sh"], encoding="utf-8", cwd=project_repo) c.expect(f"{BOUNCER} has been successfully uninstalled") c.wait() assert c.terminated assert c.exitstatus == 0 # installed files assert not os.path.exists(CONFIG) assert not os.path.exists(f"/usr/local/bin/{BOUNCER}") # the bouncer is unregistered assert len(list(cscli.get_bouncers(name=bouncer_name))) == 0 ================================================ FILE: test/tests/pkg/__init__.py ================================================ ================================================ FILE: test/tests/pkg/test_build_deb.py ================================================ import pytest pytestmark = pytest.mark.deb # This test has the side effect of building the package and leaving it in the # project's parent directory. def test_deb_build(deb_package, skip_unless_deb): """Test that the package can be built.""" assert deb_package.exists(), f"Package {deb_package} not found" ================================================ FILE: test/tests/pkg/test_build_rpm.py ================================================ import pytest pytestmark = pytest.mark.rpm def test_rpm_build(rpm_package, skip_unless_rpm): """Test that the package can be built.""" assert rpm_package.exists(), f"Package {rpm_package} not found" ================================================ FILE: test/tests/pkg/test_scripts_nonroot.py ================================================ import os import subprocess def test_scripts_nonroot(project_repo, bouncer_binary, must_be_nonroot): assert os.geteuid() != 0, "This test must be run as non-root" for script in ["install.sh", "upgrade.sh", "uninstall.sh"]: c = subprocess.run( ["/usr/bin/sh", f"scripts/{script}"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=project_repo, encoding="utf-8", ) assert c.returncode == 1 assert c.stdout == "" assert "This script must be run as root" in c.stderr