├── .envrc
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.yaml
│ ├── config.yml
│ └── feature_request.yaml
├── governance.yml
├── release-drafter.yml
├── release.py
└── workflows
│ ├── build-binary-package.yml
│ ├── governance-bot.yaml
│ ├── lint.yml
│ ├── release-drafter.yml
│ ├── tests.yml
│ └── tests_deb.yml
├── .gitignore
├── .golangci.yml
├── LICENSE
├── Makefile
├── README.md
├── cmd
└── root.go
├── config
├── crowdsec-firewall-bouncer.service
└── crowdsec-firewall-bouncer.yaml
├── debian
├── changelog
├── compat
├── control
├── crowdsec-firewall-bouncer-iptables.postinst
├── crowdsec-firewall-bouncer-iptables.postrm
├── crowdsec-firewall-bouncer-iptables.preinst
├── crowdsec-firewall-bouncer-iptables.prerm
├── crowdsec-firewall-bouncer-nftables.postinst
├── crowdsec-firewall-bouncer-nftables.postrm
├── crowdsec-firewall-bouncer-nftables.preinst
├── crowdsec-firewall-bouncer-nftables.prerm
└── rules
├── docs
└── assets
│ └── crowdsec_linux_logo.png
├── flake.lock
├── flake.nix
├── go.mod
├── go.sum
├── main.go
├── pkg
├── backend
│ └── backend.go
├── cfg
│ ├── config.go
│ └── logging.go
├── dryrun
│ └── dryrun.go
├── ipsetcmd
│ └── ipset.go
├── iptables
│ ├── iptables.go
│ ├── iptables_context.go
│ ├── iptables_stub.go
│ └── metrics.go
├── metrics
│ └── metrics.go
├── nftables
│ ├── metrics.go
│ ├── nftables.go
│ ├── nftables_context.go
│ └── nftables_stub.go
├── pf
│ ├── metrics.go
│ ├── metrics_test.go
│ ├── pf.go
│ └── pf_context.go
└── types
│ └── types.go
├── rpm
├── SOURCES
│ └── 80-crowdsec-firewall-bouncer.preset
└── SPECS
│ └── crowdsec-firewall-bouncer.spec
├── scripts
├── _bouncer.sh
├── install.sh
├── uninstall.sh
└── upgrade.sh
└── test
├── .python-version
├── README.md
├── default.env
├── pyproject.toml
├── pytest.ini
├── tests
├── __init__.py
├── backends
│ ├── __init__.py
│ ├── iptables
│ │ ├── __init__.py
│ │ ├── crowdsec-firewall-bouncer-logging.yaml
│ │ ├── crowdsec-firewall-bouncer.yaml
│ │ └── test_iptables.py
│ ├── mock_lapi.py
│ ├── nftables
│ │ ├── __init__.py
│ │ ├── crowdsec-firewall-bouncer.yaml
│ │ └── test_nftables.py
│ └── utils.py
├── bouncer
│ ├── __init__.py
│ ├── test_firewall_bouncer.py
│ ├── test_iptables_deny_action.py
│ ├── test_tls.py
│ └── test_yaml_local.py
├── conftest.py
├── install
│ ├── __init__.py
│ ├── no_crowdsec
│ │ ├── __init__.py
│ │ ├── test_no_crowdsec_deb.py
│ │ └── test_no_crowdsec_scripts.py
│ └── with_crowdsec
│ │ ├── __init__.py
│ │ ├── test_crowdsec_deb.py
│ │ └── test_crowdsec_scripts.py
└── pkg
│ ├── __init__.py
│ ├── test_build_deb.py
│ ├── test_build_rpm.py
│ └── test_scripts_nonroot.py
└── uv.lock
/.envrc:
--------------------------------------------------------------------------------
1 | use flake
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yaml:
--------------------------------------------------------------------------------
1 | name: Bug report
2 | description: Report a bug encountered while operating crowdsec
3 | labels: kind/bug
4 | body:
5 | - type: textarea
6 | id: problem
7 | attributes:
8 | label: What happened?
9 | description: |
10 | Please provide as much info as possible. Not doing so may result in your bug not being addressed in a timely manner.
11 | If this matter is security related, please disclose it privately to security@crowdsec.net
12 | validations:
13 | required: true
14 |
15 | - type: textarea
16 | id: expected
17 | attributes:
18 | label: What did you expect to happen?
19 | validations:
20 | required: true
21 |
22 | - type: textarea
23 | id: repro
24 | attributes:
25 | label: How can we reproduce it (as minimally and precisely as possible)?
26 | validations:
27 | required: true
28 |
29 | - type: textarea
30 | id: additional
31 | attributes:
32 | label: Anything else we need to know?
33 |
34 | - type: textarea
35 | id: Version
36 | attributes:
37 | label: version
38 | value: |
39 | remediation component version:
40 |
41 |
42 |
43 | ```console
44 | $ crowdsec-firewall-bouncer --version
45 | # paste output here
46 | ```
47 |
48 |
49 | validations:
50 | required: true
51 | - type: textarea
52 | id: CS-Version
53 | attributes:
54 | label: crowdsec version
55 | value: |
56 | crowdsec version:
57 |
58 |
59 |
60 | ```console
61 | $ crowdsec --version
62 | # paste output here
63 | ```
64 |
65 |
66 | validations:
67 | required: true
68 |
69 | - type: textarea
70 | id: osVersion
71 | attributes:
72 | label: OS version
73 | value: |
74 |
75 |
76 | ```console
77 | # On Linux:
78 | $ cat /etc/os-release
79 | # paste output here
80 | $ uname -a
81 | # paste output here
82 |
83 | # On Windows:
84 | C:\> wmic os get Caption, Version, BuildNumber, OSArchitecture
85 | # paste output here
86 | ```
87 |
88 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | contact_links:
2 | - name: Support Request
3 | url: https://discourse.crowdsec.net
4 | about: Support request or question relating to Crowdsec
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yaml:
--------------------------------------------------------------------------------
1 | name: Feature request
2 | description: Suggest an improvement or a new feature
3 | body:
4 | - type: textarea
5 | id: feature
6 | attributes:
7 | label: What would you like to be added?
8 | description: |
9 | Significant feature requests are unlikely to make progress as issues. Please consider engaging on discord (discord.gg/crowdsec) and forums (https://discourse.crowdsec.net), instead.
10 | value: |
11 | For feature request please pick a kind label by removing `` that wrap the example lines below
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | validations:
20 | required: true
21 |
22 | - type: textarea
23 | id: rationale
24 | attributes:
25 | label: Why is this needed?
26 | validations:
27 | required: true
28 |
--------------------------------------------------------------------------------
/.github/governance.yml:
--------------------------------------------------------------------------------
1 | version: v1
2 |
3 | issue:
4 | captures:
5 | - regex: 'version: v(.+)-'
6 | github_release: true
7 | ignore_case: true
8 | label: 'version/$CAPTURED'
9 |
10 | labels:
11 | - prefix: triage
12 | list: ['accepted']
13 | multiple: false
14 | author_association:
15 | collaborator: true
16 | member: true
17 | owner: true
18 | needs:
19 | comment: |
20 | @$AUTHOR: Thanks for opening an issue, it is currently awaiting triage.
21 |
22 | In the meantime, you can:
23 |
24 | 1. Check [Documentation](https://docs.crowdsec.net/docs/next/bouncers/firewall) to see if your issue can be self resolved.
25 | 2. You can also join our [Discord](https://discord.gg/crowdsec)
26 |
27 | - prefix: kind
28 | list: ['feature', 'bug', 'packaging', 'enhancement']
29 | multiple: false
30 | author_association:
31 | author: true
32 | collaborator: true
33 | member: true
34 | owner: true
35 | needs:
36 | comment: |
37 | @$AUTHOR: There are no 'kind' label on this issue. You need a 'kind' label to start the triage process.
38 | * `/kind feature`
39 | * `/kind enhancement`
40 | * `/kind bug`
41 | * `/kind packaging`
42 |
--------------------------------------------------------------------------------
/.github/release-drafter.yml:
--------------------------------------------------------------------------------
1 | template: |
2 | ## What’s Changed
3 |
4 | $CHANGES
5 |
--------------------------------------------------------------------------------
/.github/release.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import argparse
4 | import json
5 | import os
6 | import shutil
7 | import subprocess
8 | import sys
9 |
10 |
11 | def _goos():
12 | yield 'linux'
13 | yield 'freebsd'
14 |
15 |
16 | def _goarch(goos):
17 | yield '386'
18 | yield 'amd64'
19 | yield 'arm'
20 | yield 'arm64'
21 | if goos == 'linux':
22 | yield 'ppc64le'
23 | yield 's390x'
24 | yield 'riscv64'
25 |
26 |
27 | def _goarm(goarch):
28 | if goarch != 'arm':
29 | yield ''
30 | return
31 | yield '6'
32 | yield '7'
33 |
34 |
35 | def _build_tarball(os):
36 | if os == 'linux':
37 | yield True
38 | else:
39 | yield False
40 |
41 |
42 | def filename_for_entry(prog_name, entry):
43 | arch = entry['goarch']
44 | if entry['goarch'] == 'arm':
45 | arch += 'v' + entry['goarm']
46 | ret = f'{prog_name}-{entry["goos"]}-{arch}'
47 | if entry['build_tarball']:
48 | ret += '.tgz'
49 | return ret
50 |
51 |
52 | def matrix(prog_name):
53 | for goos in _goos():
54 | for goarch in _goarch(goos):
55 | for goarm in _goarm(goarch):
56 | for build_tarball in _build_tarball(goos):
57 | yield {
58 | 'goos': goos,
59 | 'goarch': goarch,
60 | 'goarm': goarm,
61 | 'build_tarball': build_tarball,
62 | }
63 |
64 |
65 | def print_matrix(prog_name):
66 | j = {'include': list(matrix(prog_name))}
67 |
68 | if os.isatty(sys.stdout.fileno()):
69 | print(json.dumps(j, indent=2))
70 | else:
71 | print(json.dumps(j))
72 |
73 |
74 | default_tarball = {
75 | 'goos': 'linux',
76 | 'goarch': 'amd64',
77 | 'goarm': '',
78 | 'build_tarball': True,
79 | }
80 |
81 | default_binary = {
82 | 'goos': 'linux',
83 | 'goarch': 'amd64',
84 | 'goarm': '',
85 | 'build_tarball': False,
86 | }
87 |
88 |
89 | def run_build(prog_name):
90 | # call the makefile for each matrix entry
91 |
92 | default_tarball_filename = None
93 | default_binary_filename = None
94 |
95 | for entry in matrix(prog_name):
96 | env = {'GOOS': entry['goos'], 'GOARCH': entry['goarch']}
97 |
98 | if entry['goarm']:
99 | env['GOARM'] = entry['goarm']
100 |
101 | if entry['build_tarball']:
102 | target = 'tarball'
103 | else:
104 | target = 'binary'
105 |
106 | print(f"Running make {target} for {env}")
107 |
108 | subprocess.run(['make', target], env=os.environ | env, check=True)
109 |
110 | want_filename = filename_for_entry(prog_name, entry)
111 |
112 | if entry['build_tarball']:
113 | os.rename(f'{prog_name}.tgz', want_filename)
114 | else:
115 | os.rename(f'{prog_name}', want_filename)
116 |
117 | # if this is the default tarball or binary, save the filename
118 | # we'll use it later to publish a "default" package
119 |
120 | if entry == default_tarball:
121 | default_tarball_filename = want_filename
122 |
123 | if entry == default_binary:
124 | default_binary_filename = want_filename
125 |
126 | # Remove the directory to reuse it
127 | subprocess.run(['make', 'clean-release-dir'], env=os.environ | env, check=True)
128 |
129 | # publish the default tarball and binary
130 | if default_tarball_filename:
131 | shutil.copy(default_tarball_filename, f'{prog_name}.tgz')
132 |
133 | if default_binary_filename:
134 | shutil.copy(default_binary_filename, f'{prog_name}')
135 |
136 |
137 | def main():
138 | parser = argparse.ArgumentParser(
139 | description='Build release binaries and tarballs for all supported platforms')
140 | parser.add_argument('action', help='Action to perform (ex. run-build, print-matrix)')
141 | parser.add_argument('prog_name', help='Name of the program (ex. crowdsec-firewall-bouncer)')
142 |
143 | args = parser.parse_args()
144 |
145 | if args.action == 'print-matrix':
146 | print_matrix(args.prog_name)
147 |
148 | if args.action == 'run-build':
149 | run_build(args.prog_name)
150 |
151 |
152 | if __name__ == '__main__':
153 | main()
154 |
--------------------------------------------------------------------------------
/.github/workflows/build-binary-package.yml:
--------------------------------------------------------------------------------
1 | name: build-binary-package
2 |
3 | on:
4 | release:
5 | types:
6 | - prereleased
7 |
8 | permissions:
9 | # Use write for: hub release edit
10 | contents: write
11 |
12 | env:
13 | PROGRAM_NAME: crowdsec-firewall-bouncer
14 |
15 | jobs:
16 | build:
17 | name: Build and upload all platforms
18 | runs-on: ubuntu-latest
19 |
20 | steps:
21 |
22 | - name: Check out repository
23 | uses: actions/checkout@v4
24 | with:
25 | fetch-depth: 0
26 |
27 | - name: Set up Go
28 | uses: actions/setup-go@v5
29 | with:
30 | go-version-file: go.mod
31 |
32 | - name: Build all platforms
33 | run: |
34 | # build platform-all first so the .xz vendor file is not removed
35 | make platform-all vendor
36 |
37 | - name: Upload to release
38 | env:
39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
40 | run: |
41 | tag_name="${GITHUB_REF##*/}"
42 | # this will upload the $PROGRAM_NAME-vendor.tar.xz file as well
43 | gh release upload "$tag_name" $PROGRAM_NAME* vendor.tgz
44 |
--------------------------------------------------------------------------------
/.github/workflows/governance-bot.yaml:
--------------------------------------------------------------------------------
1 | # .github/workflow/governance.yml
2 |
3 | on:
4 | pull_request_target:
5 | types: [ synchronize, opened, labeled, unlabeled ]
6 | issues:
7 | types: [ opened, labeled, unlabeled ]
8 | issue_comment:
9 | types: [ created ]
10 |
11 | # You can use permissions to modify the default permissions granted to the GITHUB_TOKEN,
12 | # adding or removing access as required, so that you only allow the minimum required access.
13 | permissions:
14 | contents: read
15 | issues: write
16 | pull-requests: write
17 | statuses: write
18 | checks: write
19 |
20 | jobs:
21 | governance:
22 | name: Governance
23 | runs-on: ubuntu-latest
24 | steps:
25 | # Semantic versioning, lock to different version: v2, v2.0 or a commit hash.
26 | - uses: BirthdayResearch/oss-governance-bot@v3
27 | with:
28 | # You can use a PAT to post a comment/label/status so that it shows up as a user instead of github-actions
29 | github-token: ${{secrets.GITHUB_TOKEN}} # optional, default to '${{ github.token }}'
30 | config-path: .github/governance.yml # optional, default to '.github/governance.yml'
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Static Analysis
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 |
10 | jobs:
11 | build:
12 | name: "golangci-lint + codeql"
13 | runs-on: ubuntu-latest
14 |
15 | permissions:
16 | security-events: write
17 |
18 | steps:
19 | - name: Check out code into the Go module directory
20 | uses: actions/checkout@v4
21 | with:
22 | fetch-depth: 0
23 |
24 | - name: Set up Go
25 | uses: actions/setup-go@v5
26 | with:
27 | go-version-file: go.mod
28 |
29 | - name: Initialize CodeQL
30 | uses: github/codeql-action/init@v3
31 | with:
32 | languages: go, python
33 |
34 | - name: Build
35 | run: |
36 | make build
37 |
38 | - name: golangci-lint
39 | uses: golangci/golangci-lint-action@v7
40 | with:
41 | version: v2.1
42 | args: --issues-exit-code=1 --timeout 10m
43 | only-new-issues: false
44 |
45 | - name: Perform CodeQL Analysis
46 | uses: github/codeql-action/analyze@v3
47 |
--------------------------------------------------------------------------------
/.github/workflows/release-drafter.yml:
--------------------------------------------------------------------------------
1 | name: Release Drafter
2 |
3 | on:
4 | push:
5 | # branches to consider in the event; optional, defaults to all
6 | branches:
7 | - main
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | update_release_draft:
14 | permissions:
15 | # write permission is required to create a github release
16 | contents: write
17 | # write permission is required for autolabeler
18 | # otherwise, read permission is required at least
19 | pull-requests: read
20 | runs-on: ubuntu-latest
21 | name: Update the release draft
22 | steps:
23 | # Drafts your next Release notes as Pull Requests are merged into "main"
24 | - uses: release-drafter/release-drafter@v6
25 | with:
26 | config-name: release-drafter.yml
27 | # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml
28 | # config-name: my-config.yml
29 | env:
30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Build + tests
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | build:
14 | name: "Build + tests"
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 |
19 | - name: Check out code into the Go module directory
20 | uses: actions/checkout@v4
21 | with:
22 | fetch-depth: 0
23 |
24 | - name: Set up Go
25 | uses: actions/setup-go@v5
26 | with:
27 | go-version-file: go.mod
28 |
29 | - name: Build
30 | run: |
31 | make build
32 |
33 | - name: Run unit tests
34 | run: |
35 | go install github.com/kyoh86/richgo@v0.3.12
36 | set -o pipefail
37 | make test | richgo testfilter
38 | env:
39 | RICHGO_FORCE_COLOR: 1
40 |
41 | - name: Install uv
42 | uses: astral-sh/setup-uv@v5
43 | with:
44 | version: 0.5.24
45 | enable-cache: true
46 | cache-dependency-glob: "test/uv.lock"
47 |
48 | - name: "Set up Python"
49 | uses: actions/setup-python@v5
50 | with:
51 | python-version-file: "test/.python-version"
52 |
53 | - name: Install the project
54 | working-directory: ./test
55 | run: uv sync --all-extras --dev
56 |
57 | - name: Install functional test dependencies
58 | run: |
59 | sudo apt update
60 | sudo apt install -y nftables iptables ipset
61 | docker network create net-test
62 |
63 | - name: Run functional tests
64 | env:
65 | CROWDSEC_TEST_VERSION: dev
66 | CROWDSEC_TEST_FLAVORS: full
67 | CROWDSEC_TEST_NETWORK: net-test
68 | CROWDSEC_TEST_TIMEOUT: 60
69 | PYTEST_ADDOPTS: --durations=0 -vv --color=yes -m "not (deb or rpm)"
70 | working-directory: ./test
71 | run: |
72 | # everything except for
73 | # - install (requires root, ignored by default)
74 | # - backends (requires root, ignored by default)
75 | # - deb/rpm (on their own workflows)
76 | uv run pytest
77 | # these need root
78 | sudo -E $(which uv) run pytest ./tests/backends
79 | sudo -E $(which uv) run pytest ./tests/install/no_crowdsec
80 | # these need a running crowdsec
81 | docker run -d --name crowdsec -e CI_TESTING=true -e DISABLE_ONLINE_API=true -ti crowdsecurity/crowdsec
82 | install -m 0755 /dev/stdin /usr/local/bin/cscli <<'EOT'
83 | #!/bin/sh
84 | docker exec crowdsec cscli "$@"
85 | EOT
86 | sleep 5
87 | sudo -E $(which uv) run pytest ./tests/install/with_crowdsec
88 |
89 | - name: Lint
90 | working-directory: ./test
91 | run: |
92 | uv run ruff check
93 | uv run basedpyright
94 |
95 |
--------------------------------------------------------------------------------
/.github/workflows/tests_deb.yml:
--------------------------------------------------------------------------------
1 | name: Test .deb packaging
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | build:
14 | name: "Test .deb packages"
15 | runs-on: ubuntu-latest
16 |
17 | steps:
18 |
19 | - name: Check out code into the Go module directory
20 | uses: actions/checkout@v4
21 | with:
22 | fetch-depth: 0
23 |
24 | - name: Set up Go
25 | uses: actions/setup-go@v5
26 | with:
27 | go-version-file: go.mod
28 |
29 | - name: Install uv
30 | uses: astral-sh/setup-uv@v5
31 | with:
32 | version: 0.5.24
33 | enable-cache: true
34 | cache-dependency-glob: "test/uv.lock"
35 |
36 | - name: "Set up Python"
37 | uses: actions/setup-python@v5
38 | with:
39 | python-version-file: "test/.python-version"
40 |
41 | - name: Install the project
42 | run: uv sync --all-extras --dev
43 | working-directory: ./test
44 |
45 | - name: Install functional test dependencies
46 | run: |
47 | sudo apt update
48 | sudo apt install -y nftables iptables ipset build-essential debhelper devscripts fakeroot lintian
49 | docker network create net-test
50 |
51 | - name: Run functional tests
52 | env:
53 | CROWDSEC_TEST_VERSION: dev
54 | CROWDSEC_TEST_FLAVORS: full
55 | CROWDSEC_TEST_NETWORK: net-test
56 | CROWDSEC_TEST_TIMEOUT: 60
57 | PYTEST_ADDOPTS: --durations=0 -vv --color=yes
58 | working-directory: ./test
59 | run: |
60 | uv run pytest ./tests/pkg/test_build_deb.py
61 | sudo -E $(which uv) run pytest -m deb ./tests/install/no_crowdsec
62 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Test binary, built with `go test -c`
2 | *.test
3 |
4 | # Output of the go coverage tool, specifically when used with LiteIDE
5 | *.out
6 |
7 | # Dependencies are not vendored by default, but a tarball is created by "make vendor"
8 | # and provided in the release. Used by freebsd, gentoo, etc.
9 | vendor/
10 | vendor.tgz
11 |
12 | # Python
13 | __pycache__/
14 | *.py[cod]
15 | *$py.class
16 | venv/
17 |
18 | # built by make
19 | /crowdsec-firewall-bouncer
20 | /crowdsec-firewall-bouncer-*
21 | /crowdsec-firewall-bouncer.tgz
22 |
23 | # built by dpkg-buildpackage
24 | /debian/crowdsec-firewall-bouncer-iptables
25 | /debian/crowdsec-firewall-bouncer-nftables
26 | /debian/files
27 | /debian/*.substvars
28 | /debian/*.debhelper
29 | /debian/*-stamp
30 |
31 | # built by rpmbuild
32 | /rpm/BUILD
33 | /rpm/BUILDROOT
34 | /rpm/RPMS
35 | /rpm/SOURCES/*.tar.gz
36 | /rpm/SRPMS
37 |
38 | # nix generated dirs
39 | .direnv
40 | .devenv
41 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | version: "2"
2 |
3 | linters:
4 | default: all
5 | disable:
6 | - cyclop # revive
7 | - funlen # revive
8 | - gocognit # revive
9 | - gocyclo # revive
10 | - lll # revive
11 |
12 | - dupl
13 | - err113
14 | - exhaustruct
15 | - gochecknoglobals
16 | - goconst
17 | - godox
18 | - gosec
19 | - ireturn
20 | - maintidx
21 | - mnd
22 | - nlreturn
23 | - paralleltest
24 | - tagliatelle
25 | - testpackage
26 | - unparam
27 | - varnamelen
28 | - whitespace
29 | - wrapcheck
30 | - wsl
31 | - funcorder
32 | settings:
33 |
34 | depguard:
35 | rules:
36 | main:
37 | deny:
38 | - pkg: github.com/pkg/errors
39 | desc: errors.Wrap() is deprecated in favor of fmt.Errorf()
40 |
41 | errcheck:
42 | check-type-assertions: false
43 |
44 | gocritic:
45 | enable-all: true
46 | disabled-checks:
47 | - appendCombine
48 | - paramTypeCombine
49 | - sloppyReassign
50 | - unnamedResult
51 | - importShadow
52 |
53 | govet:
54 | disable:
55 | - fieldalignment
56 | enable-all: true
57 |
58 | misspell:
59 | locale: US
60 |
61 | nestif:
62 | # lower this after refactoring
63 | min-complexity: 13
64 |
65 | nlreturn:
66 | block-size: 4
67 |
68 | nolintlint:
69 | require-explanation: false
70 | require-specific: false
71 | allow-unused: false
72 |
73 | revive:
74 | severity: error
75 | enable-all-rules: true
76 | rules:
77 | - name: add-constant
78 | disabled: true
79 | - name: cognitive-complexity
80 | arguments:
81 | # lower this after refactoring
82 | - 49
83 | - name: comment-spacings
84 | disabled: true
85 | - name: confusing-results
86 | disabled: true
87 | - name: cyclomatic
88 | arguments:
89 | # lower this after refactoring
90 | - 28
91 | - name: flag-parameter
92 | disabled: true
93 | - name: function-length
94 | arguments:
95 | # lower this after refactoring
96 | - 74
97 | - 149
98 | - name: import-alias-naming
99 | disabled: true
100 | - name: import-shadowing
101 | disabled: true
102 | - name: line-length-limit
103 | disabled: true
104 | - name: nested-structs
105 | disabled: true
106 | - name: exported
107 | disabled: true
108 | - name: unexported-return
109 | disabled: true
110 | - name: unhandled-error
111 | arguments:
112 | - fmt.Print
113 | - fmt.Printf
114 | - fmt.Println
115 | - name: function-result-limit
116 | arguments:
117 | - 5
118 | staticcheck:
119 | checks:
120 | - all
121 | wsl:
122 | allow-trailing-comment: true
123 | exclusions:
124 | presets:
125 | - comments
126 | - common-false-positives
127 | - legacy
128 | - std-error-handling
129 | rules:
130 | - linters:
131 | - govet
132 | text: 'shadow: declaration of "(err|ctx)" shadows declaration'
133 |
134 | - linters:
135 | - perfsprint
136 | text: fmt.Sprintf can be replaced .*
137 | paths:
138 | - third_party$
139 | - builtin$
140 | - examples$
141 |
142 | issues:
143 | max-issues-per-linter: 0
144 | max-same-issues: 0
145 |
146 | formatters:
147 | settings:
148 | gci:
149 | sections:
150 | - standard
151 | - default
152 | - prefix(github.com/crowdsecurity)
153 | - prefix(github.com/crowdsecurity/crowdsec)
154 | - prefix(github.com/crowdsecurity/cs-firewall-bouncer)
155 | exclusions:
156 | paths:
157 | - third_party$
158 | - builtin$
159 | - examples$
160 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020-2021 crowdsecurity
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | GO = go
2 | GOBUILD = $(GO) build
3 | GOTEST = $(GO) test
4 |
5 | BINARY_NAME=crowdsec-firewall-bouncer
6 | TARBALL_NAME=$(BINARY_NAME).tgz
7 |
8 | ifdef BUILD_STATIC
9 | $(warning WARNING: The BUILD_STATIC variable is deprecated and has no effect. Builds are static by default now.)
10 | endif
11 |
12 | # Versioning information can be overridden in the environment
13 | BUILD_VERSION?=$(shell git describe --tags)
14 | BUILD_TIMESTAMP?=$(shell date +%F"_"%T)
15 | BUILD_TAG?=$(shell git rev-parse HEAD)
16 |
17 | LD_OPTS_VARS=\
18 | -X 'github.com/crowdsecurity/go-cs-lib/version.Version=$(BUILD_VERSION)' \
19 | -X 'github.com/crowdsecurity/go-cs-lib/version.BuildDate=$(BUILD_TIMESTAMP)' \
20 | -X 'github.com/crowdsecurity/go-cs-lib/version.Tag=$(BUILD_TAG)'
21 |
22 | ifneq (,$(DOCKER_BUILD))
23 | LD_OPTS_VARS += -X 'github.com/crowdsecurity/go-cs-lib/version.System=docker'
24 | endif
25 |
26 | export CGO_ENABLED=0
27 | export LD_OPTS=-ldflags "-s -extldflags '-static' $(LD_OPTS_VARS)" \
28 | -trimpath -tags netgo
29 |
30 | .PHONY: all
31 | all: build test
32 |
33 | # same as "$(MAKE) -f debian/rules clean" but without the dependency on debhelper
34 | .PHONY: clean-debian
35 | clean-debian:
36 | @$(RM) -r debian/crowdsec-firewall-bouncer-iptables
37 | @$(RM) -r debian/crowdsec-firewall-bouncer-nftables
38 | @$(RM) -r debian/files
39 | @$(RM) -r debian/.debhelper
40 | @$(RM) -r debian/*.substvars
41 | @$(RM) -r debian/*-stamp
42 |
43 | .PHONY: clean-rpm
44 | clean-rpm:
45 | @$(RM) -r rpm/BUILD
46 | @$(RM) -r rpm/BUILDROOT
47 | @$(RM) -r rpm/RPMS
48 | @$(RM) -r rpm/SOURCES/*.tar.gz
49 | @$(RM) -r rpm/SRPMS
50 |
51 | # Remove everything including all platform binaries and tarballs
52 | .PHONY: clean
53 | clean: clean-release-dir clean-debian clean-rpm
54 | @$(RM) $(BINARY_NAME)
55 | @$(RM) $(TARBALL_NAME)
56 | @$(RM) -r $(BINARY_NAME)-* # platform binary name and leftover release dir
57 | @$(RM) $(BINARY_NAME)-*.tgz # platform release file
58 |
59 | #
60 | # Build binaries
61 | #
62 |
63 | .PHONY: binary
64 | binary:
65 | $(GOBUILD) $(LD_OPTS) -o $(BINARY_NAME)
66 |
67 | .PHONY: build
68 | build: clean binary
69 |
70 | #
71 | # Unit and integration tests
72 | #
73 |
74 | .PHONY: lint
75 | lint:
76 | golangci-lint run
77 |
78 | .PHONY: test
79 | test:
80 | @$(GOTEST) $(LD_OPTS) ./...
81 |
82 | .PHONY: func-tests
83 | func-tests: build
84 | pipenv install --dev
85 | pipenv run pytest -v
86 |
87 | #
88 | # Build release tarballs
89 | #
90 |
91 | RELDIR = $(BINARY_NAME)-$(BUILD_VERSION)
92 |
93 | .PHONY: vendor
94 | vendor: vendor-remove
95 | $(GO) mod vendor
96 | tar czf vendor.tgz vendor
97 | tar --create --auto-compress --file=$(RELDIR)-vendor.tar.xz vendor
98 |
99 | .PHONY: vendor-remove
100 | vendor-remove:
101 | $(RM) -r vendor vendor.tgz *-vendor.tar.xz
102 |
103 | # Called during platform-all, to reuse the directory for other platforms
104 | .PHONY: clean-release-dir
105 | clean-release-dir:
106 | @$(RM) -r $(RELDIR)
107 |
108 | .PHONY: tarball
109 | tarball: binary
110 | @if [ -z $(BUILD_VERSION) ]; then BUILD_VERSION="local" ; fi
111 | @if [ -d $(RELDIR) ]; then echo "$(RELDIR) already exists, please run 'make clean' and retry" ; exit 1 ; fi
112 | @echo Building Release to dir $(RELDIR)
113 | @mkdir -p $(RELDIR)/scripts
114 | @cp $(BINARY_NAME) $(RELDIR)/
115 | @cp -R ./config $(RELDIR)/
116 | @cp ./scripts/install.sh $(RELDIR)/
117 | @cp ./scripts/uninstall.sh $(RELDIR)/
118 | @cp ./scripts/upgrade.sh $(RELDIR)/
119 | @cp ./scripts/_bouncer.sh $(RELDIR)/scripts/
120 | @chmod +x $(RELDIR)/install.sh
121 | @chmod +x $(RELDIR)/uninstall.sh
122 | @chmod +x $(RELDIR)/upgrade.sh
123 | @tar cvzf $(TARBALL_NAME) $(RELDIR)
124 |
125 | .PHONY: release
126 | release: clean tarball
127 |
128 | #
129 | # Build binaries and release tarballs for all platforms
130 | #
131 |
132 | .PHONY: platform-all
133 | platform-all: clean
134 | python3 .github/release.py run-build $(BINARY_NAME)
135 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | 📚 Documentation
10 | 💠 Hub
11 | 💬 Discourse
12 |
13 |
14 |
15 | # crowdsec-firewall-bouncer
16 | Crowdsec bouncer written in golang for firewalls.
17 |
18 | crowdsec-firewall-bouncer will fetch new and old decisions from a CrowdSec API to add them in a blocklist used by supported firewalls.
19 |
20 | Supported firewalls:
21 | - iptables (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: )
22 | - nftables (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: )
23 | - ipset only (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: )
24 | - pf (IPV4 :heavy_check_mark: / IPV6 :heavy_check_mark: )
25 |
26 | # Installation
27 |
28 | Please follow the [official documentation](https://doc.crowdsec.net/docs/bouncers/firewall).
29 |
--------------------------------------------------------------------------------
/cmd/root.go:
--------------------------------------------------------------------------------
1 | package cmd
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "flag"
7 | "fmt"
8 | "net"
9 | "net/http"
10 | "os"
11 | "os/signal"
12 | "slices"
13 | "strings"
14 | "syscall"
15 |
16 | "github.com/prometheus/client_golang/prometheus"
17 | "github.com/prometheus/client_golang/prometheus/promhttp"
18 | log "github.com/sirupsen/logrus"
19 | "golang.org/x/sync/errgroup"
20 |
21 | csbouncer "github.com/crowdsecurity/go-cs-bouncer"
22 | "github.com/crowdsecurity/go-cs-lib/csdaemon"
23 | "github.com/crowdsecurity/go-cs-lib/csstring"
24 | "github.com/crowdsecurity/go-cs-lib/version"
25 |
26 | "github.com/crowdsecurity/crowdsec/pkg/models"
27 |
28 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/backend"
29 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg"
30 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics"
31 | )
32 |
33 | const bouncerType = "crowdsec-firewall-bouncer"
34 |
35 | func backendCleanup(backend *backend.BackendCTX) {
36 | log.Info("Shutting down backend")
37 |
38 | if err := backend.ShutDown(); err != nil {
39 | log.Errorf("while shutting down backend: %s", err)
40 | }
41 | }
42 |
43 | func HandleSignals(ctx context.Context) error {
44 | signalChan := make(chan os.Signal, 1)
45 | signal.Notify(signalChan, syscall.SIGTERM, os.Interrupt)
46 |
47 | select {
48 | case s := <-signalChan:
49 | switch s {
50 | case syscall.SIGTERM:
51 | return errors.New("received SIGTERM")
52 | case os.Interrupt: // cross-platform SIGINT
53 | return errors.New("received interrupt")
54 | }
55 | case <-ctx.Done():
56 | return ctx.Err()
57 | }
58 |
59 | return nil
60 | }
61 |
62 | func deleteDecisions(backend *backend.BackendCTX, decisions []*models.Decision, config *cfg.BouncerConfig) {
63 | nbDeletedDecisions := 0
64 |
65 | for _, d := range decisions {
66 | if !slices.Contains(config.SupportedDecisionsTypes, strings.ToLower(*d.Type)) {
67 | log.Debugf("decisions for ip '%s' will not be deleted because its type is '%s'", *d.Value, *d.Type)
68 | continue
69 | }
70 |
71 | if err := backend.Delete(d); err != nil {
72 | if !strings.Contains(err.Error(), "netlink receive: no such file or directory") {
73 | log.Errorf("unable to delete decision for '%s': %s", *d.Value, err)
74 | }
75 |
76 | continue
77 | }
78 |
79 | log.Debugf("deleted %s", *d.Value)
80 |
81 | nbDeletedDecisions++
82 | }
83 |
84 | noun := "decisions"
85 | if nbDeletedDecisions == 1 {
86 | noun = "decision"
87 | }
88 |
89 | if nbDeletedDecisions > 0 {
90 | log.Debug("committing expired decisions")
91 |
92 | if err := backend.Commit(); err != nil {
93 | log.Errorf("unable to commit expired decisions %v", err)
94 | return
95 | }
96 |
97 | log.Debug("committed expired decisions")
98 | log.Infof("%d %s deleted", nbDeletedDecisions, noun)
99 | }
100 | }
101 |
102 | func addDecisions(backend *backend.BackendCTX, decisions []*models.Decision, config *cfg.BouncerConfig) {
103 | nbNewDecisions := 0
104 |
105 | for _, d := range decisions {
106 | if !slices.Contains(config.SupportedDecisionsTypes, strings.ToLower(*d.Type)) {
107 | log.Debugf("decisions for ip '%s' will not be added because its type is '%s'", *d.Value, *d.Type)
108 | continue
109 | }
110 |
111 | if err := backend.Add(d); err != nil {
112 | log.Errorf("unable to insert decision for '%s': %s", *d.Value, err)
113 | continue
114 | }
115 |
116 | log.Debugf("Adding '%s' for '%s'", *d.Value, *d.Duration)
117 |
118 | nbNewDecisions++
119 | }
120 |
121 | noun := "decisions"
122 | if nbNewDecisions == 1 {
123 | noun = "decision"
124 | }
125 |
126 | if nbNewDecisions > 0 {
127 | log.Debug("committing added decisions")
128 |
129 | if err := backend.Commit(); err != nil {
130 | log.Errorf("unable to commit add decisions %v", err)
131 | return
132 | }
133 |
134 | log.Debug("committed added decisions")
135 | log.Infof("%d %s added", nbNewDecisions, noun)
136 | }
137 | }
138 |
139 | func Execute() error {
140 | configPath := flag.String("c", "", "path to crowdsec-firewall-bouncer.yaml")
141 | verbose := flag.Bool("v", false, "set verbose mode")
142 | bouncerVersion := flag.Bool("V", false, "display version and exit (deprecated)")
143 | flag.BoolVar(bouncerVersion, "version", *bouncerVersion, "display version and exit")
144 | testConfig := flag.Bool("t", false, "test config and exit")
145 | showConfig := flag.Bool("T", false, "show full config (.yaml + .yaml.local) and exit")
146 |
147 | flag.Parse()
148 |
149 | if *bouncerVersion {
150 | fmt.Fprint(os.Stdout, version.FullString())
151 | return nil
152 | }
153 |
154 | if configPath == nil || *configPath == "" {
155 | return errors.New("configuration file is required")
156 | }
157 |
158 | configMerged, err := cfg.MergedConfig(*configPath)
159 | if err != nil {
160 | return fmt.Errorf("unable to read config file: %w", err)
161 | }
162 |
163 | if *showConfig {
164 | fmt.Fprintln(os.Stdout, string(configMerged))
165 | return nil
166 | }
167 |
168 | configExpanded := csstring.StrictExpand(string(configMerged), os.LookupEnv)
169 |
170 | config, err := cfg.NewConfig(strings.NewReader(configExpanded))
171 | if err != nil {
172 | return fmt.Errorf("unable to load configuration: %w", err)
173 | }
174 |
175 | if *verbose && !log.IsLevelEnabled(log.DebugLevel) {
176 | log.SetLevel(log.DebugLevel)
177 | }
178 |
179 | log.Infof("Starting %s %s", bouncerType, version.String())
180 |
181 | backend, err := backend.NewBackend(config)
182 | if err != nil {
183 | return err
184 | }
185 |
186 | if err = backend.Init(); err != nil {
187 | return err
188 | }
189 |
190 | defer backendCleanup(backend)
191 |
192 | bouncer := &csbouncer.StreamBouncer{}
193 |
194 | err = bouncer.ConfigReader(strings.NewReader(configExpanded))
195 | if err != nil {
196 | return err
197 | }
198 |
199 | bouncer.UserAgent = fmt.Sprintf("%s/%s", bouncerType, version.String())
200 | if err := bouncer.Init(); err != nil {
201 | return fmt.Errorf("unable to configure bouncer: %w", err)
202 | }
203 |
204 | if *testConfig {
205 | log.Info("config is valid")
206 | return nil
207 | }
208 |
209 | if bouncer.InsecureSkipVerify != nil {
210 | log.Debugf("InsecureSkipVerify is set to %t", *bouncer.InsecureSkipVerify)
211 | }
212 |
213 | g, ctx := errgroup.WithContext(context.Background())
214 |
215 | g.Go(func() error {
216 | bouncer.Run(ctx)
217 | return errors.New("bouncer stream halted")
218 | })
219 |
220 | mHandler := metrics.Handler{
221 | Backend: backend,
222 | }
223 |
224 | metricsProvider, err := csbouncer.NewMetricsProvider(bouncer.APIClient, bouncerType, mHandler.MetricsUpdater, log.StandardLogger())
225 | if err != nil {
226 | return fmt.Errorf("unable to create metrics provider: %w", err)
227 | }
228 |
229 | g.Go(func() error {
230 | return metricsProvider.Run(ctx)
231 | })
232 |
233 | if config.Mode == cfg.IptablesMode || config.Mode == cfg.NftablesMode || config.Mode == cfg.IpsetMode || config.Mode == cfg.PfMode {
234 | metrics.Map.MustRegisterAll()
235 | }
236 |
237 | prometheus.MustRegister(csbouncer.TotalLAPICalls, csbouncer.TotalLAPIError)
238 |
239 | if config.PrometheusConfig.Enabled {
240 | go func() {
241 | http.Handle("/metrics", mHandler.ComputeMetricsHandler(promhttp.Handler()))
242 |
243 | listenOn := net.JoinHostPort(
244 | config.PrometheusConfig.ListenAddress,
245 | config.PrometheusConfig.ListenPort,
246 | )
247 | log.Infof("Serving metrics at %s", listenOn+"/metrics")
248 | log.Error(http.ListenAndServe(listenOn, nil))
249 | }()
250 | }
251 |
252 | g.Go(func() error {
253 | log.Infof("Processing new and deleted decisions . . .")
254 |
255 | for {
256 | select {
257 | case <-ctx.Done():
258 | return nil
259 | case decisions := <-bouncer.Stream:
260 | if decisions == nil {
261 | continue
262 | }
263 |
264 | deleteDecisions(backend, decisions.Deleted, config)
265 | addDecisions(backend, decisions.New, config)
266 | }
267 | }
268 | })
269 |
270 | if config.Daemon != nil {
271 | if *config.Daemon {
272 | log.Debug("Ignoring deprecated 'daemonize' option")
273 | } else {
274 | log.Warn("The 'daemonize' config option is deprecated and treated as always true")
275 | }
276 | }
277 |
278 | _ = csdaemon.Notify(csdaemon.Ready, log.StandardLogger())
279 |
280 | g.Go(func() error {
281 | return HandleSignals(ctx)
282 | })
283 |
284 | if err := g.Wait(); err != nil {
285 | return fmt.Errorf("process terminated with error: %w", err)
286 | }
287 |
288 | return nil
289 | }
290 |
--------------------------------------------------------------------------------
/config/crowdsec-firewall-bouncer.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=The firewall bouncer for CrowdSec
3 | After=syslog.target network.target remote-fs.target nss-lookup.target crowdsec.service
4 |
5 | [Service]
6 | Type=notify
7 | ExecStart=${BIN} -c ${CFG}/crowdsec-firewall-bouncer.yaml
8 | ExecStartPre=${BIN} -c ${CFG}/crowdsec-firewall-bouncer.yaml -t
9 | ExecStartPost=/bin/sleep 0.1
10 | Restart=always
11 | RestartSec=10
12 | LimitNOFILE=65536
13 | # don't send a termination signal to the children processes,
14 | # because the iptables backend needs to run ipset multiple times to properly shutdown
15 | KillMode=mixed
16 |
17 | [Install]
18 | WantedBy=multi-user.target
19 |
--------------------------------------------------------------------------------
/config/crowdsec-firewall-bouncer.yaml:
--------------------------------------------------------------------------------
1 | mode: ${BACKEND}
2 | update_frequency: 10s
3 | log_mode: file
4 | log_dir: /var/log/
5 | log_level: info
6 | log_compression: true
7 | log_max_size: 100
8 | log_max_backups: 3
9 | log_max_age: 30
10 | api_url: http://127.0.0.1:8080/
11 | api_key: ${API_KEY}
12 | ## TLS Authentication
13 | # cert_path: /etc/crowdsec/tls/cert.pem
14 | # key_path: /etc/crowdsec/tls/key.pem
15 | # ca_cert_path: /etc/crowdsec/tls/ca.crt
16 | insecure_skip_verify: false
17 | disable_ipv6: false
18 | deny_action: DROP
19 | deny_log: false
20 | supported_decisions_types:
21 | - ban
22 | #to change log prefix
23 | #deny_log_prefix: "crowdsec: "
24 | #to change the blacklists name
25 | blacklists_ipv4: crowdsec-blacklists
26 | blacklists_ipv6: crowdsec6-blacklists
27 | #type of ipset to use
28 | ipset_type: nethash
29 | #if present, insert rule in those chains
30 | iptables_chains:
31 | - INPUT
32 | # - FORWARD
33 | # - DOCKER-USER
34 | iptables_add_rule_comments: true
35 |
36 | ## nftables
37 | nftables:
38 | ipv4:
39 | enabled: true
40 | set-only: false
41 | table: crowdsec
42 | chain: crowdsec-chain
43 | priority: -10
44 | ipv6:
45 | enabled: true
46 | set-only: false
47 | table: crowdsec6
48 | chain: crowdsec6-chain
49 | priority: -10
50 |
51 | nftables_hooks:
52 | - input
53 | - forward
54 |
55 | # packet filter
56 | pf:
57 | # an empty string disables the anchor
58 | anchor_name: ""
59 |
60 | prometheus:
61 | enabled: false
62 | listen_addr: 127.0.0.1
63 | listen_port: 60601
64 |
--------------------------------------------------------------------------------
/debian/changelog:
--------------------------------------------------------------------------------
1 | crowdsec-firewall-bouncer (1.0.12) UNRELEASED; urgency=medium
2 |
3 | * debian package
4 | * pf support
5 |
6 | -- Manuel Sabban Mon, 08 Feb 2021 09:38:06 +0100
7 |
--------------------------------------------------------------------------------
/debian/compat:
--------------------------------------------------------------------------------
1 | 11
2 |
--------------------------------------------------------------------------------
/debian/control:
--------------------------------------------------------------------------------
1 | Source: crowdsec-firewall-bouncer
2 | Maintainer: Crowdsec Team
3 | Build-Depends: debhelper
4 | Section: admin
5 | Priority: optional
6 |
7 | Package: crowdsec-firewall-bouncer-iptables
8 | Architecture: any
9 | Description: Firewall bouncer for Crowdsec (iptables+ipset)
10 | Depends: gettext-base, iptables, ipset
11 | Replaces: crowdsec-firewall-bouncer
12 | Conflicts: crowdsec-firewall-bouncer-nftables
13 |
14 | Package: crowdsec-firewall-bouncer-nftables
15 | Architecture: any
16 | Description: Firewall bouncer for Crowdsec (nftables)
17 | Depends: gettext-base, nftables
18 | Replaces: crowdsec-firewall-bouncer
19 | Conflicts: crowdsec-firewall-bouncer-iptables
20 |
--------------------------------------------------------------------------------
/debian/crowdsec-firewall-bouncer-iptables.postinst:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | systemctl daemon-reload
4 |
5 | #shellcheck source=./scripts/_bouncer.sh
6 | . "/usr/lib/$DPKG_MAINTSCRIPT_PACKAGE/_bouncer.sh"
7 | START=1
8 |
9 | if [ "$1" = "configure" ]; then
10 | if need_api_key; then
11 | if ! set_api_key; then
12 | START=0
13 | fi
14 | fi
15 | fi
16 |
17 | systemctl --quiet is-enabled "$SERVICE" || systemctl unmask "$SERVICE" && systemctl enable "$SERVICE"
18 |
19 | set_local_port
20 |
21 | if [ "$START" -eq 0 ]; then
22 | echo "no api key was generated, you can generate one on your LAPI server by running 'cscli bouncers add ' and add it to '$CONFIG'" >&2
23 | else
24 | systemctl start "$SERVICE"
25 | fi
26 |
--------------------------------------------------------------------------------
/debian/crowdsec-firewall-bouncer-iptables.postrm:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -eu
4 |
5 | BOUNCER="crowdsec-firewall-bouncer"
6 | CONFIG="/etc/crowdsec/bouncers/$BOUNCER.yaml"
7 |
8 | if [ "$1" = "purge" ]; then
9 | if [ -f "$CONFIG.id" ]; then
10 | bouncer_id=$(cat "$CONFIG.id")
11 | cscli -oraw bouncers delete "$bouncer_id" 2>/dev/null || true
12 | rm -f "$CONFIG.id"
13 | fi
14 | fi
15 |
--------------------------------------------------------------------------------
/debian/crowdsec-firewall-bouncer-iptables.preinst:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -e
4 |
5 | # Source debconf library.
6 | . /usr/share/debconf/confmodule
7 |
--------------------------------------------------------------------------------
/debian/crowdsec-firewall-bouncer-iptables.prerm:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -eu
4 |
5 | BOUNCER="crowdsec-firewall-bouncer"
6 |
7 | systemctl stop "$BOUNCER" || echo "cannot stop service"
8 | systemctl disable "$BOUNCER" || echo "cannot disable service"
9 |
--------------------------------------------------------------------------------
/debian/crowdsec-firewall-bouncer-nftables.postinst:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | systemctl daemon-reload
4 |
5 | #shellcheck source=./scripts/_bouncer.sh
6 | . "/usr/lib/$DPKG_MAINTSCRIPT_PACKAGE/_bouncer.sh"
7 | START=1
8 |
9 | if [ "$1" = "configure" ]; then
10 | if need_api_key; then
11 | if ! set_api_key; then
12 | START=0
13 | fi
14 | fi
15 | fi
16 |
17 | systemctl --quiet is-enabled "$SERVICE" || systemctl unmask "$SERVICE" && systemctl enable "$SERVICE"
18 |
19 | set_local_port
20 |
21 | if [ "$START" -eq 0 ]; then
22 | echo "no api key was generated, you can generate one on your LAPI server by running 'cscli bouncers add ' and add it to '$CONFIG'" >&2
23 | else
24 | systemctl start "$SERVICE"
25 | fi
26 |
--------------------------------------------------------------------------------
/debian/crowdsec-firewall-bouncer-nftables.postrm:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -eu
4 |
5 | BOUNCER="crowdsec-firewall-bouncer"
6 | CONFIG="/etc/crowdsec/bouncers/$BOUNCER.yaml"
7 |
8 | if [ "$1" = "purge" ]; then
9 | if [ -f "$CONFIG.id" ]; then
10 | bouncer_id=$(cat "$CONFIG.id")
11 | cscli -oraw bouncers delete "$bouncer_id" 2>/dev/null || true
12 | rm -f "$CONFIG.id"
13 | fi
14 | fi
15 |
--------------------------------------------------------------------------------
/debian/crowdsec-firewall-bouncer-nftables.preinst:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -e
4 |
5 | # Source debconf library.
6 | . /usr/share/debconf/confmodule
7 |
8 | echo "pre-inst (nftables)"
9 |
--------------------------------------------------------------------------------
/debian/crowdsec-firewall-bouncer-nftables.prerm:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -eu
4 |
5 | BOUNCER="crowdsec-firewall-bouncer"
6 |
7 | systemctl stop "$BOUNCER" || echo "cannot stop service"
8 | systemctl disable "$BOUNCER" || echo "cannot disable service"
9 |
--------------------------------------------------------------------------------
/debian/rules:
--------------------------------------------------------------------------------
1 | #!/usr/bin/make -f
2 |
3 | export DEB_VERSION=$(shell dpkg-parsechangelog | grep -E '^Version:' | cut -f 2 -d ' ')
4 | export BUILD_VERSION=v${DEB_VERSION}-debian-pragmatic
5 |
6 | %:
7 | dh $@
8 |
9 | override_dh_systemd_start:
10 | echo "Not running dh_systemd_start"
11 | override_dh_auto_clean:
12 | override_dh_auto_test:
13 | override_dh_auto_build:
14 | override_dh_auto_install:
15 | @make build
16 |
17 | @BOUNCER=crowdsec-firewall-bouncer; \
18 | for BACKEND in iptables nftables; do \
19 | PKG="$$BOUNCER-$$BACKEND"; \
20 | install -D $$BOUNCER -t "debian/$$PKG/usr/bin/"; \
21 | install -D scripts/_bouncer.sh -t "debian/$$PKG/usr/lib/$$PKG/"; \
22 | BACKEND=$$BACKEND envsubst '$$BACKEND' < config/$$BOUNCER.yaml | install -D /dev/stdin "debian/$$PKG/etc/crowdsec/bouncers/$$BOUNCER.yaml"; \
23 | BIN="/usr/bin/$$BOUNCER" CFG="/etc/crowdsec/bouncers" envsubst '$$BIN $$CFG' < "config/$$BOUNCER.service" | install -D /dev/stdin "debian/$$PKG/etc/systemd/system/$$BOUNCER.service"; \
24 | mkdir -p "debian/$$PKG/usr/sbin/"; \
25 | ln -s "/usr/bin/$$BOUNCER" "debian/$$PKG/usr/sbin/$$BOUNCER"; \
26 | done
27 |
28 | execute_after_dh_fixperms:
29 | @BOUNCER=crowdsec-firewall-bouncer; \
30 | for BACKEND in iptables nftables; do \
31 | PKG="$$BOUNCER-$$BACKEND"; \
32 | chmod 0755 "debian/$$PKG/usr/bin/$$BOUNCER"; \
33 | chmod 0600 "debian/$$PKG/usr/lib/$$PKG/_bouncer.sh"; \
34 | chmod 0600 "debian/$$PKG/etc/crowdsec/bouncers/$$BOUNCER.yaml"; \
35 | chmod 0644 "debian/$$PKG/etc/systemd/system/$$BOUNCER.service"; \
36 | done
37 |
--------------------------------------------------------------------------------
/docs/assets/crowdsec_linux_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/docs/assets/crowdsec_linux_logo.png
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "nixpkgs": {
4 | "locked": {
5 | "lastModified": 1739736696,
6 | "narHash": "sha256-zON2GNBkzsIyALlOCFiEBcIjI4w38GYOb+P+R4S8Jsw=",
7 | "rev": "d74a2335ac9c133d6bbec9fc98d91a77f1604c1f",
8 | "revCount": 754461,
9 | "type": "tarball",
10 | "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.754461%2Brev-d74a2335ac9c133d6bbec9fc98d91a77f1604c1f/01951426-5a87-7b75-8413-1a0d9ec5ff04/source.tar.gz"
11 | },
12 | "original": {
13 | "type": "tarball",
14 | "url": "https://flakehub.com/f/NixOS/nixpkgs/0.1.%2A.tar.gz"
15 | }
16 | },
17 | "root": {
18 | "inputs": {
19 | "nixpkgs": "nixpkgs"
20 | }
21 | }
22 | },
23 | "root": "root",
24 | "version": 7
25 | }
26 |
--------------------------------------------------------------------------------
/flake.nix:
--------------------------------------------------------------------------------
1 | {
2 | description = "A Nix-flake-based Go 1.22 development environment";
3 |
4 | inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1.*.tar.gz";
5 |
6 | outputs = { self, nixpkgs }:
7 | let
8 | goVersion = 24; # Change this to update the whole stack
9 |
10 | supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
11 | forEachSupportedSystem = f: nixpkgs.lib.genAttrs supportedSystems (system: f {
12 | pkgs = import nixpkgs {
13 | inherit system;
14 | overlays = [ self.overlays.default ];
15 | };
16 | });
17 | in
18 | {
19 | overlays.default = final: prev: {
20 | go = final."go_1_${toString goVersion}";
21 | };
22 |
23 | devShells = forEachSupportedSystem ({ pkgs }: {
24 | default = pkgs.mkShell {
25 | packages = with pkgs; [
26 | # go (version is specified by overlay)
27 | go
28 |
29 | # goimports, godoc, etc.
30 | gotools
31 |
32 | # https://github.com/golangci/golangci-lint
33 | golangci-lint
34 | golangci-lint-langserver
35 | gopls
36 | ];
37 | };
38 | });
39 | };
40 | }
41 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/crowdsecurity/cs-firewall-bouncer
2 |
3 | go 1.24.1
4 |
5 | toolchain go1.24.2
6 |
7 | require (
8 | github.com/crowdsecurity/crowdsec v1.6.8
9 | github.com/crowdsecurity/go-cs-bouncer v0.0.16
10 | github.com/crowdsecurity/go-cs-lib v0.0.16
11 | github.com/google/nftables v0.2.0
12 | github.com/prometheus/client_golang v1.21.1
13 | github.com/prometheus/client_model v0.6.1
14 | github.com/sirupsen/logrus v1.9.3
15 | github.com/stretchr/testify v1.10.0
16 | golang.org/x/sync v0.12.0
17 | golang.org/x/sys v0.31.0
18 | gopkg.in/natefinch/lumberjack.v2 v2.2.1
19 | gopkg.in/yaml.v2 v2.4.0
20 | )
21 |
22 | require (
23 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
24 | github.com/beorn7/perks v1.0.1 // indirect
25 | github.com/blackfireio/osinfo v1.0.5 // indirect
26 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
27 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect
28 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
29 | github.com/expr-lang/expr v1.16.9 // indirect
30 | github.com/fatih/color v1.18.0 // indirect
31 | github.com/go-openapi/analysis v0.23.0 // indirect
32 | github.com/go-openapi/errors v0.22.0 // indirect
33 | github.com/go-openapi/jsonpointer v0.21.0 // indirect
34 | github.com/go-openapi/jsonreference v0.21.0 // indirect
35 | github.com/go-openapi/loads v0.22.0 // indirect
36 | github.com/go-openapi/spec v0.21.0 // indirect
37 | github.com/go-openapi/strfmt v0.23.0 // indirect
38 | github.com/go-openapi/swag v0.23.0 // indirect
39 | github.com/go-openapi/validate v0.24.0 // indirect
40 | github.com/goccy/go-yaml v1.12.0 // indirect
41 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
42 | github.com/google/go-cmp v0.6.0 // indirect
43 | github.com/google/go-querystring v1.1.0 // indirect
44 | github.com/google/uuid v1.6.0 // indirect
45 | github.com/josharian/intern v1.0.0 // indirect
46 | github.com/josharian/native v1.1.0 // indirect
47 | github.com/klauspost/compress v1.17.11 // indirect
48 | github.com/mailru/easyjson v0.7.7 // indirect
49 | github.com/mattn/go-colorable v0.1.13 // indirect
50 | github.com/mattn/go-isatty v0.0.20 // indirect
51 | github.com/mdlayher/netlink v1.7.2 // indirect
52 | github.com/mdlayher/socket v0.5.1 // indirect
53 | github.com/mitchellh/mapstructure v1.5.0 // indirect
54 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
55 | github.com/oklog/ulid v1.3.1 // indirect
56 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
57 | github.com/prometheus/common v0.62.0 // indirect
58 | github.com/prometheus/procfs v0.15.1 // indirect
59 | go.mongodb.org/mongo-driver v1.16.1 // indirect
60 | golang.org/x/net v0.37.0 // indirect
61 | golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
62 | google.golang.org/protobuf v1.36.3 // indirect
63 | gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect
64 | gopkg.in/yaml.v3 v3.0.1 // indirect
65 | )
66 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | log "github.com/sirupsen/logrus"
5 |
6 | "github.com/crowdsecurity/cs-firewall-bouncer/cmd"
7 | )
8 |
9 | func main() {
10 | err := cmd.Execute()
11 | if err != nil {
12 | log.Fatal(err)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/pkg/backend/backend.go:
--------------------------------------------------------------------------------
1 | package backend
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "runtime"
7 |
8 | log "github.com/sirupsen/logrus"
9 |
10 | "github.com/crowdsecurity/crowdsec/pkg/models"
11 |
12 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg"
13 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/dryrun"
14 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/iptables"
15 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/nftables"
16 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/pf"
17 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types"
18 | )
19 |
20 | type BackendCTX struct {
21 | firewall types.Backend
22 | }
23 |
24 | func (b *BackendCTX) Init() error {
25 | return b.firewall.Init()
26 | }
27 |
28 | func (b *BackendCTX) Commit() error {
29 | return b.firewall.Commit()
30 | }
31 |
32 | func (b *BackendCTX) ShutDown() error {
33 | return b.firewall.ShutDown()
34 | }
35 |
36 | func (b *BackendCTX) Add(decision *models.Decision) error {
37 | return b.firewall.Add(decision)
38 | }
39 |
40 | func (b *BackendCTX) Delete(decision *models.Decision) error {
41 | return b.firewall.Delete(decision)
42 | }
43 |
44 | func (b *BackendCTX) CollectMetrics() {
45 | log.Trace("Collecting backend-specific metrics")
46 | b.firewall.CollectMetrics()
47 | }
48 |
49 | func isPFSupported(runtimeOS string) bool {
50 | var supported bool
51 |
52 | switch runtimeOS {
53 | case "openbsd", "freebsd":
54 | supported = true
55 | default:
56 | supported = false
57 | }
58 |
59 | return supported
60 | }
61 |
62 | func NewBackend(config *cfg.BouncerConfig) (*BackendCTX, error) {
63 | var err error
64 |
65 | b := &BackendCTX{}
66 |
67 | log.Infof("backend type: %s", config.Mode)
68 |
69 | if config.DisableIPV6 {
70 | log.Info("IPV6 is disabled")
71 | }
72 |
73 | if config.DisableIPV4 {
74 | log.Info("IPV4 is disabled")
75 | }
76 |
77 | switch config.Mode {
78 | case cfg.IptablesMode, cfg.IpsetMode:
79 | if runtime.GOOS != "linux" {
80 | return nil, errors.New("iptables and ipset is linux only")
81 | }
82 |
83 | b.firewall, err = iptables.NewIPTables(config)
84 | if err != nil {
85 | return nil, err
86 | }
87 | case cfg.NftablesMode:
88 | if runtime.GOOS != "linux" {
89 | return nil, errors.New("nftables is linux only")
90 | }
91 |
92 | b.firewall, err = nftables.NewNFTables(config)
93 | if err != nil {
94 | return nil, err
95 | }
96 | case "pf":
97 | if !isPFSupported(runtime.GOOS) {
98 | log.Warning("pf mode can only work with openbsd and freebsd. It is available on other platforms only for testing purposes")
99 | }
100 |
101 | b.firewall, err = pf.NewPF(config)
102 | if err != nil {
103 | return nil, err
104 | }
105 | case "dry-run":
106 | b.firewall, err = dryrun.NewDryRun(config)
107 | if err != nil {
108 | return nil, err
109 | }
110 | default:
111 | return b, fmt.Errorf("firewall '%s' is not supported", config.Mode)
112 | }
113 |
114 | return b, nil
115 | }
116 |
--------------------------------------------------------------------------------
/pkg/cfg/config.go:
--------------------------------------------------------------------------------
1 | package cfg
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "io"
7 |
8 | log "github.com/sirupsen/logrus"
9 | "gopkg.in/yaml.v2"
10 |
11 | "github.com/crowdsecurity/go-cs-lib/ptr"
12 | "github.com/crowdsecurity/go-cs-lib/yamlpatch"
13 | )
14 |
15 | type PrometheusConfig struct {
16 | Enabled bool `yaml:"enabled"`
17 | ListenAddress string `yaml:"listen_addr"`
18 | ListenPort string `yaml:"listen_port"`
19 | }
20 |
21 | type nftablesFamilyConfig struct {
22 | Enabled *bool `yaml:"enabled"`
23 | SetOnly bool `yaml:"set-only"`
24 | Table string `yaml:"table"`
25 | Chain string `yaml:"chain"`
26 | Priority int `yaml:"priority"`
27 | }
28 |
29 | const (
30 | IpsetMode = "ipset"
31 | IptablesMode = "iptables"
32 | NftablesMode = "nftables"
33 | PfMode = "pf"
34 | DryRunMode = "dry-run"
35 | )
36 |
37 | type BouncerConfig struct {
38 | Mode string `yaml:"mode"` // ipset,iptables,tc
39 | PidDir string `yaml:"pid_dir"` // unused
40 | UpdateFrequency string `yaml:"update_frequency"`
41 | Daemon *bool `yaml:"daemonize"` // unused
42 | Logging LoggingConfig `yaml:",inline"`
43 | DisableIPV6 bool `yaml:"disable_ipv6"`
44 | DisableIPV4 bool `yaml:"disable_ipv4"`
45 | DenyAction string `yaml:"deny_action"`
46 | DenyLog bool `yaml:"deny_log"`
47 | DenyLogPrefix string `yaml:"deny_log_prefix"`
48 | BlacklistsIpv4 string `yaml:"blacklists_ipv4"`
49 | BlacklistsIpv6 string `yaml:"blacklists_ipv6"`
50 | SetType string `yaml:"ipset_type"`
51 | SetSize int `yaml:"ipset_size"`
52 | SetDisableTimeouts bool `yaml:"ipset_disable_timeouts"`
53 |
54 | // specific to iptables, following https://github.com/crowdsecurity/cs-firewall-bouncer/issues/19
55 | IptablesChains []string `yaml:"iptables_chains"`
56 | IptablesAddRuleComments bool `yaml:"iptables_add_rule_comments"`
57 |
58 | SupportedDecisionsTypes []string `yaml:"supported_decisions_types"`
59 | // specific to nftables, following https://github.com/crowdsecurity/cs-firewall-bouncer/issues/74
60 | Nftables struct {
61 | Ipv4 nftablesFamilyConfig `yaml:"ipv4"`
62 | Ipv6 nftablesFamilyConfig `yaml:"ipv6"`
63 | } `yaml:"nftables"`
64 | NftablesHooks []string `yaml:"nftables_hooks"`
65 | PF struct {
66 | AnchorName string `yaml:"anchor_name"`
67 | BatchSize int `yaml:"batch_size"`
68 | } `yaml:"pf"`
69 | PrometheusConfig PrometheusConfig `yaml:"prometheus"`
70 | }
71 |
72 | // MergedConfig() returns the byte content of the patched configuration file (with .yaml.local).
73 | func MergedConfig(configPath string) ([]byte, error) {
74 | patcher := yamlpatch.NewPatcher(configPath, ".local")
75 |
76 | data, err := patcher.MergedPatchContent()
77 | if err != nil {
78 | return nil, err
79 | }
80 |
81 | return data, nil
82 | }
83 |
84 | func NewConfig(reader io.Reader) (*BouncerConfig, error) {
85 | config := &BouncerConfig{
86 | IptablesAddRuleComments: true,
87 | }
88 |
89 | fcontent, err := io.ReadAll(reader)
90 | if err != nil {
91 | return nil, err
92 | }
93 |
94 | err = yaml.Unmarshal(fcontent, &config)
95 | if err != nil {
96 | return nil, fmt.Errorf("failed to unmarshal: %w", err)
97 | }
98 |
99 | if err = config.Logging.setup("crowdsec-firewall-bouncer.log"); err != nil {
100 | return nil, fmt.Errorf("failed to setup logging: %w", err)
101 | }
102 |
103 | if config.Mode == "" {
104 | return nil, errors.New("config does not contain 'mode'")
105 | }
106 |
107 | if len(config.SupportedDecisionsTypes) == 0 {
108 | config.SupportedDecisionsTypes = []string{"ban"}
109 | }
110 |
111 | if config.PidDir != "" {
112 | log.Debug("Ignoring deprecated 'pid_dir' option")
113 | }
114 |
115 | if config.DenyLog && config.DenyLogPrefix == "" {
116 | config.DenyLogPrefix = "crowdsec drop: "
117 | }
118 |
119 | // for config file backward compatibility
120 | if config.BlacklistsIpv4 == "" {
121 | config.BlacklistsIpv4 = "crowdsec-blacklists"
122 | }
123 |
124 | if config.BlacklistsIpv6 == "" {
125 | config.BlacklistsIpv6 = "crowdsec6-blacklists"
126 | }
127 |
128 | if config.SetType == "" {
129 | config.SetType = "nethash"
130 | }
131 |
132 | if config.SetSize == 0 {
133 | config.SetSize = 131072
134 | }
135 |
136 | if config.DisableIPV4 && config.DisableIPV6 && config.Mode != NftablesMode {
137 | // we return an error for pf or iptables because nftables has it own way to handle this
138 | return nil, errors.New("both IPv4 and IPv6 disabled, doing nothing")
139 | }
140 |
141 | switch config.Mode {
142 | case NftablesMode:
143 | err := nftablesConfig(config)
144 | if err != nil {
145 | return nil, err
146 | }
147 | case IpsetMode, IptablesMode:
148 | // nothing specific to do
149 | case PfMode:
150 | err := pfConfig(config)
151 | if err != nil {
152 | return nil, err
153 | }
154 | case DryRunMode:
155 | // nothing specific to do
156 | default:
157 | log.Warningf("unexpected %s mode", config.Mode)
158 | }
159 |
160 | return config, nil
161 | }
162 |
163 | func pfConfig(_ *BouncerConfig) error {
164 | return nil
165 | }
166 |
167 | func nftablesConfig(config *BouncerConfig) error {
168 | // deal with defaults in a backward compatible way
169 | if config.Nftables.Ipv4.Enabled == nil {
170 | config.Nftables.Ipv4.Enabled = ptr.Of(!config.DisableIPV4)
171 | }
172 |
173 | if config.Nftables.Ipv6.Enabled == nil {
174 | config.Nftables.Ipv6.Enabled = ptr.Of(!config.DisableIPV6)
175 | }
176 |
177 | if *config.Nftables.Ipv4.Enabled {
178 | if config.Nftables.Ipv4.Table == "" {
179 | config.Nftables.Ipv4.Table = "crowdsec"
180 | }
181 |
182 | if config.Nftables.Ipv4.Chain == "" {
183 | config.Nftables.Ipv4.Chain = "crowdsec-chain"
184 | }
185 | }
186 |
187 | if *config.Nftables.Ipv6.Enabled {
188 | if config.Nftables.Ipv6.Table == "" {
189 | config.Nftables.Ipv6.Table = "crowdsec6"
190 | }
191 |
192 | if config.Nftables.Ipv6.Chain == "" {
193 | config.Nftables.Ipv6.Chain = "crowdsec6-chain"
194 | }
195 | }
196 |
197 | if !*config.Nftables.Ipv4.Enabled && !*config.Nftables.Ipv6.Enabled {
198 | return errors.New("both IPv4 and IPv6 disabled, doing nothing")
199 | }
200 |
201 | if len(config.NftablesHooks) == 0 {
202 | config.NftablesHooks = []string{"input"}
203 | }
204 |
205 | return nil
206 | }
207 |
--------------------------------------------------------------------------------
/pkg/cfg/logging.go:
--------------------------------------------------------------------------------
1 | package cfg
2 |
3 | import (
4 | "errors"
5 | "io"
6 | "os"
7 | "path/filepath"
8 | "time"
9 |
10 | log "github.com/sirupsen/logrus"
11 | "github.com/sirupsen/logrus/hooks/writer"
12 | "gopkg.in/natefinch/lumberjack.v2"
13 |
14 | "github.com/crowdsecurity/go-cs-lib/ptr"
15 | )
16 |
17 | type LoggingConfig struct {
18 | LogLevel *log.Level `yaml:"log_level"`
19 | LogMode string `yaml:"log_mode"`
20 | LogDir string `yaml:"log_dir"`
21 | LogMaxSize int `yaml:"log_max_size,omitempty"`
22 | LogMaxFiles int `yaml:"log_max_files,omitempty"`
23 | LogMaxAge int `yaml:"log_max_age,omitempty"`
24 | CompressLogs *bool `yaml:"compress_logs,omitempty"`
25 | }
26 |
27 | func (c *LoggingConfig) LoggerForFile(fileName string) (io.Writer, error) {
28 | if c.LogMode == "stdout" {
29 | return os.Stderr, nil
30 | }
31 |
32 | // default permissions will be 0600 from lumberjack
33 | // and are preserved if the file already exists
34 |
35 | l := &lumberjack.Logger{
36 | Filename: filepath.Join(c.LogDir, fileName),
37 | MaxSize: c.LogMaxSize,
38 | MaxBackups: c.LogMaxFiles,
39 | MaxAge: c.LogMaxAge,
40 | Compress: *c.CompressLogs,
41 | }
42 |
43 | return l, nil
44 | }
45 |
46 | func (c *LoggingConfig) setDefaults() {
47 | if c.LogMode == "" {
48 | c.LogMode = "stdout"
49 | }
50 |
51 | if c.LogDir == "" {
52 | c.LogDir = "/var/log/"
53 | }
54 |
55 | if c.LogLevel == nil {
56 | c.LogLevel = ptr.Of(log.InfoLevel)
57 | }
58 |
59 | if c.LogMaxSize == 0 {
60 | c.LogMaxSize = 500
61 | }
62 |
63 | if c.LogMaxFiles == 0 {
64 | c.LogMaxFiles = 3
65 | }
66 |
67 | if c.LogMaxAge == 0 {
68 | c.LogMaxAge = 30
69 | }
70 |
71 | if c.CompressLogs == nil {
72 | c.CompressLogs = ptr.Of(true)
73 | }
74 | }
75 |
76 | func (c *LoggingConfig) validate() error {
77 | if c.LogMode != "stdout" && c.LogMode != "file" {
78 | return errors.New("log_mode should be either 'stdout' or 'file'")
79 | }
80 |
81 | return nil
82 | }
83 |
84 | func (c *LoggingConfig) setup(fileName string) error {
85 | c.setDefaults()
86 |
87 | if err := c.validate(); err != nil {
88 | return err
89 | }
90 |
91 | log.SetLevel(*c.LogLevel)
92 |
93 | if c.LogMode == "stdout" {
94 | return nil
95 | }
96 |
97 | log.SetFormatter(&log.TextFormatter{TimestampFormat: time.RFC3339, FullTimestamp: true})
98 |
99 | logger, err := c.LoggerForFile(fileName)
100 | if err != nil {
101 | return err
102 | }
103 |
104 | log.SetOutput(logger)
105 |
106 | // keep stderr for panic/fatal, otherwise process failures
107 | // won't be visible enough
108 | log.AddHook(&writer.Hook{
109 | Writer: os.Stderr,
110 | LogLevels: []log.Level{
111 | log.PanicLevel,
112 | log.FatalLevel,
113 | },
114 | })
115 |
116 | return nil
117 | }
118 |
--------------------------------------------------------------------------------
/pkg/dryrun/dryrun.go:
--------------------------------------------------------------------------------
1 | package dryrun
2 |
3 | import (
4 | log "github.com/sirupsen/logrus"
5 |
6 | "github.com/crowdsecurity/crowdsec/pkg/models"
7 |
8 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg"
9 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types"
10 | )
11 |
12 | type dryRun struct{}
13 |
14 | func NewDryRun(_ *cfg.BouncerConfig) (types.Backend, error) {
15 | return &dryRun{}, nil
16 | }
17 |
18 | func (*dryRun) Init() error {
19 | log.Infof("backend.Init() called")
20 | return nil
21 | }
22 |
23 | func (*dryRun) Commit() error {
24 | log.Infof("backend.Commit() called")
25 | return nil
26 | }
27 |
28 | func (*dryRun) Add(decision *models.Decision) error {
29 | log.Infof("backend.Add() called with %s", *decision.Value)
30 | return nil
31 | }
32 |
33 | func (*dryRun) CollectMetrics() {
34 | log.Infof("backend.CollectMetrics() called")
35 | }
36 |
37 | func (*dryRun) Delete(decision *models.Decision) error {
38 | log.Infof("backend.Delete() called with %s", *decision.Value)
39 | return nil
40 | }
41 |
42 | func (*dryRun) ShutDown() error {
43 | log.Infof("backend.ShutDown() called")
44 | return nil
45 | }
46 |
--------------------------------------------------------------------------------
/pkg/ipsetcmd/ipset.go:
--------------------------------------------------------------------------------
1 | package ipsetcmd
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "os/exec"
7 | "strconv"
8 | "strings"
9 |
10 | log "github.com/sirupsen/logrus"
11 | )
12 |
13 | type IPSet struct {
14 | binaryPath string
15 | setName string
16 | }
17 |
18 | type CreateOptions struct {
19 | Timeout string
20 | MaxElem string
21 | Family string
22 | Type string
23 | DisableTimeouts bool
24 | }
25 |
26 | const ipsetBinary = "ipset"
27 |
28 | func NewIPSet(setName string) (*IPSet, error) {
29 | ipsetBin, err := exec.LookPath(ipsetBinary)
30 | if err != nil {
31 | return nil, errors.New("unable to find ipset")
32 | }
33 |
34 | return &IPSet{
35 | binaryPath: ipsetBin,
36 | setName: setName,
37 | }, nil
38 | }
39 |
40 | // Wraps all the ipset commands.
41 | func (i *IPSet) Create(opts CreateOptions) error {
42 | cmdArgs := []string{"create", i.setName}
43 |
44 | if opts.Type != "" {
45 | cmdArgs = append(cmdArgs, opts.Type)
46 | }
47 |
48 | if opts.Timeout != "" && !opts.DisableTimeouts {
49 | cmdArgs = append(cmdArgs, "timeout", opts.Timeout)
50 | }
51 |
52 | if opts.MaxElem != "" {
53 | cmdArgs = append(cmdArgs, "maxelem", opts.MaxElem)
54 | }
55 |
56 | if opts.Family != "" {
57 | cmdArgs = append(cmdArgs, "family", opts.Family)
58 | }
59 |
60 | cmd := exec.Command(i.binaryPath, cmdArgs...)
61 |
62 | log.Debugf("ipset create command: %v", cmd.String())
63 |
64 | out, err := cmd.CombinedOutput()
65 | if err != nil {
66 | return fmt.Errorf("error creating ipset: %s", out)
67 | }
68 |
69 | return nil
70 | }
71 |
72 | func (i *IPSet) Add(entry string) error {
73 | cmd := exec.Command(i.binaryPath, "add", i.setName, entry)
74 |
75 | log.Debugf("ipset add command: %v", cmd.String())
76 |
77 | out, err := cmd.CombinedOutput()
78 | if err != nil {
79 | return fmt.Errorf("error creating ipset: %s", out)
80 | }
81 |
82 | return nil
83 | }
84 |
85 | func (i *IPSet) DeleteEntry(entry string) error {
86 | cmd := exec.Command(i.binaryPath, "del", i.setName, entry)
87 |
88 | log.Debugf("ipset delete entry command: %v", cmd.String())
89 |
90 | out, err := cmd.CombinedOutput()
91 | if err != nil {
92 | return fmt.Errorf("error creating ipset: %s", out)
93 | }
94 |
95 | return nil
96 | }
97 |
98 | func (i *IPSet) List() ([]string, error) {
99 | cmd := exec.Command(i.binaryPath, "list", i.setName)
100 |
101 | log.Debugf("ipset list command: %v", cmd.String())
102 |
103 | out, err := cmd.CombinedOutput()
104 | if err != nil {
105 | return nil, fmt.Errorf("error listing ipset: %s", out)
106 | }
107 |
108 | return strings.Split(string(out), "\n"), nil
109 | }
110 |
111 | func (i *IPSet) Flush() error {
112 | cmd := exec.Command(i.binaryPath, "flush", i.setName)
113 |
114 | log.Debugf("ipset flush command: %v", cmd.String())
115 |
116 | out, err := cmd.CombinedOutput()
117 | if err != nil {
118 | return fmt.Errorf("error flushing ipset: %s", out)
119 | }
120 |
121 | return nil
122 | }
123 |
124 | func (i *IPSet) Destroy() error {
125 | cmd := exec.Command(i.binaryPath, "destroy", i.setName)
126 |
127 | log.Debugf("ipset destroy command: %v", cmd.String())
128 |
129 | out, err := cmd.CombinedOutput()
130 | if err != nil {
131 | return fmt.Errorf("error destroying ipset: %s", out)
132 | }
133 |
134 | return nil
135 | }
136 |
137 | func (i *IPSet) Rename(toSetName string) error {
138 | cmd := exec.Command(i.binaryPath, "rename", i.setName, toSetName)
139 |
140 | log.Debugf("ipset rename command: %v", cmd.String())
141 |
142 | out, err := cmd.CombinedOutput()
143 | if err != nil {
144 | return fmt.Errorf("error renaming ipset: %s", out)
145 | }
146 |
147 | i.setName = toSetName
148 |
149 | return nil
150 | }
151 |
152 | func (i *IPSet) Test(entry string) error {
153 | cmd := exec.Command(i.binaryPath, "test", i.setName, entry)
154 |
155 | log.Debugf("ipset test command: %v", cmd.String())
156 |
157 | out, err := cmd.CombinedOutput()
158 | if err != nil {
159 | return fmt.Errorf("error testing ipset: %s", out)
160 | }
161 |
162 | return nil
163 | }
164 |
165 | func (i *IPSet) Save() ([]string, error) {
166 | cmd := exec.Command(i.binaryPath, "save", i.setName)
167 |
168 | log.Debugf("ipset save command: %v", cmd.String())
169 |
170 | out, err := cmd.CombinedOutput()
171 | if err != nil {
172 | return nil, fmt.Errorf("error saving ipset: %s", out)
173 | }
174 |
175 | return strings.Split(string(out), "\n"), nil
176 | }
177 |
178 | func (i *IPSet) Restore(filename string) error {
179 | cmd := exec.Command(i.binaryPath, "restore", "-file", filename)
180 |
181 | log.Debugf("ipset restore command: %v", cmd.String())
182 |
183 | out, err := cmd.CombinedOutput()
184 | if err != nil {
185 | return fmt.Errorf("error restoring ipset: %s", out)
186 | }
187 |
188 | return nil
189 | }
190 |
191 | func (i *IPSet) Swap(toSetName string) error {
192 | cmd := exec.Command(i.binaryPath, "swap", i.setName, toSetName)
193 |
194 | log.Debugf("ipset swap command: %v", cmd.String())
195 |
196 | out, err := cmd.CombinedOutput()
197 | if err != nil {
198 | return fmt.Errorf("error swapping ipset: %s", out)
199 | }
200 |
201 | i.setName = toSetName
202 |
203 | return nil
204 | }
205 |
206 | func (i *IPSet) Name() string {
207 | return i.setName
208 | }
209 |
210 | func (i *IPSet) Exists() bool {
211 | cmd := exec.Command(i.binaryPath, "list", i.setName)
212 |
213 | err := cmd.Run()
214 |
215 | return err == nil
216 | }
217 |
218 | func (i *IPSet) Len() int {
219 | cmd := exec.Command(i.binaryPath, "list", i.setName)
220 |
221 | log.Debugf("ipset list command: %v", cmd.String())
222 |
223 | out, err := cmd.CombinedOutput()
224 | if err != nil {
225 | return 0
226 | }
227 |
228 | for _, line := range strings.Split(string(out), "\n") {
229 | if !strings.Contains(strings.ToLower(line), "number of entries:") {
230 | continue
231 | }
232 |
233 | fields := strings.Split(line, ":")
234 | if len(fields) != 2 {
235 | continue
236 | }
237 |
238 | count, err := strconv.Atoi(strings.TrimSpace(fields[1]))
239 | if err != nil {
240 | return 0
241 | }
242 |
243 | return count
244 | }
245 |
246 | return 0
247 | }
248 |
249 | // Helpers.
250 | func GetSetsStartingWith(name string) (map[string]*IPSet, error) {
251 | cmd := exec.Command(ipsetBinary, "list", "-name")
252 |
253 | log.Debugf("ipset list command: %v", cmd.String())
254 |
255 | out, err := cmd.CombinedOutput()
256 | if err != nil {
257 | return nil, fmt.Errorf("error listing ipset: %s", out)
258 | }
259 |
260 | sets := make(map[string]*IPSet, 0)
261 |
262 | for _, line := range strings.Split(string(out), "\n") {
263 | if !strings.HasPrefix(line, name) {
264 | continue
265 | }
266 |
267 | fields := strings.Fields(line)
268 | if len(fields) != 1 {
269 | continue
270 | }
271 |
272 | set, err := NewIPSet(fields[0])
273 | if err != nil {
274 | return nil, err
275 | }
276 |
277 | sets[fields[0]] = set
278 | }
279 |
280 | return sets, nil
281 | }
282 |
--------------------------------------------------------------------------------
/pkg/iptables/iptables.go:
--------------------------------------------------------------------------------
1 | //go:build linux
2 |
3 | package iptables
4 |
5 | import (
6 | "errors"
7 | "fmt"
8 | "os/exec"
9 | "slices"
10 | "strings"
11 |
12 | log "github.com/sirupsen/logrus"
13 |
14 | "github.com/crowdsecurity/crowdsec/pkg/models"
15 |
16 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg"
17 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/ipsetcmd"
18 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types"
19 | )
20 |
21 | const (
22 | IPTablesDroppedPacketIdx = 0
23 | IPTablesDroppedByteIdx = 1
24 | )
25 |
26 | type iptables struct {
27 | v4 *ipTablesContext
28 | v6 *ipTablesContext
29 | }
30 |
31 | func NewIPTables(config *cfg.BouncerConfig) (types.Backend, error) {
32 | var err error
33 |
34 | ret := &iptables{}
35 |
36 | defaultSet, err := ipsetcmd.NewIPSet("")
37 | if err != nil {
38 | return nil, err
39 | }
40 |
41 | allowedActions := []string{"DROP", "REJECT", "TARPIT", "LOG"}
42 |
43 | target := strings.ToUpper(config.DenyAction)
44 | if target == "" {
45 | target = "DROP"
46 | }
47 |
48 | log.Infof("using '%s' as deny_action", target)
49 |
50 | if !slices.Contains(allowedActions, target) {
51 | return nil, fmt.Errorf("invalid deny_action '%s', must be one of %s", config.DenyAction, strings.Join(allowedActions, ", "))
52 | }
53 |
54 | v4Sets := make(map[string]*ipsetcmd.IPSet)
55 | v6Sets := make(map[string]*ipsetcmd.IPSet)
56 |
57 | ipv4Ctx := &ipTablesContext{
58 | version: "v4",
59 | SetName: config.BlacklistsIpv4,
60 | SetType: config.SetType,
61 | SetSize: config.SetSize,
62 | ipsetDisableTimeouts: config.SetDisableTimeouts,
63 | Chains: []string{},
64 | defaultSet: defaultSet,
65 | target: target,
66 | loggingEnabled: config.DenyLog,
67 | loggingPrefix: config.DenyLogPrefix,
68 | addRuleComments: config.IptablesAddRuleComments,
69 | }
70 | ipv6Ctx := &ipTablesContext{
71 | version: "v6",
72 | SetName: config.BlacklistsIpv6,
73 | SetType: config.SetType,
74 | SetSize: config.SetSize,
75 | ipsetDisableTimeouts: config.SetDisableTimeouts,
76 | Chains: []string{},
77 | defaultSet: defaultSet,
78 | target: target,
79 | loggingEnabled: config.DenyLog,
80 | loggingPrefix: config.DenyLogPrefix,
81 | addRuleComments: config.IptablesAddRuleComments,
82 | }
83 |
84 | if !config.DisableIPV4 {
85 | ipv4Ctx.iptablesSaveBin, err = exec.LookPath("iptables-save")
86 | if err != nil {
87 | return nil, errors.New("unable to find iptables-save")
88 | }
89 |
90 | if config.Mode == cfg.IpsetMode {
91 | ipv4Ctx.ipsetContentOnly = true
92 |
93 | set, err := ipsetcmd.NewIPSet(config.BlacklistsIpv4)
94 | if err != nil {
95 | return nil, err
96 | }
97 |
98 | v4Sets["ipset"] = set
99 | } else {
100 | ipv4Ctx.iptablesBin, err = exec.LookPath("iptables")
101 | if err != nil {
102 | return nil, errors.New("unable to find iptables")
103 | }
104 |
105 | // Try to "adopt" any leftover sets from a previous run if we crashed
106 | // They will get flushed/deleted just after
107 | v4Sets, _ = ipsetcmd.GetSetsStartingWith(config.BlacklistsIpv4)
108 | v6Sets, _ = ipsetcmd.GetSetsStartingWith(config.BlacklistsIpv6)
109 |
110 | ipv4Ctx.Chains = config.IptablesChains
111 | }
112 |
113 | ipv4Ctx.ipsets = v4Sets
114 | ret.v4 = ipv4Ctx
115 | }
116 |
117 | if !config.DisableIPV6 {
118 | ipv6Ctx.iptablesSaveBin, err = exec.LookPath("ip6tables-save")
119 | if err != nil {
120 | return nil, errors.New("unable to find ip6tables-save")
121 | }
122 |
123 | if config.Mode == cfg.IpsetMode {
124 | ipv6Ctx.ipsetContentOnly = true
125 |
126 | set, err := ipsetcmd.NewIPSet(config.BlacklistsIpv6)
127 | if err != nil {
128 | return nil, err
129 | }
130 |
131 | v6Sets["ipset"] = set
132 | } else {
133 | ipv6Ctx.iptablesBin, err = exec.LookPath("ip6tables")
134 | if err != nil {
135 | return nil, errors.New("unable to find ip6tables")
136 | }
137 |
138 | ipv6Ctx.Chains = config.IptablesChains
139 | }
140 |
141 | ipv6Ctx.ipsets = v6Sets
142 | ret.v6 = ipv6Ctx
143 | }
144 | return ret, nil
145 | }
146 |
147 | func (ipt *iptables) Init() error {
148 | if ipt.v4 != nil {
149 | log.Info("iptables for ipv4 initiated")
150 |
151 | // flush before init
152 | if err := ipt.v4.shutDown(); err != nil {
153 | return fmt.Errorf("iptables shutdown failed: %w", err)
154 | }
155 |
156 | if !ipt.v4.ipsetContentOnly {
157 | ipt.v4.setupChain()
158 | }
159 | }
160 |
161 | if ipt.v6 != nil {
162 | log.Info("iptables for ipv6 initiated")
163 |
164 | if err := ipt.v6.shutDown(); err != nil {
165 | return fmt.Errorf("iptables shutdown failed: %w", err)
166 | }
167 |
168 | if !ipt.v6.ipsetContentOnly {
169 | ipt.v6.setupChain()
170 | }
171 | }
172 |
173 | return nil
174 | }
175 |
176 | func (ipt *iptables) Commit() error {
177 | if ipt.v4 != nil {
178 | err := ipt.v4.commit()
179 | if err != nil {
180 | return fmt.Errorf("ipset for ipv4 commit failed: %w", err)
181 | }
182 | }
183 |
184 | if ipt.v6 != nil {
185 | err := ipt.v6.commit()
186 | if err != nil {
187 | return fmt.Errorf("ipset for ipv6 commit failed: %w", err)
188 | }
189 | }
190 |
191 | return nil
192 | }
193 |
194 | func (ipt *iptables) Add(decision *models.Decision) error {
195 | if strings.HasPrefix(*decision.Type, "simulation:") {
196 | log.Debugf("measure against '%s' is in simulation mode, skipping it", *decision.Value)
197 | return nil
198 | }
199 |
200 | if strings.Contains(*decision.Value, ":") {
201 | if ipt.v6 == nil {
202 | log.Debugf("not adding '%s' because ipv6 is disabled", *decision.Value)
203 | return nil
204 | }
205 |
206 | ipt.v6.add(decision)
207 | } else {
208 | if ipt.v4 == nil {
209 | log.Debugf("not adding '%s' because ipv4 is disabled", *decision.Value)
210 | return nil
211 | }
212 | ipt.v4.add(decision)
213 | }
214 |
215 | return nil
216 | }
217 |
218 | func (ipt *iptables) ShutDown() error {
219 | if ipt.v4 != nil {
220 | if err := ipt.v4.shutDown(); err != nil {
221 | return fmt.Errorf("iptables for ipv4 shutdown failed: %w", err)
222 | }
223 | }
224 |
225 | if ipt.v6 != nil {
226 | if err := ipt.v6.shutDown(); err != nil {
227 | return fmt.Errorf("iptables for ipv6 shutdown failed: %w", err)
228 | }
229 | }
230 |
231 | return nil
232 | }
233 |
234 | func (ipt *iptables) Delete(decision *models.Decision) error {
235 | done := false
236 |
237 | if strings.Contains(*decision.Value, ":") {
238 | if ipt.v6 == nil {
239 | log.Debugf("not deleting '%s' because ipv6 is disabled", *decision.Value)
240 | return nil
241 | }
242 |
243 | if err := ipt.v6.delete(decision); err != nil {
244 | return errors.New("failed deleting ban")
245 | }
246 |
247 | done = true
248 | }
249 |
250 | if strings.Contains(*decision.Value, ".") {
251 | if ipt.v4 == nil {
252 | log.Debugf("not deleting '%s' because ipv4 is disabled", *decision.Value)
253 | return nil
254 | }
255 |
256 | if err := ipt.v4.delete(decision); err != nil {
257 | return errors.New("failed deleting ban")
258 | }
259 |
260 | done = true
261 | }
262 |
263 | if !done {
264 | return fmt.Errorf("failed deleting ban: ip %s was not recognized", *decision.Value)
265 | }
266 |
267 | return nil
268 | }
269 |
--------------------------------------------------------------------------------
/pkg/iptables/iptables_context.go:
--------------------------------------------------------------------------------
1 | //go:build linux
2 |
3 | package iptables
4 |
5 | import (
6 | "fmt"
7 | "os"
8 | "os/exec"
9 | "slices"
10 | "strconv"
11 | "strings"
12 | "time"
13 |
14 | log "github.com/sirupsen/logrus"
15 |
16 | "github.com/crowdsecurity/crowdsec/pkg/models"
17 |
18 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/ipsetcmd"
19 | )
20 |
21 | const (
22 | chainName = "CROWDSEC_CHAIN"
23 | loggingChainName = "CROWDSEC_LOG"
24 | maxBanSeconds = 2147483
25 | defaultTimeout = "300"
26 | )
27 |
28 | type ipTablesContext struct {
29 | version string
30 | iptablesBin string
31 | iptablesSaveBin string
32 | SetName string // crowdsec-netfilter
33 | SetType string
34 | SetSize int
35 | ipsetContentOnly bool
36 | ipsetDisableTimeouts bool
37 | Chains []string
38 |
39 | target string
40 |
41 | ipsets map[string]*ipsetcmd.IPSet
42 | defaultSet *ipsetcmd.IPSet // This one is only used to restore the content, as the file will contain the name of the set for each decision
43 |
44 | toAdd []*models.Decision
45 | toDel []*models.Decision
46 |
47 | // To avoid issues with set name length (ipsest name length is limited to 31 characters)
48 | // Store the origin of the decisions, and use the index in the slice as the name
49 | // This is not stable (ie, between two runs, the index of a set can change), but it's (probably) not an issue
50 | originSetMapping []string
51 |
52 | loggingEnabled bool
53 | loggingPrefix string
54 |
55 | addRuleComments bool
56 | }
57 |
58 | func (ctx *ipTablesContext) setupChain() {
59 | cmd := []string{"-N", chainName, "-t", "filter"}
60 |
61 | c := exec.Command(ctx.iptablesBin, cmd...)
62 |
63 | log.Infof("Creating chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " "))
64 |
65 | if out, err := c.CombinedOutput(); err != nil {
66 | log.Errorf("error while creating chain : %v --> %s", err, string(out))
67 | return
68 | }
69 |
70 | for _, chain := range ctx.Chains {
71 | cmd = []string{"-I", chain, "-j", chainName}
72 |
73 | c = exec.Command(ctx.iptablesBin, cmd...)
74 |
75 | log.Infof("Adding rule : %s %s", ctx.iptablesBin, strings.Join(cmd, " "))
76 |
77 | if out, err := c.CombinedOutput(); err != nil {
78 | log.Errorf("error while adding rule : %v --> %s", err, string(out))
79 | continue
80 | }
81 | }
82 |
83 | if ctx.loggingEnabled {
84 | // Create the logging chain
85 | cmd = []string{"-N", loggingChainName, "-t", "filter"}
86 |
87 | c = exec.Command(ctx.iptablesBin, cmd...)
88 |
89 | log.Infof("Creating logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " "))
90 |
91 | if out, err := c.CombinedOutput(); err != nil {
92 | log.Errorf("error while creating logging chain : %v --> %s", err, string(out))
93 | return
94 | }
95 |
96 | // Insert the logging rule
97 | cmd = []string{"-I", loggingChainName, "-j", "LOG", "--log-prefix", ctx.loggingPrefix}
98 |
99 | c = exec.Command(ctx.iptablesBin, cmd...)
100 |
101 | log.Infof("Adding logging rule : %s %s", ctx.iptablesBin, strings.Join(cmd, " "))
102 |
103 | if out, err := c.CombinedOutput(); err != nil {
104 | log.Errorf("error while adding logging rule : %v --> %s", err, string(out))
105 | }
106 |
107 | // Add the desired target to the logging chain
108 |
109 | cmd = []string{"-A", loggingChainName, "-j", ctx.target}
110 |
111 | c = exec.Command(ctx.iptablesBin, cmd...)
112 |
113 | log.Infof("Adding target rule to logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " "))
114 |
115 | if out, err := c.CombinedOutput(); err != nil {
116 | log.Errorf("error while setting logging chain policy : %v --> %s", err, string(out))
117 | }
118 | }
119 | }
120 |
121 | func (ctx *ipTablesContext) deleteChain() {
122 | for _, chain := range ctx.Chains {
123 | cmd := []string{"-D", chain, "-j", chainName}
124 |
125 | c := exec.Command(ctx.iptablesBin, cmd...)
126 |
127 | log.Infof("Deleting rule : %s %s", ctx.iptablesBin, strings.Join(cmd, " "))
128 |
129 | if out, err := c.CombinedOutput(); err != nil {
130 | log.Errorf("error while removing rule : %v --> %s", err, string(out))
131 | }
132 | }
133 |
134 | cmd := []string{"-F", chainName}
135 |
136 | c := exec.Command(ctx.iptablesBin, cmd...)
137 |
138 | log.Infof("Flushing chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " "))
139 |
140 | if out, err := c.CombinedOutput(); err != nil {
141 | log.Errorf("error while flushing chain : %v --> %s", err, string(out))
142 | }
143 |
144 | cmd = []string{"-X", chainName}
145 |
146 | c = exec.Command(ctx.iptablesBin, cmd...)
147 |
148 | log.Infof("Deleting chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " "))
149 |
150 | if out, err := c.CombinedOutput(); err != nil {
151 | log.Errorf("error while deleting chain : %v --> %s", err, string(out))
152 | }
153 |
154 | if ctx.loggingEnabled {
155 | cmd = []string{"-F", loggingChainName}
156 |
157 | c = exec.Command(ctx.iptablesBin, cmd...)
158 |
159 | log.Infof("Flushing logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " "))
160 |
161 | if out, err := c.CombinedOutput(); err != nil {
162 | log.Errorf("error while flushing logging chain : %v --> %s", err, string(out))
163 | }
164 |
165 | cmd = []string{"-X", loggingChainName}
166 |
167 | c = exec.Command(ctx.iptablesBin, cmd...)
168 |
169 | log.Infof("Deleting logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " "))
170 |
171 | if out, err := c.CombinedOutput(); err != nil {
172 | log.Errorf("error while deleting logging chain : %v --> %s", err, string(out))
173 | }
174 | }
175 | }
176 |
177 | func (ctx *ipTablesContext) createRule(setName string, origin string) {
178 | target := ctx.target
179 |
180 | if ctx.loggingEnabled {
181 | target = loggingChainName
182 | }
183 |
184 | cmd := []string{"-I", chainName, "-m", "set", "--match-set", setName, "src", "-j", target}
185 |
186 | if ctx.addRuleComments {
187 | cmd = append(cmd, "-m", "comment", "--comment", "CrowdSec: "+origin)
188 | }
189 |
190 | c := exec.Command(ctx.iptablesBin, cmd...)
191 |
192 | log.Infof("Creating rule : %s %s", ctx.iptablesBin, strings.Join(cmd, " "))
193 |
194 | if out, err := c.CombinedOutput(); err != nil {
195 | log.Errorf("error while inserting set entry in iptables : %v --> %s", err, string(out))
196 | }
197 | }
198 |
199 | func (ctx *ipTablesContext) commit() error {
200 | tmpFile, err := os.CreateTemp("", "cs-firewall-bouncer-ipset-")
201 | if err != nil {
202 | return err
203 | }
204 |
205 | defer func() {
206 | tmpFile.Close()
207 | os.Remove(tmpFile.Name())
208 |
209 | ctx.toAdd = nil
210 | ctx.toDel = nil
211 | }()
212 |
213 | for _, decision := range ctx.toDel {
214 | var (
215 | set *ipsetcmd.IPSet
216 | ok bool
217 | )
218 |
219 | // Decisions coming from lists will have "lists" as origin, and the scenario will be the list name
220 | // We use those to build a custom origin because we want to track metrics per list
221 | // In case of other origin (crowdsec, cscli, ...), we do not really care about the scenario, it would be too noisy
222 | origin := *decision.Origin
223 | if origin == "lists" {
224 | origin = origin + ":" + *decision.Scenario
225 | }
226 |
227 | if ctx.ipsetContentOnly {
228 | set = ctx.ipsets["ipset"]
229 | } else {
230 | set, ok = ctx.ipsets[origin]
231 | if !ok {
232 | // No set for this origin, skip, as there's nothing to delete
233 | continue
234 | }
235 | }
236 |
237 | delCmd := fmt.Sprintf("del %s %s -exist\n", set.Name(), *decision.Value)
238 |
239 | log.Debugf("%s", delCmd)
240 |
241 | _, err = tmpFile.WriteString(delCmd)
242 | if err != nil {
243 | log.Errorf("error while writing to temp file : %s", err)
244 | continue
245 | }
246 | }
247 |
248 | for _, decision := range ctx.toAdd {
249 | banDuration, err := time.ParseDuration(*decision.Duration)
250 | if err != nil {
251 | log.Errorf("error while parsing ban duration : %s", err)
252 | continue
253 | }
254 |
255 | var (
256 | set *ipsetcmd.IPSet
257 | ok bool
258 | )
259 |
260 | if banDuration.Seconds() > maxBanSeconds {
261 | log.Warnf("Ban duration too long (%d seconds), maximum for ipset is %d, setting duration to %d", int(banDuration.Seconds()), maxBanSeconds, maxBanSeconds-1)
262 | banDuration = time.Duration(maxBanSeconds-1) * time.Second
263 | }
264 |
265 | origin := *decision.Origin
266 |
267 | if origin == "lists" {
268 | origin = origin + ":" + *decision.Scenario
269 | }
270 |
271 | if ctx.ipsetContentOnly {
272 | set = ctx.ipsets["ipset"]
273 | } else {
274 | set, ok = ctx.ipsets[origin]
275 |
276 | if !ok {
277 | idx := slices.Index(ctx.originSetMapping, origin)
278 |
279 | if idx == -1 {
280 | ctx.originSetMapping = append(ctx.originSetMapping, origin)
281 | idx = len(ctx.originSetMapping) - 1
282 | }
283 |
284 | setName := fmt.Sprintf("%s-%d", ctx.SetName, idx)
285 |
286 | log.Infof("Using %s as set for origin %s", setName, origin)
287 |
288 | set, err = ipsetcmd.NewIPSet(setName)
289 | if err != nil {
290 | log.Errorf("error while creating ipset : %s", err)
291 | continue
292 | }
293 |
294 | family := "inet"
295 |
296 | if ctx.version == "v6" {
297 | family = "inet6"
298 | }
299 |
300 | err = set.Create(ipsetcmd.CreateOptions{
301 | Family: family,
302 | Timeout: defaultTimeout,
303 | MaxElem: strconv.Itoa(ctx.SetSize),
304 | Type: ctx.SetType,
305 | DisableTimeouts: ctx.ipsetDisableTimeouts,
306 | })
307 | // Ignore errors if the set already exists
308 | if err != nil {
309 | log.Errorf("error while creating ipset : %s", err)
310 | continue
311 | }
312 |
313 | ctx.ipsets[origin] = set
314 |
315 | if !ctx.ipsetContentOnly {
316 | // Create the rule to use the set
317 | ctx.createRule(set.Name(), origin)
318 | }
319 | }
320 | }
321 |
322 | var addCmd string
323 | if ctx.ipsetDisableTimeouts {
324 | addCmd = fmt.Sprintf("add %s %s -exist\n", set.Name(), *decision.Value)
325 | } else {
326 | addCmd = fmt.Sprintf("add %s %s timeout %d -exist\n", set.Name(), *decision.Value, int(banDuration.Seconds()))
327 | }
328 |
329 | log.Debugf("%s", addCmd)
330 |
331 | _, err = tmpFile.WriteString(addCmd)
332 | if err != nil {
333 | log.Errorf("error while writing to temp file : %s", err)
334 | continue
335 | }
336 | }
337 |
338 | if len(ctx.toAdd) == 0 && len(ctx.toDel) == 0 {
339 | return nil
340 | }
341 |
342 | return ctx.defaultSet.Restore(tmpFile.Name())
343 | }
344 |
345 | func (ctx *ipTablesContext) add(decision *models.Decision) {
346 | ctx.toAdd = append(ctx.toAdd, decision)
347 | }
348 |
349 | func (ctx *ipTablesContext) shutDown() error {
350 | // Remove rules
351 | if !ctx.ipsetContentOnly {
352 | ctx.deleteChain()
353 | }
354 |
355 | time.Sleep(1 * time.Second)
356 |
357 | // Clean sets
358 | for _, set := range ctx.ipsets {
359 | if ctx.ipsetContentOnly {
360 | err := set.Flush()
361 | if err != nil {
362 | log.Errorf("error while flushing ipset : %s", err)
363 | }
364 | } else {
365 | err := set.Destroy()
366 | if err != nil {
367 | log.Errorf("error while destroying set %s : %s", set.Name(), err)
368 | }
369 | }
370 | }
371 |
372 | if !ctx.ipsetContentOnly {
373 | // In case we are starting, just reset the map
374 | ctx.ipsets = make(map[string]*ipsetcmd.IPSet)
375 | }
376 |
377 | return nil
378 | }
379 |
380 | func (ctx *ipTablesContext) delete(decision *models.Decision) error {
381 | ctx.toDel = append(ctx.toDel, decision)
382 | return nil
383 | }
384 |
--------------------------------------------------------------------------------
/pkg/iptables/iptables_stub.go:
--------------------------------------------------------------------------------
1 | //go:build !linux
2 | // +build !linux
3 |
4 | package iptables
5 |
6 | import (
7 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg"
8 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types"
9 | )
10 |
11 | func NewIPTables(config *cfg.BouncerConfig) (types.Backend, error) {
12 | return nil, nil
13 | }
14 |
--------------------------------------------------------------------------------
/pkg/iptables/metrics.go:
--------------------------------------------------------------------------------
1 | //go:build linux
2 |
3 | package iptables
4 |
5 | import (
6 | "bufio"
7 | "os/exec"
8 | "regexp"
9 | "strconv"
10 | "strings"
11 |
12 | "github.com/prometheus/client_golang/prometheus"
13 | log "github.com/sirupsen/logrus"
14 |
15 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics"
16 | )
17 |
18 | // iptables does not provide a "nice" way to get the counters for a rule, so we have to parse the output of iptables-save
19 | // chainRegexp is just used to get the counters for the chain CROWDSEC_CHAIN (the chain managed by the bouncer that will contains our rules) from the JUMP rule
20 | // ruleRegexp is used to get the counters for the rules we have added that will actually block the traffic
21 | // Example output of iptables-save :
22 | // [2080:13210403] -A INPUT -j CROWDSEC_CHAIN
23 | // ...
24 | // [0:0] -A CROWDSEC_CHAIN -m set --match-set test-set-ipset-mode-0 src -j DROP
25 | // First number is the number of packets, second is the number of bytes
26 | // In case of a jump, the counters represent the number of packets and bytes that have been processed by the chain (ie, whether the packets have been accepted or dropped)
27 | // In case of a rule, the counters represent the number of packets and bytes that have been matched by the rule (ie, the packets that have been dropped).
28 |
29 | var (
30 | chainRegexp = regexp.MustCompile(`^\[(\d+):(\d+)\]`)
31 | ruleRegexp = regexp.MustCompile(`^\[(\d+):(\d+)\] -A [0-9A-Za-z_-]+ -m set --match-set (.*) src .*-j \w+`)
32 | )
33 |
34 | // In ipset mode, we have to track the numbers of processed bytes/packets at the chain level
35 | // This is not really accurate, as a rule *before* the crowdsec rule could impact the numbers, but we don't have any other way.
36 |
37 | var (
38 | ipsetChainDeclaration = regexp.MustCompile(`^:([0-9A-Za-z_-]+) ([0-9A-Za-z_-]+) \[(\d+):(\d+)\]`)
39 | ipsetRule = regexp.MustCompile(`^\[(\d+):(\d+)\] -A ([0-9A-Za-z_-]+)`)
40 | )
41 |
42 | func (ctx *ipTablesContext) collectMetricsIptables(scanner *bufio.Scanner) (map[string]int, map[string]int, int, int) {
43 | processedBytes := 0
44 | processedPackets := 0
45 |
46 | droppedBytes := make(map[string]int)
47 | droppedPackets := make(map[string]int)
48 |
49 | for scanner.Scan() {
50 | line := scanner.Text()
51 |
52 | // Ignore chain declaration
53 | if line[0] == ':' {
54 | continue
55 | }
56 |
57 | // Jump to our chain, we can get the processed packets and bytes
58 | if strings.Contains(line, "-j "+chainName) {
59 | matches := chainRegexp.FindStringSubmatch(line)
60 | if len(matches) != 3 {
61 | log.Errorf("error while parsing counters : %s | not enough matches", line)
62 | continue
63 | }
64 |
65 | val, err := strconv.Atoi(matches[1])
66 | if err != nil {
67 | log.Errorf("error while parsing counters : %s", line)
68 | continue
69 | }
70 |
71 | processedPackets += val
72 |
73 | val, err = strconv.Atoi(matches[2])
74 | if err != nil {
75 | log.Errorf("error while parsing counters : %s", line)
76 | continue
77 | }
78 |
79 | processedBytes += val
80 |
81 | continue
82 | }
83 |
84 | // This is a rule
85 | if strings.Contains(line, "-A "+chainName) {
86 | matches := ruleRegexp.FindStringSubmatch(line)
87 | if len(matches) != 4 {
88 | log.Errorf("error while parsing counters : %s | not enough matches", line)
89 | continue
90 | }
91 |
92 | originIDStr, found := strings.CutPrefix(matches[3], ctx.SetName+"-")
93 | if !found {
94 | log.Errorf("error while parsing counters : %s | no origin found", line)
95 | continue
96 | }
97 |
98 | originID, err := strconv.Atoi(originIDStr)
99 | if err != nil {
100 | log.Errorf("error while parsing counters : %s | %s", line, err)
101 | continue
102 | }
103 |
104 | if len(ctx.originSetMapping) < originID {
105 | log.Errorf("Found unknown origin id : %d", originID)
106 | continue
107 | }
108 |
109 | origin := ctx.originSetMapping[originID]
110 |
111 | val, err := strconv.Atoi(matches[1])
112 | if err != nil {
113 | log.Errorf("error while parsing counters : %s | %s", line, err)
114 | continue
115 | }
116 |
117 | droppedPackets[origin] += val
118 |
119 | val, err = strconv.Atoi(matches[2])
120 | if err != nil {
121 | log.Errorf("error while parsing counters : %s | %s", line, err)
122 | continue
123 | }
124 |
125 | droppedBytes[origin] += val
126 | }
127 | }
128 |
129 | return droppedPackets, droppedBytes, processedPackets, processedBytes
130 | }
131 |
132 | type chainCounters struct {
133 | bytes int
134 | packets int
135 | }
136 |
137 | // In ipset mode, we only get dropped packets and bytes by matching on the set name in the rule
138 | // It's probably not perfect, but good enough for most users.
139 | func (ctx *ipTablesContext) collectMetricsIpset(scanner *bufio.Scanner) (map[string]int, map[string]int, int, int) {
140 | processedBytes := 0
141 | processedPackets := 0
142 |
143 | droppedBytes := make(map[string]int)
144 | droppedPackets := make(map[string]int)
145 |
146 | // We need to store the counters for all chains
147 | // As we don't know in which chain the user has setup the rules
148 | // We'll resolve the value laters.
149 | chainsCounter := make(map[string]chainCounters)
150 |
151 | // Hardcode the origin to ipset as we cannot know it based on the rule.
152 | droppedBytes["ipset"] = 0
153 | droppedPackets["ipset"] = 0
154 |
155 | for scanner.Scan() {
156 | line := scanner.Text()
157 |
158 | // Chain declaration
159 | if line[0] == ':' {
160 | matches := ipsetChainDeclaration.FindStringSubmatch(line)
161 | if len(matches) != 5 {
162 | log.Errorf("error while parsing counters : %s | not enough matches", line)
163 | continue
164 | }
165 |
166 | log.Debugf("Found chain %s with matches %+v", matches[1], matches)
167 |
168 | c, ok := chainsCounter[matches[1]]
169 | if !ok {
170 | c = chainCounters{}
171 | }
172 |
173 | val, err := strconv.Atoi(matches[3])
174 | if err != nil {
175 | log.Errorf("error while parsing counters : %s", line)
176 | continue
177 | }
178 |
179 | c.packets += val
180 |
181 | val, err = strconv.Atoi(matches[4])
182 | if err != nil {
183 | log.Errorf("error while parsing counters : %s", line)
184 | continue
185 | }
186 |
187 | c.bytes += val
188 | chainsCounter[matches[1]] = c
189 |
190 | continue
191 | }
192 |
193 | // Assume that if a line contains the set name, it's a rule we are interested in.
194 | if strings.Contains(line, ctx.SetName) {
195 | matches := ipsetRule.FindStringSubmatch(line)
196 | if len(matches) != 4 {
197 | log.Errorf("error while parsing counters : %s | not enough matches", line)
198 | continue
199 | }
200 |
201 | val, err := strconv.Atoi(matches[1])
202 | if err != nil {
203 | log.Errorf("error while parsing counters : %s", line)
204 | continue
205 | }
206 |
207 | droppedPackets["ipset"] += val
208 |
209 | val, err = strconv.Atoi(matches[2])
210 | if err != nil {
211 | log.Errorf("error while parsing counters : %s", line)
212 | continue
213 | }
214 |
215 | droppedBytes["ipset"] += val
216 |
217 | // Resolve the chain counters
218 | c, ok := chainsCounter[matches[3]]
219 | if !ok {
220 | log.Errorf("error while parsing counters : %s | chain not found", line)
221 | continue
222 | }
223 |
224 | processedPackets += c.packets
225 | processedBytes += c.bytes
226 | }
227 | }
228 |
229 | return droppedPackets, droppedBytes, processedPackets, processedBytes
230 | }
231 |
232 | func (ctx *ipTablesContext) collectMetrics() (map[string]int, map[string]int, int, int, error) {
233 | //-c is required to get the counters
234 | cmd := []string{ctx.iptablesSaveBin, "-c", "-t", "filter"}
235 | saveCmd := exec.Command(cmd[0], cmd[1:]...)
236 |
237 | out, err := saveCmd.CombinedOutput()
238 | if err != nil {
239 | log.Errorf("error while getting iptables rules with cmd %+v : %v --> %s", cmd, err, string(out))
240 | return nil, nil, 0, 0, err
241 | }
242 |
243 | var (
244 | processedBytes int
245 | processedPackets int
246 | droppedBytes map[string]int
247 | droppedPackets map[string]int
248 | )
249 |
250 | scanner := bufio.NewScanner(strings.NewReader(string(out)))
251 |
252 | if !ctx.ipsetContentOnly {
253 | droppedPackets, droppedBytes, processedPackets, processedBytes = ctx.collectMetricsIptables(scanner)
254 | } else {
255 | droppedPackets, droppedBytes, processedPackets, processedBytes = ctx.collectMetricsIpset(scanner)
256 | }
257 |
258 | log.Debugf("Processed %d packets and %d bytes", processedPackets, processedBytes)
259 | log.Debugf("Dropped packets : %v", droppedPackets)
260 | log.Debugf("Dropped bytes : %v", droppedBytes)
261 |
262 | return droppedPackets, droppedBytes, processedPackets, processedBytes, nil
263 | }
264 |
265 | func (ipt *iptables) CollectMetrics() {
266 | if ipt.v4 != nil {
267 | for origin, set := range ipt.v4.ipsets {
268 | metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": origin}).Set(float64(set.Len()))
269 | }
270 |
271 | ipv4DroppedPackets, ipv4DroppedBytes, ipv4ProcessedPackets, ipv4ProcessedBytes, err := ipt.v4.collectMetrics()
272 |
273 | if err != nil {
274 | log.Errorf("can't collect dropped packets for ipv4 from iptables: %s", err)
275 | } else {
276 | metrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv4"}).Set(float64(ipv4ProcessedPackets))
277 | metrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv4"}).Set(float64(ipv4ProcessedBytes))
278 |
279 | for origin, count := range ipv4DroppedPackets {
280 | metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": origin}).Set(float64(count))
281 | }
282 |
283 | for origin, count := range ipv4DroppedBytes {
284 | metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": origin}).Set(float64(count))
285 | }
286 | }
287 | }
288 |
289 | if ipt.v6 != nil {
290 | for origin, set := range ipt.v6.ipsets {
291 | metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": origin}).Set(float64(set.Len()))
292 | }
293 |
294 | ipv6DroppedPackets, ipv6DroppedBytes, ipv6ProcessedPackets, ipv6ProcessedBytes, err := ipt.v6.collectMetrics()
295 |
296 | if err != nil {
297 | log.Errorf("can't collect dropped packets for ipv6 from iptables: %s", err)
298 | } else {
299 | metrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv6"}).Set(float64(ipv6ProcessedPackets))
300 | metrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv6"}).Set(float64(ipv6ProcessedBytes))
301 |
302 | for origin, count := range ipv6DroppedPackets {
303 | metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": origin}).Set(float64(count))
304 | }
305 |
306 | for origin, count := range ipv6DroppedBytes {
307 | metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": origin}).Set(float64(count))
308 | }
309 | }
310 | }
311 | }
312 |
--------------------------------------------------------------------------------
/pkg/metrics/metrics.go:
--------------------------------------------------------------------------------
1 | package metrics
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "github.com/prometheus/client_golang/prometheus"
8 | io_prometheus_client "github.com/prometheus/client_model/go"
9 | log "github.com/sirupsen/logrus"
10 |
11 | "github.com/crowdsecurity/go-cs-lib/ptr"
12 |
13 | "github.com/crowdsecurity/crowdsec/pkg/models"
14 | )
15 |
16 | const CollectionInterval = time.Second * 10
17 |
18 | type metricName string
19 |
20 | const (
21 | DroppedPackets metricName = "fw_bouncer_dropped_packets"
22 | DroppedBytes metricName = "fw_bouncer_dropped_bytes"
23 | ProcessedPackets metricName = "fw_bouncer_processed_packets"
24 | ProcessedBytes metricName = "fw_bouncer_processed_bytes"
25 | ActiveBannedIPs metricName = "fw_bouncer_banned_ips"
26 | )
27 |
28 | type backendCollector interface {
29 | CollectMetrics()
30 | }
31 |
32 | type Handler struct {
33 | Backend backendCollector
34 | }
35 |
36 | type metricConfig struct {
37 | Name string
38 | Unit string
39 | Gauge *prometheus.GaugeVec
40 | LabelKeys []string
41 | LastValueMap map[string]float64 // keep last value to send deltas -- nil if absolute
42 | KeyFunc func(labels []*io_prometheus_client.LabelPair) string
43 | }
44 |
45 | type metricMap map[metricName]*metricConfig
46 |
47 | func (m metricMap) MustRegisterAll() {
48 | for _, met := range m {
49 | prometheus.MustRegister(met.Gauge)
50 | }
51 | }
52 |
53 | var Map = metricMap{
54 | ActiveBannedIPs: {
55 | Name: "active_decisions",
56 | Unit: "ip",
57 | Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{
58 | Name: string(ActiveBannedIPs),
59 | Help: "Denotes the number of IPs which are currently banned",
60 | }, []string{"origin", "ip_type"}),
61 | LabelKeys: []string{"origin", "ip_type"},
62 | LastValueMap: nil,
63 | KeyFunc: func([]*io_prometheus_client.LabelPair) string { return "" },
64 | },
65 | DroppedBytes: {
66 | Name: "dropped",
67 | Unit: "byte",
68 | Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{
69 | Name: string(DroppedBytes),
70 | Help: "Denotes the number of total dropped bytes because of rule(s) created by crowdsec",
71 | }, []string{"origin", "ip_type"}),
72 | LabelKeys: []string{"origin", "ip_type"},
73 | LastValueMap: make(map[string]float64),
74 | KeyFunc: func(labels []*io_prometheus_client.LabelPair) string {
75 | return getLabelValue(labels, "origin") + getLabelValue(labels, "ip_type")
76 | },
77 | },
78 | DroppedPackets: {
79 | Name: "dropped",
80 | Unit: "packet",
81 | Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{
82 | Name: string(DroppedPackets),
83 | Help: "Denotes the number of total dropped packets because of rule(s) created by crowdsec",
84 | }, []string{"origin", "ip_type"}),
85 | LabelKeys: []string{"origin", "ip_type"},
86 | LastValueMap: make(map[string]float64),
87 | KeyFunc: func(labels []*io_prometheus_client.LabelPair) string {
88 | return getLabelValue(labels, "origin") + getLabelValue(labels, "ip_type")
89 | },
90 | },
91 | ProcessedBytes: {
92 | Name: "processed",
93 | Unit: "byte",
94 | Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{
95 | Name: string(ProcessedBytes),
96 | Help: "Denotes the number of total processed bytes by the rules created by crowdsec",
97 | }, []string{"ip_type"}),
98 | LabelKeys: []string{"ip_type"},
99 | LastValueMap: make(map[string]float64),
100 | KeyFunc: func(labels []*io_prometheus_client.LabelPair) string {
101 | return getLabelValue(labels, "ip_type")
102 | },
103 | },
104 | ProcessedPackets: {
105 | Name: "processed",
106 | Unit: "packet",
107 | Gauge: prometheus.NewGaugeVec(prometheus.GaugeOpts{
108 | Name: string(ProcessedPackets),
109 | Help: "Denotes the number of total processed packets by the rules created by crowdsec",
110 | }, []string{"ip_type"}),
111 | LabelKeys: []string{"ip_type"},
112 | LastValueMap: make(map[string]float64),
113 | KeyFunc: func(labels []*io_prometheus_client.LabelPair) string {
114 | return getLabelValue(labels, "ip_type")
115 | },
116 | },
117 | }
118 |
119 | func getLabelValue(labels []*io_prometheus_client.LabelPair, key string) string {
120 | for _, label := range labels {
121 | if label.GetName() == key {
122 | return label.GetValue()
123 | }
124 | }
125 |
126 | return ""
127 | }
128 |
129 | // MetricsUpdater receives a metrics struct with basic data and populates it with the current metrics.
130 | func (m Handler) MetricsUpdater(met *models.RemediationComponentsMetrics, updateInterval time.Duration) {
131 | log.Debugf("Updating metrics")
132 |
133 | m.Backend.CollectMetrics()
134 |
135 | // Most of the common fields are set automatically by the metrics provider
136 | // We only need to care about the metrics themselves
137 |
138 | promMetrics, err := prometheus.DefaultGatherer.Gather()
139 | if err != nil {
140 | log.Errorf("unable to gather prometheus metrics: %s", err)
141 | return
142 | }
143 |
144 | met.Metrics = append(met.Metrics, &models.DetailedMetrics{
145 | Meta: &models.MetricsMeta{
146 | UtcNowTimestamp: ptr.Of(time.Now().Unix()),
147 | WindowSizeSeconds: ptr.Of(int64(updateInterval.Seconds())),
148 | },
149 | Items: make([]*models.MetricsDetailItem, 0),
150 | })
151 |
152 | for _, metricFamily := range promMetrics {
153 | cfg, ok := Map[metricName(metricFamily.GetName())]
154 | if !ok {
155 | continue
156 | }
157 |
158 | for _, metric := range metricFamily.GetMetric() {
159 | labels := metric.GetLabel()
160 | value := metric.GetGauge().GetValue()
161 |
162 | labelMap := make(map[string]string)
163 | for _, key := range cfg.LabelKeys {
164 | labelMap[key] = getLabelValue(labels, key)
165 | }
166 |
167 | finalValue := value
168 |
169 | if cfg.LastValueMap == nil {
170 | // always send absolute values
171 | log.Debugf("Sending %s for %+v %f", cfg.Name, labelMap, finalValue)
172 | } else {
173 | // the final value to send must be relative, and never negative
174 | // because the firewall counter may have been reset since last collection.
175 | key := cfg.KeyFunc(labels)
176 |
177 | // no need to guard access to LastValueMap, as we are in the main thread -- it's
178 | // the gauge that is updated by the requests
179 | finalValue = value - cfg.LastValueMap[key]
180 |
181 | if finalValue < 0 {
182 | finalValue = -finalValue
183 |
184 | log.Warningf("metric value for %s %+v is negative, assuming external counter was reset", cfg.Name, labelMap)
185 | }
186 |
187 | cfg.LastValueMap[key] = value
188 | log.Debugf("Sending %s for %+v %f | current value: %f | previous value: %f", cfg.Name, labelMap, finalValue, value, cfg.LastValueMap[key])
189 | }
190 |
191 | met.Metrics[0].Items = append(met.Metrics[0].Items, &models.MetricsDetailItem{
192 | Name: ptr.Of(cfg.Name),
193 | Value: &finalValue,
194 | Labels: labelMap,
195 | Unit: ptr.Of(cfg.Unit),
196 | })
197 | }
198 | }
199 | }
200 |
201 | func (m Handler) ComputeMetricsHandler(next http.Handler) http.Handler {
202 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
203 | m.Backend.CollectMetrics()
204 | next.ServeHTTP(w, r)
205 | })
206 | }
207 |
--------------------------------------------------------------------------------
/pkg/nftables/metrics.go:
--------------------------------------------------------------------------------
1 | //go:build linux
2 |
3 | package nftables
4 |
5 | import (
6 | "fmt"
7 | "strings"
8 | "time"
9 |
10 | "github.com/google/nftables/expr"
11 | "github.com/prometheus/client_golang/prometheus"
12 | log "github.com/sirupsen/logrus"
13 |
14 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics"
15 | )
16 |
17 | func (c *nftContext) collectDroppedPackets() (map[string]int, map[string]int, int, int, error) {
18 | droppedPackets := make(map[string]int)
19 | droppedBytes := make(map[string]int)
20 | processedPackets := 0
21 | processedBytes := 0
22 | // setName := ""
23 | for chainName, chain := range c.chains {
24 | rules, err := c.conn.GetRules(c.table, chain)
25 | if err != nil {
26 | log.Errorf("can't get rules for chain %s: %s", chainName, err)
27 | continue
28 | }
29 |
30 | for _, rule := range rules {
31 | for _, xpr := range rule.Exprs {
32 | obj, ok := xpr.(*expr.Counter)
33 | if ok {
34 | log.Debugf("rule %d (%s): packets %d, bytes %d (%s)", rule.Position, rule.Table.Name, obj.Packets, obj.Bytes, rule.UserData)
35 |
36 | if string(rule.UserData) == "processed" {
37 | processedPackets += int(obj.Packets)
38 | processedBytes += int(obj.Bytes)
39 |
40 | continue
41 | }
42 |
43 | origin, _ := strings.CutPrefix(string(rule.UserData), c.blacklists+"-")
44 |
45 | if origin == "" {
46 | continue
47 | }
48 |
49 | droppedPackets[origin] += int(obj.Packets)
50 | droppedBytes[origin] += int(obj.Bytes)
51 | }
52 | }
53 | }
54 | }
55 |
56 | return droppedPackets, droppedBytes, processedPackets, processedBytes, nil
57 | }
58 |
59 | func (c *nftContext) collectActiveBannedIPs() (map[string]int, error) {
60 | // Find the size of the set we have created
61 | ret := make(map[string]int)
62 |
63 | for origin, set := range c.sets {
64 | setContent, err := c.conn.GetSetElements(set)
65 | if err != nil {
66 | return nil, fmt.Errorf("can't get set elements for %s: %w", set.Name, err)
67 | }
68 |
69 | if c.setOnly {
70 | ret[c.blacklists] = len(setContent)
71 | } else {
72 | ret[origin] = len(setContent)
73 | }
74 |
75 | return ret, nil
76 | }
77 |
78 | return ret, nil
79 | }
80 |
81 | func (c *nftContext) collectDropped() (map[string]int, map[string]int, int, int, map[string]int) {
82 | if c.conn == nil {
83 | return nil, nil, 0, 0, nil
84 | }
85 |
86 | droppedPackets, droppedBytes, processedPackets, processedBytes, err := c.collectDroppedPackets()
87 | if err != nil {
88 | log.Errorf("can't collect dropped packets for ip%s from nft: %s", c.version, err)
89 | }
90 |
91 | banned, err := c.collectActiveBannedIPs()
92 | if err != nil {
93 | log.Errorf("can't collect total banned IPs for ip%s from nft: %s", c.version, err)
94 | }
95 |
96 | return droppedPackets, droppedBytes, processedPackets, processedBytes, banned
97 | }
98 |
99 | func getOriginForList(origin string) string {
100 | if !strings.HasPrefix(origin, "lists-") {
101 | return origin
102 | }
103 |
104 | return strings.Replace(origin, "-", ":", 1)
105 | }
106 |
107 | func (n *nft) CollectMetrics() {
108 | startTime := time.Now()
109 | ip4DroppedPackets, ip4DroppedBytes, ip4ProcessedPackets, ip4ProcessedBytes, bannedIP4 := n.v4.collectDropped()
110 | ip6DroppedPackets, ip6DroppedBytes, ip6ProcessedPackets, ip6ProcessedBytes, bannedIP6 := n.v6.collectDropped()
111 |
112 | log.Debugf("metrics collection took %s", time.Since(startTime))
113 | log.Debugf("ip4: dropped packets: %+v, dropped bytes: %+v, banned IPs: %+v, proccessed packets: %d, processed bytes: %d", ip4DroppedPackets, ip4DroppedBytes, bannedIP4, ip4ProcessedPackets, ip4ProcessedBytes)
114 | log.Debugf("ip6: dropped packets: %+v, dropped bytes: %+v, banned IPs: %+v, proccessed packets: %d, processed bytes: %d", ip6DroppedPackets, ip6DroppedBytes, bannedIP6, ip6ProcessedPackets, ip6ProcessedBytes)
115 |
116 | metrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv4"}).Set(float64(ip4ProcessedPackets))
117 | metrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv4"}).Set(float64(ip4ProcessedBytes))
118 |
119 | metrics.Map[metrics.ProcessedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv6"}).Set(float64(ip6ProcessedPackets))
120 | metrics.Map[metrics.ProcessedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv6"}).Set(float64(ip6ProcessedBytes))
121 |
122 | for origin, count := range bannedIP4 {
123 | origin = getOriginForList(origin)
124 | metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv4"}).Set(float64(count))
125 | }
126 |
127 | for origin, count := range bannedIP6 {
128 | origin = getOriginForList(origin)
129 | metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv6"}).Set(float64(count))
130 | }
131 |
132 | for origin, count := range ip4DroppedPackets {
133 | origin = getOriginForList(origin)
134 | metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv4"}).Set(float64(count))
135 | }
136 |
137 | for origin, count := range ip6DroppedPackets {
138 | origin = getOriginForList(origin)
139 | metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv6"}).Set(float64(count))
140 | }
141 |
142 | for origin, count := range ip4DroppedBytes {
143 | origin = getOriginForList(origin)
144 | metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv4"}).Set(float64(count))
145 | }
146 |
147 | for origin, count := range ip6DroppedBytes {
148 | origin = getOriginForList(origin)
149 | metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"origin": origin, "ip_type": "ipv6"}).Set(float64(count))
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/pkg/nftables/nftables.go:
--------------------------------------------------------------------------------
1 | //go:build linux
2 |
3 | package nftables
4 |
5 | import (
6 | "fmt"
7 | "net"
8 | "strings"
9 | "time"
10 |
11 | "github.com/google/nftables"
12 | "github.com/google/nftables/binaryutil"
13 | log "github.com/sirupsen/logrus"
14 |
15 | "github.com/crowdsecurity/crowdsec/pkg/models"
16 |
17 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg"
18 | )
19 |
20 | const (
21 | chunkSize = 200
22 | defaultTimeout = "4h"
23 | )
24 |
25 | type nft struct {
26 | v4 *nftContext
27 | v6 *nftContext
28 | decisionsToAdd []*models.Decision
29 | decisionsToDelete []*models.Decision
30 | DenyAction string
31 | DenyLog bool
32 | DenyLogPrefix string
33 | Hooks []string
34 | }
35 |
36 | func NewNFTables(config *cfg.BouncerConfig) (*nft, error) {
37 | ret := &nft{
38 | v4: NewNFTV4Context(config),
39 | v6: NewNFTV6Context(config),
40 | DenyAction: config.DenyAction,
41 | DenyLog: config.DenyLog,
42 | DenyLogPrefix: config.DenyLogPrefix,
43 | Hooks: config.NftablesHooks,
44 | }
45 |
46 | return ret, nil
47 | }
48 |
49 | func (n *nft) Init() error {
50 | log.Debug("nftables: Init()")
51 |
52 | if err := n.v4.init(n.Hooks); err != nil {
53 | return err
54 | }
55 |
56 | if err := n.v6.init(n.Hooks); err != nil {
57 | return err
58 | }
59 |
60 | log.Infof("nftables initiated")
61 |
62 | return nil
63 | }
64 |
65 | func (n *nft) Add(decision *models.Decision) error {
66 | n.decisionsToAdd = append(n.decisionsToAdd, decision)
67 | return nil
68 | }
69 |
70 | func (n *nft) getBannedState() (map[string]struct{}, error) {
71 | banned := make(map[string]struct{})
72 | if err := n.v4.setBanned(banned); err != nil {
73 | return nil, err
74 | }
75 |
76 | if err := n.v6.setBanned(banned); err != nil {
77 | return nil, err
78 | }
79 |
80 | return banned, nil
81 | }
82 |
83 | func (n *nft) reset() {
84 | n.decisionsToAdd = make([]*models.Decision, 0)
85 | n.decisionsToDelete = make([]*models.Decision, 0)
86 | }
87 |
88 | func (n *nft) commitDeletedDecisions() error {
89 | banned, err := n.getBannedState()
90 | if err != nil {
91 | return fmt.Errorf("failed to get current state: %w", err)
92 | }
93 |
94 | ip4 := []nftables.SetElement{}
95 | ip6 := []nftables.SetElement{}
96 |
97 | n.decisionsToDelete = normalizedDecisions(n.decisionsToDelete)
98 |
99 | for _, decision := range n.decisionsToDelete {
100 | ip := net.ParseIP(*decision.Value)
101 | if _, ok := banned[ip.String()]; !ok {
102 | log.Debugf("not deleting %s since it's not in the set", ip)
103 | continue
104 | }
105 |
106 | if strings.Contains(ip.String(), ":") {
107 | if n.v6.conn != nil {
108 | log.Tracef("adding %s to buffer", ip)
109 |
110 | ip6 = append(ip6, nftables.SetElement{Key: ip.To16()})
111 | }
112 |
113 | continue
114 | }
115 |
116 | if n.v4.conn != nil {
117 | log.Tracef("adding %s to buffer", ip)
118 |
119 | ip4 = append(ip4, nftables.SetElement{Key: ip.To4()})
120 | }
121 | }
122 |
123 | if len(ip4) > 0 {
124 | log.Debugf("removing %d ip%s elements from set", len(ip4), n.v4.version)
125 |
126 | if err := n.v4.deleteElements(ip4); err != nil {
127 | return err
128 | }
129 | }
130 |
131 | if len(ip6) > 0 {
132 | log.Debugf("removing %d ip%s elements from set", len(ip6), n.v6.version)
133 |
134 | if err := n.v6.deleteElements(ip6); err != nil {
135 | return err
136 | }
137 | }
138 |
139 | return nil
140 | }
141 |
142 | func (n *nft) createSetAndRuleForOrigin(ctx *nftContext, origin string) error {
143 | if _, ok := ctx.sets[origin]; !ok {
144 | // First time we see this origin, create the rule/set for all hooks
145 | set := &nftables.Set{
146 | Name: fmt.Sprintf("%s-%s", ctx.blacklists, origin),
147 | Table: ctx.table,
148 | KeyType: ctx.typeIPAddr,
149 | KeyByteOrder: binaryutil.BigEndian,
150 | HasTimeout: true,
151 | }
152 |
153 | ctx.sets[origin] = set
154 |
155 | if err := ctx.conn.AddSet(set, []nftables.SetElement{}); err != nil {
156 | return err
157 | }
158 |
159 | for _, chain := range ctx.chains {
160 | rule, err := ctx.createRule(chain, set, n.DenyLog, n.DenyLogPrefix, n.DenyAction)
161 | if err != nil {
162 | return err
163 | }
164 |
165 | ctx.conn.AddRule(rule)
166 | log.Infof("Created set and rule for origin %s and type %s in chain %s", origin, ctx.typeIPAddr.Name, chain.Name)
167 | }
168 | }
169 |
170 | return nil
171 | }
172 |
173 | func (n *nft) commitAddedDecisions() error {
174 | banned, err := n.getBannedState()
175 | if err != nil {
176 | return fmt.Errorf("failed to get current state: %w", err)
177 | }
178 |
179 | ip4 := make(map[string][]nftables.SetElement, 0)
180 | ip6 := make(map[string][]nftables.SetElement, 0)
181 |
182 | n.decisionsToAdd = normalizedDecisions(n.decisionsToAdd)
183 |
184 | for _, decision := range n.decisionsToAdd {
185 | ip := net.ParseIP(*decision.Value)
186 | if _, ok := banned[ip.String()]; ok {
187 | log.Debugf("not adding %s since it's already in the set", ip)
188 | continue
189 | }
190 |
191 | t, _ := time.ParseDuration(*decision.Duration)
192 |
193 | origin := *decision.Origin
194 |
195 | if origin == "lists" {
196 | origin = origin + "-" + *decision.Scenario
197 | }
198 |
199 | if strings.Contains(ip.String(), ":") {
200 | if n.v6.conn != nil {
201 | if n.v6.setOnly {
202 | origin = n.v6.blacklists
203 | }
204 |
205 | log.Tracef("adding %s to buffer", ip)
206 |
207 | if _, ok := ip6[origin]; !ok {
208 | ip6[origin] = make([]nftables.SetElement, 0)
209 | }
210 |
211 | ip6[origin] = append(ip6[origin], nftables.SetElement{Timeout: t, Key: ip.To16()})
212 |
213 | if !n.v6.setOnly {
214 | err := n.createSetAndRuleForOrigin(n.v6, origin)
215 | if err != nil {
216 | return err
217 | }
218 | }
219 | }
220 |
221 | continue
222 | }
223 |
224 | if n.v4.conn != nil {
225 | if n.v4.setOnly {
226 | origin = n.v4.blacklists
227 | }
228 |
229 | log.Tracef("adding %s to buffer", ip)
230 |
231 | if _, ok := ip4[origin]; !ok {
232 | ip4[origin] = make([]nftables.SetElement, 0)
233 | }
234 |
235 | ip4[origin] = append(ip4[origin], nftables.SetElement{Timeout: t, Key: ip.To4()})
236 |
237 | if !n.v4.setOnly {
238 | err := n.createSetAndRuleForOrigin(n.v4, origin)
239 | if err != nil {
240 | return err
241 | }
242 | }
243 | }
244 | }
245 |
246 | if err := n.v4.addElements(ip4); err != nil {
247 | return err
248 | }
249 |
250 | return n.v6.addElements(ip6)
251 | }
252 |
253 | func (n *nft) Commit() error {
254 | defer n.reset()
255 |
256 | if err := n.commitDeletedDecisions(); err != nil {
257 | return err
258 | }
259 |
260 | return n.commitAddedDecisions()
261 | }
262 |
263 | type tmpDecisions struct {
264 | duration time.Duration
265 | origin string
266 | scenario string
267 | }
268 |
269 | // remove duplicates, normalize decision timeouts, keep the longest decision when dups are present.
270 | func normalizedDecisions(decisions []*models.Decision) []*models.Decision {
271 | vals := make(map[string]tmpDecisions)
272 | finalDecisions := make([]*models.Decision, 0)
273 |
274 | for _, d := range decisions {
275 | t, err := time.ParseDuration(*d.Duration)
276 | if err != nil {
277 | t, _ = time.ParseDuration(defaultTimeout)
278 | }
279 |
280 | *d.Value = strings.Split(*d.Value, "/")[0]
281 | if longest, ok := vals[*d.Value]; !ok || t > longest.duration {
282 | vals[*d.Value] = tmpDecisions{
283 | duration: t,
284 | origin: *d.Origin,
285 | scenario: *d.Scenario,
286 | }
287 | }
288 | }
289 |
290 | for ip, decision := range vals {
291 | d := decision.duration.String()
292 | i := ip // copy it because we don't same value for all decisions as `ip` is same pointer :)
293 | origin := decision.origin
294 | scenario := decision.scenario
295 |
296 | finalDecisions = append(finalDecisions, &models.Decision{
297 | Duration: &d,
298 | Value: &i,
299 | Origin: &origin,
300 | Scenario: &scenario,
301 | })
302 | }
303 |
304 | return finalDecisions
305 | }
306 |
307 | func (n *nft) Delete(decision *models.Decision) error {
308 | n.decisionsToDelete = append(n.decisionsToDelete, decision)
309 | return nil
310 | }
311 |
312 | func (n *nft) ShutDown() error {
313 | if err := n.v4.shutDown(); err != nil {
314 | return err
315 | }
316 |
317 | return n.v6.shutDown()
318 | }
319 |
--------------------------------------------------------------------------------
/pkg/nftables/nftables_stub.go:
--------------------------------------------------------------------------------
1 | //go:build !linux
2 | // +build !linux
3 |
4 | package nftables
5 |
6 | import (
7 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg"
8 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types"
9 | )
10 |
11 | func NewNFTables(config *cfg.BouncerConfig) (types.Backend, error) {
12 | return nil, nil
13 | }
14 |
--------------------------------------------------------------------------------
/pkg/pf/metrics.go:
--------------------------------------------------------------------------------
1 | package pf
2 |
3 | import (
4 | "bufio"
5 | "regexp"
6 | "slices"
7 | "strconv"
8 | "strings"
9 |
10 | "github.com/prometheus/client_golang/prometheus"
11 | log "github.com/sirupsen/logrus"
12 |
13 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics"
14 | )
15 |
16 | type counter struct {
17 | packets int
18 | bytes int
19 | }
20 |
21 | var (
22 | // table names can contain _ or - characters.
23 | rexpTable = regexp.MustCompile(`^block .* from <(?P[^ ]+)> .*"$`)
24 | rexpMetrics = regexp.MustCompile(`^\s+\[.*Packets: (?P\d+)\s+Bytes: (?P\d+).*\]$`)
25 | )
26 |
27 | func parseMetrics(reader *strings.Reader, tables []string) map[string]counter {
28 | ret := make(map[string]counter)
29 |
30 | // scan until we find a table name between <>
31 | scanner := bufio.NewScanner(reader)
32 | for scanner.Scan() {
33 | line := scanner.Text()
34 | // parse the line and extract the table name
35 | match := rexpTable.FindStringSubmatch(line)
36 | if len(match) == 0 {
37 | continue
38 | }
39 |
40 | table := match[1]
41 | // if the table is not in the list of tables we want to parse, skip it
42 | if !slices.Contains(tables, table) {
43 | continue
44 | }
45 |
46 | // parse the line with the actual metrics
47 | if !scanner.Scan() {
48 | break
49 | }
50 |
51 | line = scanner.Text()
52 |
53 | match = rexpMetrics.FindStringSubmatch(line)
54 | if len(match) == 0 {
55 | log.Errorf("failed to parse metrics: %s", line)
56 | continue
57 | }
58 |
59 | packets, err := strconv.Atoi(match[1])
60 | if err != nil {
61 | log.Errorf("failed to parse metrics - dropped packets: %s", err)
62 |
63 | packets = 0
64 | }
65 |
66 | bytes, err := strconv.Atoi(match[2])
67 | if err != nil {
68 | log.Errorf("failed to parse metrics - dropped bytes: %s", err)
69 |
70 | bytes = 0
71 | }
72 |
73 | ret[table] = counter{
74 | packets: packets,
75 | bytes: bytes,
76 | }
77 | }
78 |
79 | return ret
80 | }
81 |
82 | // countIPs returns the number of IPs in a table.
83 | func countIPs(table string) int {
84 | cmd := execPfctl("", "-T", "show", "-t", table)
85 |
86 | out, err := cmd.Output()
87 | if err != nil {
88 | log.Errorf("failed to run 'pfctl -T show -t %s': %s", table, err)
89 | return 0
90 | }
91 |
92 | // one IP per line
93 | return strings.Count(string(out), "\n")
94 | }
95 |
96 | // CollectMetrics collects metrics from pfctl.
97 | // In pf mode the firewall rules are not controlled by the bouncer, so we can only
98 | // trust they are set up correctly, and retrieve stats from the pfctl tables.
99 | func (pf *pf) CollectMetrics() {
100 | tables := []string{}
101 |
102 | if pf.inet != nil {
103 | tables = append(tables, pf.inet.table)
104 | }
105 |
106 | if pf.inet6 != nil {
107 | tables = append(tables, pf.inet6.table)
108 | }
109 |
110 | cmd := execPfctl("", "-v", "-sr")
111 |
112 | out, err := cmd.Output()
113 | if err != nil {
114 | log.Errorf("failed to run 'pfctl -v -sr': %s", err)
115 | return
116 | }
117 |
118 | reader := strings.NewReader(string(out))
119 | stats := parseMetrics(reader, tables)
120 |
121 | for _, table := range tables {
122 | st, ok := stats[table]
123 | if !ok {
124 | continue
125 | }
126 |
127 | droppedPackets := float64(st.packets)
128 | droppedBytes := float64(st.bytes)
129 | bannedIPs := countIPs(table)
130 |
131 | if pf.inet != nil && table == pf.inet.table {
132 | metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": ""}).Set(droppedPackets)
133 | metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": ""}).Set(droppedBytes)
134 | metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": ""}).Set(float64(bannedIPs))
135 | } else if pf.inet6 != nil && table == pf.inet6.table {
136 | metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": ""}).Set(droppedPackets)
137 | metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": ""}).Set(droppedBytes)
138 | metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": ""}).Set(float64(bannedIPs))
139 | }
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/pkg/pf/metrics_test.go:
--------------------------------------------------------------------------------
1 | package pf
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | "github.com/stretchr/testify/require"
9 | )
10 |
11 | func TestParseMetrics(t *testing.T) {
12 | metricsInput := `block drop in quick inet from to any label "CrowdSec IPv4"
13 | [ Evaluations: 1519 Packets: 16 Bytes: 4096 States: 0 ]
14 | [ Inserted: uid 0 pid 14219 State Creations: 0 ]
15 | block drop in quick inet6 from to any label "CrowdSec IPv6"
16 | [ Evaluations: 914 Packets: 8 Bytes: 2048 States: 0 ]
17 | [ Inserted: uid 0 pid 14219 State Creations: 0 ]`
18 |
19 | reader := strings.NewReader(metricsInput)
20 | tables := []string{"crowdsec_blacklists", "crowdsec6_blacklists"}
21 |
22 | metrics := parseMetrics(reader, tables)
23 |
24 | require.Contains(t, metrics, "crowdsec_blacklists")
25 | require.Contains(t, metrics, "crowdsec6_blacklists")
26 |
27 | ip4Metrics := metrics["crowdsec_blacklists"]
28 | assert.Equal(t, 16, ip4Metrics.packets)
29 | assert.Equal(t, 4096, ip4Metrics.bytes)
30 |
31 | ip6Metrics := metrics["crowdsec6_blacklists"]
32 | assert.Equal(t, 8, ip6Metrics.packets)
33 | assert.Equal(t, 2048, ip6Metrics.bytes)
34 | }
35 |
--------------------------------------------------------------------------------
/pkg/pf/pf.go:
--------------------------------------------------------------------------------
1 | package pf
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "os/exec"
7 | "strings"
8 |
9 | log "github.com/sirupsen/logrus"
10 |
11 | "github.com/crowdsecurity/crowdsec/pkg/models"
12 |
13 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg"
14 | "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types"
15 | )
16 |
17 | type pf struct {
18 | inet *pfContext
19 | inet6 *pfContext
20 | decisionsToAdd []*models.Decision
21 | decisionsToDelete []*models.Decision
22 | }
23 |
24 | const (
25 | pfctlCmd = "/sbin/pfctl"
26 | pfDevice = "/dev/pf"
27 | )
28 |
29 | func NewPF(config *cfg.BouncerConfig) (types.Backend, error) {
30 | ret := &pf{}
31 |
32 | batchSize := config.PF.BatchSize
33 | if batchSize == 0 {
34 | batchSize = 2000
35 | }
36 |
37 | inetCtx := &pfContext{
38 | table: config.BlacklistsIpv4,
39 | proto: "inet",
40 | anchor: config.PF.AnchorName,
41 | version: "ipv4",
42 | batchSize: batchSize,
43 | }
44 |
45 | inet6Ctx := &pfContext{
46 | table: config.BlacklistsIpv6,
47 | proto: "inet6",
48 | anchor: config.PF.AnchorName,
49 | version: "ipv6",
50 | batchSize: batchSize,
51 | }
52 |
53 | if !config.DisableIPV4 {
54 | ret.inet = inetCtx
55 | }
56 |
57 | if !config.DisableIPV6 {
58 | ret.inet6 = inet6Ctx
59 | }
60 |
61 | return ret, nil
62 | }
63 |
64 | // execPfctl runs a pfctl command by prepending the anchor name if needed.
65 | // Some commands return an error if an anchor is specified.
66 | func execPfctl(anchor string, arg ...string) *exec.Cmd {
67 | if anchor != "" {
68 | arg = append([]string{"-a", anchor}, arg...)
69 | }
70 |
71 | log.Debugf("Running: %s %s", pfctlCmd, arg)
72 |
73 | return exec.Command(pfctlCmd, arg...)
74 | }
75 |
76 | func (pf *pf) Init() error {
77 | if _, err := os.Stat(pfDevice); err != nil {
78 | return fmt.Errorf("%s device not found: %w", pfDevice, err)
79 | }
80 |
81 | if _, err := exec.LookPath(pfctlCmd); err != nil {
82 | return fmt.Errorf("%s command not found: %w", pfctlCmd, err)
83 | }
84 |
85 | if pf.inet != nil {
86 | if err := pf.inet.init(); err != nil {
87 | return err
88 | }
89 | }
90 |
91 | if pf.inet6 != nil {
92 | if err := pf.inet6.init(); err != nil {
93 | return err
94 | }
95 | }
96 |
97 | return nil
98 | }
99 |
100 | func (pf *pf) Commit() error {
101 | defer pf.reset()
102 |
103 | if err := pf.commitDeletedDecisions(); err != nil {
104 | return err
105 | }
106 |
107 | return pf.commitAddedDecisions()
108 | }
109 |
110 | func (pf *pf) Add(decision *models.Decision) error {
111 | pf.decisionsToAdd = append(pf.decisionsToAdd, decision)
112 | return nil
113 | }
114 |
115 | func (pf *pf) reset() {
116 | pf.decisionsToAdd = make([]*models.Decision, 0)
117 | pf.decisionsToDelete = make([]*models.Decision, 0)
118 | }
119 |
120 | func (pf *pf) commitDeletedDecisions() error {
121 | ipv4decisions := make([]*models.Decision, 0)
122 | ipv6decisions := make([]*models.Decision, 0)
123 |
124 | for _, d := range pf.decisionsToDelete {
125 | if strings.Contains(*d.Value, ":") && pf.inet6 != nil {
126 | ipv6decisions = append(ipv6decisions, d)
127 | } else if pf.inet != nil {
128 | ipv4decisions = append(ipv4decisions, d)
129 | }
130 | }
131 |
132 | if len(ipv6decisions) > 0 {
133 | if pf.inet6 == nil {
134 | log.Debugf("not removing '%d' decisions because ipv6 is disabled", len(ipv6decisions))
135 | } else if err := pf.inet6.delete(ipv6decisions); err != nil {
136 | return err
137 | }
138 | }
139 |
140 | if len(ipv4decisions) > 0 {
141 | if pf.inet == nil {
142 | log.Debugf("not removing '%d' decisions because ipv4 is disabled", len(ipv4decisions))
143 | } else if err := pf.inet.delete(ipv4decisions); err != nil {
144 | return err
145 | }
146 | }
147 |
148 | return nil
149 | }
150 |
151 | func (pf *pf) commitAddedDecisions() error {
152 | ipv4decisions := make([]*models.Decision, 0)
153 | ipv6decisions := make([]*models.Decision, 0)
154 |
155 | for _, d := range pf.decisionsToAdd {
156 | if strings.Contains(*d.Value, ":") && pf.inet6 != nil {
157 | ipv6decisions = append(ipv6decisions, d)
158 | } else if pf.inet != nil {
159 | ipv4decisions = append(ipv4decisions, d)
160 | }
161 | }
162 |
163 | if len(ipv6decisions) > 0 {
164 | if pf.inet6 == nil {
165 | log.Debugf("not adding '%d' decisions because ipv6 is disabled", len(ipv6decisions))
166 | } else if err := pf.inet6.add(ipv6decisions); err != nil {
167 | return err
168 | }
169 | }
170 |
171 | if len(ipv4decisions) > 0 {
172 | if pf.inet == nil {
173 | log.Debugf("not adding '%d' decisions because ipv4 is disabled", len(ipv4decisions))
174 | } else if err := pf.inet.add(ipv4decisions); err != nil {
175 | return err
176 | }
177 | }
178 |
179 | return nil
180 | }
181 |
182 | func (pf *pf) Delete(decision *models.Decision) error {
183 | pf.decisionsToDelete = append(pf.decisionsToDelete, decision)
184 | return nil
185 | }
186 |
187 | func (pf *pf) ShutDown() error {
188 | log.Infof("flushing 'crowdsec' table(s)")
189 |
190 | if pf.inet != nil {
191 | if err := pf.inet.shutDown(); err != nil {
192 | return fmt.Errorf("unable to flush %s table (%s): ", pf.inet.version, pf.inet.table)
193 | }
194 | }
195 |
196 | if pf.inet6 != nil {
197 | if err := pf.inet6.shutDown(); err != nil {
198 | return fmt.Errorf("unable to flush %s table (%s): ", pf.inet6.version, pf.inet6.table)
199 | }
200 | }
201 |
202 | return nil
203 | }
204 |
--------------------------------------------------------------------------------
/pkg/pf/pf_context.go:
--------------------------------------------------------------------------------
1 | package pf
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "os/exec"
7 | "strings"
8 |
9 | log "github.com/sirupsen/logrus"
10 |
11 | "github.com/crowdsecurity/go-cs-lib/slicetools"
12 |
13 | "github.com/crowdsecurity/crowdsec/pkg/models"
14 | )
15 |
16 | type pfContext struct {
17 | proto string
18 | anchor string
19 | table string
20 | version string
21 | batchSize int
22 | }
23 |
24 | const backendName = "pf"
25 |
26 | func (ctx *pfContext) checkTable() error {
27 | log.Infof("Checking pf table: %s", ctx.table)
28 |
29 | cmd := execPfctl(ctx.anchor, "-s", "Tables")
30 |
31 | out, err := cmd.CombinedOutput()
32 | if err != nil {
33 | return fmt.Errorf("pfctl error: %s - %w", out, err)
34 | }
35 |
36 | if !strings.Contains(string(out), ctx.table) {
37 | if ctx.anchor != "" {
38 | return fmt.Errorf("table %s in anchor %s doesn't exist", ctx.table, ctx.anchor)
39 | }
40 |
41 | return fmt.Errorf("table %s doesn't exist", ctx.table)
42 | }
43 |
44 | return nil
45 | }
46 |
47 | func (ctx *pfContext) shutDown() error {
48 | cmd := execPfctl(ctx.anchor, "-t", ctx.table, "-T", "flush")
49 | log.Infof("pf table clean-up: %s", cmd)
50 |
51 | if out, err := cmd.CombinedOutput(); err != nil {
52 | log.Errorf("Error while flushing table (%s): %v --> %s", cmd, err, out)
53 | }
54 |
55 | return nil
56 | }
57 |
58 | // getStateIPs returns a list of IPs that are currently in the state table.
59 | func getStateIPs() (map[string]bool, error) {
60 | ret := make(map[string]bool)
61 |
62 | cmd := exec.Command(pfctlCmd, "-s", "states")
63 |
64 | out, err := cmd.Output()
65 | if err != nil {
66 | return nil, err
67 | }
68 |
69 | scanner := bufio.NewScanner(strings.NewReader(string(out)))
70 | for scanner.Scan() {
71 | fields := strings.Fields(scanner.Text())
72 | if len(fields) < 6 {
73 | continue
74 | }
75 |
76 | // don't bother to parse the direction, we'll block both anyway
77 |
78 | // right side
79 | ip := fields[4]
80 | if strings.Contains(ip, ":") {
81 | ip = strings.Split(ip, ":")[0]
82 | }
83 |
84 | ret[ip] = true
85 |
86 | // left side
87 | ip = fields[2]
88 | if strings.Contains(ip, ":") {
89 | ip = strings.Split(ip, ":")[0]
90 | }
91 |
92 | ret[ip] = true
93 | }
94 |
95 | log.Debugf("Found IPs in state table: %v", len(ret))
96 |
97 | return ret, nil
98 | }
99 |
100 | func (ctx *pfContext) add(decisions []*models.Decision) error {
101 | chunks := slicetools.Chunks(decisions, ctx.batchSize)
102 | for _, chunk := range chunks {
103 | if err := ctx.addChunk(chunk); err != nil {
104 | log.Errorf("error while adding decision chunk: %s", err)
105 | }
106 | }
107 |
108 | bannedIPs := make(map[string]bool)
109 | for _, d := range decisions {
110 | bannedIPs[*d.Value] = true
111 | }
112 |
113 | if len(bannedIPs) == 0 {
114 | log.Debugf("No new banned IPs")
115 | return nil
116 | }
117 |
118 | log.Debugf("New banned IPs: %v", bannedIPs)
119 |
120 | stateIPs, err := getStateIPs()
121 | if err != nil {
122 | return fmt.Errorf("error while getting state IPs: %w", err)
123 | }
124 |
125 | // Reset the states of connections coming from or going to an IP if it's both in stateIPs and bannedIPs
126 |
127 | for ip := range bannedIPs {
128 | if stateIPs[ip] {
129 | // incoming
130 | cmd := execPfctl("", "-k", ip)
131 | if out, err := cmd.CombinedOutput(); err != nil {
132 | log.Errorf("Error while flushing state (%s): %v --> %s", cmd, err, out)
133 | }
134 |
135 | // outgoing
136 | cmd = execPfctl("", "-k", "0.0.0.0/0", "-k", ip)
137 | if out, err := cmd.CombinedOutput(); err != nil {
138 | log.Errorf("Error while flushing state (%s): %v --> %s", cmd, err, out)
139 | }
140 | }
141 | }
142 |
143 | return nil
144 | }
145 |
146 | func (ctx *pfContext) addChunk(decisions []*models.Decision) error {
147 | log.Debugf("Adding chunk with %d decisions", len(decisions))
148 |
149 | addArgs := []string{"-t", ctx.table, "-T", "add"}
150 |
151 | for _, d := range decisions {
152 | addArgs = append(addArgs, *d.Value)
153 | }
154 |
155 | cmd := execPfctl(ctx.anchor, addArgs...)
156 | if out, err := cmd.CombinedOutput(); err != nil {
157 | return fmt.Errorf("error while adding to table (%s): %w --> %s", cmd, err, out)
158 | }
159 |
160 | return nil
161 | }
162 |
163 | func (ctx *pfContext) delete(decisions []*models.Decision) error {
164 | chunks := slicetools.Chunks(decisions, ctx.batchSize)
165 | for _, chunk := range chunks {
166 | if err := ctx.deleteChunk(chunk); err != nil {
167 | log.Errorf("error while deleting decision chunk: %s", err)
168 | }
169 | }
170 |
171 | return nil
172 | }
173 |
174 | func (ctx *pfContext) deleteChunk(decisions []*models.Decision) error {
175 | delArgs := []string{"-t", ctx.table, "-T", "delete"}
176 |
177 | for _, d := range decisions {
178 | delArgs = append(delArgs, *d.Value)
179 | }
180 |
181 | cmd := execPfctl(ctx.anchor, delArgs...)
182 | if out, err := cmd.CombinedOutput(); err != nil {
183 | log.Infof("Error while deleting from table (%s): %v --> %s", cmd, err, out)
184 | }
185 |
186 | return nil
187 | }
188 |
189 | func (ctx *pfContext) init() error {
190 | if err := ctx.shutDown(); err != nil {
191 | return fmt.Errorf("pf table flush failed: %w", err)
192 | }
193 |
194 | if err := ctx.checkTable(); err != nil {
195 | return fmt.Errorf("pf init failed: %w", err)
196 | }
197 |
198 | log.Infof("%s initiated for %s", backendName, ctx.version)
199 |
200 | return nil
201 | }
202 |
--------------------------------------------------------------------------------
/pkg/types/types.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "github.com/crowdsecurity/crowdsec/pkg/models"
5 | )
6 |
7 | type Backend interface {
8 | Init() error
9 | ShutDown() error
10 | Add(decision *models.Decision) error
11 | Delete(decision *models.Decision) error
12 | Commit() error
13 | CollectMetrics()
14 | }
15 |
--------------------------------------------------------------------------------
/rpm/SOURCES/80-crowdsec-firewall-bouncer.preset:
--------------------------------------------------------------------------------
1 | # This file is part of crowdsec-firewall-bouncer
2 |
3 | enable crowdsec-firewall-bouncer.service
4 |
--------------------------------------------------------------------------------
/rpm/SPECS/crowdsec-firewall-bouncer.spec:
--------------------------------------------------------------------------------
1 | Name: crowdsec-firewall-bouncer-iptables
2 | Version: %(echo $VERSION)
3 | Release: %(echo $PACKAGE_NUMBER)%{?dist}
4 | Summary: Firewall bouncer for Crowdsec (iptables+ipset configuration)
5 |
6 | License: MIT
7 | URL: https://crowdsec.net
8 | Source0: https://github.com/crowdsecurity/%{name}/archive/v%(echo $VERSION).tar.gz
9 | Source1: 80-crowdsec-firewall-bouncer.preset
10 | BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n)
11 |
12 | BuildRequires: make
13 | %{?fc33:BuildRequires: systemd-rpm-macros}
14 |
15 | Requires: gettext,iptables,ipset,ipset-libs
16 |
17 | %define debug_package %{nil}
18 |
19 | %define version_number %(echo $VERSION)
20 | %define releasever %(echo $RELEASEVER)
21 | %global local_version v%{version_number}-%{releasever}-rpm
22 | %global name crowdsec-firewall-bouncer
23 | %global __mangle_shebangs_exclude_from /usr/bin/env
24 |
25 | %prep
26 | %setup -q -T -b 0 -n %{name}-%{version_number}
27 |
28 | %build
29 | BUILD_VERSION=%{local_version} make
30 |
31 | %install
32 | rm -rf %{buildroot}
33 |
34 | mkdir -p %{buildroot}%{_bindir}
35 | install -m 755 %{name} %{buildroot}%{_bindir}/%{name}
36 | # symlink for compatibility with old versions
37 |
38 | mkdir -p %{buildroot}/etc/crowdsec/bouncers
39 | install -m 600 config/%{name}.yaml %{buildroot}/etc/crowdsec/bouncers/%{name}.yaml
40 |
41 | mkdir -p %{buildroot}/usr/lib/%{name}
42 | install -m 600 scripts/_bouncer.sh %{buildroot}/usr/lib/%{name}/_bouncer.sh
43 |
44 | mkdir -p %{buildroot}%{_unitdir}
45 | BIN=%{_bindir}/%{name} CFG=/etc/crowdsec/bouncers envsubst '$BIN $CFG' < config/%{name}.service | install -m 0644 /dev/stdin %{buildroot}%{_unitdir}/%{name}.service
46 |
47 | mkdir -p %{buildroot}%{_presetdir}
48 | install -D -m 644 %{SOURCE1} %{buildroot}%{_presetdir}/
49 |
50 | %clean
51 | rm -rf %{buildroot}
52 |
53 | %changelog
54 | * Tue Feb 16 2021 Manuel Sabban
55 | - First initial packaging
56 |
57 | # ------------------------------------
58 | # iptables
59 | # ------------------------------------
60 |
61 | %description -n %{name}-iptables
62 |
63 | %files -n %{name}-iptables
64 | %defattr(-,root,root,-)
65 | %{_bindir}/%{name}
66 | /usr/lib/%{name}/_bouncer.sh
67 | %{_unitdir}/%{name}.service
68 | %config(noreplace) /etc/crowdsec/bouncers/%{name}.yaml
69 | %config(noreplace) %{_presetdir}/80-crowdsec-firewall-bouncer.preset
70 |
71 | %post -n %{name}-iptables
72 | systemctl daemon-reload
73 |
74 | . /usr/lib/%{name}/_bouncer.sh
75 | START=1
76 |
77 | if grep -q '${BACKEND}' "$CONFIG"; then
78 | newconfig=$(BACKEND="iptables" envsubst '$BACKEND' < "$CONFIG")
79 | echo "$newconfig" | install -m 0600 /dev/stdin "$CONFIG"
80 | fi
81 |
82 | if [ "$1" = "1" ]; then
83 | if need_api_key; then
84 | if ! set_api_key; then
85 | START=0
86 | fi
87 | fi
88 | fi
89 |
90 | set_local_port
91 |
92 | if [ ! -e /usr/sbin/crowdsec-firewall-bouncer ]; then
93 | if [ ! -L /usr/sbin ]; then
94 | ln -s ../bin/crowdsec-firewall-bouncer /usr/sbin/crowdsec-firewall-bouncer
95 | fi
96 | fi
97 |
98 |
99 | %systemd_post %{name}.service
100 |
101 | if [ "$START" -eq 0 ]; then
102 | echo "no api key was generated, you can generate one on your LAPI Server by running 'cscli bouncers add ' and add it to '$CONFIG'" >&2
103 | else
104 | %if 0%{?fc35}
105 | systemctl enable "$SERVICE"
106 | %endif
107 | systemctl start "$SERVICE"
108 | fi
109 |
110 | echo "$BOUNCER has been successfully installed"
111 |
112 | %preun -n %{name}-iptables
113 | . /usr/lib/%{name}/_bouncer.sh
114 |
115 | if [ "$1" = "0" ]; then
116 | systemctl stop "$SERVICE" || echo "cannot stop service"
117 | systemctl disable "$SERVICE" || echo "cannot disable service"
118 | delete_bouncer
119 | fi
120 |
121 | %postun -n %{name}-iptables
122 | if [ "$1" = "1" ]; then
123 | systemctl restart %{name} || echo "cannot restart service"
124 | fi
125 |
126 | if [ -L /usr/sbin/crowdsec-firewall-bouncer ]; then
127 | rm -f /usr/sbin/crowdsec-firewall-bouncer
128 | fi
129 |
130 |
131 | # ------------------------------------
132 | # nftables
133 | # ------------------------------------
134 |
135 | %package -n %{name}-nftables
136 | Summary: Firewall bouncer for Crowdsec (nftables configuration)
137 | Requires: nftables,gettext
138 |
139 | %description -n %{name}-nftables
140 |
141 | %files -n %{name}-nftables
142 | %defattr(-,root,root,-)
143 | %{_bindir}/%{name}
144 | /usr/lib/%{name}/_bouncer.sh
145 | %{_unitdir}/%{name}.service
146 | %config(noreplace) /etc/crowdsec/bouncers/%{name}.yaml
147 | %config(noreplace) %{_presetdir}/80-crowdsec-firewall-bouncer.preset
148 |
149 | %post -n %{name}-nftables
150 | systemctl daemon-reload
151 |
152 | . /usr/lib/%{name}/_bouncer.sh
153 | START=1
154 |
155 | if grep -q '${BACKEND}' "$CONFIG"; then
156 | newconfig=$(BACKEND="nftables" envsubst '$BACKEND' < "$CONFIG")
157 | echo "$newconfig" | install -m 0600 /dev/stdin "$CONFIG"
158 | fi
159 |
160 | if [ "$1" = "1" ]; then
161 | if need_api_key; then
162 | if ! set_api_key; then
163 | START=0
164 | fi
165 | fi
166 | fi
167 |
168 | set_local_port
169 |
170 | if [ ! -e /usr/sbin/crowdsec-firewall-bouncer ]; then
171 | if [ ! -L /usr/sbin ]; then
172 | ln -s ../bin/crowdsec-firewall-bouncer /usr/sbin/crowdsec-firewall-bouncer
173 | fi
174 | fi
175 |
176 | %systemd_post %{name}.service
177 |
178 | if [ "$START" -eq 0 ]; then
179 | echo "no api key was generated, you can generate one on your LAPI Server by running 'cscli bouncers add ' and add it to '$CONFIG'" >&2
180 | else
181 | %if 0%{?fc35}
182 | systemctl enable "$SERVICE"
183 | %endif
184 | systemctl start "$SERVICE"
185 | fi
186 |
187 | echo "$BOUNCER has been successfully installed"
188 |
189 | %preun -n %{name}-nftables
190 | . /usr/lib/%{name}/_bouncer.sh
191 |
192 | if [ "$1" = "0" ]; then
193 | systemctl stop "$SERVICE" || echo "cannot stop service"
194 | systemctl disable "$SERVICE" || echo "cannot disable service"
195 | delete_bouncer
196 | fi
197 |
198 | %postun -n %{name}-nftables
199 | if [ "$1" = "1" ]; then
200 | systemctl restart %{name} || echo "cannot restart service"
201 | fi
202 |
203 | if [ -L /usr/sbin/crowdsec-firewall-bouncer ]; then
204 | rm -f /usr/sbin/crowdsec-firewall-bouncer
205 | fi
206 |
--------------------------------------------------------------------------------
/scripts/_bouncer.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | #shellcheck disable=SC3043
3 |
4 | set -eu
5 |
6 | BOUNCER="crowdsec-firewall-bouncer"
7 | BOUNCER_PREFIX=$(echo "$BOUNCER" | sed 's/crowdsec-/cs-/g')
8 |
9 | # This is a library of functions that can be sourced by other scripts
10 | # to install and configure bouncers.
11 | #
12 | # While not requiring bash, it is not strictly POSIX-compliant because
13 | # it uses local variables, but it should work with every modern shell.
14 | #
15 | # Since passing/parsing arguments in posix sh is tricky, we share
16 | # some environment variables with the functions. It's a matter of
17 | # readability balance between shorter vs cleaner code.
18 |
19 | if [ ! -t 0 ]; then
20 | # terminal is not interactive; no colors
21 | FG_RED=""
22 | FG_GREEN=""
23 | FG_YELLOW=""
24 | FG_CYAN=""
25 | RESET=""
26 | elif tput sgr0 >/dev/null 2>&1; then
27 | # terminfo
28 | FG_RED=$(tput setaf 1)
29 | FG_GREEN=$(tput setaf 2)
30 | FG_YELLOW=$(tput setaf 3)
31 | FG_CYAN=$(tput setaf 6)
32 | RESET=$(tput sgr0)
33 | else
34 | FG_RED=$(printf '%b' '\033[31m')
35 | FG_GREEN=$(printf '%b' '\033[32m')
36 | FG_YELLOW=$(printf '%b' '\033[33m')
37 | FG_CYAN=$(printf '%b' '\033[36m')
38 | RESET=$(printf '%b' '\033[0m')
39 | fi
40 |
41 | msg() {
42 | case "$1" in
43 | info) echo "${FG_CYAN}$2${RESET}" >&2 ;;
44 | warn) echo "${FG_YELLOW}WARN:${RESET} $2" >&2 ;;
45 | err) echo "${FG_RED}ERR:${RESET} $2" >&2 ;;
46 | succ) echo "${FG_GREEN}$2${RESET}" >&2 ;;
47 | *) echo "$1" >&2 ;;
48 | esac
49 | }
50 |
51 | require() {
52 | set | grep -q "^$1=" || { msg err "missing required variable \$$1"; exit 1; }
53 | shift
54 | [ "$#" -eq 0 ] || require "$@"
55 | }
56 |
57 | # shellcheck disable=SC2034
58 | {
59 | SERVICE="$BOUNCER.service"
60 | BIN_PATH_INSTALLED="/usr/local/bin/$BOUNCER"
61 | BIN_PATH="./$BOUNCER"
62 | CONFIG_DIR="/etc/crowdsec/bouncers"
63 | CONFIG_FILE="$BOUNCER.yaml"
64 | CONFIG="$CONFIG_DIR/$CONFIG_FILE"
65 | SYSTEMD_PATH_FILE="/etc/systemd/system/$SERVICE"
66 | }
67 |
68 | assert_root() {
69 | #shellcheck disable=SC2312
70 | if [ "$(id -u)" -ne 0 ]; then
71 | msg err "This script must be run as root"
72 | exit 1
73 | fi
74 | }
75 |
76 | # Check if the configuration file contains a variable
77 | # which has not yet been interpolated, like "$API_KEY",
78 | # and return true if it does.
79 | config_not_set() {
80 | require 'CONFIG'
81 | local varname before after
82 |
83 | varname=$1
84 | if [ "$varname" = "" ]; then
85 | msg err "missing required variable name"
86 | exit 1
87 | fi
88 |
89 | before=$("$BOUNCER" -c "$CONFIG" -T)
90 | # shellcheck disable=SC2016
91 | after=$(echo "$before" | envsubst "\$$varname")
92 |
93 | if [ "$before" = "$after" ]; then
94 | return 1
95 | fi
96 | return 0
97 | }
98 |
99 | need_api_key() {
100 | if config_not_set 'API_KEY'; then
101 | return 0
102 | fi
103 | return 1
104 | }
105 |
106 | # Interpolate a variable in the config file with a value.
107 | set_config_var_value() {
108 | require 'CONFIG'
109 | local varname value before
110 |
111 | varname=$1
112 | if [ "$varname" = "" ]; then
113 | msg err "missing required variable name"
114 | exit 1
115 | fi
116 |
117 | value=$2
118 | if [ "$value" = "" ]; then
119 | msg err "missing required variable value"
120 | exit 1
121 | fi
122 |
123 | before=$(cat "$CONFIG")
124 | echo "$before" | \
125 | env "$varname=$value" envsubst "\$$varname" | \
126 | install -m 0600 /dev/stdin "$CONFIG"
127 | }
128 |
129 | set_api_key() {
130 | require 'CONFIG' 'BOUNCER_PREFIX'
131 | local api_key ret bouncer_id before
132 | # if we can't set the key, the user will take care of it
133 | ret=0
134 |
135 | if command -v cscli >/dev/null; then
136 | echo "cscli/crowdsec is present, generating API key" >&2
137 | bouncer_id="$BOUNCER_PREFIX-$(date +%s)"
138 | api_key=$(cscli -oraw bouncers add "$bouncer_id" || true)
139 | if [ "$api_key" = "" ]; then
140 | echo "failed to create API key" >&2
141 | api_key=""
142 | ret=1
143 | else
144 | echo "API Key successfully created" >&2
145 | echo "$bouncer_id" > "$CONFIG.id"
146 | fi
147 | else
148 | echo "cscli/crowdsec is not present, please set the API key manually" >&2
149 | api_key=""
150 | ret=1
151 | fi
152 |
153 | if [ "$api_key" != "" ]; then
154 | set_config_var_value 'API_KEY' "$api_key"
155 | fi
156 |
157 | return "$ret"
158 | }
159 |
160 | set_local_port() {
161 | require 'CONFIG'
162 | local port
163 | command -v cscli >/dev/null || return 0
164 | # the following will fail with a non-LAPI local crowdsec, leaving empty port
165 | port=$(cscli config show -oraw --key "Config.API.Server.ListenURI" 2>/dev/null | cut -d ":" -f2 || true)
166 | if [ "$port" != "" ]; then
167 | sed -i "s/localhost:8080/127.0.0.1:$port/g" "$CONFIG"
168 | sed -i "s/127.0.0.1:8080/127.0.0.1:$port/g" "$CONFIG"
169 | fi
170 | }
171 |
172 | set_local_lapi_url() {
173 | require 'CONFIG'
174 | local port before varname
175 | # $varname is the name of the variable to interpolate
176 | # in the config file with the URL of the LAPI server,
177 | # assuming it is running on the same host as the
178 | # bouncer.
179 | varname=$1
180 | if [ "$varname" = "" ]; then
181 | msg err "missing required variable name"
182 | exit 1
183 | fi
184 | command -v cscli >/dev/null || return 0
185 |
186 | port=$(cscli config show -oraw --key "Config.API.Server.ListenURI" 2>/dev/null | cut -d ":" -f2 || true)
187 | if [ "$port" = "" ]; then
188 | port=8080
189 | fi
190 |
191 | set_config_var_value "$varname" "http://127.0.0.1:$port"
192 | }
193 |
194 | delete_bouncer() {
195 | require 'CONFIG'
196 | local bouncer_id
197 | if [ -f "$CONFIG.id" ]; then
198 | bouncer_id=$(cat "$CONFIG.id")
199 | cscli -oraw bouncers delete "$bouncer_id" 2>/dev/null || true
200 | rm -f "$CONFIG.id"
201 | fi
202 | }
203 |
204 | upgrade_bin() {
205 | require 'BIN_PATH' 'BIN_PATH_INSTALLED'
206 | rm "$BIN_PATH_INSTALLED"
207 | install -v -m 0755 -D "$BIN_PATH" "$BIN_PATH_INSTALLED"
208 | }
209 |
--------------------------------------------------------------------------------
/scripts/install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -eu
4 |
5 | . ./scripts/_bouncer.sh
6 |
7 | assert_root
8 |
9 | # --------------------------------- #
10 |
11 | API_KEY=""
12 |
13 | install_pkg() {
14 | pkg="$1"
15 | if [ -f /etc/redhat-release ]; then
16 | yum install -y "$pkg"
17 | elif grep -q "Amazon Linux release 2 (Karoo)" /etc/system-release 2>/dev/null; then
18 | yum install -y "$pkg"
19 | elif grep -q "suse" /etc/os-release 2>/dev/null; then
20 | zypper install -y "$pkg"
21 | elif [ -f /etc/debian_version ]; then
22 | apt install -y "$pkg"
23 | else
24 | msg warn "This distribution is not supported"
25 | return 1
26 | fi
27 | msg succ "$pkg successfully installed"
28 | return 0
29 | }
30 |
31 | check_firewall() {
32 | # Default firewall backend is nftables
33 | FW_BACKEND="nftables"
34 |
35 | iptables="true"
36 | if command -v iptables >/dev/null; then
37 | FW_BACKEND="iptables"
38 | msg info "iptables found"
39 | else
40 | msg warn "iptables not found"
41 | iptables="false"
42 | fi
43 |
44 | nftables="true"
45 | if command -v nft >/dev/null; then
46 | FW_BACKEND="nftables"
47 | msg info "nftables found"
48 | else
49 | msg warn "nftables not found"
50 | nftables="false"
51 | fi
52 |
53 | if [ "$nftables" = "false" ] && [ "$iptables" = "false" ]; then
54 | printf '%s ' "No firewall found, do you want to install nftables (Y/n) ?"
55 | read -r answer
56 | if echo "$answer" | grep -iq '^n'; then
57 | msg err "unable to continue without nftables. Please install nftables or iptables to use this bouncer."
58 | exit 1
59 | fi
60 | # shellcheck disable=SC2310
61 | install_pkg nftables || ( msg err "Cannot install nftables, please install it manually"; exit 1 )
62 | fi
63 |
64 | if [ "$nftables" = "true" ] && [ "$iptables" = "true" ]; then
65 | printf '%s ' "Found nftables (default) and iptables, which firewall do you want to use (nftables/iptables) ?"
66 | read -r answer
67 | if [ "$answer" = "iptables" ]; then
68 | FW_BACKEND="iptables"
69 | fi
70 | fi
71 |
72 | if [ "$FW_BACKEND" = "iptables" ]; then
73 | check_ipset
74 | fi
75 | }
76 |
77 | check_ipset() {
78 | if ! command -v ipset >/dev/null; then
79 | printf '%s ' "ipset not found, do you want to install it (Y/n) ?"
80 | read -r answer
81 | if echo "$answer" | grep -iq '^n'; then
82 | msg err "unable to continue without ipset. Exiting"
83 | exit 1
84 | fi
85 | # shellcheck disable=SC2310
86 | install_pkg ipset || ( msg err "Cannot install ipset, please install it manually"; exit 1 )
87 | fi
88 | }
89 |
90 | gen_apikey() {
91 | if command -v cscli >/dev/null; then
92 | msg succ "cscli found, generating bouncer api key."
93 | bouncer_id="$BOUNCER_PREFIX-$(date +%s)"
94 | API_KEY=$(cscli -oraw bouncers add "$bouncer_id")
95 | echo "$bouncer_id" > "$CONFIG.id"
96 | msg info "API Key: $API_KEY"
97 | READY="yes"
98 | else
99 | msg warn "cscli not found, you will need to generate an api key."
100 | READY="no"
101 | fi
102 | }
103 |
104 | gen_config_file() {
105 | # shellcheck disable=SC2016
106 | API_KEY=${API_KEY} BACKEND=${FW_BACKEND} envsubst '$API_KEY $BACKEND' <"./config/$CONFIG_FILE" | \
107 | install -D -m 0600 /dev/stdin "$CONFIG"
108 | }
109 |
110 | install_bouncer() {
111 | if [ ! -f "$BIN_PATH" ]; then
112 | msg err "$BIN_PATH not found, exiting."
113 | exit 1
114 | fi
115 | if [ -e "$BIN_PATH_INSTALLED" ]; then
116 | msg err "$BIN_PATH_INSTALLED is already installed. Exiting"
117 | exit 1
118 | fi
119 | msg "Installing $BOUNCER"
120 | check_firewall
121 | install -v -m 0755 -D "$BIN_PATH" "$BIN_PATH_INSTALLED"
122 | install -D -m 0600 "./config/$CONFIG_FILE" "$CONFIG"
123 | # shellcheck disable=SC2016
124 | CFG=${CONFIG_DIR} BIN=${BIN_PATH_INSTALLED} envsubst '$CFG $BIN' <"./config/$SERVICE" >"$SYSTEMD_PATH_FILE"
125 | systemctl daemon-reload
126 | gen_apikey
127 | gen_config_file
128 | set_local_port
129 | }
130 |
131 | # --------------------------------- #
132 |
133 | install_bouncer
134 |
135 | systemctl enable "$SERVICE"
136 | if [ "$READY" = "yes" ]; then
137 | systemctl start "$SERVICE"
138 | else
139 | msg warn "service not started. You need to get an API key and configure it in $CONFIG"
140 | fi
141 |
142 | msg succ "The $BOUNCER service has been installed."
143 | exit 0
144 |
--------------------------------------------------------------------------------
/scripts/uninstall.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -eu
4 |
5 | . ./scripts/_bouncer.sh
6 |
7 | assert_root
8 |
9 | # --------------------------------- #
10 |
11 | uninstall() {
12 | systemctl stop "$SERVICE" || true
13 | delete_bouncer
14 | rm -f "$CONFIG"
15 | rm -f "$SYSTEMD_PATH_FILE"
16 | rm -f "$BIN_PATH_INSTALLED"
17 | rm -f "/var/log/$BOUNCER.log"
18 | }
19 |
20 | uninstall
21 | msg succ "$BOUNCER has been successfully uninstalled"
22 | exit 0
23 |
--------------------------------------------------------------------------------
/scripts/upgrade.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -eu
4 |
5 | . ./scripts/_bouncer.sh
6 |
7 | assert_root
8 |
9 | # --------------------------------- #
10 |
11 | systemctl stop "$SERVICE"
12 |
13 | if ! upgrade_bin; then
14 | msg err "failed to upgrade $BOUNCER"
15 | exit 1
16 | fi
17 |
18 | systemctl start "$SERVICE" || msg warn "$SERVICE failed to start, please check the systemd logs"
19 |
20 | msg succ "$BOUNCER upgraded successfully."
21 | exit 0
22 |
--------------------------------------------------------------------------------
/test/.python-version:
--------------------------------------------------------------------------------
1 | 3.12
2 |
--------------------------------------------------------------------------------
/test/README.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/README.md
--------------------------------------------------------------------------------
/test/default.env:
--------------------------------------------------------------------------------
1 | CROWDSEC_TEST_VERSION="dev"
2 | CROWDSEC_TEST_FLAVORS="full"
3 | CROWDSEC_TEST_NETWORK="net-test"
4 |
--------------------------------------------------------------------------------
/test/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "cs-firewall-bouncer-tests"
3 | version = "0.1.0"
4 | description = "Tests for cs-firewall-bouncer"
5 | readme = "README.md"
6 | requires-python = ">=3.12"
7 | dependencies = [
8 | "flask>=3.1.0",
9 | "pexpect>=4.9.0",
10 | "psutil>=6.1.1",
11 | "pytest>=8.3.4",
12 | "pytest-cs",
13 | "pytest-dependency>=0.6.0",
14 | "pytest-dotenv>=0.5.2",
15 | "pytimeparse>=1.1.8",
16 | "zxcvbn>=4.4.28",
17 | ]
18 |
19 | [tool.uv.sources]
20 | pytest-cs = { git = "https://github.com/crowdsecurity/pytest-cs" }
21 |
22 | [dependency-groups]
23 | dev = [
24 | "basedpyright>=1.26.0",
25 | "ipdb>=0.13.13",
26 | "ruff>=0.9.4",
27 | ]
28 |
29 | #[tool.uv.sources]
30 | #pytest-cs = { path = "../../../pytest-cs", editable = true }
31 |
32 |
33 | [tool.ruff]
34 |
35 | line-length = 120
36 |
37 | [tool.ruff.lint]
38 | select = [
39 | "ALL"
40 | ]
41 |
42 | ignore = [
43 | "ANN", # Missing type annotations
44 | "A002", # Function argument `id` is shadowing a Python builtin
45 | "ARG001", # Unused function argument: `...`
46 | "COM812", # Trailing comma missing
47 | "D100", # Missing docstring in public module
48 | "D101", # Missing docstring in public class
49 | "D102", # Missing docstring in public method
50 | "D103", # Missing docstring in public function
51 | "D104", # Missing docstring in public package
52 | "D107", # Missing docstring in __init__
53 | "D203", # incorrect-blank-line-before-class
54 | "D212", # Multi-line docstring summary should start at the first line
55 | "D212", # Multi-line docstring summary should start at the first line
56 | "D400", # First line should end with a period
57 | "D415", # First line should end with a period, question mark, or exclamation point
58 | "DTZ005", # `datetime.datetime.now()` called without a `tz` argument
59 | "EM102", # Exception must not use an f-string literal, assign to variable first
60 | "ERA001", # Found commented-out code
61 | "FBT002", # Boolean default positional argument in function definition
62 | "FIX002", # Line contains TODO, consider resolving the issue
63 | "FIX003", # Line contains XXX, consider resolving the issue
64 | "N802", # Function name `testLogging` should be lowercase
65 | "PLW1510", # `subprocess.run` without explicit `check` argument
66 | "S101", # Use of 'assert' detected
67 | "S104", # Possible binding to all interfaces
68 | "S314", # Using `xml` to parse untrusted data is known to be vulnerable to XML attacks; use `defusedxml` equivalents
69 | "S603", # `subprocess` call: check for execution of untrusted input
70 | "S604", # Function call with `shell=True` parameter identified, security issue
71 | "S607", # Starting a process with a partial executable path
72 | "SIM108", # Use ternary operator `...` instead of `if`-`else`-block
73 | "TD001", # Invalid TODO tag: `XXX`
74 | "TD002", # Missing author in TODO
75 | "TD003", # Missing issue link for this TODO
76 | "TRY003", # Avoid specifying long messages outside the exception class
77 | "PLR2004", # Magic value used in comparison, consider replacing `...` with a constant variable
78 | "PLR0913", # Too many arguments in function definition (6 > 5)
79 | "PTH107", # `os.remove()` should be replaced by `Path.unlink()`
80 | "PTH108", # `os.unlink()` should be replaced by `Path.unlink()`
81 | "PTH110", # `os.path.exists()` should be replaced by `Path.exists()`
82 | "PTH116", # `os.stat()` should be replaced by `Path.stat()`, `Path.owner()`, or `Path.group()`
83 | "PTH120", # `os.path.dirname()` should be replaced by `Path.parent`
84 | "PTH123", # `open()` should be replaced by `Path.open()`
85 | "PT009", # Use a regular `assert` instead of unittest-style `assertEqual`
86 | "PT022", # No teardown in fixture `fw_cfg_factory`, use `return` instead of `yield`
87 | "TID252", # Prefer absolute imports over relative imports from parent modules
88 | "UP022", # Prefer `capture_output` over sending `stdout` and `stderr` to `PIPE`
89 | ]
90 |
91 | [tool.basedpyright]
92 | reportAny = "none"
93 | reportArgumentType = "none"
94 | reportAttributeAccessIssue = "none"
95 | reportImplicitOverride = "none"
96 | reportImplicitStringConcatenation = "none"
97 | reportMissingParameterType = "none"
98 | reportMissingTypeStubs = "none"
99 | reportOptionalMemberAccess = "none"
100 | reportUnannotatedClassAttribute = "none"
101 | reportUninitializedInstanceVariable = "none"
102 | reportUnknownArgumentType = "none"
103 | reportUnknownMemberType = "none"
104 | reportUnknownParameterType = "none"
105 | reportUnknownVariableType = "none"
106 | reportUnusedCallResult = "none"
107 | reportUnusedParameter = "none"
108 |
--------------------------------------------------------------------------------
/test/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | addopts =
3 | --pdbcls=IPython.terminal.debugger:Pdb
4 | --ignore=tests/install
5 | --ignore=tests/backends
6 | --strict-markers
7 | markers:
8 | deb: mark tests related to deb packaging
9 | rpm: mark tests related to rpm packaging
10 | systemd_debug: dump systemd status and journal on test failure
11 | env_files =
12 | .env
13 | default.env
14 |
--------------------------------------------------------------------------------
/test/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/__init__.py
--------------------------------------------------------------------------------
/test/tests/backends/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/backends/__init__.py
--------------------------------------------------------------------------------
/test/tests/backends/iptables/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/backends/iptables/__init__.py
--------------------------------------------------------------------------------
/test/tests/backends/iptables/crowdsec-firewall-bouncer-logging.yaml:
--------------------------------------------------------------------------------
1 | mode: iptables
2 | update_frequency: 0.1s
3 | log_mode: stdout
4 | log_dir: ./
5 | log_level: info
6 | api_url: http://127.0.0.1:8081/
7 | api_key: 1237adaf7a1724ac68a3288828820a67
8 | disable_ipv6: false
9 | deny_action: DROP
10 | deny_log: true
11 | deny_log_prefix: "blocked by crowdsec"
12 | supported_decisions_types:
13 | - ban
14 | iptables_chains:
15 | - INPUT
16 |
--------------------------------------------------------------------------------
/test/tests/backends/iptables/crowdsec-firewall-bouncer.yaml:
--------------------------------------------------------------------------------
1 | mode: iptables
2 | update_frequency: 0.1s
3 | log_mode: stdout
4 | log_dir: ./
5 | log_level: info
6 | api_url: http://127.0.0.1:8081/
7 | api_key: 1237adaf7a1724ac68a3288828820a67
8 | disable_ipv6: false
9 | deny_action: DROP
10 | deny_log: false
11 | supported_decisions_types:
12 | - ban
13 | iptables_chains:
14 | - INPUT
15 |
--------------------------------------------------------------------------------
/test/tests/backends/iptables/test_iptables.py:
--------------------------------------------------------------------------------
1 | import os
2 | import subprocess
3 | import unittest
4 | import xml.etree.ElementTree as ET
5 | from ipaddress import ip_address
6 | from pathlib import Path
7 | from time import sleep
8 |
9 | from ..mock_lapi import MockLAPI
10 | from ..utils import generate_n_decisions, new_decision, run_cmd
11 |
12 | SCRIPT_DIR = Path(os.path.dirname(os.path.realpath(__file__)))
13 | PROJECT_ROOT = SCRIPT_DIR.parent.parent.parent.parent
14 | BINARY_PATH = PROJECT_ROOT.joinpath("crowdsec-firewall-bouncer")
15 | CONFIG_PATH = SCRIPT_DIR.joinpath("crowdsec-firewall-bouncer.yaml")
16 | CONFIG_PATH_LOGGING = SCRIPT_DIR.joinpath("crowdsec-firewall-bouncer-logging.yaml")
17 |
18 | SET_NAME_IPV4 = "crowdsec-blacklists-0"
19 | SET_NAME_IPV6 = "crowdsec6-blacklists-0"
20 |
21 | RULES_CHAIN_NAME = "CROWDSEC_CHAIN"
22 | LOGGING_CHAIN_NAME = "CROWDSEC_LOG"
23 | CHAIN_NAME = "INPUT"
24 |
25 |
26 | class TestIPTables(unittest.TestCase):
27 | def setUp(self):
28 | self.fb = subprocess.Popen([BINARY_PATH, "-c", CONFIG_PATH])
29 | self.lapi = MockLAPI()
30 | self.lapi.start()
31 | return super().setUp()
32 |
33 | def tearDown(self):
34 | self.fb.kill()
35 | self.fb.wait()
36 | self.lapi.stop()
37 |
38 | def test_table_rule_set_are_created(self):
39 | d1 = generate_n_decisions(3)
40 | d2 = generate_n_decisions(1, ipv4=False)
41 | self.lapi.ds.insert_decisions(d1 + d2)
42 | sleep(3)
43 |
44 | # IPV4 Chain
45 | # Check the rules with the sets
46 | output = run_cmd("iptables", "-L", RULES_CHAIN_NAME)
47 | rules = [line for line in output.split("\n") if SET_NAME_IPV4 in line]
48 |
49 | self.assertEqual(len(rules), 1)
50 | assert f"match-set {SET_NAME_IPV4} src" in rules[0]
51 |
52 | # Check the JUMP to CROWDSEC_CHAIN
53 | output = run_cmd("iptables", "-L", CHAIN_NAME)
54 | rules = [line for line in output.split("\n") if RULES_CHAIN_NAME in line]
55 |
56 | self.assertEqual(len(rules), 1)
57 | assert f"{RULES_CHAIN_NAME}" in rules[0]
58 |
59 | # IPV6 Chain
60 | output = run_cmd("ip6tables", "-L", RULES_CHAIN_NAME)
61 | rules = [line for line in output.split("\n") if SET_NAME_IPV6 in line]
62 |
63 | self.assertEqual(len(rules), 1)
64 | assert f"match-set {SET_NAME_IPV6} src" in rules[0]
65 |
66 | # Check the JUMP to CROWDSEC_CHAIN
67 | output = run_cmd("ip6tables", "-L", CHAIN_NAME)
68 | rules = [line for line in output.split("\n") if RULES_CHAIN_NAME in line]
69 |
70 | self.assertEqual(len(rules), 1)
71 | assert f"{RULES_CHAIN_NAME}" in rules[0]
72 |
73 | output = run_cmd("ipset", "list")
74 |
75 | assert SET_NAME_IPV6 in output
76 | assert SET_NAME_IPV4 in output
77 |
78 | def test_duplicate_decisions_across_decision_stream(self):
79 | d1, d2, d3 = generate_n_decisions(3, dup_count=1)
80 | self.lapi.ds.insert_decisions([d1])
81 | sleep(3)
82 | res = get_set_elements(SET_NAME_IPV4)
83 | self.assertEqual(res, {"0.0.0.0"})
84 |
85 | self.lapi.ds.insert_decisions([d2, d3])
86 | sleep(3)
87 | assert self.fb.poll() is None
88 | self.assertEqual(get_set_elements(SET_NAME_IPV4), {"0.0.0.0", "0.0.0.1"})
89 |
90 | self.lapi.ds.delete_decision_by_id(d1["id"])
91 | self.lapi.ds.delete_decision_by_id(d2["id"])
92 | sleep(3)
93 | self.assertEqual(get_set_elements(SET_NAME_IPV4), set())
94 | assert self.fb.poll() is None
95 |
96 | self.lapi.ds.delete_decision_by_id(d3["id"])
97 | sleep(3)
98 | self.assertEqual(get_set_elements(SET_NAME_IPV6), set())
99 | assert self.fb.poll() is None
100 |
101 | def test_decision_insertion_deletion_ipv4(self):
102 | total_decisions, duplicate_decisions = 100, 23
103 | decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions)
104 | self.lapi.ds.insert_decisions(decisions)
105 | sleep(3) # let the bouncer insert the decisions
106 |
107 | set_elements = get_set_elements(SET_NAME_IPV4)
108 | self.assertEqual(len(set_elements), total_decisions - duplicate_decisions)
109 | self.assertEqual({i["value"] for i in decisions}, set_elements)
110 | self.assertIn("0.0.0.0", set_elements)
111 |
112 | self.lapi.ds.delete_decisions_by_ip("0.0.0.0")
113 | sleep(3)
114 |
115 | set_elements = get_set_elements(SET_NAME_IPV4)
116 | self.assertEqual({i["value"] for i in decisions if i["value"] != "0.0.0.0"}, set_elements)
117 | self.assertEqual(len(set_elements), total_decisions - duplicate_decisions - 1)
118 | self.assertNotIn("0.0.0.0", set_elements)
119 |
120 | def test_decision_insertion_deletion_ipv6(self):
121 | total_decisions, duplicate_decisions = 100, 23
122 | decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions, ipv4=False)
123 | self.lapi.ds.insert_decisions(decisions)
124 | sleep(3)
125 |
126 | set_elements = get_set_elements(SET_NAME_IPV6)
127 | set_elements = set(map(ip_address, set_elements))
128 | self.assertEqual(len(set_elements), total_decisions - duplicate_decisions)
129 | self.assertEqual({ip_address(i["value"]) for i in decisions}, set_elements)
130 | self.assertIn(ip_address("::1:0:3"), set_elements)
131 |
132 | self.lapi.ds.delete_decisions_by_ip("::1:0:3")
133 | sleep(3)
134 |
135 | set_elements = get_set_elements(SET_NAME_IPV6)
136 | set_elements = set(map(ip_address, set_elements))
137 | self.assertEqual(len(set_elements), total_decisions - duplicate_decisions - 1)
138 | self.assertEqual(
139 | {ip_address(i["value"]) for i in decisions if ip_address(i["value"]) != ip_address("::1:0:3")},
140 | set_elements,
141 | )
142 | self.assertNotIn(ip_address("::1:0:3"), set_elements)
143 |
144 | def test_longest_decision_insertion(self):
145 | decisions = [
146 | {
147 | "value": "123.45.67.12",
148 | "scope": "ip",
149 | "type": "ban",
150 | "origin": "script",
151 | "duration": f"{i}h",
152 | "reason": "for testing",
153 | }
154 | for i in range(1, 201)
155 | ]
156 | self.lapi.ds.insert_decisions(decisions)
157 | sleep(3)
158 | elems = get_set_elements(SET_NAME_IPV4, with_timeout=True)
159 | self.assertEqual(len(elems), 1)
160 | elems = list(elems)
161 | self.assertEqual(elems[0][0], "123.45.67.12")
162 | self.assertLessEqual(abs(elems[0][1] - 200 * 60 * 60), 15)
163 |
164 |
165 | def get_set_elements(set_name, with_timeout=False):
166 | output = run_cmd("ipset", "list", "-o", "xml")
167 | root = ET.fromstring(output)
168 | elements = set()
169 | for member in root.findall(f"ipset[@name='{set_name}']/members/member"):
170 | if with_timeout:
171 | to_add = (member.find("elem").text, int(member.find("timeout").text))
172 | else:
173 | to_add = member.find("elem").text
174 | elements.add(to_add)
175 | return elements
176 |
177 |
178 | class TestIPTablesLogging(unittest.TestCase):
179 | def setUp(self):
180 | self.fb = subprocess.Popen([BINARY_PATH, "-c", CONFIG_PATH_LOGGING])
181 | self.lapi = MockLAPI()
182 | self.lapi.start()
183 | return super().setUp()
184 |
185 | def tearDown(self):
186 | self.fb.kill()
187 | self.fb.wait()
188 | self.lapi.stop()
189 |
190 | def testLogging(self):
191 | # We use 1.1.1.1 because we want to see some dropped packets in the logs
192 | # We know this IP responds to ping, and the response will be dropped by the firewall
193 | d = new_decision("1.1.1.1")
194 | self.lapi.ds.insert_decisions([d])
195 | sleep(3)
196 |
197 | # Check if our logging chain is in place
198 |
199 | output = run_cmd("iptables", "-L", LOGGING_CHAIN_NAME)
200 | rules = [line for line in output.split("\n") if "anywhere" in line]
201 |
202 | # 2 rules: one logging, one generic drop
203 | self.assertEqual(len(rules), 2)
204 |
205 | # Check if the logging chain is called from the main chain
206 | output = run_cmd("iptables", "-L", CHAIN_NAME)
207 |
208 | rules = [line for line in output.split("\n") if RULES_CHAIN_NAME in line]
209 |
210 | self.assertEqual(len(rules), 1)
211 |
212 | # Check if logging/drop chain is called from the rules chain
213 | output = run_cmd("iptables", "-L", RULES_CHAIN_NAME)
214 |
215 | rules = [line for line in output.split("\n") if LOGGING_CHAIN_NAME in line]
216 |
217 | self.assertEqual(len(rules), 1)
218 |
219 | # Now, try to ping the IP
220 |
221 | output = run_cmd(
222 | "curl", "--connect-timeout", "1", "1.1.1.1", ignore_error=True
223 | ) # We don't care about the output, we just want to trigger the rule
224 |
225 | # Check if the firewall has logged the dropped response
226 |
227 | output = run_cmd("dmesg | tail -n 10", shell=True)
228 |
229 | assert "blocked by crowdsec" in output
230 |
--------------------------------------------------------------------------------
/test/tests/backends/mock_lapi.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import logging
3 | from datetime import timedelta
4 | from ipaddress import ip_address
5 | from threading import Thread
6 | from time import sleep
7 |
8 | from flask import Flask, abort, request
9 | from pytimeparse.timeparse import timeparse
10 | from werkzeug.serving import make_server
11 |
12 |
13 | # This is the "database" of our dummy LAPI
14 | class DataStore:
15 | def __init__(self) -> None:
16 | self.id = 0
17 | self.decisions = []
18 | self.bouncer_lastpull_by_api_key = {}
19 |
20 | def insert_decisions(self, decisions):
21 | for i, _ in enumerate(decisions):
22 | decisions[i]["created_at"] = datetime.datetime.now()
23 | decisions[i]["deleted_at"] = self.get_decision_expiry_time(decisions[i])
24 | decisions[i]["id"] = self.id
25 | self.id += 1
26 | self.decisions.extend(decisions)
27 |
28 | # This methods can be made more generic by taking lambda expr as input for filtering
29 | # decisions to delete
30 | def delete_decisions_by_ip(self, ip):
31 | for i, decision in enumerate(self.decisions):
32 | if ip_address(decision["value"]) == ip_address(ip):
33 | self.decisions[i]["deleted_at"] = datetime.datetime.now()
34 |
35 | def delete_decision_by_id(self, id):
36 | for i, decision in enumerate(self.decisions):
37 | if decision["id"] == id:
38 | self.decisions[i]["deleted_at"] = datetime.datetime.now()
39 | break
40 |
41 | def update_bouncer_pull(self, api_key):
42 | self.bouncer_lastpull_by_api_key[api_key] = datetime.datetime.now()
43 |
44 | def get_active_and_expired_decisions_since(self, since):
45 | expired_decisions = []
46 | active_decisions = []
47 |
48 | for decision in self.decisions:
49 | # decision["deleted_at"] > datetime.datetime.now() means that decision hasn't yet expired
50 | if decision["deleted_at"] > since and decision["deleted_at"] < datetime.datetime.now():
51 | expired_decisions.append(decision)
52 |
53 | elif decision["created_at"] > since:
54 | active_decisions.append(decision)
55 | return active_decisions, expired_decisions
56 |
57 | def get_decisions_for_bouncer(self, api_key, startup=False):
58 | if startup or api_key not in self.bouncer_lastpull_by_api_key:
59 | since = datetime.datetime.min
60 | self.bouncer_lastpull_by_api_key[api_key] = since
61 | else:
62 | since = self.bouncer_lastpull_by_api_key[api_key]
63 |
64 | self.update_bouncer_pull(api_key)
65 | return self.get_active_and_expired_decisions_since(since)
66 |
67 | @staticmethod
68 | def get_decision_expiry_time(decision):
69 | return decision["created_at"] + timedelta(seconds=timeparse(decision["duration"]))
70 |
71 |
72 | class MockLAPI:
73 | def __init__(self) -> None:
74 | self.app = Flask(__name__)
75 | self.app.add_url_rule("/v1/decisions/stream", view_func=self.decisions)
76 | log = logging.getLogger("werkzeug")
77 | log.setLevel(logging.ERROR)
78 | self.app.logger.disabled = True
79 | log.disabled = True
80 | self.ds = DataStore()
81 |
82 | def decisions(self):
83 | api_key = request.headers.get("x-api-key")
84 | if not api_key:
85 | abort(404)
86 | startup = request.args.get("startup") == "true"
87 | active_decisions, expired_decisions = self.ds.get_decisions_for_bouncer(api_key, startup)
88 | return {
89 | "new": formatted_decisions(active_decisions),
90 | "deleted": formatted_decisions(expired_decisions),
91 | }
92 |
93 | def start(self, port=8081):
94 | self.server_thread = ServerThread(self.app, port=port)
95 | self.server_thread.start()
96 |
97 | def stop(self):
98 | self.server_thread.shutdown()
99 |
100 |
101 | def formatted_decisions(decisions):
102 | formatted_decisions = []
103 | for decision in decisions:
104 | expiry_time = decision["created_at"] + timedelta(seconds=timeparse(decision["duration"]))
105 | duration = expiry_time - datetime.datetime.now()
106 | formatted_decisions.append(
107 | {
108 | "duration": f"{duration.total_seconds()}s",
109 | "id": decision["id"],
110 | "origin": decision["origin"],
111 | "scenario": "cscli",
112 | "scope": decision["scope"],
113 | "type": decision["type"],
114 | "value": decision["value"],
115 | }
116 | )
117 | return formatted_decisions
118 |
119 |
120 | # Copied from https://stackoverflow.com/a/45017691 .
121 | # We run server inside thread instead of process to avoid
122 | # huge complexity of sharing objects
123 | class ServerThread(Thread):
124 | def __init__(self, app, port=8081):
125 | Thread.__init__(self)
126 | self.server = make_server("127.0.0.1", port, app)
127 | self.ctx = app.app_context()
128 | self.ctx.push()
129 |
130 | def run(self):
131 | self.server.serve_forever()
132 |
133 | def shutdown(self):
134 | self.server.shutdown()
135 |
136 |
137 | if __name__ == "__main__":
138 | MockLAPI().start()
139 | sleep(100)
140 |
--------------------------------------------------------------------------------
/test/tests/backends/nftables/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/backends/nftables/__init__.py
--------------------------------------------------------------------------------
/test/tests/backends/nftables/crowdsec-firewall-bouncer.yaml:
--------------------------------------------------------------------------------
1 | mode: nftables
2 | update_frequency: 0.01s
3 | log_mode: stdout
4 | log_dir: ./
5 | log_level: info
6 | api_url: http://127.0.0.1:8081/
7 | api_key: 1237adaf7a1724ac68a3288828820a67
8 | disable_ipv6: false
9 | deny_action: DROP
10 | deny_log: false
11 | supported_decisions_types:
12 | - ban
13 | iptables_chains:
14 | - INPUT
15 |
16 | nftables_hooks:
17 | - input
18 | - forward
19 |
20 | nftables:
21 | ipv4:
22 | enabled: true
23 | set-only: false
24 | table: crowdsec
25 | chain: crowdsec-chain
26 | ipv6:
27 | enabled: true
28 | set-only: false
29 | table: crowdsec6
30 | chain: crowdsec6-chain
31 |
--------------------------------------------------------------------------------
/test/tests/backends/nftables/test_nftables.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import subprocess
4 | import unittest
5 | from ipaddress import ip_address
6 | from pathlib import Path
7 | from time import sleep
8 |
9 | from ..mock_lapi import MockLAPI
10 | from ..utils import generate_n_decisions, run_cmd
11 |
12 | SCRIPT_DIR = Path(os.path.dirname(os.path.realpath(__file__)))
13 | PROJECT_ROOT = SCRIPT_DIR.parent.parent.parent.parent
14 | BINARY_PATH = PROJECT_ROOT.joinpath("crowdsec-firewall-bouncer")
15 | CONFIG_PATH = SCRIPT_DIR.joinpath("crowdsec-firewall-bouncer.yaml")
16 |
17 |
18 | class TestNFTables(unittest.TestCase):
19 | def setUp(self):
20 | self.fb = subprocess.Popen([BINARY_PATH, "-c", CONFIG_PATH])
21 | self.lapi = MockLAPI()
22 | self.lapi.start()
23 | return super().setUp()
24 |
25 | def tearDown(self):
26 | self.fb.kill()
27 | self.fb.wait()
28 | self.lapi.stop()
29 | run_cmd("nft", "delete", "table", "ip", "crowdsec", ignore_error=True)
30 | run_cmd("nft", "delete", "table", "ip6", "crowdsec6", ignore_error=True)
31 |
32 | def test_table_rule_set_are_created(self):
33 | d1 = generate_n_decisions(3)
34 | d2 = generate_n_decisions(1, ipv4=False)
35 | self.lapi.ds.insert_decisions(d1 + d2)
36 | sleep(1)
37 | output = json.loads(run_cmd("nft", "-j", "list", "tables"))
38 | tables = {(node["table"]["family"], node["table"]["name"]) for node in output["nftables"] if "table" in node}
39 | assert ("ip6", "crowdsec6") in tables
40 | assert ("ip", "crowdsec") in tables
41 |
42 | # IPV4
43 | output = json.loads(run_cmd("nft", "-j", "list", "table", "ip", "crowdsec"))
44 | sets = {
45 | (node["set"]["family"], node["set"]["name"], node["set"]["type"])
46 | for node in output["nftables"]
47 | if "set" in node
48 | }
49 | assert ("ip", "crowdsec-blacklists-script", "ipv4_addr") in sets
50 | rules = {node["rule"]["chain"] for node in output["nftables"] if "rule" in node} # maybe stricter check ?
51 | assert "crowdsec-chain-forward" in rules
52 | assert "crowdsec-chain-input" in rules
53 |
54 | # IPV6
55 | output = json.loads(run_cmd("nft", "-j", "list", "table", "ip6", "crowdsec6"))
56 | sets = {
57 | (node["set"]["family"], node["set"]["name"], node["set"]["type"])
58 | for node in output["nftables"]
59 | if "set" in node
60 | }
61 | assert ("ip6", "crowdsec6-blacklists-script", "ipv6_addr") in sets
62 |
63 | rules = {node["rule"]["chain"] for node in output["nftables"] if "rule" in node} # maybe stricter check ?
64 | assert "crowdsec6-chain-input" in rules
65 | assert "crowdsec6-chain-forward" in rules
66 |
67 | def test_duplicate_decisions_across_decision_stream(self):
68 | d1, d2, d3 = generate_n_decisions(3, dup_count=1)
69 | self.lapi.ds.insert_decisions([d1])
70 | sleep(1)
71 | self.assertEqual(
72 | get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script"),
73 | {"0.0.0.0"},
74 | )
75 |
76 | self.lapi.ds.insert_decisions([d2, d3])
77 | sleep(1)
78 | assert self.fb.poll() is None
79 | self.assertEqual(
80 | get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script"),
81 | {"0.0.0.0", "0.0.0.1"},
82 | )
83 |
84 | self.lapi.ds.delete_decision_by_id(d1["id"])
85 | self.lapi.ds.delete_decision_by_id(d2["id"])
86 | sleep(1)
87 | self.assertEqual(get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script"), set())
88 | assert self.fb.poll() is None
89 |
90 | self.lapi.ds.delete_decision_by_id(d3["id"])
91 | sleep(1)
92 | self.assertEqual(get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script"), set())
93 | assert self.fb.poll() is None
94 |
95 | def test_decision_insertion_deletion_ipv4(self):
96 | total_decisions, duplicate_decisions = 100, 23
97 | decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions)
98 | self.lapi.ds.insert_decisions(decisions)
99 | sleep(1) # let the bouncer insert the decisions
100 |
101 | set_elements = get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script")
102 | self.assertEqual(len(set_elements), total_decisions - duplicate_decisions)
103 | assert {i["value"] for i in decisions} == set_elements
104 | assert "0.0.0.0" in set_elements
105 |
106 | self.lapi.ds.delete_decisions_by_ip("0.0.0.0")
107 | sleep(1)
108 |
109 | set_elements = get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script")
110 | assert {i["value"] for i in decisions if i["value"] != "0.0.0.0"} == set_elements
111 | assert len(set_elements) == total_decisions - duplicate_decisions - 1
112 | assert "0.0.0.0" not in set_elements
113 |
114 | def test_decision_insertion_deletion_ipv6(self):
115 | total_decisions, duplicate_decisions = 100, 23
116 | decisions = generate_n_decisions(total_decisions, dup_count=duplicate_decisions, ipv4=False)
117 | self.lapi.ds.insert_decisions(decisions)
118 | sleep(1)
119 |
120 | set_elements = get_set_elements("ip6", "crowdsec6", "crowdsec6-blacklists-script")
121 | set_elements = set(map(ip_address, set_elements))
122 | assert len(set_elements) == total_decisions - duplicate_decisions
123 | assert {ip_address(i["value"]) for i in decisions} == set_elements
124 | assert ip_address("::1:0:3") in set_elements
125 |
126 | self.lapi.ds.delete_decisions_by_ip("::1:0:3")
127 | sleep(1)
128 |
129 | set_elements = get_set_elements("ip6", "crowdsec6", "crowdsec6-blacklists-script")
130 | set_elements = set(map(ip_address, set_elements))
131 | self.assertEqual(len(set_elements), total_decisions - duplicate_decisions - 1)
132 | assert (
133 | {ip_address(i["value"]) for i in decisions if ip_address(i["value"]) != ip_address("::1:0:3")}
134 | ) == set_elements
135 | assert ip_address("::1:0:3") not in set_elements
136 |
137 | def test_longest_decision_insertion(self):
138 | decisions = [
139 | {
140 | "value": "123.45.67.12",
141 | "scope": "ip",
142 | "type": "ban",
143 | "origin": "script",
144 | "duration": f"{i}h",
145 | "reason": "for testing",
146 | }
147 | for i in range(1, 201)
148 | ]
149 | self.lapi.ds.insert_decisions(decisions)
150 | sleep(1)
151 | elems = get_set_elements("ip", "crowdsec", "crowdsec-blacklists-script", with_timeout=True)
152 | assert len(elems) == 1
153 | elems = list(elems)
154 | assert elems[0][0] == "123.45.67.12"
155 | assert abs(elems[0][1] - 200 * 60 * 60) <= 3
156 |
157 |
158 | def get_set_elements(family, table_name, set_name, with_timeout=False):
159 | output = json.loads(run_cmd("nft", "-j", "list", "set", family, table_name, set_name))
160 | for node in output["nftables"]:
161 | if "set" not in node or "elem" not in node["set"]:
162 | continue
163 | if not isinstance(node["set"]["elem"][0], dict):
164 | return set(node["set"]["elem"])
165 |
166 | if not with_timeout:
167 | return {elem["elem"]["val"] for elem in node["set"]["elem"]}
168 | return {(elem["elem"]["val"], elem["elem"]["timeout"]) for elem in node["set"]["elem"]}
169 | return set()
170 |
--------------------------------------------------------------------------------
/test/tests/backends/utils.py:
--------------------------------------------------------------------------------
1 | import subprocess
2 | from ipaddress import ip_address
3 |
4 |
5 | def run_cmd(*cmd, ignore_error=False, shell=False):
6 | p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, shell=shell)
7 | if not ignore_error and p.returncode:
8 | raise SystemExit(f"{cmd} exited with non-zero code with following logs:\n {p.stdout}")
9 |
10 | return p.stdout
11 |
12 |
13 | def generate_n_decisions(n: int, action="ban", dup_count=0, ipv4=True, duration="4h"):
14 | if dup_count >= n:
15 | raise SystemExit(f"generate_n_decisions got dup_count={dup_count} which is >=n")
16 |
17 | unique_decision_count = n - dup_count
18 | decisions = []
19 | for i in range(unique_decision_count):
20 | if ipv4:
21 | ip = ip_address(i)
22 | else:
23 | ip = ip_address(2**32 + i)
24 | decisions.append(
25 | {
26 | "value": ip.__str__(),
27 | "scope": "ip",
28 | "type": action,
29 | "origin": "script",
30 | "duration": duration,
31 | "reason": "for testing",
32 | }
33 | )
34 | decisions += decisions[: n % unique_decision_count]
35 | decisions *= n // unique_decision_count
36 | return decisions
37 |
38 |
39 | def new_decision(ip: str):
40 | return {
41 | "value": ip,
42 | "scope": "ip",
43 | "type": "ban",
44 | "origin": "script",
45 | "duration": "4h",
46 | "reason": "for testing",
47 | }
48 |
--------------------------------------------------------------------------------
/test/tests/bouncer/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/bouncer/__init__.py
--------------------------------------------------------------------------------
/test/tests/bouncer/test_firewall_bouncer.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 |
4 | def test_backend_mode(bouncer, fw_cfg_factory):
5 | cfg = fw_cfg_factory()
6 |
7 | del cfg["mode"]
8 |
9 | with bouncer(cfg) as fw:
10 | fw.wait_for_lines_fnmatch(
11 | [
12 | "*unable to load configuration: config does not contain 'mode'*",
13 | ]
14 | )
15 | fw.proc.wait(timeout=0.2)
16 | assert not fw.proc.is_running()
17 |
18 | cfg["mode"] = "whatever"
19 |
20 | with bouncer(cfg) as fw:
21 | fw.wait_for_lines_fnmatch(
22 | [
23 | "*firewall 'whatever' is not supported*",
24 | ]
25 | )
26 | fw.proc.wait(timeout=0.2)
27 | assert not fw.proc.is_running()
28 |
29 | cfg["mode"] = "dry-run"
30 |
31 | with bouncer(cfg) as fw:
32 | fw.wait_for_lines_fnmatch(
33 | [
34 | "*Starting crowdsec-firewall-bouncer*",
35 | "*backend type: dry-run*",
36 | "*backend.Init() called*",
37 | "*unable to configure bouncer: config does not contain LAPI url*",
38 | ]
39 | )
40 | fw.proc.wait(timeout=0.2)
41 | assert not fw.proc.is_running()
42 |
43 |
44 | def test_api_url(crowdsec, bouncer, fw_cfg_factory):
45 | cfg = fw_cfg_factory()
46 |
47 | with bouncer(cfg) as fw:
48 | fw.wait_for_lines_fnmatch(
49 | [
50 | "*unable to configure bouncer: config does not contain LAPI url*",
51 | ]
52 | )
53 | fw.proc.wait()
54 | assert not fw.proc.is_running()
55 |
56 | cfg["api_url"] = ""
57 |
58 | with bouncer(cfg) as fw:
59 | fw.wait_for_lines_fnmatch(
60 | [
61 | "*unable to configure bouncer: config does not contain LAPI url*",
62 | ]
63 | )
64 | fw.proc.wait()
65 | assert not fw.proc.is_running()
66 |
67 |
68 | def test_api_key(crowdsec, bouncer, fw_cfg_factory, api_key_factory, bouncer_under_test):
69 | api_key = api_key_factory()
70 | env = {"BOUNCER_KEY_bouncer": api_key}
71 |
72 | with crowdsec(environment=env) as lapi:
73 | lapi.wait_for_http(8080, "/health")
74 | port = lapi.probe.get_bound_port("8080")
75 |
76 | cfg = fw_cfg_factory()
77 | cfg["api_url"] = f"http://localhost:{port}"
78 |
79 | with bouncer(cfg) as fw:
80 | fw.wait_for_lines_fnmatch(
81 | [
82 | "*unable to configure bouncer: config does not contain LAPI key or certificate*",
83 | ]
84 | )
85 | fw.proc.wait()
86 | assert not fw.proc.is_running()
87 |
88 | cfg["api_key"] = "badkey"
89 |
90 | with bouncer(cfg) as fw:
91 | fw.wait_for_lines_fnmatch(
92 | [
93 | "*Using API key auth*",
94 | "*API error: access forbidden*",
95 | "*process terminated with error: bouncer stream halted*",
96 | ]
97 | )
98 | fw.proc.wait()
99 | assert not fw.proc.is_running()
100 |
101 | cfg["api_key"] = api_key
102 |
103 | with bouncer(cfg) as fw:
104 | fw.wait_for_lines_fnmatch(
105 | [
106 | "*Using API key auth*",
107 | "*Processing new and deleted decisions*",
108 | ]
109 | )
110 | assert fw.proc.is_running()
111 |
112 | # check that the bouncer is registered
113 | res = lapi.cont.exec_run("cscli bouncers list -o json")
114 | assert res.exit_code == 0
115 | bouncers = json.loads(res.output)
116 | assert len(bouncers) == 1
117 | assert bouncers[0]["name"] == "bouncer"
118 | assert bouncers[0]["auth_type"] == "api-key"
119 | assert bouncers[0]["type"] == bouncer_under_test
120 |
--------------------------------------------------------------------------------
/test/tests/bouncer/test_iptables_deny_action.py:
--------------------------------------------------------------------------------
1 | def test_iptables_deny_action(bouncer, fw_cfg_factory):
2 | cfg = fw_cfg_factory()
3 |
4 | cfg["log_level"] = "trace"
5 | cfg["mode"] = "iptables"
6 |
7 | with bouncer(cfg) as fw:
8 | fw.wait_for_lines_fnmatch(
9 | [
10 | "*using 'DROP' as deny_action*",
11 | ]
12 | )
13 | fw.proc.wait(timeout=5)
14 | assert not fw.proc.is_running()
15 |
16 | cfg["deny_action"] = "drop"
17 |
18 | with bouncer(cfg) as fw:
19 | fw.wait_for_lines_fnmatch(
20 | [
21 | "*using 'DROP' as deny_action*",
22 | ]
23 | )
24 | fw.proc.wait(timeout=5)
25 | assert not fw.proc.is_running()
26 |
27 | cfg["deny_action"] = "reject"
28 |
29 | with bouncer(cfg) as fw:
30 | fw.wait_for_lines_fnmatch(
31 | [
32 | "*using 'REJECT' as deny_action*",
33 | ]
34 | )
35 | fw.proc.wait(timeout=5)
36 | assert not fw.proc.is_running()
37 |
38 | cfg["deny_action"] = "tarpit"
39 |
40 | with bouncer(cfg) as fw:
41 | fw.wait_for_lines_fnmatch(
42 | [
43 | "*using 'TARPIT' as deny_action*",
44 | ]
45 | )
46 | fw.proc.wait(timeout=5)
47 | assert not fw.proc.is_running()
48 |
49 | cfg["deny_action"] = "somethingelse"
50 |
51 | with bouncer(cfg) as fw:
52 | fw.wait_for_lines_fnmatch(
53 | [
54 | "*invalid deny_action 'somethingelse', must be one of DROP, REJECT, TARPIT*",
55 | ]
56 | )
57 | fw.proc.wait(timeout=5)
58 | assert not fw.proc.is_running()
59 |
--------------------------------------------------------------------------------
/test/tests/bouncer/test_tls.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 |
4 | def test_tls_server(crowdsec, certs_dir, api_key_factory, bouncer, fw_cfg_factory):
5 | """TLS with server-only certificate"""
6 | api_key = api_key_factory()
7 |
8 | lapi_env = {
9 | "CACERT_FILE": "/etc/ssl/crowdsec/ca.crt",
10 | "LAPI_CERT_FILE": "/etc/ssl/crowdsec/lapi.crt",
11 | "LAPI_KEY_FILE": "/etc/ssl/crowdsec/lapi.key",
12 | "USE_TLS": "true",
13 | "LOCAL_API_URL": "https://localhost:8080",
14 | "BOUNCER_KEY_bouncer": api_key,
15 | }
16 |
17 | certs = certs_dir(lapi_hostname="lapi")
18 |
19 | volumes = {
20 | certs: {"bind": "/etc/ssl/crowdsec", "mode": "ro"},
21 | }
22 |
23 | with crowdsec(environment=lapi_env, volumes=volumes) as cs:
24 | cs.wait_for_log("*CrowdSec Local API listening*")
25 | # TODO: wait_for_https
26 | cs.wait_for_http(8080, "/health", want_status=None)
27 |
28 | port = cs.probe.get_bound_port("8080")
29 | cfg = fw_cfg_factory()
30 | cfg["api_url"] = f"https://localhost:{port}"
31 | cfg["api_key"] = api_key
32 |
33 | with bouncer(cfg) as cb:
34 | cb.wait_for_lines_fnmatch(
35 | [
36 | "*backend type: dry-run*",
37 | "*Using API key auth*",
38 | "*auth-api: auth with api key failed*",
39 | "*tls: failed to verify certificate: x509: certificate signed by unknown authority*",
40 | ]
41 | )
42 |
43 | cfg["ca_cert_path"] = (certs / "ca.crt").as_posix()
44 |
45 | with bouncer(cfg) as cb:
46 | cb.wait_for_lines_fnmatch(
47 | [
48 | "*backend type: dry-run*",
49 | "*Using CA cert *ca.crt*",
50 | "*Using API key auth*",
51 | "*Processing new and deleted decisions*",
52 | ]
53 | )
54 |
55 |
56 | def test_tls_mutual(crowdsec, certs_dir, api_key_factory, bouncer, fw_cfg_factory, bouncer_under_test):
57 | """TLS with two-way bouncer/lapi authentication"""
58 | lapi_env = {
59 | "CACERT_FILE": "/etc/ssl/crowdsec/ca.crt",
60 | "LAPI_CERT_FILE": "/etc/ssl/crowdsec/lapi.crt",
61 | "LAPI_KEY_FILE": "/etc/ssl/crowdsec/lapi.key",
62 | "USE_TLS": "true",
63 | "LOCAL_API_URL": "https://localhost:8080",
64 | }
65 |
66 | certs = certs_dir(lapi_hostname="lapi")
67 |
68 | volumes = {
69 | certs: {"bind": "/etc/ssl/crowdsec", "mode": "ro"},
70 | }
71 |
72 | with crowdsec(environment=lapi_env, volumes=volumes) as cs:
73 | cs.wait_for_log("*CrowdSec Local API listening*")
74 | # TODO: wait_for_https
75 | cs.wait_for_http(8080, "/health", want_status=None)
76 |
77 | port = cs.probe.get_bound_port("8080")
78 | cfg = fw_cfg_factory()
79 | cfg["api_url"] = f"https://localhost:{port}"
80 | cfg["ca_cert_path"] = (certs / "ca.crt").as_posix()
81 |
82 | cfg["cert_path"] = (certs / "agent.crt").as_posix()
83 | cfg["key_path"] = (certs / "agent.key").as_posix()
84 |
85 | with bouncer(cfg) as cb:
86 | cb.wait_for_lines_fnmatch(
87 | [
88 | "*Starting crowdsec-firewall-bouncer*",
89 | "*Using CA cert*",
90 | "*Using cert auth with cert * and key *",
91 | "*API error: access forbidden*",
92 | ]
93 | )
94 |
95 | cs.wait_for_log("*client certificate OU ?agent-ou? doesn't match expected OU ?bouncer-ou?*")
96 |
97 | cfg["cert_path"] = (certs / "bouncer.crt").as_posix()
98 | cfg["key_path"] = (certs / "bouncer.key").as_posix()
99 |
100 | with bouncer(cfg) as cb:
101 | cb.wait_for_lines_fnmatch(
102 | [
103 | "*backend type: dry-run*",
104 | "*Using CA cert*",
105 | "*Using cert auth with cert * and key *",
106 | "*Processing new and deleted decisions . . .*",
107 | ]
108 | )
109 |
110 | # check that the bouncer is registered
111 | res = cs.cont.exec_run("cscli bouncers list -o json")
112 | assert res.exit_code == 0
113 | bouncers = json.loads(res.output)
114 | assert len(bouncers) == 1
115 | assert bouncers[0]["name"].startswith("@")
116 | assert bouncers[0]["auth_type"] == "tls"
117 | assert bouncers[0]["type"] == bouncer_under_test
118 |
119 |
120 | def test_api_key_and_cert(crowdsec, certs_dir, api_key_factory, bouncer, fw_cfg_factory):
121 | """Attempt to send an api key and a certificate too"""
122 | api_key = api_key_factory()
123 |
124 | lapi_env = {
125 | "CACERT_FILE": "/etc/ssl/crowdsec/ca.crt",
126 | "LAPI_CERT_FILE": "/etc/ssl/crowdsec/lapi.crt",
127 | "LAPI_KEY_FILE": "/etc/ssl/crowdsec/lapi.key",
128 | "USE_TLS": "true",
129 | "LOCAL_API_URL": "https://localhost:8080",
130 | "BOUNCER_KEY_bouncer": api_key,
131 | }
132 |
133 | certs = certs_dir(lapi_hostname="lapi")
134 |
135 | volumes = {
136 | certs: {"bind": "/etc/ssl/crowdsec", "mode": "ro"},
137 | }
138 |
139 | with crowdsec(environment=lapi_env, volumes=volumes) as cs:
140 | cs.wait_for_log("*CrowdSec Local API listening*")
141 | cs.wait_for_http(8080, "/health", want_status=None)
142 |
143 | port = cs.probe.get_bound_port("8080")
144 | cfg = fw_cfg_factory()
145 | cfg["api_url"] = f"https://localhost:{port}"
146 | cfg["ca_cert_path"] = (certs / "ca.crt").as_posix()
147 | cfg["api_key"] = api_key
148 |
149 | cfg["cert_path"] = (certs / "bouncer.crt").as_posix()
150 | cfg["key_path"] = (certs / "bouncer.key").as_posix()
151 |
152 | cs.wait_for_log("*Starting processing data*")
153 |
154 | with bouncer(cfg) as cb:
155 | cb.wait_for_lines_fnmatch(
156 | [
157 | "*Starting crowdsec-firewall-bouncer*",
158 | "*unable to configure bouncer: api client init: cannot use both API key and certificate auth*",
159 | ]
160 | )
161 |
--------------------------------------------------------------------------------
/test/tests/bouncer/test_yaml_local.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 |
4 | def test_yaml_local(bouncer, fw_cfg_factory):
5 | cfg = fw_cfg_factory()
6 |
7 | cfg.pop("mode")
8 |
9 | with bouncer(cfg) as fw:
10 | fw.wait_for_lines_fnmatch(
11 | [
12 | "*unable to load configuration: config does not contain 'mode'*",
13 | ]
14 | )
15 | fw.proc.wait(timeout=0.2)
16 | assert not fw.proc.is_running()
17 |
18 | config_local = {"mode": "whatever"}
19 |
20 | with bouncer(cfg, config_local=config_local) as fw:
21 | fw.wait_for_lines_fnmatch(
22 | [
23 | "*firewall 'whatever' is not supported*",
24 | ]
25 | )
26 | fw.proc.wait(timeout=0.2)
27 | assert not fw.proc.is_running()
28 |
29 | # variable expansion
30 |
31 | config_local = {"mode": "$BOUNCER_MODE"}
32 |
33 | os.environ["BOUNCER_MODE"] = "fromenv"
34 |
35 | with bouncer(cfg, config_local=config_local) as fw:
36 | fw.wait_for_lines_fnmatch(
37 | [
38 | "*firewall 'fromenv' is not supported*",
39 | ]
40 | )
41 | fw.proc.wait(timeout=0.2)
42 | assert not fw.proc.is_running()
43 |
--------------------------------------------------------------------------------
/test/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 |
3 | import pytest
4 | from pytest_cs import plugin
5 |
6 | # pytest_exception_interact = plugin.pytest_exception_interact
7 |
8 |
9 | # provide the name of the bouncer binary to test
10 | @pytest.fixture(scope="session")
11 | def bouncer_under_test():
12 | return "crowdsec-firewall-bouncer"
13 |
14 |
15 | # Create a lapi container, register a bouncer and run it with the updated config.
16 | # - Return context manager that yields a tuple of (bouncer, lapi)
17 | @pytest.fixture(scope="session")
18 | def bouncer_with_lapi(bouncer, crowdsec, fw_cfg_factory, api_key_factory: plugin.ApiKeyFactoryType):
19 | @contextlib.contextmanager
20 | def closure(config_lapi=None, config_bouncer=None, api_key=None):
21 | if config_bouncer is None:
22 | config_bouncer = {}
23 | if config_lapi is None:
24 | config_lapi = {}
25 | # can be overridden by config_lapi + config_bouncer
26 | api_key = api_key_factory()
27 | env = {
28 | "BOUNCER_KEY_custom": api_key,
29 | }
30 | try:
31 | env.update(config_lapi)
32 | with crowdsec(environment=env) as lapi:
33 | lapi.wait_for_http(8080, "/health")
34 | port = lapi.probe.get_bound_port("8080")
35 | cfg = fw_cfg_factory()
36 | cfg["api_url"] = f"http://localhost:{port}/"
37 | cfg["api_key"] = api_key
38 | cfg.update(config_bouncer)
39 | with bouncer(cfg) as cb:
40 | yield cb, lapi
41 | finally:
42 | pass
43 |
44 | yield closure
45 |
46 |
47 | _default_config = {
48 | "mode": "dry-run",
49 | "log_level": "info",
50 | }
51 |
52 |
53 | @pytest.fixture(scope="session")
54 | def fw_cfg_factory():
55 | def closure(**kw):
56 | cfg = _default_config.copy()
57 | cfg |= kw
58 | return cfg | kw
59 |
60 | yield closure
61 |
--------------------------------------------------------------------------------
/test/tests/install/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/install/__init__.py
--------------------------------------------------------------------------------
/test/tests/install/no_crowdsec/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/install/no_crowdsec/__init__.py
--------------------------------------------------------------------------------
/test/tests/install/no_crowdsec/test_no_crowdsec_deb.py:
--------------------------------------------------------------------------------
1 | import os
2 | import subprocess
3 |
4 | import pytest
5 |
6 | pytestmark = pytest.mark.deb
7 |
8 |
9 | def test_deb_install_purge(deb_package_path, bouncer_under_test, must_be_root):
10 | # test the full install-purge cycle, doing that in separate tests would
11 | # be a bit too much
12 |
13 | # TODO: remove and reinstall
14 |
15 | # use the package built as non-root by test_deb_build()
16 | assert deb_package_path.exists(), f"This test requires {deb_package_path}"
17 |
18 | bouncer_exe = f"/usr/bin/{bouncer_under_test}"
19 | assert not os.path.exists(bouncer_exe)
20 |
21 | config = f"/etc/crowdsec/bouncers/{bouncer_under_test}.yaml"
22 | assert not os.path.exists(config)
23 |
24 | # install the package
25 | p = subprocess.run(
26 | ["dpkg", "--install", deb_package_path.as_posix()],
27 | stdout=subprocess.PIPE,
28 | stderr=subprocess.PIPE,
29 | encoding="utf-8",
30 | )
31 | assert p.returncode == 0, f"Failed to install {deb_package_path}"
32 |
33 | assert os.path.exists(bouncer_exe)
34 | assert os.stat(bouncer_exe).st_mode & 0o777 == 0o755
35 |
36 | assert os.path.exists(config)
37 | assert os.stat(config).st_mode & 0o777 == 0o600
38 |
39 | p = subprocess.check_output(["dpkg-deb", "-f", deb_package_path.as_posix(), "Package"], encoding="utf-8")
40 | package_name = p.strip()
41 |
42 | p = subprocess.run(
43 | ["dpkg", "--purge", package_name],
44 | stdout=subprocess.PIPE,
45 | stderr=subprocess.PIPE,
46 | encoding="utf-8",
47 | )
48 | assert p.returncode == 0, f"Failed to purge {package_name}"
49 |
50 | assert not os.path.exists(bouncer_exe)
51 | assert not os.path.exists(config)
52 |
--------------------------------------------------------------------------------
/test/tests/install/no_crowdsec/test_no_crowdsec_scripts.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 |
4 | import pexpect
5 | import pytest
6 | import yaml
7 |
8 | BOUNCER = "crowdsec-firewall-bouncer"
9 | CONFIG = f"/etc/crowdsec/bouncers/{BOUNCER}.yaml"
10 |
11 |
12 | @pytest.mark.dependency
13 | def test_install_no_crowdsec(project_repo, bouncer_binary, must_be_root):
14 | c = pexpect.spawn("/usr/bin/sh", ["scripts/install.sh"], cwd=project_repo)
15 |
16 | c.expect(f"Installing {BOUNCER}")
17 | c.expect("iptables found")
18 | c.expect("nftables found")
19 | c.expect(re.escape("Found nftables (default) and iptables, which firewall do you want to use (nftables/iptables)"))
20 | c.sendline("nftables")
21 | c.expect("WARN.* cscli not found, you will need to generate an api key.")
22 | c.expect(f"WARN.* service not started. You need to get an API key and configure it in {CONFIG}")
23 | c.expect(f"The {BOUNCER} service has been installed.")
24 | c.wait()
25 | assert c.terminated
26 | assert c.exitstatus == 0
27 |
28 | with open(CONFIG) as f:
29 | y = yaml.safe_load(f)
30 | assert y["api_key"] == ""
31 | assert y["mode"] == "nftables"
32 |
33 | assert os.path.exists(CONFIG)
34 | assert os.stat(CONFIG).st_mode & 0o777 == 0o600
35 | assert os.path.exists(f"/usr/local/bin/{BOUNCER}")
36 | assert os.stat(f"/usr/local/bin/{BOUNCER}").st_mode & 0o777 == 0o755
37 |
38 | c = pexpect.spawn("/usr/bin/sh", ["scripts/install.sh"], cwd=project_repo)
39 |
40 | c.expect(f"ERR.* /usr/local/bin/{BOUNCER} is already installed. Exiting")
41 |
42 |
43 | @pytest.mark.dependency(depends=["test_install_no_crowdsec"])
44 | def test_upgrade_no_crowdsec(project_repo, must_be_root):
45 | os.remove(f"/usr/local/bin/{BOUNCER}")
46 |
47 | c = pexpect.spawn("/usr/bin/sh", ["scripts/upgrade.sh"], cwd=project_repo)
48 |
49 | c.expect(f"{BOUNCER} upgraded successfully")
50 | c.wait()
51 | assert c.terminated
52 | assert c.exitstatus == 0
53 |
54 | assert os.path.exists(f"/usr/local/bin/{BOUNCER}")
55 | assert os.stat(f"/usr/local/bin/{BOUNCER}").st_mode & 0o777 == 0o755
56 |
57 |
58 | @pytest.mark.dependency(depends=["test_upgrade_no_crowdsec"])
59 | def test_uninstall_no_crowdsec(project_repo, must_be_root):
60 | c = pexpect.spawn("/usr/bin/sh", ["scripts/uninstall.sh"], cwd=project_repo)
61 |
62 | c.expect(f"{BOUNCER} has been successfully uninstalled")
63 | c.wait()
64 | assert c.terminated
65 | assert c.exitstatus == 0
66 |
67 | assert not os.path.exists(CONFIG)
68 | assert not os.path.exists(f"/usr/local/bin/{BOUNCER}")
69 |
--------------------------------------------------------------------------------
/test/tests/install/with_crowdsec/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/install/with_crowdsec/__init__.py
--------------------------------------------------------------------------------
/test/tests/install/with_crowdsec/test_crowdsec_deb.py:
--------------------------------------------------------------------------------
1 | import os
2 | import subprocess
3 | from pathlib import Path
4 |
5 | import pytest
6 | import yaml
7 | from zxcvbn import zxcvbn
8 |
9 | pytestmark = pytest.mark.deb
10 |
11 |
12 | # TODO: use fixtures to install/purge and register/unregister bouncers
13 |
14 |
15 | def test_deb_install_purge(deb_package_path, bouncer_under_test, must_be_root):
16 | # test the full install-purge cycle, doing that in separate tests would
17 | # be a bit too much
18 |
19 | # TODO: remove and reinstall
20 |
21 | # use the package built as non-root by test_deb_build()
22 | assert deb_package_path.exists(), f"This test requires {deb_package_path}"
23 |
24 | p = subprocess.check_output(["dpkg-deb", "-f", deb_package_path.as_posix(), "Package"], encoding="utf-8")
25 | package_name = p.strip()
26 |
27 | subprocess.check_call(["dpkg", "--purge", package_name])
28 |
29 | bouncer_exe = f"/usr/bin/{bouncer_under_test}"
30 | assert not os.path.exists(bouncer_exe)
31 |
32 | config = f"/etc/crowdsec/bouncers/{bouncer_under_test}.yaml"
33 | assert not os.path.exists(config)
34 |
35 | # install the package
36 | p = subprocess.run(
37 | ["dpkg", "--install", deb_package_path.as_posix()],
38 | stdout=subprocess.PIPE,
39 | stderr=subprocess.PIPE,
40 | encoding="utf-8",
41 | )
42 | assert p.returncode == 0, f"Failed to install {deb_package_path}"
43 |
44 | assert os.path.exists(bouncer_exe)
45 | assert os.stat(bouncer_exe).st_mode & 0o777 == 0o755
46 |
47 | assert os.path.exists(config)
48 | assert os.stat(config).st_mode & 0o777 == 0o600
49 |
50 | with open(config) as f:
51 | cfg = yaml.safe_load(f)
52 | api_key = cfg["api_key"]
53 | # the api key has been set to a random value
54 | assert zxcvbn(api_key)["score"] == 4, f"weak api_key: '{api_key}'"
55 |
56 | with open(config + ".id") as f:
57 | bouncer_name = f.read().strip()
58 |
59 | p = subprocess.check_output(["cscli", "bouncers", "list", "-o", "json"])
60 | bouncers = yaml.safe_load(p)
61 | assert len([b for b in bouncers if b["name"] == bouncer_name]) == 1
62 |
63 | p = subprocess.run(
64 | ["dpkg", "--purge", package_name],
65 | stdout=subprocess.PIPE,
66 | stderr=subprocess.PIPE,
67 | encoding="utf-8",
68 | )
69 | assert p.returncode == 0, f"Failed to purge {package_name}"
70 |
71 | assert not os.path.exists(bouncer_exe)
72 | assert not os.path.exists(config)
73 |
74 |
75 | def test_deb_install_purge_yaml_local(deb_package_path, bouncer_under_test, must_be_root):
76 | """
77 | Check .deb package installation with:
78 |
79 | - a pre-existing .yaml.local file with an api key
80 | - a pre-registered bouncer
81 |
82 | => the configuration files are not touched (no new api key)
83 | """
84 | assert deb_package_path.exists(), f"This test requires {deb_package_path}"
85 |
86 | p = subprocess.check_output(["dpkg-deb", "-f", deb_package_path.as_posix(), "Package"], encoding="utf-8")
87 | package_name = p.strip()
88 |
89 | subprocess.check_call(["dpkg", "--purge", package_name])
90 | subprocess.run(["cscli", "bouncers", "delete", "testbouncer"])
91 |
92 | bouncer_exe = f"/usr/bin/{bouncer_under_test}"
93 | config = Path(f"/etc/crowdsec/bouncers/{bouncer_under_test}.yaml")
94 | config.parent.mkdir(parents=True, exist_ok=True)
95 |
96 | subprocess.check_call(["cscli", "bouncers", "add", "testbouncer", "-k", "123456"])
97 |
98 | with open(config.with_suffix(".yaml.local"), "w") as f:
99 | f.write('api_key: "123456"')
100 |
101 | p = subprocess.run(
102 | ["dpkg", "--install", deb_package_path.as_posix()],
103 | stdout=subprocess.PIPE,
104 | stderr=subprocess.PIPE,
105 | encoding="utf-8",
106 | )
107 | assert p.returncode == 0, f"Failed to install {deb_package_path}"
108 |
109 | assert os.path.exists(bouncer_exe)
110 | assert os.path.exists(config)
111 |
112 | with open(config) as f:
113 | cfg = yaml.safe_load(f)
114 | api_key = cfg["api_key"]
115 | # the api key has not been set
116 | assert api_key == "${API_KEY}"
117 |
118 | p = subprocess.check_output([bouncer_exe, "-c", config, "-T"])
119 | merged_config = yaml.safe_load(p)
120 | assert merged_config["api_key"] == "123456"
121 |
122 | os.unlink(config.with_suffix(".yaml.local"))
123 |
124 | p = subprocess.run(
125 | ["dpkg", "--purge", package_name],
126 | stdout=subprocess.PIPE,
127 | stderr=subprocess.PIPE,
128 | encoding="utf-8",
129 | )
130 | assert p.returncode == 0, f"Failed to purge {package_name}"
131 |
132 | assert not os.path.exists(bouncer_exe)
133 | assert not os.path.exists(config)
134 |
--------------------------------------------------------------------------------
/test/tests/install/with_crowdsec/test_crowdsec_scripts.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 |
4 | import pexpect
5 | import pytest
6 | import yaml
7 | from pytest_cs.lib import cscli, text
8 |
9 | BOUNCER = "crowdsec-firewall-bouncer"
10 | CONFIG = f"/etc/crowdsec/bouncers/{BOUNCER}.yaml"
11 |
12 |
13 | @pytest.mark.systemd_debug(BOUNCER)
14 | @pytest.mark.dependency
15 | def test_install_crowdsec(project_repo, bouncer_binary, must_be_root):
16 | c = pexpect.spawn("/usr/bin/sh", ["scripts/install.sh"], encoding="utf-8", cwd=project_repo)
17 |
18 | c.expect(f"Installing {BOUNCER}")
19 | c.expect("iptables found")
20 | c.expect("nftables found")
21 | c.expect(re.escape("Found nftables (default) and iptables, which firewall do you want to use (nftables/iptables)"))
22 | c.sendline("fntables")
23 | c.expect("cscli found, generating bouncer api key.")
24 | c.expect("API Key: (.*)")
25 | api_key = text.nocolor(c.match.group(1).strip())
26 | # XXX: what do we expect here ?
27 | c.wait()
28 | assert c.terminated
29 | # XXX: partial configuration, the service won't start
30 | # assert c.exitstatus == 0
31 |
32 | # installed files
33 | assert os.path.exists(CONFIG)
34 | assert os.stat(CONFIG).st_mode & 0o777 == 0o600
35 | assert os.path.exists(f"/usr/local/bin/{BOUNCER}")
36 | assert os.stat(f"/usr/local/bin/{BOUNCER}").st_mode & 0o777 == 0o755
37 |
38 | # configuration check
39 | with open(CONFIG) as f:
40 | y = yaml.safe_load(f)
41 | assert y["api_key"] == api_key
42 |
43 | # the bouncer is registered
44 | with open(f"{CONFIG}.id") as f:
45 | bouncer_name = f.read().strip()
46 |
47 | assert len(list(cscli.get_bouncers(name=bouncer_name))) == 1
48 |
49 | c = pexpect.spawn("/usr/bin/sh", ["scripts/install.sh"], encoding="utf-8", cwd=project_repo)
50 |
51 | c.expect(f"ERR:.* /usr/local/bin/{BOUNCER} is already installed. Exiting")
52 |
53 |
54 | @pytest.mark.dependency(depends=["test_install_crowdsec"])
55 | def test_upgrade_crowdsec(project_repo, must_be_root):
56 | os.remove(f"/usr/local/bin/{BOUNCER}")
57 |
58 | c = pexpect.spawn("/usr/bin/sh", ["scripts/upgrade.sh"], encoding="utf-8", cwd=project_repo)
59 |
60 | c.expect(f"{BOUNCER} upgraded successfully")
61 | c.wait()
62 | assert c.terminated
63 | assert c.exitstatus == 0
64 |
65 | assert os.path.exists(f"/usr/local/bin/{BOUNCER}")
66 | assert os.stat(f"/usr/local/bin/{BOUNCER}").st_mode & 0o777 == 0o755
67 |
68 |
69 | @pytest.mark.dependency(depends=["test_upgrade_crowdsec"])
70 | def test_uninstall_crowdsec(project_repo, must_be_root):
71 | # the bouncer is registered
72 | with open(f"{CONFIG}.id") as f:
73 | bouncer_name = f.read().strip()
74 |
75 | c = pexpect.spawn("/usr/bin/sh", ["scripts/uninstall.sh"], encoding="utf-8", cwd=project_repo)
76 |
77 | c.expect(f"{BOUNCER} has been successfully uninstalled")
78 | c.wait()
79 | assert c.terminated
80 | assert c.exitstatus == 0
81 |
82 | # installed files
83 | assert not os.path.exists(CONFIG)
84 | assert not os.path.exists(f"/usr/local/bin/{BOUNCER}")
85 |
86 | # the bouncer is unregistered
87 | assert len(list(cscli.get_bouncers(name=bouncer_name))) == 0
88 |
--------------------------------------------------------------------------------
/test/tests/pkg/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/crowdsecurity/cs-firewall-bouncer/9783ec8442d7aa11b8d092bd0bfd57edc39bb834/test/tests/pkg/__init__.py
--------------------------------------------------------------------------------
/test/tests/pkg/test_build_deb.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | pytestmark = pytest.mark.deb
4 |
5 |
6 | # This test has the side effect of building the package and leaving it in the
7 | # project's parent directory.
8 | def test_deb_build(deb_package, skip_unless_deb):
9 | """Test that the package can be built."""
10 | assert deb_package.exists(), f"Package {deb_package} not found"
11 |
--------------------------------------------------------------------------------
/test/tests/pkg/test_build_rpm.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | pytestmark = pytest.mark.rpm
4 |
5 |
6 | def test_rpm_build(rpm_package, skip_unless_rpm):
7 | """Test that the package can be built."""
8 | assert rpm_package.exists(), f"Package {rpm_package} not found"
9 |
--------------------------------------------------------------------------------
/test/tests/pkg/test_scripts_nonroot.py:
--------------------------------------------------------------------------------
1 | import os
2 | import subprocess
3 |
4 |
5 | def test_scripts_nonroot(project_repo, bouncer_binary, must_be_nonroot):
6 | assert os.geteuid() != 0, "This test must be run as non-root"
7 |
8 | for script in ["install.sh", "upgrade.sh", "uninstall.sh"]:
9 | c = subprocess.run(
10 | ["/usr/bin/sh", f"scripts/{script}"],
11 | stdout=subprocess.PIPE,
12 | stderr=subprocess.PIPE,
13 | cwd=project_repo,
14 | encoding="utf-8",
15 | )
16 |
17 | assert c.returncode == 1
18 | assert c.stdout == ""
19 | assert "This script must be run as root" in c.stderr
20 |
--------------------------------------------------------------------------------