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
================================================
📚 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