├── .assets
├── f2b.png
├── gopher-alexandre_bossut-lasry.png
├── gopher-clement_david.png
├── gopher-martin_huvelle.png
└── gopher-tom_moulard.png
├── .github
├── dependabot.yml
└── workflows
│ ├── codeql.yml
│ ├── main.yml
│ └── release.yml
├── .gitignore
├── .golangci.yml
├── .goreleaser.yml
├── .traefik.yml
├── LICENSE
├── Makefile
├── README.md
├── ci
├── scripts
│ ├── check-local-banned.sh
│ ├── check-local-whited.sh
│ ├── check-no-rules.sh
│ ├── e2e.sh
│ └── rules.sh
└── yamls
│ ├── local-banned.yaml
│ ├── local-whited.yaml
│ ├── no-rules.yaml
│ └── traefik-ci.yaml
├── docker-compose.yml
├── fail2ban.go
├── fail2ban_test.go
├── go.mod
├── go.sum
├── pkg
├── chain
│ ├── chain.go
│ ├── chain_test.go
│ └── example_test.go
├── data
│ ├── data.go
│ └── data_test.go
├── fail2ban
│ ├── fail2ban.go
│ ├── fail2ban_test.go
│ └── handler
│ │ └── handler.go
├── ipchecking
│ ├── ipChecking.go
│ └── ipChecking_test.go
├── list
│ ├── allow
│ │ ├── allow.go
│ │ └── allow_test.go
│ └── deny
│ │ ├── deny.go
│ │ └── deny_test.go
├── response
│ └── status
│ │ ├── code_catcher.go
│ │ ├── http_code_range.go
│ │ ├── http_code_range_test.go
│ │ ├── status.go
│ │ └── status_test.go
├── rules
│ ├── rules.go
│ └── rules_test.go
├── url
│ ├── allow
│ │ ├── allow.go
│ │ └── allow_test.go
│ └── deny
│ │ ├── deny.go
│ │ └── deny_test.go
└── utils
│ └── time
│ ├── time-test.go
│ └── time.go
└── tests
└── test-ipfile.txt
/.assets/f2b.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tomMoulard/fail2ban/310a11cca144fd13c40de6c9201c004ae7dc8578/.assets/f2b.png
--------------------------------------------------------------------------------
/.assets/gopher-alexandre_bossut-lasry.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tomMoulard/fail2ban/310a11cca144fd13c40de6c9201c004ae7dc8578/.assets/gopher-alexandre_bossut-lasry.png
--------------------------------------------------------------------------------
/.assets/gopher-clement_david.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tomMoulard/fail2ban/310a11cca144fd13c40de6c9201c004ae7dc8578/.assets/gopher-clement_david.png
--------------------------------------------------------------------------------
/.assets/gopher-martin_huvelle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tomMoulard/fail2ban/310a11cca144fd13c40de6c9201c004ae7dc8578/.assets/gopher-martin_huvelle.png
--------------------------------------------------------------------------------
/.assets/gopher-tom_moulard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tomMoulard/fail2ban/310a11cca144fd13c40de6c9201c004ae7dc8578/.assets/gopher-tom_moulard.png
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | # Maintain dependencies for Go
9 | - package-ecosystem: "gomod"
10 | directory: "/"
11 | schedule:
12 | interval: "weekly"
13 |
14 | # Maintain dependencies for GitHub Actions
15 | - package-ecosystem: "github-actions"
16 | directory: "/"
17 | schedule:
18 | interval: "weekly"
19 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | name: "CodeQL"
2 |
3 | on:
4 | push:
5 | pull_request:
6 | schedule:
7 | - cron: '11 22 * * 1'
8 |
9 | jobs:
10 | analyze:
11 | name: Analyze
12 | runs-on: ubuntu-latest
13 | permissions:
14 | actions: read
15 | contents: read
16 | security-events: write
17 |
18 | strategy:
19 | fail-fast: false
20 | matrix:
21 | language: [ 'go' ]
22 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
23 | # Use only 'java' to analyze code written in Java, Kotlin or both
24 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
25 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
26 |
27 | steps:
28 | - name: Checkout repository
29 | uses: actions/checkout@v4
30 |
31 | # Initializes the CodeQL tools for scanning.
32 | - name: Initialize CodeQL
33 | uses: github/codeql-action/init@v3
34 | with:
35 | languages: ${{ matrix.language }}
36 | # If you wish to specify custom queries, you can do so here or in a config file.
37 | # By default, queries listed here will override any specified in a config file.
38 | # Prefix the list here with "+" to use these queries and those in the config file.
39 |
40 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
41 | # queries: security-extended,security-and-quality
42 |
43 |
44 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
45 | # If this step fails, then you should remove it and run the build manually (see below)
46 | - name: Autobuild
47 | uses: github/codeql-action/autobuild@v3
48 |
49 | # ℹ️ Command-line programs to run using the OS shell.
50 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
51 |
52 | # If the Autobuild fails above, remove it and uncomment the following three lines.
53 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
54 |
55 | # - run: |
56 | # echo "Run, Build Application using script"
57 | # ./location_of_script_within_repo/buildscript.sh
58 |
59 | - name: Perform CodeQL Analysis
60 | uses: github/codeql-action/analyze@v3
61 | with:
62 | category: "/language:${{matrix.language}}"
63 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Main
2 |
3 | on:
4 | push:
5 | pull_request:
6 |
7 | jobs:
8 |
9 | main:
10 | name: Main Process
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | # https://github.com/marketplace/actions/checkout
15 | - uses: actions/checkout@v4
16 |
17 | # https://github.com/marketplace/actions/setup-go-environment
18 | - uses: actions/setup-go@v5
19 | with:
20 | go-version: stable
21 |
22 | - name: Lint and Tests
23 | run: |
24 | make ci
25 | git diff --exit-code
26 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*'
7 | workflow_dispatch:
8 |
9 | jobs:
10 | goreleaser:
11 | permissions:
12 | contents: write
13 | packages: write
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v4
17 | with:
18 | fetch-depth: 0
19 |
20 | - uses: actions/setup-go@v5
21 | with:
22 | go-version: 'stable'
23 |
24 | - uses: docker/login-action@v3
25 | with:
26 | registry: ghcr.io
27 | username: ${{ github.actor }}
28 | password: ${{ secrets.GITHUB_TOKEN }}
29 |
30 | - run: go tool goreleaser release
31 | env:
32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33 |
34 | - uses: actions/upload-artifact@v4
35 | with:
36 | name: dist
37 | path: dist
38 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | vendor/
16 | traefik.yml
17 | traefik.yaml
18 |
19 | plugins-storage
20 |
21 | # Vim trash files
22 | *.swp
23 | test.html
24 | coverage.txt
25 |
26 | # goreleaser
27 | dist/
28 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | linters:
2 | enable-all: true
3 | disable:
4 | - varnamelen # Not relevant
5 | - lll # not relevant
6 | - exhaustruct # Not relevant
7 | - err113 # deprecated
8 | - gochecknoglobals
9 | - gochecknoinits # useless
10 | - ireturn # Not relevant
11 | - nilnil # Not relevant
12 | - testpackage # Too strict
13 | - cyclop # duplicate of gocyclo
14 | - exportloopref # deprecated
15 |
16 | linters-settings:
17 | misspell:
18 | locale: US
19 | funlen:
20 | lines: -1
21 | statements: 120
22 | depguard:
23 | rules:
24 | main:
25 | deny:
26 | - pkg: "github.com/instana/testify"
27 | desc: not allowed
28 | godox:
29 | keywords:
30 | - FIXME
31 | revive:
32 | rules:
33 | - name: struct-tag
34 | - name: blank-imports
35 | - name: context-as-argument
36 | - name: context-keys-type
37 | - name: dot-imports
38 | - name: error-return
39 | - name: error-strings
40 | - name: error-naming
41 | - name: exported
42 | disabled: true
43 | - name: if-return
44 | - name: increment-decrement
45 | - name: var-naming
46 | - name: var-declaration
47 | - name: package-comments
48 | disabled: true
49 | - name: range
50 | - name: receiver-naming
51 | - name: time-naming
52 | - name: unexported-return
53 | disabled: true
54 | - name: indent-error-flow
55 | - name: errorf
56 | - name: empty-block
57 | - name: superfluous-else
58 | - name: unused-parameter
59 | disabled: true
60 | - name: unreachable-code
61 | - name: redefines-builtin-id
62 | testpackage:
63 | allow-packages:
64 | - fail2ban
65 |
66 | issues:
67 | exclude-use-default: false
68 | max-issues-per-linter: 0
69 | max-same-issues: 0
70 | exclude-rules:
71 | - path: '(.+)_test.go'
72 | linters:
73 | - funlen
74 | - path: 'fail2ban.go'
75 | text: 'calculated cyclomatic complexity for function New is 13'
76 | linters:
77 | - cyclop
78 | - path: 'fail2ban.go'
79 | text: 'G304: Potential file inclusion via variable'
80 | linters:
81 | - gosec
82 | - text: 'use of `fmt.Printf` forbidden' # FIXME: add revert this change ASAP
83 | linters:
84 | - forbidigo
85 | - text: 'use of `fmt.Print` forbidden' # FIXME: add revert this change ASAP
86 | linters:
87 | - forbidigo
88 | - text: 'use of `fmt.Println` forbidden' # FIXME: add revert this change ASAP
89 | linters:
90 | - forbidigo
91 |
92 | output:
93 | show-stats: true
94 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | before:
4 | hooks:
5 | - go mod download
6 |
7 | builds:
8 | - skip: true
9 |
10 | release:
11 | github: {}
12 | prerelease: auto
13 | mode: append
14 | footer: "**Full Changelog**: https://github.com/tomMoulard/fail2ban/compare/{{ .PreviousTag }}...{{ .Tag }}"
15 | changelog:
16 | use: github
17 | sort: asc
18 | groups:
19 | - title: Features
20 | regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$'
21 | order: 0
22 | - title: "Bug fixes"
23 | regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
24 | order: 1
25 | - title: "Documentation"
26 | regexp: '^.*?doc(\([[:word:]]+\))??!?:.+$'
27 | order: 1
28 | - title: Others
29 | order: 999
30 | filters:
31 | exclude:
32 | - Merge branch
33 | - Merge pull request
34 | - Merge remote-tracking branch
35 | - chore
36 | - go mod tidy
37 | - merge conflict
38 | - test
39 | - typo
40 |
--------------------------------------------------------------------------------
/.traefik.yml:
--------------------------------------------------------------------------------
1 | displayName: Fail2Ban
2 | type: middleware
3 | iconPath: .assets/f2b.png
4 |
5 | import: github.com/tomMoulard/fail2ban
6 |
7 | summary: 'Fail2ban for Traefik'
8 |
9 | testData:
10 | allowlist:
11 | # allow requests from ::1 or 127.0.0.1
12 | ip: "::1,127.0.0.1"
13 | denylist:
14 | # do not allow requests from 192.168.0.0/24
15 | ip: "192.168.0.0/24"
16 | rules:
17 | # forbid users to make more than 4 requests per 10m
18 | bantime: "3h"
19 | findtime: "10m"
20 | maxretry: 4
21 | enabled: true
22 | statuscode: "400,401,403-499"
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Tom Moulard
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .POSIX: # the first failing command in a recipe will cause the recipe to fail immediately
2 |
3 | .PHONY: all
4 | all: spell lint build test
5 |
6 | .PHONY: ci
7 | ci: tidy all vulncheck
8 |
9 | .PHONY: lint
10 | lint:
11 | go tool goreleaser check
12 | go tool golangci-lint run
13 |
14 | .PHONY: test
15 | TEST_ARGS ?= -cover -race -tags DEBUG,TEST
16 | test:
17 | go test ${TEST_ARGS} ./...
18 |
19 | vendor:
20 | go mod vendor -v
21 |
22 | .PHONY: clean
23 | clean:
24 | $(RM) -r ./vendor
25 |
26 | .PHONY: yaegi_test
27 | YAEGI_TEST_ARGS ?= -v
28 | yaegi_test: vendor
29 | go run github.com/traefik/yaegi/cmd/yaegi@v0.16.1 test ${YAEGI_TEST_ARGS} .
30 |
31 | .PHONY: entr
32 | # https://github.com/eradman/entr
33 | entr:
34 | find | entr -r -s "docker compose up --remove-orphans"
35 |
36 | .PHONY: tidy
37 | tidy:
38 | go mod tidy
39 |
40 | .PHONY: spell
41 | spell:
42 | go tool misspell -error -locale=US -w **.md
43 |
44 | .PHONY: mod
45 | mod: ## go mod tidy
46 | go mod tidy
47 |
48 | .PHONY: vulncheck
49 | vulncheck:
50 | go tool govulncheck ./...
51 |
52 | .PHONY: build
53 | build:
54 | go tool goreleaser build --clean --single-target --snapshot
55 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Fail2ban plugin for traefik
2 |
3 | [](https://github.com/tomMoulard/fail2ban/actions/workflows/main.yml)
4 |
5 | This plugin is an implementation of a Fail2ban instance as a middleware
6 | plugin for Traefik.
7 |
8 | ## Middleware
9 |
10 | After installing the plugin, it can be configured through a Middleware, e.g.:
11 |
12 | ```yml
13 | apiVersion: traefik.io/v1alpha1
14 | kind: Middleware
15 | metadata:
16 | name: fail2ban-test
17 | spec:
18 | plugin:
19 | fail2ban:
20 | logLevel: DEBUG
21 | denylist:
22 | ip: 127.0.0.1
23 | ```
24 |
25 |
26 | Add the middleware to an ingressroute
27 |
28 | ```yml
29 | apiVersion: traefik.io/v1alpha1
30 | kind: IngressRoute
31 | metadata:
32 | name: simplecrd
33 | namespace: default
34 | spec:
35 | entryPoints:
36 | - web
37 | routes:
38 | - match: Host(`fail2ban.localhost`)
39 | kind: Rule
40 | middlewares:
41 | - name: fail2ban-test
42 | services:
43 | ...
44 | ```
45 |
46 |
47 |
48 | ## Configuration
49 |
50 | Please note that the allowlist and denylist functionality described below can
51 | _only_ be used _concurrently_ with Fail2ban functionality (if you are looking
52 | for a way to allowlist or denylist IPs without using any of the Fail2ban
53 | logic, you might want to use a different plugin.)
54 |
55 | ### Allowlist
56 | You can allowlist some IP using this:
57 | ```yml
58 | testData:
59 | allowlist:
60 | files:
61 | - "tests/test-ipfile.txt"
62 | ip:
63 | - "::1"
64 | - "127.0.0.1"
65 | ```
66 |
67 | Where you can use some IP in an array of files or directly in the
68 | configuration.
69 |
70 | If you have a single IP, this: `ip: 127.0.0.1` should also work.
71 |
72 | ### Denylist
73 | Like allowlist, you can denylist some IP using this:
74 | ```yml
75 | testData:
76 | denylist:
77 | files:
78 | - "tests/test-ipfile.txt"
79 | ip:
80 | - "::1"
81 | - "127.0.0.1"
82 | ```
83 |
84 | Where you can use some IP in an array of files or directly in the
85 | configuration.
86 |
87 | Please note that Fail2ban logs will _only_ be visible when Traefik's log level
88 | is set to `DEBUG`.
89 |
90 | ## Fail2ban
91 | We plan to use all default fail2ban configuration but at this time only a
92 | few features are implemented:
93 | ```yml
94 | testData:
95 | rules:
96 | urlregexps:
97 | - regexp: "/no"
98 | mode: block
99 | - regexp: "/yes"
100 | mode: allow
101 | bantime: "3h"
102 | findtime: "10m"
103 | maxretry: 4
104 | enabled: true
105 | statuscode: "400,401,403-499"
106 | ```
107 |
108 | Where:
109 | - `findtime`: is the time slot used to count requests (if there is too many
110 | requests with the same ip in this slot of time, the ip goes into ban). You can
111 | use 'smart' strings: "4h", "2m", "1s", ...
112 | - `bantime`: correspond to the amount of time the IP is in Ban mode.
113 | - `maxretry`: number of request before Ban mode.
114 | - `enabled`: allow to enable or disable the plugin (must be set to `true` to
115 | enable the plugin).
116 | - `urlregexp`: a regexp list to block / allow requests with regexps on the url
117 | - `statuscode`: a comma separated list of status code (or range of status
118 | codes) to consider as a failed request.
119 |
120 | #### URL Regexp
121 | Urlregexp are used to defined witch part of your website will be either
122 | allowed, blocked or filtered :
123 | - allow : all requests where the url match the regexp will be forwarded to the
124 | backend without any check
125 | - block : all requests where the url match the regexp will be stopped
126 |
127 | ##### No definitions
128 |
129 | ```yml
130 | testData:
131 | rules: {}
132 | ```
133 |
134 | By default, fail2ban will be applied.
135 |
136 | ##### Multiple definition
137 |
138 | ```yml
139 | testData:
140 | rules:
141 | urlregexps:
142 | - regexp: "/whoami"
143 | mode: allow
144 | - regexp: "/do-not-access"
145 | mode: block
146 | ```
147 |
148 | In the case where you define multiple regexp on the same url, the order of
149 | process will be :
150 | 1. Block
151 | 2. Allow
152 |
153 | In this example, all requests to `/do-not-access` will be denied and all
154 | requests to `/whoami` will be allowed without any fail2ban interaction.
155 |
156 | #### Status code
157 | When this configuration is set (i.e., `statuscode` is not empty), the plugin
158 | will wait for the request to be completed and check the status code of the
159 | response. If the status code is in the list of status codes, the request will
160 | be considered as a failed request.
161 |
162 | Note that the request is considered completed when the response is back sent to the
163 | plugin, therefore, the request went through the middleware, traefik, to the backend,
164 | and back to the middleware.
165 |
166 |
167 | Here is a little schema to explain the process
168 |
169 | ```mermaid
170 | sequenceDiagram
171 | actor C as Client
172 | participant A as Middleware
173 | participant B as Backend
174 | C->>A: Request
175 | A->>B: Request
176 | B->>A: Response
177 | A->>A: Check status code
178 | critical [Check status code]
179 | option Invalid status code
180 | A--X C: Log error
181 | option valid status code
182 | A->>C: Log error
183 | end
184 | ```
185 |
186 |
187 |
188 | #### Schema
189 | First request, IP is added to the Pool, and the `findtime` timer is started:
190 | ```
191 | A |------------->
192 | ↑
193 | ```
194 |
195 | Second request, `findtime` is not yet finished thus the request is fine:
196 | ```
197 | A |--x---------->
198 | ↑
199 | ```
200 |
201 | Third request, `maxretry` is now full, this request is fine but the next wont.
202 | ```
203 | A |--x--x------->
204 | ↑
205 | ```
206 |
207 | Fourth request, too bad, now it's jail time, next request will go through after
208 | `bantime`:
209 | ```
210 | A |--x--x--x---->
211 | ↓
212 | B |------------->
213 | ```
214 |
215 | Fifth request, the IP is in Ban mode, nothing happen:
216 | ```
217 | A |--x--x--x---->
218 | B |--x---------->
219 | ↑
220 | ```
221 |
222 | Last request, the `bantime` is now over, another `findtime` is started:
223 | ```
224 | A |--x--x--x----> |------------->
225 | ↑
226 | B |--x---------->
227 | ```
228 |
229 | ## How to dev
230 |
231 | ```bash
232 | $ docker compose up
233 | ```
234 |
235 | # Authors
236 | | Tom Moulard | Clément David | Martin Huvelle | Alexandre Bossut-Lasry |
237 | |-------------|---------------|----------------|------------------------|
238 | |[](https://tom.moulard.org)|[](https://github.com/cledavid)|[](https://github.com/nitra-mfs)|[](https://www.linkedin.com/in/alexandre-bossut-lasry/)|
239 |
--------------------------------------------------------------------------------
/ci/scripts/check-local-banned.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -ev
3 |
4 | grep "denylisted: '127.0.0.1/32'" ci/inside_ci/logs.all && echo 'OK'
5 |
6 | grep "127.0.0.1 is denylisted" ci/inside_ci/logs.all && echo 'OK'
7 |
8 | grep "127.0.0.1 is now banned temporarily" ci/inside_ci/logs.all || echo 'OK'
--------------------------------------------------------------------------------
/ci/scripts/check-local-whited.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -ev
3 |
4 | grep "Allowlisted: '127.0.0.1/32'" ci/inside_ci/logs.all && echo 'OK'
5 |
6 | grep "127.0.0.1 is denylisted" ci/inside_ci/logs.all || echo 'OK'
7 |
8 | grep "127.0.0.1 is now banned temporarily" ci/inside_ci/logs.all || echo 'OK'
--------------------------------------------------------------------------------
/ci/scripts/check-no-rules.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -ev
3 |
4 | grep "127.0.0.1 is now banned temporarily" ci/inside_ci/logs.all && echo 'OK'
--------------------------------------------------------------------------------
/ci/scripts/e2e.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -ev
3 |
4 | docker run -d --network host containous/whoami -port 5000
5 |
6 | curl -L -O https://github.com/traefik/traefik/releases/download/v2.3.6/traefik_v2.3.6_linux_amd64.tar.gz
7 | tar -zxvf traefik_v2.3.6_linux_amd64.tar.gz
8 |
9 | sed -i "/goPath:/ s;$; $GOPATH;" "ci/yamls/traefik-ci.yaml"
10 |
11 | mkdir ci/inside_ci
12 |
13 | ./ci/scripts/rules.sh no-rules
14 |
15 | ./ci/scripts/rules.sh local-banned
16 |
17 | ./ci/scripts/rules.sh local-allowd
--------------------------------------------------------------------------------
/ci/scripts/rules.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -ev
3 |
4 | echo "########### $1 # START ##############"
5 |
6 | sed "/filename:/ s;$; ci/yamls/$1.yaml;" "ci/yamls/traefik-ci.yaml" > ci/inside_ci/ci-$1.yaml
7 |
8 | timeout 20s ./traefik --configfile ci/inside_ci/ci-$1.yaml 1> ci/inside_ci/logs.all || echo 'timeout traefik' &
9 |
10 | sleep 5
11 |
12 | curl 'http://localhost:8000/whoami'
13 | curl 'http://localhost:8000/whoami'
14 | curl 'http://localhost:8000/whoami'
15 | curl 'http://localhost:8000/whoami'
16 | curl 'http://localhost:8000/whoami'
17 | curl 'http://localhost:8000/whoami'
18 | curl 'http://localhost:8000/whoami'
19 | curl 'http://localhost:8000/whoami'
20 | curl 'http://localhost:8000/whoami'
21 | curl 'http://localhost:8000/whoami'
22 | curl 'http://localhost:8000/whoami'
23 | curl 'http://localhost:8000/whoami'
24 | curl 'http://localhost:8000/whoami'
25 | curl 'http://localhost:8000/whoami'
26 | curl 'http://localhost:8000/yes'
27 | curl 'http://localhost:8000/no'
28 | curl 'http://localhost:8000/blocked'
29 |
30 |
31 | sleep 20
32 |
33 | cat ci/inside_ci/logs.all
34 |
35 | ./ci/scripts/check-$1.sh
36 |
37 | echo "########### $1 # END ##############"
38 |
--------------------------------------------------------------------------------
/ci/yamls/local-banned.yaml:
--------------------------------------------------------------------------------
1 | # Template for configuration
2 |
3 | http:
4 | routers:
5 | my-router:
6 | middlewares:
7 | - fail2ban
8 | entrypoints:
9 | - http
10 | service: service-whoami
11 | rule: Path(`/whoami`)
12 |
13 | services:
14 | service-whoami:
15 | loadBalancer:
16 | servers:
17 | - url: http://localhost:5000
18 | passHostHeader: false
19 | middlewares:
20 | fail2ban:
21 | plugin:
22 | dev:
23 | denylist:
24 | ip:
25 | - "127.0.0.1"
26 | rules:
27 | urlregexps:
28 | - regexp: "/blocked"
29 | mode: block
30 | bantime: "3h"
31 | enabled: true
32 | findtime: "3h"
33 | maxretry: 4
34 |
--------------------------------------------------------------------------------
/ci/yamls/local-whited.yaml:
--------------------------------------------------------------------------------
1 | # Template for configuration
2 |
3 | http:
4 | routers:
5 | my-router:
6 | middlewares:
7 | - fail2ban
8 | entrypoints:
9 | - http
10 | service: service-whoami
11 | rule: Path(`/whoami`)
12 |
13 | services:
14 | service-whoami:
15 | loadBalancer:
16 | servers:
17 | - url: http://localhost:5000
18 | passHostHeader: false
19 | middlewares:
20 | fail2ban:
21 | plugin:
22 | dev:
23 | allowlist:
24 | ip:
25 | - "127.0.0.1"
26 | rules:
27 | urlregexps:
28 | - regexp: "/blocked"
29 | mode: block
30 | bantime: "3h"
31 | enabled: true
32 | findtime: "3h"
33 | maxretry: 4
34 |
--------------------------------------------------------------------------------
/ci/yamls/no-rules.yaml:
--------------------------------------------------------------------------------
1 | # Template for configuration
2 |
3 | http:
4 | routers:
5 | my-router:
6 | middlewares:
7 | - fail2ban
8 | entrypoints:
9 | - http
10 | service: service-whoami
11 | rule: Path(`/whoami`)
12 |
13 | services:
14 | service-whoami:
15 | loadBalancer:
16 | servers:
17 | - url: http://localhost:5000
18 | passHostHeader: false
19 | middlewares:
20 | fail2ban:
21 | plugin:
22 | dev:
23 | rules:
24 | urlregexps:
25 | - regexp: "/blocked"
26 | mode: block
27 | bantime: "3h"
28 | enabled: true
29 | findtime: "3h"
30 | maxretry: 4
31 |
--------------------------------------------------------------------------------
/ci/yamls/traefik-ci.yaml:
--------------------------------------------------------------------------------
1 | pilot:
2 | token: "1e10ba4b-7aa1-4af9-a568-510751e3a142"
3 |
4 | experimental:
5 | devPlugin:
6 | goPath:
7 | moduleName: github.com/tomMoulard/fail2ban
8 |
9 | entryPoints:
10 | http:
11 | address: ":8000"
12 | forwardedHeaders:
13 | insecure: true
14 |
15 | api:
16 | dashboard: true
17 | insecure: true
18 |
19 | providers:
20 | file:
21 | filename:
22 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | traefik:
3 | image: traefik:v3.3
4 | command:
5 | - --api.insecure=true
6 | - --providers.docker
7 | - --log.level=DEBUG
8 | - --accesslog
9 | - --experimental.localPlugins.fail2ban-local.moduleName=github.com/tomMoulard/fail2ban
10 | - --experimental.plugins.fail2ban-registery.modulename=github.com/tomMoulard/fail2ban
11 | - --experimental.plugins.fail2ban-registery.version=v0.8.3
12 | ports:
13 | - 80:80
14 | - 8080:8080
15 | volumes:
16 | - /var/run/docker.sock:/var/run/docker.sock
17 | - .:/plugins-local/src/github.com/tomMoulard/fail2ban/
18 | tty: true
19 |
20 | whoami:
21 | image: traefik/whoami # https://github.com/traefik/whoami
22 | command: >-
23 | -name whoami -verbose true
24 | labels:
25 | traefik.http.routers.fail2ban-local.rule: Host(`fail2ban-local.localhost`)
26 | traefik.http.routers.fail2ban-local.middlewares: fail2ban-local
27 | traefik.http.middlewares.fail2ban-local.plugin.fail2ban-local.rules.enabled: true
28 | traefik.http.middlewares.fail2ban-local.plugin.fail2ban-local.rules.bantime: 3h
29 | traefik.http.middlewares.fail2ban-local.plugin.fail2ban-local.rules.findtime: 3h
30 | traefik.http.middlewares.fail2ban-local.plugin.fail2ban-local.rules.maxretry: 4
31 | traefik.http.middlewares.fail2ban-local.plugin.fail2ban-local.allowlist.ip: 127.0.0.2
32 | traefik.http.middlewares.fail2ban-local.plugin.fail2ban-local.denylist.ip: 127.0.0.3
33 | traefik.http.middlewares.fail2ban-local.plugin.fail2ban-local.rules.urlregexps[0].regexp: /no
34 | traefik.http.middlewares.fail2ban-local.plugin.fail2ban-local.rules.urlregexps[0].mode: block
35 | traefik.http.middlewares.fail2ban-local.plugin.fail2ban-local.rules.urlregexps[1].regexp: /yes
36 | traefik.http.middlewares.fail2ban-local.plugin.fail2ban-local.rules.urlregexps[1].mode: allow
37 | traefik.http.middlewares.fail2ban-local.plugin.fail2ban-local.rules.statuscode: "400,401,403-499"
38 |
39 | traefik.http.routers.fail2ban-registery.rule: Host(`fail2ban-registery.localhost`)
40 | traefik.http.routers.fail2ban-registery.middlewares: fail2ban-registery
41 | traefik.http.middlewares.fail2ban-registery.plugin.fail2ban-registery.enabled: true
42 |
--------------------------------------------------------------------------------
/fail2ban.go:
--------------------------------------------------------------------------------
1 | // Package fail2ban contains the Fail2ban mechanism for the plugin.
2 | package fail2ban
3 |
4 | import (
5 | "context"
6 | "fmt"
7 | "log"
8 | "net/http"
9 | "os"
10 | "strings"
11 |
12 | "github.com/tomMoulard/fail2ban/pkg/chain"
13 | "github.com/tomMoulard/fail2ban/pkg/fail2ban"
14 | f2bHandler "github.com/tomMoulard/fail2ban/pkg/fail2ban/handler"
15 | lAllow "github.com/tomMoulard/fail2ban/pkg/list/allow"
16 | lDeny "github.com/tomMoulard/fail2ban/pkg/list/deny"
17 | "github.com/tomMoulard/fail2ban/pkg/response/status"
18 | "github.com/tomMoulard/fail2ban/pkg/rules"
19 | uAllow "github.com/tomMoulard/fail2ban/pkg/url/allow"
20 | uDeny "github.com/tomMoulard/fail2ban/pkg/url/deny"
21 | )
22 |
23 | func init() {
24 | log.SetOutput(os.Stdout)
25 | }
26 |
27 | // List struct.
28 | type List struct {
29 | IP []string
30 | Files []string
31 | }
32 |
33 | // Config struct.
34 | type Config struct {
35 | Denylist List `yaml:"denylist"`
36 | Allowlist List `yaml:"allowlist"`
37 | Rules rules.Rules `yaml:"port"`
38 |
39 | // deprecated
40 | Blacklist List `yaml:"blacklist"`
41 | // deprecated
42 | Whitelist List `yaml:"whitelist"`
43 | }
44 |
45 | // CreateConfig populates the Config data object.
46 | func CreateConfig() *Config {
47 | return &Config{
48 | Rules: rules.Rules{
49 | Bantime: "300s",
50 | Findtime: "120s",
51 | Enabled: true,
52 | },
53 | }
54 | }
55 |
56 | // ImportIP extract all ip from config sources.
57 | func ImportIP(list List) ([]string, error) {
58 | var rlist []string
59 |
60 | for _, ip := range list.Files {
61 | content, err := os.ReadFile(ip)
62 | if err != nil {
63 | return nil, fmt.Errorf("error when getting file content: %w", err)
64 | }
65 |
66 | rlist = append(rlist, strings.Split(string(content), "\n")...)
67 | if len(rlist) > 1 {
68 | rlist = rlist[:len(rlist)-1]
69 | }
70 | }
71 |
72 | rlist = append(rlist, list.IP...)
73 |
74 | return rlist, nil
75 | }
76 |
77 | // New instantiates and returns the required components used to handle a HTTP
78 | // request.
79 | func New(_ context.Context, next http.Handler, config *Config, _ string) (http.Handler, error) {
80 | if !config.Rules.Enabled {
81 | log.Println("Plugin: FailToBan is disabled")
82 |
83 | return next, nil
84 | }
85 |
86 | allowIPs, err := ImportIP(config.Allowlist)
87 | if err != nil {
88 | return nil, fmt.Errorf("failed to parse allowlist IPs: %w", err)
89 | }
90 |
91 | if len(config.Whitelist.IP) > 0 || len(config.Whitelist.Files) > 0 {
92 | log.Println("Plugin: FailToBan: 'whitelist' is deprecated, please use 'denylist' instead")
93 |
94 | whiteips, err := ImportIP(config.Whitelist)
95 | if err != nil {
96 | return nil, fmt.Errorf("failed to parse whitelist IPs: %w", err)
97 | }
98 |
99 | allowIPs = append(allowIPs, whiteips...)
100 | }
101 |
102 | allowHandler, err := lAllow.New(allowIPs)
103 | if err != nil {
104 | return nil, fmt.Errorf("failed to parse whitelist IPs: %w", err)
105 | }
106 |
107 | denyIPs, err := ImportIP(config.Denylist)
108 | if err != nil {
109 | return nil, fmt.Errorf("failed to parse denylist IPs: %w", err)
110 | }
111 |
112 | if len(config.Blacklist.IP) > 0 || len(config.Blacklist.Files) > 0 {
113 | log.Println("Plugin: FailToBan: 'blacklist' is deprecated, please use 'denylist' instead")
114 |
115 | blackips, err := ImportIP(config.Blacklist)
116 | if err != nil {
117 | return nil, fmt.Errorf("failed to parse blacklist IPs: %w", err)
118 | }
119 |
120 | denyIPs = append(denyIPs, blackips...)
121 | }
122 |
123 | denyHandler, err := lDeny.New(denyIPs)
124 | if err != nil {
125 | return nil, fmt.Errorf("failed to parse blacklist IPs: %w", err)
126 | }
127 |
128 | rules, err := rules.TransformRule(config.Rules)
129 | if err != nil {
130 | return nil, fmt.Errorf("error when Transforming rules: %w", err)
131 | }
132 |
133 | log.Println("Plugin: FailToBan is up and running")
134 |
135 | f2b := fail2ban.New(rules)
136 |
137 | c := chain.New(
138 | next,
139 | denyHandler,
140 | allowHandler,
141 | uDeny.New(rules.URLRegexpBan, f2b),
142 | uAllow.New(rules.URLRegexpAllow),
143 | f2bHandler.New(f2b),
144 | )
145 |
146 | if rules.StatusCode != "" {
147 | statusCodeHandler, err := status.New(next, rules.StatusCode, f2b)
148 | if err != nil {
149 | return nil, fmt.Errorf("failed to create status handler: %w", err)
150 | }
151 |
152 | c.WithStatus(statusCodeHandler)
153 | }
154 |
155 | return c, nil
156 | }
157 |
--------------------------------------------------------------------------------
/fail2ban_test.go:
--------------------------------------------------------------------------------
1 | package fail2ban
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "io"
7 | "net/http"
8 | "net/http/httptest"
9 | "strconv"
10 | "strings"
11 | "sync/atomic"
12 | "testing"
13 |
14 | "github.com/stretchr/testify/assert"
15 | "github.com/tomMoulard/fail2ban/pkg/rules"
16 | "golang.org/x/net/websocket"
17 | )
18 |
19 | func TestDummy(t *testing.T) {
20 | t.Parallel()
21 |
22 | cfg := CreateConfig()
23 | t.Log(cfg)
24 | }
25 |
26 | func TestImportIP(t *testing.T) {
27 | t.Parallel()
28 |
29 | tests := []struct {
30 | name string
31 | list List
32 | strWant []string
33 | err error
34 | }{
35 | {
36 | name: "empty list",
37 | list: List{
38 | IP: []string{},
39 | Files: []string{},
40 | },
41 | strWant: []string{},
42 | err: nil,
43 | },
44 |
45 | {
46 | name: "simple import",
47 | list: List{
48 | IP: []string{"192.168.0.0", "0.0.0.0", "255.255.255.255"},
49 | Files: []string{"tests/test-ipfile.txt"},
50 | },
51 | strWant: []string{"192.168.0.0", "255.0.0.0", "42.42.42.42", "13.38.70.00", "192.168.0.0", "0.0.0.0", "255.255.255.255"},
52 | err: nil,
53 | },
54 |
55 | {
56 | name: "import only file",
57 | list: List{
58 | IP: []string{},
59 | Files: []string{"tests/test-ipfile.txt"},
60 | },
61 | strWant: []string{"192.168.0.0", "255.0.0.0", "42.42.42.42", "13.38.70.00"},
62 | err: nil,
63 | },
64 |
65 | {
66 | name: "import two file",
67 | list: List{
68 | IP: []string{},
69 | Files: []string{"tests/test-ipfile.txt", "tests/test-ipfile.txt"},
70 | },
71 | strWant: []string{"192.168.0.0", "255.0.0.0", "42.42.42.42", "13.38.70.00", "192.168.0.0", "255.0.0.0", "42.42.42.42", "13.38.70.00"},
72 | err: nil,
73 | },
74 |
75 | {
76 | name: "import only ip",
77 | list: List{
78 | IP: []string{"192.168.0.0", "0.0.0.0", "255.255.255.255"},
79 | Files: []string{},
80 | },
81 | strWant: []string{"192.168.0.0", "0.0.0.0", "255.255.255.255"},
82 | err: nil,
83 | },
84 |
85 | {
86 | name: "import no file",
87 | list: List{
88 | IP: []string{},
89 | Files: []string{"tests/idontexist.txt"},
90 | },
91 | strWant: []string{},
92 | err: errors.New("error when getting file content: open tests/idontexist.txt: no such file or directory"),
93 | },
94 | }
95 | for _, test := range tests {
96 | t.Run(test.name, func(t *testing.T) {
97 | t.Parallel()
98 |
99 | got, e := ImportIP(test.list)
100 | t.Logf("%+v", got)
101 |
102 | if e != nil && e.Error() != test.err.Error() {
103 | t.Errorf("wanted %q got %q", test.err, e)
104 | }
105 |
106 | if len(got) != len(test.strWant) {
107 | t.Errorf("wanted '%d' got '%d'", len(test.strWant), len(got))
108 | }
109 |
110 | for i, elt := range test.strWant {
111 | if got[i] != elt {
112 | t.Errorf("wanted %q got %q", elt, got[i])
113 | }
114 | }
115 | })
116 | }
117 | }
118 |
119 | func TestFail2Ban(t *testing.T) {
120 | t.Parallel()
121 |
122 | remoteAddr := "10.0.0.0"
123 | tests := []struct {
124 | name string
125 | url string
126 | cfg *Config
127 | newError bool
128 | expectStatus int
129 | }{
130 | {
131 | name: "no bantime",
132 | cfg: &Config{
133 | Rules: rules.Rules{
134 | Enabled: true,
135 | Findtime: "300s",
136 | Maxretry: 20,
137 | },
138 | },
139 | newError: true,
140 | expectStatus: http.StatusOK,
141 | },
142 | {
143 | name: "no findtime",
144 | cfg: &Config{
145 | Rules: rules.Rules{
146 | Enabled: true,
147 | Bantime: "300s",
148 | Maxretry: 20,
149 | },
150 | },
151 | newError: true,
152 | expectStatus: http.StatusOK,
153 | },
154 | {
155 | name: "rule enabled",
156 | cfg: &Config{
157 | Rules: rules.Rules{
158 | Enabled: true,
159 | Bantime: "300s",
160 | Findtime: "300s",
161 | Maxretry: 20,
162 | },
163 | },
164 | newError: false,
165 | expectStatus: http.StatusOK,
166 | },
167 | {
168 | name: "rule not enabled beside being denylisted",
169 | cfg: &Config{
170 | Rules: rules.Rules{
171 | Enabled: false,
172 | },
173 | Denylist: List{
174 | IP: []string{remoteAddr},
175 | },
176 | },
177 | newError: false,
178 | expectStatus: http.StatusOK,
179 | },
180 | {
181 | name: "bad regexp",
182 | url: "/test",
183 | cfg: &Config{
184 | Rules: rules.Rules{
185 | Enabled: true,
186 | Bantime: "300s",
187 | Findtime: "300s",
188 | Maxretry: 10,
189 | Urlregexps: []rules.Urlregexp{
190 | {
191 | Regexp: "/(test",
192 | Mode: "allow",
193 | },
194 | },
195 | },
196 | },
197 | newError: true,
198 | },
199 | {
200 | name: "invalid Regexp mode",
201 | url: "/test",
202 | cfg: &Config{
203 | Rules: rules.Rules{
204 | Enabled: true,
205 | Bantime: "300s",
206 | Findtime: "300s",
207 | Maxretry: 20,
208 | Urlregexps: []rules.Urlregexp{
209 | {
210 | Regexp: "/test",
211 | Mode: "not-an-actual-mode",
212 | },
213 | },
214 | },
215 | },
216 | newError: false,
217 | expectStatus: http.StatusOK, // request not denylisted
218 | },
219 | {
220 | name: "url allowlisted",
221 | url: "/test",
222 | cfg: &Config{
223 | Rules: rules.Rules{
224 | Enabled: true,
225 | Bantime: "300s",
226 | Findtime: "300s",
227 | Maxretry: 10,
228 | Urlregexps: []rules.Urlregexp{
229 | {
230 | Regexp: "/test",
231 | Mode: "allow",
232 | },
233 | },
234 | },
235 | },
236 | newError: false,
237 | expectStatus: http.StatusOK,
238 | },
239 | {
240 | name: "url denylisted",
241 | url: "/test",
242 | cfg: &Config{
243 | Rules: rules.Rules{
244 | Enabled: true,
245 | Bantime: "300s",
246 | Findtime: "300s",
247 | Maxretry: 10,
248 | Urlregexps: []rules.Urlregexp{
249 | {
250 | Regexp: "/test",
251 | Mode: "block",
252 | },
253 | },
254 | },
255 | },
256 | newError: false,
257 | expectStatus: http.StatusForbidden,
258 | },
259 | {
260 | name: "allowlist",
261 | cfg: &Config{
262 | Rules: rules.Rules{
263 | Enabled: true,
264 | Bantime: "300s",
265 | Findtime: "300s",
266 | Maxretry: 20,
267 | },
268 | Allowlist: List{
269 | IP: []string{remoteAddr},
270 | },
271 | },
272 | newError: false,
273 | expectStatus: http.StatusOK,
274 | },
275 | {
276 | name: "denylist",
277 | cfg: &Config{
278 | Rules: rules.Rules{
279 | Enabled: true,
280 | Bantime: "300s",
281 | Findtime: "300s",
282 | Maxretry: 20,
283 | },
284 | Denylist: List{
285 | IP: []string{remoteAddr},
286 | },
287 | },
288 | newError: false,
289 | expectStatus: http.StatusForbidden,
290 | },
291 | }
292 |
293 | for _, test := range tests {
294 | t.Run(test.name, func(t *testing.T) {
295 | t.Parallel()
296 |
297 | nextCount := atomic.Int32{}
298 | next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
299 | w.WriteHeader(http.StatusOK)
300 | nextCount.Add(1)
301 | })
302 |
303 | handler, err := New(t.Context(), next, test.cfg, "fail2ban_test")
304 | if err != nil {
305 | if test.newError != (err != nil) {
306 | t.Errorf("newError: wanted '%t' got '%t'", test.newError, err != nil)
307 | }
308 |
309 | return
310 | }
311 |
312 | url := "/"
313 | if test.url != "" {
314 | url = test.url
315 | }
316 |
317 | req := httptest.NewRequest(http.MethodGet, url, nil)
318 | req.RemoteAddr = remoteAddr + ":1234"
319 |
320 | for range 10 {
321 | rw := httptest.NewRecorder()
322 | handler.ServeHTTP(rw, req)
323 |
324 | if rw.Code != test.expectStatus {
325 | t.Fatalf("code: got %d, expected %d", rw.Code, test.expectStatus)
326 | }
327 | }
328 | })
329 | }
330 | }
331 |
332 | // https://github.com/tomMoulard/fail2ban/issues/67
333 | func TestDeadlockWebsocket(t *testing.T) {
334 | t.Parallel()
335 |
336 | writeChan := make(chan any)
337 | concurentWSCount := atomic.Int32{}
338 | next := websocket.Handler(func(ws *websocket.Conn) {
339 | concurentWSCount.Add(1)
340 | <-writeChan
341 | t.Cleanup(func() {
342 | concurentWSCount.Add(-1)
343 | })
344 |
345 | _, _ = io.Copy(ws, ws)
346 | })
347 |
348 | cfg := CreateConfig()
349 | cfg.Rules.Maxretry = 20
350 |
351 | handler, err := New(t.Context(), next, cfg, "fail2ban_test")
352 | if err != nil {
353 | t.Fatal(err)
354 | }
355 |
356 | s := httptest.NewServer(handler)
357 | defer s.Close()
358 |
359 | wsURL := "ws" + strings.TrimPrefix(s.URL, "http")
360 | conns := make([]*websocket.Conn, 10)
361 |
362 | for i := range 10 {
363 | ws, err := websocket.Dial(wsURL, "", "http://localhost")
364 | if err != nil {
365 | t.Fatal(err)
366 | }
367 |
368 | defer func() { _ = ws.Close() }()
369 |
370 | conns[i] = ws
371 | }
372 |
373 | close(writeChan)
374 |
375 | for i := range 10 {
376 | msg := fmt.Sprintf("hello %d", i)
377 |
378 | n, err := conns[i].Write([]byte(msg))
379 | if err != nil {
380 | t.Fatal(err)
381 | }
382 |
383 | p := make([]byte, n)
384 |
385 | _, err = conns[i].Read(p)
386 | if err != nil {
387 | t.Fatal(err)
388 | }
389 |
390 | if msg != string(p) {
391 | t.Errorf("wanted %q got %q", msg, string(p))
392 | }
393 | }
394 |
395 | if concurentWSCount.Load() != 10 {
396 | t.Errorf("wanted %d got %d", 10, concurentWSCount.Load())
397 | }
398 | }
399 |
400 | func TestFail2Ban_SuccessiveRequests(t *testing.T) {
401 | t.Parallel()
402 |
403 | remoteAddr := "10.0.0.0"
404 | tests := []struct {
405 | name string
406 | cfg *Config
407 | handlerStatus []int // HTTP code the internal HTTP handler should return
408 | expectStatus []int // HTTP code the downstream client should request after passing through fail2ban
409 | }{
410 | {
411 | name: "rule enabled, 200 code does not increment count or ban",
412 | cfg: &Config{
413 | Rules: rules.Rules{
414 | Enabled: true,
415 | Bantime: "300s",
416 | Findtime: "300s",
417 | Maxretry: 3,
418 | StatusCode: "404",
419 | },
420 | },
421 | // multiple OKs in a row should not result in a ban
422 | handlerStatus: []int{http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
423 | expectStatus: []int{http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
424 | },
425 | {
426 | name: "rule enabled, single 404 does not ban",
427 | cfg: &Config{
428 | Rules: rules.Rules{
429 | Enabled: true,
430 | Bantime: "300s",
431 | Findtime: "300s",
432 | Maxretry: 3,
433 | StatusCode: "404",
434 | },
435 | },
436 | handlerStatus: []int{http.StatusNotFound, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
437 | expectStatus: []int{http.StatusNotFound, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
438 | },
439 | {
440 | name: "rule enabled, multiple 404 causes ban",
441 | cfg: &Config{
442 | Rules: rules.Rules{
443 | Enabled: true,
444 | Bantime: "300s",
445 | Findtime: "300s",
446 | Maxretry: 3,
447 | StatusCode: "404",
448 | },
449 | },
450 | // the remaining OKs will not reach the client as it is banned
451 | handlerStatus: []int{http.StatusNotFound, http.StatusOK, http.StatusNotFound, http.StatusNotFound, http.StatusOK, http.StatusOK, http.StatusOK, http.StatusOK},
452 | expectStatus: []int{http.StatusNotFound, http.StatusOK, http.StatusNotFound, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden},
453 | },
454 | }
455 |
456 | for _, test := range tests {
457 | t.Run(test.name, func(t *testing.T) {
458 | t.Parallel()
459 |
460 | next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
461 | testno, err := strconv.Atoi(r.Header.Get("Testno"))
462 | assert.NoError(t, err)
463 |
464 | w.WriteHeader(testno)
465 | })
466 |
467 | handler, _ := New(t.Context(), next, test.cfg, "fail2ban_test")
468 |
469 | req := httptest.NewRequest(http.MethodGet, "/", nil)
470 | req.RemoteAddr = remoteAddr + ":1234"
471 |
472 | for i := range test.handlerStatus {
473 | rw := httptest.NewRecorder()
474 |
475 | req.Header.Set("Testno", strconv.Itoa(test.handlerStatus[i])) // pass the expected value to the mock handler (fail2ban response code may differ)
476 | handler.ServeHTTP(rw, req)
477 |
478 | assert.Equal(t, test.expectStatus[i], rw.Code, "request [%d] code", i)
479 | }
480 | })
481 | }
482 | }
483 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/tomMoulard/fail2ban
2 |
3 | go 1.24
4 |
5 | require (
6 | github.com/stretchr/testify v1.10.0
7 | golang.org/x/net v0.40.0
8 | )
9 |
10 | tool (
11 | github.com/golangci/golangci-lint/cmd/golangci-lint
12 | github.com/golangci/misspell/cmd/misspell
13 | github.com/goreleaser/goreleaser/v2
14 | golang.org/x/vuln/cmd/govulncheck
15 | )
16 |
17 | require (
18 | 4d63.com/gocheckcompilerdirectives v1.2.1 // indirect
19 | 4d63.com/gochecknoglobals v0.2.1 // indirect
20 | cel.dev/expr v0.16.1 // indirect
21 | cloud.google.com/go v0.115.1 // indirect
22 | cloud.google.com/go/auth v0.9.4 // indirect
23 | cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect
24 | cloud.google.com/go/compute/metadata v0.6.0 // indirect
25 | cloud.google.com/go/iam v1.2.1 // indirect
26 | cloud.google.com/go/kms v1.19.0 // indirect
27 | cloud.google.com/go/longrunning v0.6.1 // indirect
28 | cloud.google.com/go/monitoring v1.21.0 // indirect
29 | cloud.google.com/go/storage v1.45.0 // indirect
30 | code.gitea.io/sdk/gitea v0.20.0 // indirect
31 | dario.cat/mergo v1.0.1 // indirect
32 | github.com/42wim/httpsig v1.2.1 // indirect
33 | github.com/4meepo/tagalign v1.4.1 // indirect
34 | github.com/Abirdcfly/dupword v0.1.3 // indirect
35 | github.com/AlekSi/pointer v1.2.0 // indirect
36 | github.com/Antonboom/errname v1.0.0 // indirect
37 | github.com/Antonboom/nilnil v1.0.1 // indirect
38 | github.com/Antonboom/testifylint v1.5.2 // indirect
39 | github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect
40 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 // indirect
41 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 // indirect
42 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
43 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 // indirect
44 | github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect
45 | github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.3.2 // indirect
46 | github.com/Azure/go-autorest v14.2.0+incompatible // indirect
47 | github.com/Azure/go-autorest/autorest v0.11.29 // indirect
48 | github.com/Azure/go-autorest/autorest/adal v0.9.23 // indirect
49 | github.com/Azure/go-autorest/autorest/azure/auth v0.5.12 // indirect
50 | github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect
51 | github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
52 | github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect
53 | github.com/Azure/go-autorest/logger v0.2.1 // indirect
54 | github.com/Azure/go-autorest/tracing v0.6.0 // indirect
55 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect
56 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
57 | github.com/Crocmagnon/fatcontext v0.5.3 // indirect
58 | github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect
59 | github.com/GaijinEntertainment/go-exhaustruct/v3 v3.3.0 // indirect
60 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.24.1 // indirect
61 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 // indirect
62 | github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 // indirect
63 | github.com/Masterminds/goutils v1.1.1 // indirect
64 | github.com/Masterminds/semver/v3 v3.3.1 // indirect
65 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect
66 | github.com/Microsoft/go-winio v0.6.2 // indirect
67 | github.com/OpenPeeDeeP/depguard/v2 v2.2.0 // indirect
68 | github.com/ProtonMail/go-crypto v1.1.6 // indirect
69 | github.com/agnivade/levenshtein v1.2.1 // indirect
70 | github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
71 | github.com/alessio/shellescape v1.4.2 // indirect
72 | github.com/alexkohler/nakedret/v2 v2.0.5 // indirect
73 | github.com/alexkohler/prealloc v1.0.0 // indirect
74 | github.com/alingse/asasalint v0.0.11 // indirect
75 | github.com/alingse/nilnesserr v0.1.1 // indirect
76 | github.com/anchore/bubbly v0.0.0-20241107060245-f2a5536f366a // indirect
77 | github.com/anchore/go-logger v0.0.0-20241005132348-65b4486fbb28 // indirect
78 | github.com/anchore/go-macholibre v0.0.0-20220308212642-53e6d0aaf6fb // indirect
79 | github.com/anchore/quill v0.5.1 // indirect
80 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
81 | github.com/ashanbrown/forbidigo v1.6.0 // indirect
82 | github.com/ashanbrown/makezero v1.2.0 // indirect
83 | github.com/atc0005/go-teams-notify/v2 v2.13.0 // indirect
84 | github.com/aws/aws-sdk-go v1.55.6 // indirect
85 | github.com/aws/aws-sdk-go-v2 v1.36.1 // indirect
86 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
87 | github.com/aws/aws-sdk-go-v2/config v1.29.6 // indirect
88 | github.com/aws/aws-sdk-go-v2/credentials v1.17.59 // indirect
89 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 // indirect
90 | github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.10 // indirect
91 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32 // indirect
92 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32 // indirect
93 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
94 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 // indirect
95 | github.com/aws/aws-sdk-go-v2/service/ecr v1.40.3 // indirect
96 | github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.31.2 // indirect
97 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect
98 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 // indirect
99 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13 // indirect
100 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 // indirect
101 | github.com/aws/aws-sdk-go-v2/service/kms v1.35.7 // indirect
102 | github.com/aws/aws-sdk-go-v2/service/s3 v1.58.3 // indirect
103 | github.com/aws/aws-sdk-go-v2/service/sso v1.24.15 // indirect
104 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14 // indirect
105 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.14 // indirect
106 | github.com/aws/smithy-go v1.22.2 // indirect
107 | github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.9.1 // indirect
108 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
109 | github.com/bahlo/generic-list-go v0.2.0 // indirect
110 | github.com/beorn7/perks v1.0.1 // indirect
111 | github.com/bkielbasa/cyclop v1.2.3 // indirect
112 | github.com/blacktop/go-dwarf v1.0.10 // indirect
113 | github.com/blacktop/go-macho v1.1.238 // indirect
114 | github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect
115 | github.com/blang/semver v3.5.1+incompatible // indirect
116 | github.com/blizzy78/varnamelen v0.8.0 // indirect
117 | github.com/bluesky-social/indigo v0.0.0-20240813042137-4006c0eca043 // indirect
118 | github.com/bombsimon/wsl/v4 v4.5.0 // indirect
119 | github.com/breml/bidichk v0.3.2 // indirect
120 | github.com/breml/errchkjson v0.4.0 // indirect
121 | github.com/buger/jsonparser v1.1.1 // indirect
122 | github.com/butuzov/ireturn v0.3.1 // indirect
123 | github.com/butuzov/mirror v1.3.0 // indirect
124 | github.com/caarlos0/ctrlc v1.2.0 // indirect
125 | github.com/caarlos0/env/v11 v11.3.1 // indirect
126 | github.com/caarlos0/go-reddit/v3 v3.0.1 // indirect
127 | github.com/caarlos0/go-shellwords v1.0.12 // indirect
128 | github.com/caarlos0/go-version v0.2.0 // indirect
129 | github.com/caarlos0/log v0.4.8 // indirect
130 | github.com/carlmjohnson/versioninfo v0.22.5 // indirect
131 | github.com/catenacyber/perfsprint v0.7.1 // indirect
132 | github.com/cavaliergopher/cpio v1.0.1 // indirect
133 | github.com/ccojocar/zxcvbn-go v1.0.2 // indirect
134 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect
135 | github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
136 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
137 | github.com/charithe/durationcheck v0.0.10 // indirect
138 | github.com/charmbracelet/bubbletea v1.3.0 // indirect
139 | github.com/charmbracelet/lipgloss v1.0.0 // indirect
140 | github.com/charmbracelet/x/ansi v0.8.0 // indirect
141 | github.com/charmbracelet/x/term v0.2.1 // indirect
142 | github.com/chavacava/garif v0.1.0 // indirect
143 | github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect
144 | github.com/ckaznocha/intrange v0.3.0 // indirect
145 | github.com/cloudflare/circl v1.3.8 // indirect
146 | github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect
147 | github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
148 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
149 | github.com/curioswitch/go-reassign v0.3.0 // indirect
150 | github.com/cyberphone/json-canonicalization v0.0.0-20231011164504-785e29786b46 // indirect
151 | github.com/cyphar/filepath-securejoin v0.3.6 // indirect
152 | github.com/daixiang0/gci v0.13.5 // indirect
153 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
154 | github.com/davidmz/go-pageant v1.0.2 // indirect
155 | github.com/denis-tingaikin/go-header v0.5.0 // indirect
156 | github.com/dghubble/go-twitter v0.0.0-20211115160449-93a8679adecb // indirect
157 | github.com/dghubble/oauth1 v0.7.3 // indirect
158 | github.com/dghubble/sling v1.4.0 // indirect
159 | github.com/dimchansky/utfbom v1.1.1 // indirect
160 | github.com/distribution/reference v0.6.0 // indirect
161 | github.com/docker/cli v27.5.0+incompatible // indirect
162 | github.com/docker/distribution v2.8.3+incompatible // indirect
163 | github.com/docker/docker v27.5.0+incompatible // indirect
164 | github.com/docker/docker-credential-helpers v0.8.2 // indirect
165 | github.com/docker/go-connections v0.5.0 // indirect
166 | github.com/docker/go-units v0.5.0 // indirect
167 | github.com/dustin/go-humanize v1.0.1 // indirect
168 | github.com/elliotchance/orderedmap/v2 v2.7.0 // indirect
169 | github.com/emirpasic/gods v1.18.1 // indirect
170 | github.com/envoyproxy/go-control-plane v0.13.0 // indirect
171 | github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect
172 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
173 | github.com/ettle/strcase v0.2.0 // indirect
174 | github.com/evanphx/json-patch/v5 v5.6.0 // indirect
175 | github.com/fatih/color v1.18.0 // indirect
176 | github.com/fatih/structtag v1.2.0 // indirect
177 | github.com/felixge/httpsnoop v1.0.4 // indirect
178 | github.com/firefart/nonamedreturns v1.0.5 // indirect
179 | github.com/fsnotify/fsnotify v1.8.0 // indirect
180 | github.com/fzipp/gocyclo v0.6.0 // indirect
181 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect
182 | github.com/ghostiam/protogetter v0.3.8 // indirect
183 | github.com/github/smimesign v0.2.0 // indirect
184 | github.com/go-chi/chi v4.1.2+incompatible // indirect
185 | github.com/go-critic/go-critic v0.11.5 // indirect
186 | github.com/go-fed/httpsig v1.1.0 // indirect
187 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
188 | github.com/go-git/go-billy/v5 v5.6.1 // indirect
189 | github.com/go-git/go-git/v5 v5.13.1 // indirect
190 | github.com/go-jose/go-jose/v4 v4.0.5 // indirect
191 | github.com/go-logr/logr v1.4.2 // indirect
192 | github.com/go-logr/stdr v1.2.2 // indirect
193 | github.com/go-openapi/analysis v0.23.0 // indirect
194 | github.com/go-openapi/errors v0.22.0 // indirect
195 | github.com/go-openapi/jsonpointer v0.21.0 // indirect
196 | github.com/go-openapi/jsonreference v0.21.0 // indirect
197 | github.com/go-openapi/loads v0.22.0 // indirect
198 | github.com/go-openapi/runtime v0.28.0 // indirect
199 | github.com/go-openapi/spec v0.21.0 // indirect
200 | github.com/go-openapi/strfmt v0.23.0 // indirect
201 | github.com/go-openapi/swag v0.23.0 // indirect
202 | github.com/go-openapi/validate v0.24.0 // indirect
203 | github.com/go-restruct/restruct v1.2.0-alpha // indirect
204 | github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 // indirect
205 | github.com/go-toolsmith/astcast v1.1.0 // indirect
206 | github.com/go-toolsmith/astcopy v1.1.0 // indirect
207 | github.com/go-toolsmith/astequal v1.2.0 // indirect
208 | github.com/go-toolsmith/astfmt v1.1.0 // indirect
209 | github.com/go-toolsmith/astp v1.1.0 // indirect
210 | github.com/go-toolsmith/strparse v1.1.0 // indirect
211 | github.com/go-toolsmith/typep v1.1.0 // indirect
212 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
213 | github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
214 | github.com/gobwas/glob v0.2.3 // indirect
215 | github.com/gofrs/flock v0.12.1 // indirect
216 | github.com/gogo/protobuf v1.3.2 // indirect
217 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
218 | github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
219 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
220 | github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a // indirect
221 | github.com/golangci/go-printf-func-name v0.1.0 // indirect
222 | github.com/golangci/gofmt v0.0.0-20241223200906-057b0627d9b9 // indirect
223 | github.com/golangci/golangci-lint v1.63.4 // indirect
224 | github.com/golangci/misspell v0.6.0 // indirect
225 | github.com/golangci/plugin-module-register v0.1.1 // indirect
226 | github.com/golangci/revgrep v0.5.3 // indirect
227 | github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect
228 | github.com/google/go-cmp v0.6.0 // indirect
229 | github.com/google/go-containerregistry v0.20.3 // indirect
230 | github.com/google/go-github/v69 v69.2.0 // indirect
231 | github.com/google/go-querystring v1.1.0 // indirect
232 | github.com/google/ko v0.17.1 // indirect
233 | github.com/google/rpmpack v0.6.1-0.20240329070804-c2247cbb881a // indirect
234 | github.com/google/s2a-go v0.1.8 // indirect
235 | github.com/google/safetext v0.0.0-20220905092116-b49f7bc46da2 // indirect
236 | github.com/google/uuid v1.6.0 // indirect
237 | github.com/google/wire v0.6.0 // indirect
238 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
239 | github.com/googleapis/gax-go/v2 v2.13.0 // indirect
240 | github.com/gordonklaus/ineffassign v0.1.0 // indirect
241 | github.com/goreleaser/chglog v0.6.2 // indirect
242 | github.com/goreleaser/fileglob v1.3.0 // indirect
243 | github.com/goreleaser/goreleaser/v2 v2.8.1 // indirect
244 | github.com/goreleaser/nfpm/v2 v2.41.3 // indirect
245 | github.com/gorilla/websocket v1.5.1 // indirect
246 | github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
247 | github.com/gostaticanalysis/comment v1.4.2 // indirect
248 | github.com/gostaticanalysis/forcetypeassert v0.1.0 // indirect
249 | github.com/gostaticanalysis/nilerr v0.1.1 // indirect
250 | github.com/hashicorp/errwrap v1.1.0 // indirect
251 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
252 | github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
253 | github.com/hashicorp/go-multierror v1.1.1 // indirect
254 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
255 | github.com/hashicorp/go-version v1.7.0 // indirect
256 | github.com/hashicorp/golang-lru v1.0.2 // indirect
257 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
258 | github.com/hashicorp/hcl v1.0.1-vault-5 // indirect
259 | github.com/hexops/gotextdiff v1.0.3 // indirect
260 | github.com/huandu/xstrings v1.5.0 // indirect
261 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
262 | github.com/invopop/jsonschema v0.13.0 // indirect
263 | github.com/ipfs/bbloom v0.0.4 // indirect
264 | github.com/ipfs/go-block-format v0.2.0 // indirect
265 | github.com/ipfs/go-cid v0.4.1 // indirect
266 | github.com/ipfs/go-datastore v0.6.0 // indirect
267 | github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
268 | github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect
269 | github.com/ipfs/go-ipfs-util v0.0.3 // indirect
270 | github.com/ipfs/go-ipld-cbor v0.1.0 // indirect
271 | github.com/ipfs/go-ipld-format v0.6.0 // indirect
272 | github.com/ipfs/go-log v1.0.5 // indirect
273 | github.com/ipfs/go-log/v2 v2.5.1 // indirect
274 | github.com/ipfs/go-metrics-interface v0.0.1 // indirect
275 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
276 | github.com/jbenet/goprocess v0.1.4 // indirect
277 | github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 // indirect
278 | github.com/jgautheron/goconst v1.7.1 // indirect
279 | github.com/jingyugao/rowserrcheck v1.1.1 // indirect
280 | github.com/jjti/go-spancheck v0.6.4 // indirect
281 | github.com/jmespath/go-jmespath v0.4.0 // indirect
282 | github.com/josharian/intern v1.0.0 // indirect
283 | github.com/julz/importas v0.2.0 // indirect
284 | github.com/karamaru-alpha/copyloopvar v1.1.0 // indirect
285 | github.com/kevinburke/ssh_config v1.2.0 // indirect
286 | github.com/kisielk/errcheck v1.8.0 // indirect
287 | github.com/kkHAIKE/contextcheck v1.1.5 // indirect
288 | github.com/klauspost/compress v1.18.0 // indirect
289 | github.com/klauspost/cpuid/v2 v2.2.7 // indirect
290 | github.com/klauspost/pgzip v1.2.6 // indirect
291 | github.com/kulti/thelper v0.6.3 // indirect
292 | github.com/kunwardeep/paralleltest v1.0.10 // indirect
293 | github.com/kylelemons/godebug v1.1.0 // indirect
294 | github.com/kyoh86/exportloopref v0.1.11 // indirect
295 | github.com/lasiar/canonicalheader v1.1.2 // indirect
296 | github.com/ldez/exptostd v0.3.1 // indirect
297 | github.com/ldez/gomoddirectives v0.6.0 // indirect
298 | github.com/ldez/grignotin v0.7.0 // indirect
299 | github.com/ldez/tagliatelle v0.7.1 // indirect
300 | github.com/ldez/usetesting v0.4.2 // indirect
301 | github.com/leonklingele/grouper v1.1.2 // indirect
302 | github.com/letsencrypt/boulder v0.0.0-20240620165639-de9c06129bec // indirect
303 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
304 | github.com/macabu/inamedparam v0.1.3 // indirect
305 | github.com/magiconair/properties v1.8.7 // indirect
306 | github.com/mailru/easyjson v0.7.7 // indirect
307 | github.com/maratori/testableexamples v1.0.0 // indirect
308 | github.com/maratori/testpackage v1.1.1 // indirect
309 | github.com/matoous/godox v0.0.0-20230222163458-006bad1f9d26 // indirect
310 | github.com/mattn/go-colorable v0.1.13 // indirect
311 | github.com/mattn/go-isatty v0.0.20 // indirect
312 | github.com/mattn/go-localereader v0.0.2-0.20220822084749-2491eb6c1c75 // indirect
313 | github.com/mattn/go-mastodon v0.0.9 // indirect
314 | github.com/mattn/go-runewidth v0.0.16 // indirect
315 | github.com/mgechev/revive v1.5.1 // indirect
316 | github.com/minio/sha256-simd v1.0.1 // indirect
317 | github.com/mitchellh/copystructure v1.2.0 // indirect
318 | github.com/mitchellh/go-homedir v1.1.0 // indirect
319 | github.com/mitchellh/mapstructure v1.5.0 // indirect
320 | github.com/mitchellh/reflectwalk v1.0.2 // indirect
321 | github.com/moby/docker-image-spec v1.3.1 // indirect
322 | github.com/moricho/tparallel v0.3.2 // indirect
323 | github.com/mr-tron/base58 v1.2.0 // indirect
324 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
325 | github.com/muesli/cancelreader v0.2.2 // indirect
326 | github.com/muesli/mango v0.1.0 // indirect
327 | github.com/muesli/mango-cobra v1.2.0 // indirect
328 | github.com/muesli/mango-pflag v0.1.0 // indirect
329 | github.com/muesli/roff v0.1.0 // indirect
330 | github.com/muesli/termenv v0.16.0 // indirect
331 | github.com/multiformats/go-base32 v0.1.0 // indirect
332 | github.com/multiformats/go-base36 v0.2.0 // indirect
333 | github.com/multiformats/go-multibase v0.2.0 // indirect
334 | github.com/multiformats/go-multihash v0.2.3 // indirect
335 | github.com/multiformats/go-varint v0.0.7 // indirect
336 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
337 | github.com/nakabonne/nestif v0.3.1 // indirect
338 | github.com/nishanths/exhaustive v0.12.0 // indirect
339 | github.com/nishanths/predeclared v0.2.2 // indirect
340 | github.com/nunnatsa/ginkgolinter v0.18.4 // indirect
341 | github.com/oklog/ulid v1.3.1 // indirect
342 | github.com/olekukonko/tablewriter v0.0.5 // indirect
343 | github.com/opencontainers/go-digest v1.0.0 // indirect
344 | github.com/opencontainers/image-spec v1.1.0 // indirect
345 | github.com/opentracing/opentracing-go v1.2.0 // indirect
346 | github.com/pelletier/go-toml v1.9.5 // indirect
347 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect
348 | github.com/pjbgf/sha1cd v0.3.0 // indirect
349 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
350 | github.com/pkg/errors v0.9.1 // indirect
351 | github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
352 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
353 | github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
354 | github.com/polyfloyd/go-errorlint v1.7.0 // indirect
355 | github.com/prometheus/client_golang v1.20.5 // indirect
356 | github.com/prometheus/client_model v0.6.1 // indirect
357 | github.com/prometheus/common v0.60.1 // indirect
358 | github.com/prometheus/procfs v0.15.1 // indirect
359 | github.com/quasilyte/go-ruleguard v0.4.3-0.20240823090925-0fe6f58b47b1 // indirect
360 | github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect
361 | github.com/quasilyte/gogrep v0.5.0 // indirect
362 | github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
363 | github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
364 | github.com/raeperd/recvcheck v0.2.0 // indirect
365 | github.com/rivo/uniseg v0.4.7 // indirect
366 | github.com/rogpeppe/go-internal v1.13.1 // indirect
367 | github.com/russross/blackfriday/v2 v2.1.0 // indirect
368 | github.com/ryancurrah/gomodguard v1.3.5 // indirect
369 | github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
370 | github.com/sagikazarmark/locafero v0.6.0 // indirect
371 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect
372 | github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
373 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
374 | github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
375 | github.com/sashamelentyev/usestdlibvars v1.28.0 // indirect
376 | github.com/sassoftware/relic v7.2.1+incompatible // indirect
377 | github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e // indirect
378 | github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect
379 | github.com/securego/gosec/v2 v2.21.4 // indirect
380 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
381 | github.com/shazow/go-diff v0.0.0-20160112020656-b6b7b6733b8c // indirect
382 | github.com/shopspring/decimal v1.4.0 // indirect
383 | github.com/sigstore/cosign/v2 v2.4.1 // indirect
384 | github.com/sigstore/protobuf-specs v0.3.2 // indirect
385 | github.com/sigstore/rekor v1.3.6 // indirect
386 | github.com/sigstore/sigstore v1.8.9 // indirect
387 | github.com/sirupsen/logrus v1.9.3 // indirect
388 | github.com/sivchari/containedctx v1.0.3 // indirect
389 | github.com/sivchari/tenv v1.12.1 // indirect
390 | github.com/skeema/knownhosts v1.3.0 // indirect
391 | github.com/slack-go/slack v0.16.0 // indirect
392 | github.com/sonatard/noctx v0.1.0 // indirect
393 | github.com/sourcegraph/conc v0.3.0 // indirect
394 | github.com/sourcegraph/go-diff v0.7.0 // indirect
395 | github.com/spaolacci/murmur3 v1.1.0 // indirect
396 | github.com/spf13/afero v1.11.0 // indirect
397 | github.com/spf13/cast v1.7.0 // indirect
398 | github.com/spf13/cobra v1.9.1 // indirect
399 | github.com/spf13/pflag v1.0.6 // indirect
400 | github.com/spf13/viper v1.19.0 // indirect
401 | github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
402 | github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect
403 | github.com/stretchr/objx v0.5.2 // indirect
404 | github.com/subosito/gotenv v1.6.0 // indirect
405 | github.com/tdakkota/asciicheck v0.3.0 // indirect
406 | github.com/tetafro/godot v1.4.20 // indirect
407 | github.com/theupdateframework/go-tuf v0.7.0 // indirect
408 | github.com/timakin/bodyclose v0.0.0-20241017074812-ed6a65f985e3 // indirect
409 | github.com/timonwong/loggercheck v0.10.1 // indirect
410 | github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect
411 | github.com/tomarrell/wrapcheck/v2 v2.10.0 // indirect
412 | github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
413 | github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 // indirect
414 | github.com/ulikunitz/xz v0.5.12 // indirect
415 | github.com/ultraware/funlen v0.2.0 // indirect
416 | github.com/ultraware/whitespace v0.2.0 // indirect
417 | github.com/uudashr/gocognit v1.2.0 // indirect
418 | github.com/uudashr/iface v1.3.0 // indirect
419 | github.com/vbatts/tar-split v0.11.6 // indirect
420 | github.com/wagoodman/go-partybus v0.0.0-20230516145632-8ccac152c651 // indirect
421 | github.com/wagoodman/go-progress v0.0.0-20220614130704-4b1c25a33c7c // indirect
422 | github.com/whyrusleeping/cbor-gen v0.1.3-0.20240731173018-74d74643234c // indirect
423 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
424 | github.com/xanzy/ssh-agent v0.3.3 // indirect
425 | github.com/xen0n/gosmopolitan v1.2.2 // indirect
426 | github.com/yagipy/maintidx v1.0.0 // indirect
427 | github.com/yeya24/promlinter v0.3.0 // indirect
428 | github.com/ykadowak/zerologlint v0.1.5 // indirect
429 | gitlab.com/bosi/decorder v0.4.2 // indirect
430 | gitlab.com/digitalxero/go-conventional-commit v1.0.7 // indirect
431 | gitlab.com/gitlab-org/api/client-go v0.124.0 // indirect
432 | go-simpler.org/musttag v0.13.0 // indirect
433 | go-simpler.org/sloglint v0.7.2 // indirect
434 | go.mongodb.org/mongo-driver v1.14.0 // indirect
435 | go.opencensus.io v0.24.0 // indirect
436 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect
437 | go.opentelemetry.io/contrib/detectors/gcp v1.29.0 // indirect
438 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
439 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
440 | go.opentelemetry.io/otel v1.33.0 // indirect
441 | go.opentelemetry.io/otel/metric v1.33.0 // indirect
442 | go.opentelemetry.io/otel/sdk v1.33.0 // indirect
443 | go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect
444 | go.opentelemetry.io/otel/trace v1.33.0 // indirect
445 | go.uber.org/atomic v1.11.0 // indirect
446 | go.uber.org/automaxprocs v1.6.0 // indirect
447 | go.uber.org/multierr v1.11.0 // indirect
448 | go.uber.org/zap v1.27.0 // indirect
449 | gocloud.dev v0.40.0 // indirect
450 | golang.org/x/crypto v0.38.0 // indirect
451 | golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
452 | golang.org/x/exp/typeparams v0.0.0-20241108190413-2d47ceb2692f // indirect
453 | golang.org/x/mod v0.24.0 // indirect
454 | golang.org/x/oauth2 v0.28.0 // indirect
455 | golang.org/x/sync v0.14.0 // indirect
456 | golang.org/x/sys v0.33.0 // indirect
457 | golang.org/x/telemetry v0.0.0-20240522233618-39ace7a40ae7 // indirect
458 | golang.org/x/term v0.32.0 // indirect
459 | golang.org/x/text v0.25.0 // indirect
460 | golang.org/x/time v0.9.0 // indirect
461 | golang.org/x/tools v0.31.0 // indirect
462 | golang.org/x/vuln v1.1.4 // indirect
463 | golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
464 | google.golang.org/api v0.198.0 // indirect
465 | google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 // indirect
466 | google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
467 | google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect
468 | google.golang.org/grpc v1.68.1 // indirect
469 | google.golang.org/grpc/stats/opentelemetry v0.0.0-20240907200651-3ffb98b2c93a // indirect
470 | google.golang.org/protobuf v1.36.3 // indirect
471 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
472 | gopkg.in/ini.v1 v1.67.0 // indirect
473 | gopkg.in/mail.v2 v2.3.1 // indirect
474 | gopkg.in/warnings.v0 v0.1.2 // indirect
475 | gopkg.in/yaml.v2 v2.4.0 // indirect
476 | gopkg.in/yaml.v3 v3.0.1 // indirect
477 | honnef.co/go/tools v0.5.1 // indirect
478 | lukechampine.com/blake3 v1.2.1 // indirect
479 | mvdan.cc/gofumpt v0.7.0 // indirect
480 | mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect
481 | sigs.k8s.io/kind v0.24.0 // indirect
482 | sigs.k8s.io/yaml v1.4.0 // indirect
483 | software.sslmate.com/src/go-pkcs12 v0.5.0 // indirect
484 | )
485 |
--------------------------------------------------------------------------------
/pkg/chain/chain.go:
--------------------------------------------------------------------------------
1 | // Package chain provides a way to chain multiple http.Handler together.
2 | package chain
3 |
4 | import (
5 | "log"
6 | "net/http"
7 |
8 | "github.com/tomMoulard/fail2ban/pkg/data"
9 | )
10 |
11 | // Status is a status that can be returned by a handler.
12 | type Status struct {
13 | // Return is a flag that tells the chain to return. If Return is true, the
14 | // chain will return a 403 (e.g., the ip is in the denylist)
15 | Return bool
16 | // Break is a flag that tells the chain to break. If Break is true, the chain
17 | // will stop (e.g., the ip is in the allowlist)
18 | Break bool
19 | }
20 |
21 | // ChainHandler is a handler that can be chained.
22 | type ChainHandler interface {
23 | ServeHTTP(w http.ResponseWriter, r *http.Request) (*Status, error)
24 | }
25 |
26 | // Chain is a chain of handlers.
27 | type Chain interface {
28 | ServeHTTP(w http.ResponseWriter, r *http.Request)
29 | WithStatus(status http.Handler)
30 | }
31 |
32 | type chain struct {
33 | handlers []ChainHandler
34 | final http.Handler
35 | status *http.Handler
36 | }
37 |
38 | // New creates a new chain.
39 | func New(final http.Handler, handlers ...ChainHandler) Chain {
40 | return &chain{
41 | handlers: handlers,
42 | final: final,
43 | }
44 | }
45 |
46 | // WithStatus sets the status handler.
47 | func (c *chain) WithStatus(status http.Handler) {
48 | c.status = &status
49 | }
50 |
51 | // ServeHTTP chains the handlers together, and calls the final handler at the end.
52 | func (c *chain) ServeHTTP(w http.ResponseWriter, r *http.Request) {
53 | r, err := data.ServeHTTP(w, r)
54 | if err != nil {
55 | log.Printf("data.ServeHTTP error: %v", err)
56 |
57 | return
58 | }
59 |
60 | for _, handler := range c.handlers {
61 | s, err := handler.ServeHTTP(w, r)
62 | if err != nil {
63 | log.Printf("handler.ServeHTTP error: %v", err)
64 |
65 | break
66 | }
67 |
68 | if s == nil {
69 | continue
70 | }
71 |
72 | if s.Return {
73 | w.WriteHeader(http.StatusForbidden)
74 |
75 | return
76 | }
77 |
78 | if s.Break {
79 | break
80 | }
81 | }
82 |
83 | if c.status != nil {
84 | (*c.status).ServeHTTP(w, r)
85 |
86 | return
87 | }
88 |
89 | c.final.ServeHTTP(w, r)
90 | }
91 |
--------------------------------------------------------------------------------
/pkg/chain/chain_test.go:
--------------------------------------------------------------------------------
1 | package chain
2 |
3 | import (
4 | "errors"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 | "github.com/tomMoulard/fail2ban/pkg/data"
12 | )
13 |
14 | type mockHandler struct {
15 | called int
16 | err error
17 | expectedCalled int
18 | }
19 |
20 | func (m *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
21 | m.called++
22 | }
23 |
24 | func (m *mockHandler) assert(t *testing.T) {
25 | t.Helper()
26 |
27 | assert.Equal(t, m.expectedCalled, m.called)
28 | }
29 |
30 | type mockChainHandler struct {
31 | mockHandler
32 | status *Status
33 | }
34 |
35 | func (m *mockChainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) (*Status, error) {
36 | m.called++
37 |
38 | return m.status, m.err
39 | }
40 |
41 | func (m *mockChainHandler) assert(t *testing.T) {
42 | t.Helper()
43 |
44 | assert.Equal(t, m.expectedCalled, m.called)
45 | }
46 |
47 | func TestChain(t *testing.T) {
48 | t.Parallel()
49 |
50 | tests := []struct {
51 | name string
52 | finalHandler *mockHandler
53 | handlers []ChainHandler
54 | expectedStatus *Status
55 | expectFinalCount int
56 | }{
57 | {
58 | name: "return",
59 | finalHandler: &mockHandler{expectedCalled: 0},
60 | handlers: []ChainHandler{&mockChainHandler{
61 | status: &Status{Return: true},
62 | mockHandler: mockHandler{expectedCalled: 1},
63 | }},
64 | expectedStatus: &Status{
65 | Return: true,
66 | },
67 | },
68 | {
69 | name: "break",
70 | finalHandler: &mockHandler{expectedCalled: 1},
71 | handlers: []ChainHandler{&mockChainHandler{
72 | status: &Status{Break: true},
73 | mockHandler: mockHandler{expectedCalled: 1},
74 | }},
75 | expectedStatus: &Status{
76 | Break: true,
77 | },
78 | },
79 | {
80 | name: "nil",
81 | finalHandler: &mockHandler{expectedCalled: 1},
82 | handlers: []ChainHandler{&mockChainHandler{
83 | status: nil,
84 | mockHandler: mockHandler{expectedCalled: 1},
85 | }},
86 | },
87 | {
88 | name: "error",
89 | finalHandler: &mockHandler{expectedCalled: 1},
90 | handlers: []ChainHandler{&mockChainHandler{
91 | mockHandler: mockHandler{
92 | err: errors.New("error"),
93 | expectedCalled: 1,
94 | },
95 | }},
96 | },
97 | }
98 |
99 | for _, test := range tests {
100 | t.Run(test.name, func(t *testing.T) {
101 | t.Parallel()
102 |
103 | c := New(test.finalHandler, test.handlers...)
104 | recorder := &httptest.ResponseRecorder{}
105 | req := httptest.NewRequest(http.MethodGet, "https://example.com/foo", nil)
106 | req, err := data.ServeHTTP(recorder, req)
107 | require.NoError(t, err)
108 |
109 | c.ServeHTTP(recorder, req)
110 |
111 | test.finalHandler.assert(t)
112 |
113 | for _, handler := range test.handlers {
114 | mch, ok := handler.(*mockChainHandler)
115 | require.True(t, ok)
116 | mch.assert(t)
117 | }
118 | })
119 | }
120 | }
121 |
122 | type mockChainOrderHandler struct {
123 | status int
124 | }
125 |
126 | var countOrder int
127 |
128 | func (m *mockChainOrderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) (*Status, error) {
129 | m.status = countOrder
130 | countOrder++
131 |
132 | return nil, nil
133 | }
134 |
135 | func TestChainOrder(t *testing.T) {
136 | t.Parallel()
137 |
138 | a := &mockChainOrderHandler{}
139 | b := &mockChainOrderHandler{}
140 | c := &mockChainOrderHandler{}
141 | final := &mockHandler{
142 | expectedCalled: 1,
143 | }
144 |
145 | ch := New(final, a, b, c)
146 | r := httptest.NewRequest(http.MethodGet, "https://example.com/foo", nil)
147 | ch.ServeHTTP(nil, r)
148 |
149 | assert.Equal(t, 0, a.status)
150 | assert.Equal(t, 1, b.status)
151 | assert.Equal(t, 2, c.status)
152 | final.assert(t)
153 | }
154 |
155 | type mockDataHandler struct {
156 | t *testing.T
157 | ExpectData *data.Data
158 | }
159 |
160 | func (m *mockDataHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) (*Status, error) {
161 | d := data.GetData(r)
162 | assert.Equal(m.t, m.ExpectData, d)
163 |
164 | return nil, nil
165 | }
166 |
167 | func TestChainRequestContext(t *testing.T) {
168 | t.Parallel()
169 |
170 | handler := &mockDataHandler{
171 | t: t,
172 | ExpectData: &data.Data{RemoteIP: "192.0.2.1"},
173 | }
174 |
175 | final := &mockHandler{
176 | expectedCalled: 1,
177 | }
178 |
179 | ch := New(final, handler)
180 | r := httptest.NewRequest(http.MethodGet, "https://example.com/foo", nil)
181 | ch.ServeHTTP(nil, r)
182 |
183 | final.assert(t)
184 | }
185 |
186 | func TestChainWithStatus(t *testing.T) {
187 | t.Parallel()
188 |
189 | handler := &mockChainHandler{
190 | mockHandler: mockHandler{expectedCalled: 1},
191 | }
192 | final := &mockHandler{expectedCalled: 0}
193 | status := &mockHandler{expectedCalled: 1}
194 |
195 | ch := New(final, handler)
196 | ch.WithStatus(status)
197 |
198 | r := httptest.NewRequest(http.MethodGet, "https://example.com/foo", nil)
199 | ch.ServeHTTP(nil, r)
200 |
201 | handler.assert(t)
202 | final.assert(t)
203 | status.assert(t)
204 | }
205 |
--------------------------------------------------------------------------------
/pkg/chain/example_test.go:
--------------------------------------------------------------------------------
1 | package chain_test
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "net/http/httptest"
7 |
8 | "github.com/tomMoulard/fail2ban/pkg/chain"
9 | "github.com/tomMoulard/fail2ban/pkg/data"
10 | )
11 |
12 | type PongHandler struct{}
13 |
14 | func (h *PongHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
15 | _, _ = fmt.Fprint(w, "pong")
16 | }
17 |
18 | type Handler struct{}
19 |
20 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) (*chain.Status, error) {
21 | d := data.GetData(r)
22 |
23 | fmt.Printf("data: %+v\n", d)
24 |
25 | return nil, nil
26 | }
27 |
28 | func Example() {
29 | // This example shows how to chain handlers together.
30 | // The final handler is called only if all the previous handlers did not
31 | // return an error.
32 | // Create a new chain with a final h.
33 | h := &Handler{}
34 | c := chain.New(&PongHandler{}, h)
35 |
36 | // Create a new request.
37 | req := httptest.NewRequest(http.MethodGet, "http://example.com", nil)
38 |
39 | // Create a new response recorder.
40 | rec := httptest.NewRecorder()
41 |
42 | // use the chain
43 | c.ServeHTTP(rec, req)
44 | fmt.Println(rec.Body.String())
45 |
46 | // Output:
47 | // data: &{RemoteIP:192.0.2.1}data: &{RemoteIP:192.0.2.1}
48 | // pong
49 | }
50 |
--------------------------------------------------------------------------------
/pkg/data/data.go:
--------------------------------------------------------------------------------
1 | // Package data provides a way to store data in the request context.
2 | package data
3 |
4 | import (
5 | "context"
6 | "fmt"
7 | "net"
8 | "net/http"
9 | )
10 |
11 | type key string
12 |
13 | const contextDataKey key = "data"
14 |
15 | type Data struct {
16 | RemoteIP string
17 | }
18 |
19 | // ServeHTTP sets data in the request context, to be extracted with GetData.
20 | func ServeHTTP(w http.ResponseWriter, r *http.Request) (*http.Request, error) {
21 | remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
22 | if err != nil {
23 | return nil, fmt.Errorf("failed to split remote address %q: %w", r.RemoteAddr, err)
24 | }
25 |
26 | data := &Data{
27 | RemoteIP: remoteIP,
28 | }
29 |
30 | fmt.Printf("data: %+v", data)
31 |
32 | return r.WithContext(context.WithValue(r.Context(), contextDataKey, data)), nil
33 | }
34 |
35 | // GetData returns the data stored in the request context.
36 | func GetData(req *http.Request) *Data {
37 | if data, ok := req.Context().Value(contextDataKey).(*Data); ok {
38 | return data
39 | }
40 |
41 | return nil
42 | }
43 |
--------------------------------------------------------------------------------
/pkg/data/data_test.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "context"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestData(t *testing.T) {
14 | t.Parallel()
15 |
16 | tests := []struct {
17 | name string
18 | expectedData *Data
19 | }{
20 | {
21 | name: "allowed",
22 | expectedData: &Data{
23 | RemoteIP: "192.0.2.1",
24 | },
25 | },
26 | }
27 |
28 | for _, test := range tests {
29 | t.Run(test.name, func(t *testing.T) {
30 | t.Parallel()
31 |
32 | recorder := &httptest.ResponseRecorder{}
33 | req := httptest.NewRequest(http.MethodGet, "https://example.com/foo", nil)
34 | req, err := ServeHTTP(recorder, req)
35 | require.NoError(t, err)
36 |
37 | got := GetData(req)
38 | assert.Equal(t, test.expectedData, got)
39 | })
40 | }
41 | }
42 |
43 | func TestGetData_InvalidData(t *testing.T) {
44 | t.Parallel()
45 |
46 | tests := []struct {
47 | name string
48 | req func(*testing.T) *http.Request
49 | expectedData *Data
50 | }{
51 | {
52 | name: "data",
53 | req: func(t *testing.T) *http.Request {
54 | t.Helper()
55 |
56 | req := httptest.NewRequest(http.MethodGet, "https://example.com/foo", nil)
57 | req, err := ServeHTTP(nil, req)
58 | require.NoError(t, err)
59 |
60 | return req
61 | },
62 | expectedData: &Data{
63 | RemoteIP: "192.0.2.1",
64 | },
65 | },
66 | {
67 | name: "no data",
68 | req: func(t *testing.T) *http.Request {
69 | t.Helper()
70 |
71 | return httptest.NewRequest(http.MethodGet, "https://example.com/foo", nil)
72 | },
73 | },
74 | {
75 | name: "invalid data",
76 | req: func(t *testing.T) *http.Request {
77 | t.Helper()
78 | req := httptest.NewRequest(http.MethodGet, "https://example.com/foo", nil)
79 |
80 | return req.WithContext(context.WithValue(req.Context(), contextDataKey, true))
81 | },
82 | },
83 | }
84 |
85 | for _, test := range tests {
86 | t.Run(test.name, func(t *testing.T) {
87 | t.Parallel()
88 |
89 | data := GetData(test.req(t))
90 | assert.Equal(t, test.expectedData, data)
91 | })
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/pkg/fail2ban/fail2ban.go:
--------------------------------------------------------------------------------
1 | // Package fail2ban provides a fail2ban implementation.
2 | package fail2ban
3 |
4 | import (
5 | "fmt"
6 | "sync"
7 | "time"
8 |
9 | "github.com/tomMoulard/fail2ban/pkg/ipchecking"
10 | "github.com/tomMoulard/fail2ban/pkg/rules"
11 | utime "github.com/tomMoulard/fail2ban/pkg/utils/time"
12 | )
13 |
14 | // Fail2Ban is a fail2ban implementation.
15 | type Fail2Ban struct {
16 | rules rules.RulesTransformed
17 |
18 | MuIP sync.Mutex
19 | IPs map[string]ipchecking.IPViewed
20 | }
21 |
22 | // New creates a new Fail2Ban.
23 | func New(rules rules.RulesTransformed) *Fail2Ban {
24 | return &Fail2Ban{
25 | rules: rules,
26 | IPs: make(map[string]ipchecking.IPViewed),
27 | }
28 | }
29 |
30 | // ShouldAllow check if the request should be allowed.
31 | // Called when a request was DENIED - increments the denied counter.
32 | func (u *Fail2Ban) ShouldAllow(remoteIP string) bool {
33 | u.MuIP.Lock()
34 | defer u.MuIP.Unlock()
35 |
36 | ip, foundIP := u.IPs[remoteIP]
37 |
38 | // Fail2Ban
39 | if !foundIP {
40 | u.IPs[remoteIP] = ipchecking.IPViewed{
41 | Viewed: utime.Now(),
42 | Count: 1,
43 | }
44 |
45 | fmt.Printf("welcome %q", remoteIP)
46 |
47 | return true
48 | }
49 |
50 | if ip.Denied {
51 | if utime.Now().Before(ip.Viewed.Add(u.rules.Bantime)) {
52 | u.IPs[remoteIP] = ipchecking.IPViewed{
53 | Viewed: ip.Viewed,
54 | Count: ip.Count + 1,
55 | Denied: true,
56 | }
57 |
58 | fmt.Printf("%q is still banned since %q, %d request",
59 | remoteIP, ip.Viewed.Format(time.RFC3339), ip.Count+1)
60 |
61 | return false
62 | }
63 |
64 | u.IPs[remoteIP] = ipchecking.IPViewed{
65 | Viewed: utime.Now(),
66 | Count: 1,
67 | Denied: false,
68 | }
69 |
70 | fmt.Println(remoteIP + " is no longer banned")
71 |
72 | return true
73 | }
74 |
75 | if utime.Now().Before(ip.Viewed.Add(u.rules.Findtime)) {
76 | if ip.Count+1 >= u.rules.MaxRetry {
77 | u.IPs[remoteIP] = ipchecking.IPViewed{
78 | Viewed: utime.Now(),
79 | Count: ip.Count + 1,
80 | Denied: true,
81 | }
82 |
83 | fmt.Printf("%q is banned for %d>=%d request",
84 | remoteIP, ip.Count+1, u.rules.MaxRetry)
85 |
86 | return false
87 | }
88 |
89 | u.IPs[remoteIP] = ipchecking.IPViewed{
90 | Viewed: ip.Viewed,
91 | Count: ip.Count + 1,
92 | Denied: false,
93 | }
94 |
95 | fmt.Printf("welcome back %q for the %d time", remoteIP, ip.Count+1)
96 |
97 | return true
98 | }
99 |
100 | u.IPs[remoteIP] = ipchecking.IPViewed{
101 | Viewed: utime.Now(),
102 | Count: 1,
103 | Denied: false,
104 | }
105 |
106 | fmt.Printf("welcome back %q", remoteIP)
107 |
108 | return true
109 | }
110 |
111 | // IsNotBanned Non-incrementing check to see if an IP is already banned.
112 | func (u *Fail2Ban) IsNotBanned(remoteIP string) bool {
113 | u.MuIP.Lock()
114 | defer u.MuIP.Unlock()
115 |
116 | ip, foundIP := u.IPs[remoteIP]
117 |
118 | // Fail2Ban
119 | if !foundIP {
120 | u.IPs[remoteIP] = ipchecking.IPViewed{
121 | Viewed: utime.Now(),
122 | Count: 0,
123 | }
124 |
125 | fmt.Printf("welcome %q", remoteIP)
126 |
127 | return true
128 | }
129 |
130 | if ip.Denied {
131 | if utime.Now().Before(ip.Viewed.Add(u.rules.Bantime)) {
132 | u.IPs[remoteIP] = ipchecking.IPViewed{
133 | Viewed: utime.Now(), // refresh ban time
134 | Count: ip.Count + 1,
135 | Denied: true,
136 | }
137 |
138 | fmt.Printf("%q is still banned since %q, %d request",
139 | remoteIP, ip.Viewed.Format(time.RFC3339), ip.Count+1)
140 |
141 | return false
142 | }
143 |
144 | u.IPs[remoteIP] = ipchecking.IPViewed{
145 | Viewed: utime.Now(),
146 | Count: 1,
147 | Denied: false,
148 | }
149 |
150 | fmt.Println(remoteIP + " is no longer banned")
151 |
152 | return true
153 | }
154 |
155 | fmt.Printf("welcome back %q", remoteIP)
156 |
157 | return true
158 | }
159 |
--------------------------------------------------------------------------------
/pkg/fail2ban/fail2ban_test.go:
--------------------------------------------------------------------------------
1 | package fail2ban
2 |
3 | import (
4 | "testing"
5 | "time"
6 |
7 | "github.com/stretchr/testify/assert"
8 | "github.com/tomMoulard/fail2ban/pkg/ipchecking"
9 | "github.com/tomMoulard/fail2ban/pkg/rules"
10 | utime "github.com/tomMoulard/fail2ban/pkg/utils/time"
11 | )
12 |
13 | func TestShouldAllow(t *testing.T) {
14 | t.Parallel()
15 |
16 | tests := []struct {
17 | name string
18 | cfg *Fail2Ban
19 | remoteIP string
20 | expect assert.BoolAssertionFunc
21 | }{
22 | {
23 | name: "first request",
24 | cfg: &Fail2Ban{
25 | IPs: map[string]ipchecking.IPViewed{},
26 | },
27 | expect: assert.True,
28 | },
29 | {
30 | name: "second request",
31 | cfg: &Fail2Ban{
32 | IPs: map[string]ipchecking.IPViewed{
33 | "10.0.0.0": {
34 | Viewed: utime.Now(),
35 | Count: 1,
36 | },
37 | },
38 | },
39 | remoteIP: "10.0.0.0",
40 | expect: assert.True,
41 | },
42 | {
43 | name: "denylisted request",
44 | cfg: &Fail2Ban{
45 | rules: rules.RulesTransformed{
46 | Bantime: 300 * time.Second,
47 | },
48 | IPs: map[string]ipchecking.IPViewed{
49 | "10.0.0.0": {
50 | Viewed: utime.Now(),
51 | Count: 1,
52 | Denied: true,
53 | },
54 | },
55 | },
56 | remoteIP: "10.0.0.0",
57 | expect: assert.False,
58 | },
59 | {
60 | name: "should unblock request", // since no request during bantime
61 | cfg: &Fail2Ban{
62 | rules: rules.RulesTransformed{
63 | Bantime: 300 * time.Second,
64 | },
65 | IPs: map[string]ipchecking.IPViewed{
66 | "10.0.0.0": {
67 | Viewed: utime.Now().Add(-600 * time.Second),
68 | Count: 1,
69 | Denied: true,
70 | },
71 | },
72 | },
73 | remoteIP: "10.0.0.0",
74 | expect: assert.True,
75 | },
76 | {
77 | name: "should block request", // since too much request during findtime
78 | cfg: &Fail2Ban{
79 | rules: rules.RulesTransformed{
80 | MaxRetry: 1,
81 | Findtime: 300 * time.Second,
82 | },
83 | IPs: map[string]ipchecking.IPViewed{
84 | "10.0.0.0": {
85 | Viewed: utime.Now().Add(600 * time.Second),
86 | Count: 1,
87 | },
88 | },
89 | },
90 | remoteIP: "10.0.0.0",
91 | expect: assert.False,
92 | },
93 | {
94 | name: "should check request",
95 | cfg: &Fail2Ban{
96 | rules: rules.RulesTransformed{
97 | MaxRetry: 3,
98 | Findtime: 300 * time.Second,
99 | },
100 | IPs: map[string]ipchecking.IPViewed{
101 | "10.0.0.0": {
102 | Viewed: utime.Now().Add(600 * time.Second),
103 | Count: 1,
104 | },
105 | },
106 | },
107 | remoteIP: "10.0.0.0",
108 | expect: assert.True,
109 | },
110 | }
111 |
112 | for _, test := range tests {
113 | t.Run(test.name, func(t *testing.T) {
114 | t.Parallel()
115 |
116 | got := test.cfg.ShouldAllow(test.remoteIP)
117 | test.expect(t, got)
118 | })
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/pkg/fail2ban/handler/handler.go:
--------------------------------------------------------------------------------
1 | // Package handler provides a fail2ban middleware.
2 | package handler
3 |
4 | import (
5 | "errors"
6 | "net/http"
7 |
8 | "github.com/tomMoulard/fail2ban/pkg/chain"
9 | "github.com/tomMoulard/fail2ban/pkg/data"
10 | "github.com/tomMoulard/fail2ban/pkg/fail2ban"
11 | )
12 |
13 | type handler struct {
14 | f2b *fail2ban.Fail2Ban
15 | }
16 |
17 | func New(f2b *fail2ban.Fail2Ban) *handler {
18 | return &handler{f2b: f2b}
19 | }
20 |
21 | // ServeHTTP iterates over every headers to match the ones specified in the
22 | // configuration and return nothing if regexp failed.
23 | func (h *handler) ServeHTTP(rw http.ResponseWriter, req *http.Request) (*chain.Status, error) {
24 | data := data.GetData(req)
25 | if data == nil {
26 | return nil, errors.New("failed to get data from request context")
27 | }
28 |
29 | if !h.f2b.IsNotBanned(data.RemoteIP) {
30 | return &chain.Status{Return: true}, nil
31 | }
32 |
33 | return nil, nil
34 | }
35 |
--------------------------------------------------------------------------------
/pkg/ipchecking/ipChecking.go:
--------------------------------------------------------------------------------
1 | // Package ipchecking wrapper over net/netip to compare both IP and CIRD.
2 | package ipchecking
3 |
4 | import (
5 | "fmt"
6 | "log"
7 | "net/netip"
8 | "strings"
9 | "time"
10 | )
11 |
12 | // IPViewed struct.
13 | type IPViewed struct {
14 | Viewed time.Time
15 | Count int
16 | Denied bool
17 | }
18 |
19 | // NetIP struct that holds an NetIP IP address, and a IP network.
20 | // If the network is nil, the NetIP is a single IP.
21 | type NetIP struct {
22 | Net *netip.Prefix
23 | Addr netip.Addr
24 | }
25 |
26 | // ParseNetIPs Parse a slice string to extract the netip.
27 | // Returns an error on the first IP that failed to parse.
28 | func ParseNetIPs(iplist []string) (NetIPs, error) {
29 | rlist := make([]NetIP, 0, len(iplist))
30 |
31 | for _, v := range iplist {
32 | ip, err := ParseNetIP(v)
33 | if err != nil {
34 | return nil, fmt.Errorf("failed to parse %q: %w", v, err)
35 | }
36 |
37 | rlist = append(rlist, ip)
38 | }
39 |
40 | return rlist, nil
41 | }
42 |
43 | // ParseNetIP Parse a string to extract the netip.
44 | func ParseNetIP(ip string) (NetIP, error) {
45 | tmpSubnet := strings.Split(ip, "/")
46 | if len(tmpSubnet) == 1 {
47 | tempIP, err := netip.ParseAddr(ip)
48 | if err != nil {
49 | return NetIP{}, fmt.Errorf("failed to parse %q: %s", ip, err.Error())
50 | }
51 |
52 | return NetIP{Addr: tempIP}, nil
53 | }
54 |
55 | ipNet, err := netip.ParsePrefix(ip)
56 | if err != nil {
57 | return NetIP{}, fmt.Errorf("failed to parse CIDR %q: %w", ip, err)
58 | }
59 |
60 | return NetIP{Net: &ipNet}, nil
61 | }
62 |
63 | // String convert IP struct to string.
64 | func (ip NetIP) String() string {
65 | if ip.Net == nil {
66 | return ip.Addr.String()
67 | }
68 |
69 | return ip.Net.String()
70 | }
71 |
72 | // Contains Check is the IP is the same or in the same subnet.
73 | func (ip NetIP) Contains(i string) bool {
74 | rip, err := netip.ParseAddr(i)
75 | if err != nil {
76 | log.Printf("%s is not a valid IP or IP/Net: %s", i, err.Error())
77 |
78 | return false
79 | }
80 |
81 | if ip.Net == nil {
82 | return ip.Addr == rip
83 | }
84 |
85 | return ip.Net.Contains(rip)
86 | }
87 |
88 | type NetIPs []NetIP
89 |
90 | // Contains Check is the IP is the same or in the same subnet.
91 | func (netIPs NetIPs) Contains(ip string) bool {
92 | rip, err := netip.ParseAddr(ip)
93 | if err != nil {
94 | log.Printf("failed to parse %q: %s", ip, err.Error())
95 |
96 | return false
97 | }
98 |
99 | for _, netIP := range netIPs {
100 | if netIP.Net == nil {
101 | if netIP.Addr == rip {
102 | return true
103 | }
104 |
105 | continue
106 | }
107 |
108 | if netIP.Net.Contains(rip) {
109 | return true
110 | }
111 | }
112 |
113 | return false
114 | }
115 |
--------------------------------------------------------------------------------
/pkg/ipchecking/ipChecking_test.go:
--------------------------------------------------------------------------------
1 | package ipchecking_test
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/tomMoulard/fail2ban/pkg/ipchecking"
8 | )
9 |
10 | func Example() {
11 | // Parse multiple IPs/CIDRS
12 | ips, err := ipchecking.ParseNetIPs([]string{
13 | "127.0.0.1",
14 | "10.0.0.0/24", // 10.0.0.1-10.0.0.254
15 | "::1",
16 | "2001:db8::/32",
17 | })
18 | if err != nil {
19 | panic(err)
20 | }
21 |
22 | // Check if an IP is either in the list, or in the list networks
23 | fmt.Println(ips.Contains("")) // false (empty string is not an IP)
24 | fmt.Println(ips.Contains("127.0.0.1")) // true
25 | fmt.Println(ips.Contains("127.0.0.2")) // false
26 | fmt.Println(ips.Contains("10.0.0.42")) // true
27 | fmt.Println(ips.Contains("::1")) // true
28 | fmt.Println(ips.Contains("2001:db8:beba:cafe::1:2")) // true
29 | fmt.Println(ips.Contains("64:ff9b::127.0.0.1")) // false
30 |
31 | // Output:
32 | // false
33 | // true
34 | // false
35 | // true
36 | // true
37 | // true
38 | // false
39 | }
40 |
41 | func TestNetIPParseNetIP(t *testing.T) {
42 | t.Parallel()
43 |
44 | tests := []struct {
45 | name string
46 | stringIP string
47 | res bool
48 | }{
49 | {
50 | name: "Valid IPv4",
51 | stringIP: "127.0.0.1",
52 | res: false,
53 | },
54 | {
55 | name: "Invalid IPv4 value 8 first bits",
56 | stringIP: "25666.0.0.1",
57 | res: true,
58 | },
59 | {
60 | name: "Invalid IPv4 value 8 second bits",
61 | stringIP: "127.4444.0.1",
62 | res: true,
63 | },
64 | {
65 | name: "Invalid IPv4 value 8 third bits",
66 | stringIP: "127.0.4440.1",
67 | res: true,
68 | },
69 | {
70 | name: "Invalid IPv4 value 8 last bits",
71 | stringIP: "127.0.0.1233",
72 | res: true,
73 | },
74 | {
75 | name: "Invalid IPv4 CIDR form",
76 | stringIP: "127.0.0.1/22/34",
77 | res: true,
78 | },
79 | {
80 | name: "Invalid IPv4 CIDR ",
81 | stringIP: "127.0.0.1/55",
82 | res: true,
83 | },
84 | {
85 | name: "Missing IPv4 CIDR ",
86 | stringIP: "127.0.0.1/",
87 | res: true,
88 | },
89 | {
90 | name: "Valid IPv4 CIDR ",
91 | stringIP: "127.0.0.1/23",
92 | res: false,
93 | },
94 | {
95 | name: "Valid IPv6",
96 | stringIP: "::1",
97 | res: false,
98 | },
99 | {
100 | name: "Invalid IPv6 value 8 first bits",
101 | stringIP: "2566634::1",
102 | res: true,
103 | },
104 | {
105 | name: "Invalid IPv6 value 8 second bits",
106 | stringIP: "127:444234564:0::1",
107 | res: true,
108 | },
109 | {
110 | name: "Invalid IPv6 value 8 third bits",
111 | stringIP: "1::4440345::1",
112 | res: true,
113 | },
114 | {
115 | name: "Invalid IPv6 value 8 last bits",
116 | stringIP: "::34561233",
117 | res: true,
118 | },
119 | {
120 | name: "Invalid IPv6 CIDR form",
121 | stringIP: "::1/22/34",
122 | res: true,
123 | },
124 | {
125 | name: "Invalid IPv6 CIDR ",
126 | stringIP: "::1/234",
127 | res: true,
128 | },
129 | {
130 | name: "Missing IPv6 CIDR ",
131 | stringIP: "::1/",
132 | res: true,
133 | },
134 | {
135 | name: "Valid IPv6 CIDR ",
136 | stringIP: "::1/53",
137 | res: false,
138 | },
139 | }
140 |
141 | for _, test := range tests {
142 | t.Run(test.name, func(t *testing.T) {
143 | t.Parallel()
144 |
145 | _, err := ipchecking.ParseNetIP(test.stringIP)
146 | if test.res != (err != nil) {
147 | t.Errorf("ParseNetIP() = %v, want %v", err, test.res)
148 | }
149 | })
150 | }
151 | }
152 |
153 | func TestNetIPParseNetIPs(t *testing.T) {
154 | t.Parallel()
155 |
156 | tests := []struct {
157 | name string
158 | ips []string
159 | expectedIPs []string
160 | expectErr bool
161 | }{
162 | {
163 | name: "valid IPv4",
164 | ips: []string{"127.0.0.1", "127.0.0.2"},
165 | expectedIPs: []string{"127.0.0.1", "127.0.0.2"},
166 | expectErr: false,
167 | },
168 | {
169 | name: "valid IPv6",
170 | ips: []string{"::1", "::2"},
171 | expectedIPs: []string{"::1", "::2"},
172 | expectErr: false,
173 | },
174 | {
175 | name: "invalid IPv4",
176 | ips: []string{"127.0.0.1.1", "127.0.0.2.42"},
177 | expectErr: true,
178 | },
179 | {
180 | name: "invalid IPv6",
181 | ips: []string{"::1", "::2::42:"},
182 | expectErr: true,
183 | },
184 | }
185 |
186 | for _, test := range tests {
187 | t.Run(test.name, func(t *testing.T) {
188 | t.Parallel()
189 |
190 | got, err := ipchecking.ParseNetIPs(test.ips)
191 | if test.expectErr != (err != nil) {
192 | t.Errorf("ParseNetIPs() = %v, want %v", err, test.expectErr)
193 | }
194 |
195 | for i, gotIP := range got {
196 | if test.expectedIPs[i] != gotIP.String() {
197 | t.Errorf("ParseNetIPs() = %q, want %q", gotIP.String(), test.expectedIPs[i])
198 | }
199 | }
200 | })
201 | }
202 | }
203 |
204 | func helpParseNetIP(t *testing.T, ip string) ipchecking.NetIP {
205 | t.Helper()
206 |
207 | nip, err := ipchecking.ParseNetIP(ip)
208 | if err != nil {
209 | t.Errorf("Error in IP building: %s, with err %v", nip, err)
210 | }
211 |
212 | return nip
213 | }
214 |
215 | func TestNetIPContains(t *testing.T) {
216 | t.Parallel()
217 |
218 | ipv4 := helpParseNetIP(t, "127.0.0.1")
219 | ipv42 := helpParseNetIP(t, "127.0.0.2")
220 | cidrv41 := helpParseNetIP(t, "127.0.0.1/24")
221 | cidrv42 := helpParseNetIP(t, "127.0.1.1/24")
222 |
223 | ipv6 := helpParseNetIP(t, "::1")
224 | ipv62 := helpParseNetIP(t, "::2")
225 | cidrv61 := helpParseNetIP(t, "::1/124")
226 | cidrv62 := helpParseNetIP(t, "::1:1/124")
227 |
228 | tests := []struct {
229 | name string
230 | stringIP string
231 | testedIP ipchecking.NetIP
232 | res bool
233 | }{
234 | {
235 | name: "IPv4 match",
236 | stringIP: "127.0.0.1",
237 | testedIP: ipv4,
238 | res: true,
239 | },
240 | {
241 | name: "IPv4 No Match",
242 | stringIP: "127.0.0.1",
243 | testedIP: ipv42,
244 | res: false,
245 | },
246 | {
247 | name: "IPv4 No Match",
248 | stringIP: "127.0.0.1",
249 | testedIP: cidrv42,
250 | res: false,
251 | },
252 | {
253 | name: "IPv4 Match",
254 | stringIP: "127.0.0.1",
255 | testedIP: cidrv41,
256 | res: true,
257 | },
258 | {
259 | name: "IPv6 match",
260 | stringIP: "::1",
261 | testedIP: ipv6,
262 | res: true,
263 | },
264 | {
265 | name: "IPv6 No Match",
266 | stringIP: "::1",
267 | testedIP: ipv62,
268 | res: false,
269 | },
270 | {
271 | name: "IPv6 No Match",
272 | stringIP: "::1",
273 | testedIP: cidrv62,
274 | res: false,
275 | },
276 | {
277 | name: "IPv6 Match",
278 | stringIP: "::1",
279 | testedIP: cidrv61,
280 | res: true,
281 | },
282 | {
283 | name: "invalid IPv4",
284 | stringIP: "127.0.0.1.42",
285 | testedIP: cidrv41,
286 | res: false,
287 | },
288 | {
289 | name: "invalid IPv6",
290 | stringIP: "::1::",
291 | testedIP: cidrv61,
292 | res: false,
293 | },
294 | }
295 |
296 | for _, test := range tests {
297 | t.Run(test.name, func(t *testing.T) {
298 | t.Parallel()
299 |
300 | r := test.testedIP.Contains(test.stringIP)
301 | if test.res != r {
302 | t.Errorf("Contains() = %v, want %v", r, test.res)
303 | }
304 | })
305 | }
306 | }
307 |
308 | func helpParseNetIPs(t *testing.T, ips []string) ipchecking.NetIPs {
309 | t.Helper()
310 |
311 | nip, err := ipchecking.ParseNetIPs(ips)
312 | if err != nil {
313 | t.Errorf("Error in IP building: %q, with err %v", nip, err)
314 | }
315 |
316 | return nip
317 | }
318 |
319 | func TestNetIPsContains(t *testing.T) {
320 | t.Parallel()
321 |
322 | ips := helpParseNetIPs(t, []string{
323 | "127.0.0.1",
324 | "10.0.0.0/24",
325 | "::1",
326 | "2001:db8::/32",
327 | })
328 |
329 | tests := []struct {
330 | name string
331 | stringIP string
332 | testedIP ipchecking.NetIP
333 | res bool
334 | }{
335 | {
336 | name: "IPv4 match",
337 | stringIP: "127.0.0.1",
338 | res: true,
339 | },
340 | {
341 | name: "IPv4 match CIDR",
342 | stringIP: "10.0.0.1",
343 | res: true,
344 | },
345 | {
346 | name: "IPv4 No Match",
347 | stringIP: "11.0.0.1",
348 | res: false,
349 | },
350 | {
351 | name: "IPv6 match",
352 | stringIP: "::1",
353 | res: true,
354 | },
355 | {
356 | name: "IPv6 match CIDR",
357 | stringIP: "2001:db8:beba:cafe::1:2",
358 | res: true,
359 | },
360 | {
361 | name: "IPv6 No Match",
362 | stringIP: "ff0X::101",
363 | res: false,
364 | },
365 | {
366 | name: "invalid IPv4",
367 | stringIP: "127.0.0.1.42",
368 | res: false,
369 | },
370 | {
371 | name: "invalid IPv6",
372 | stringIP: "::1::",
373 | res: false,
374 | },
375 | }
376 |
377 | for _, test := range tests {
378 | t.Run(test.name, func(t *testing.T) {
379 | t.Parallel()
380 |
381 | r := ips.Contains(test.stringIP)
382 | if test.res != r {
383 | t.Errorf("Contains() = %v, want %v", r, test.res)
384 | }
385 | })
386 | }
387 | }
388 |
389 | func TestNetIPString(t *testing.T) {
390 | t.Parallel()
391 |
392 | ipv4 := helpParseNetIP(t, "127.0.0.1/32")
393 | ipv6 := helpParseNetIP(t, "::1/128")
394 |
395 | tests := []struct {
396 | name string
397 | testedIP string
398 | stringIP ipchecking.NetIP
399 | expectIsEqual bool
400 | }{
401 | {
402 | name: "Valid IPv4 string",
403 | testedIP: "127.0.0.1/32",
404 | stringIP: ipv4,
405 | expectIsEqual: true,
406 | },
407 | {
408 | name: "Invalid IPv4 string",
409 | testedIP: "127.0.0.2/32",
410 | stringIP: ipv4,
411 | expectIsEqual: false,
412 | },
413 | {
414 | name: "Valid IPv6 string",
415 | testedIP: "::1/128",
416 | stringIP: ipv6,
417 | expectIsEqual: true,
418 | },
419 | {
420 | name: "Invalid IPv6 string",
421 | testedIP: "::2/128",
422 | stringIP: ipv6,
423 | expectIsEqual: false,
424 | },
425 | }
426 |
427 | for _, test := range tests {
428 | t.Run(test.name, func(t *testing.T) {
429 | t.Parallel()
430 |
431 | r := test.stringIP.String()
432 | if test.expectIsEqual {
433 | if r != test.testedIP {
434 | t.Errorf("String() = %q, want %q", r, test.testedIP)
435 | }
436 | } else {
437 | if r == test.testedIP {
438 | t.Errorf("String() = %q, want not %q", r, test.testedIP)
439 | }
440 | }
441 | })
442 | }
443 | }
444 |
--------------------------------------------------------------------------------
/pkg/list/allow/allow.go:
--------------------------------------------------------------------------------
1 | // Package allow is a middleware that force allows requests from a list of IP addresses.
2 | package allow
3 |
4 | import (
5 | "errors"
6 | "fmt"
7 | "net/http"
8 |
9 | "github.com/tomMoulard/fail2ban/pkg/chain"
10 | "github.com/tomMoulard/fail2ban/pkg/data"
11 | "github.com/tomMoulard/fail2ban/pkg/ipchecking"
12 | )
13 |
14 | type allow struct {
15 | list ipchecking.NetIPs
16 | }
17 |
18 | func New(ipList []string) (*allow, error) {
19 | list, err := ipchecking.ParseNetIPs(ipList)
20 | if err != nil {
21 | return nil, fmt.Errorf("failed to create new net ips: %w", err)
22 | }
23 |
24 | return &allow{list: list}, nil
25 | }
26 |
27 | func (a *allow) ServeHTTP(w http.ResponseWriter, r *http.Request) (*chain.Status, error) {
28 | data := data.GetData(r)
29 | if data == nil {
30 | return nil, errors.New("failed to get data from request context")
31 | }
32 |
33 | fmt.Printf("data: %+v", data)
34 |
35 | if a.list.Contains(data.RemoteIP) {
36 | fmt.Printf("IP %s is allowed", data.RemoteIP)
37 |
38 | return &chain.Status{Break: true}, nil
39 | }
40 |
41 | fmt.Printf("IP %s not is allowed", data.RemoteIP)
42 |
43 | return nil, nil
44 | }
45 |
--------------------------------------------------------------------------------
/pkg/list/allow/allow_test.go:
--------------------------------------------------------------------------------
1 | package allow
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/assert"
9 | "github.com/stretchr/testify/require"
10 | "github.com/tomMoulard/fail2ban/pkg/chain"
11 | "github.com/tomMoulard/fail2ban/pkg/data"
12 | )
13 |
14 | func TestAllow(t *testing.T) {
15 | t.Parallel()
16 |
17 | tests := []struct {
18 | name string
19 | ipList []string
20 | expectedStatus *chain.Status
21 | }{
22 | {
23 | name: "allowed",
24 | ipList: []string{"192.0.2.1"},
25 | expectedStatus: &chain.Status{
26 | Break: true,
27 | },
28 | },
29 | {
30 | name: "denied",
31 | },
32 | }
33 |
34 | for _, test := range tests {
35 | t.Run(test.name, func(t *testing.T) {
36 | t.Parallel()
37 |
38 | a, err := New(test.ipList)
39 | require.NoError(t, err)
40 |
41 | recorder := &httptest.ResponseRecorder{}
42 | req := httptest.NewRequest(http.MethodGet, "https://example.com/foo", nil)
43 | req, err = data.ServeHTTP(recorder, req)
44 | require.NoError(t, err)
45 |
46 | got, err := a.ServeHTTP(recorder, req)
47 | require.NoError(t, err)
48 | assert.Equal(t, test.expectedStatus, got)
49 | })
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/pkg/list/deny/deny.go:
--------------------------------------------------------------------------------
1 | // Package deny is a middleware that force denies requests from a list of IP addresses.
2 | package deny
3 |
4 | import (
5 | "errors"
6 | "fmt"
7 | "net/http"
8 |
9 | "github.com/tomMoulard/fail2ban/pkg/chain"
10 | "github.com/tomMoulard/fail2ban/pkg/data"
11 | "github.com/tomMoulard/fail2ban/pkg/ipchecking"
12 | )
13 |
14 | type deny struct {
15 | list ipchecking.NetIPs
16 | }
17 |
18 | func New(ipList []string) (*deny, error) {
19 | list, err := ipchecking.ParseNetIPs(ipList)
20 | if err != nil {
21 | return nil, fmt.Errorf("failed to create new net ips: %w", err)
22 | }
23 |
24 | return &deny{list: list}, nil
25 | }
26 |
27 | func (d *deny) ServeHTTP(w http.ResponseWriter, r *http.Request) (*chain.Status, error) {
28 | data := data.GetData(r)
29 | if data == nil {
30 | return nil, errors.New("failed to get data from request context")
31 | }
32 |
33 | fmt.Printf("data: %+v", data)
34 |
35 | if d.list.Contains(data.RemoteIP) {
36 | fmt.Printf("IP %s is denied", data.RemoteIP)
37 |
38 | return &chain.Status{Return: true}, nil
39 | }
40 |
41 | fmt.Printf("IP %s not is denied", data.RemoteIP)
42 |
43 | return nil, nil
44 | }
45 |
--------------------------------------------------------------------------------
/pkg/list/deny/deny_test.go:
--------------------------------------------------------------------------------
1 | package deny
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "testing"
7 |
8 | "github.com/stretchr/testify/assert"
9 | "github.com/stretchr/testify/require"
10 | "github.com/tomMoulard/fail2ban/pkg/chain"
11 | "github.com/tomMoulard/fail2ban/pkg/data"
12 | )
13 |
14 | func TestDeny(t *testing.T) {
15 | t.Parallel()
16 |
17 | tests := []struct {
18 | name string
19 | ipList []string
20 | expectedStatus *chain.Status
21 | }{
22 | {
23 | name: "denied",
24 | ipList: []string{"192.0.2.1"},
25 | expectedStatus: &chain.Status{
26 | Return: true,
27 | },
28 | },
29 | {
30 | name: "not denied",
31 | },
32 | }
33 |
34 | for _, test := range tests {
35 | t.Run(test.name, func(t *testing.T) {
36 | t.Parallel()
37 |
38 | d, err := New(test.ipList)
39 | require.NoError(t, err)
40 |
41 | recorder := &httptest.ResponseRecorder{}
42 | req := httptest.NewRequest(http.MethodGet, "https://example.com/foo", nil)
43 | req, err = data.ServeHTTP(recorder, req)
44 | require.NoError(t, err)
45 |
46 | got, err := d.ServeHTTP(recorder, req)
47 | require.NoError(t, err)
48 | assert.Equal(t, test.expectedStatus, got)
49 | })
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/pkg/response/status/code_catcher.go:
--------------------------------------------------------------------------------
1 | package status
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "net"
7 | "net/http"
8 | )
9 |
10 | // Source: https://github.com/traefik/traefik/blob/05d2c86074a21d482945b9994d85e3b66de0480d/pkg/middlewares/customerrors/custom_errors.go
11 |
12 | // codeCatcher is a response writer that detects as soon as possible whether
13 | // the response is a code within the ranges of codes it watches for.
14 | // If it is, it simply drops the data from the response.
15 | // Otherwise, it forwards the data directly to the original client.
16 | // If the backend does not call WriteHeader, we consider it's a 200.
17 | type codeCatcher struct {
18 | headerMap http.Header
19 | code int
20 | httpCodeRanges HTTPCodeRanges
21 | caughtFilteredCode bool
22 | responseWriter http.ResponseWriter
23 | headersSent bool
24 |
25 | // bytes is used to store the response body in case of a filtered code that
26 | // is allowed.
27 | bytes []byte
28 | // allowedRequest is there in case of flush when the caughtFilteredCode is
29 | // set, but the request should be forwarded.
30 | allowedRequest bool
31 | }
32 |
33 | func newCodeCatcher(rw http.ResponseWriter, httpCodeRanges HTTPCodeRanges) *codeCatcher {
34 | return &codeCatcher{
35 | headerMap: make(http.Header),
36 | code: http.StatusOK,
37 | responseWriter: rw,
38 | httpCodeRanges: httpCodeRanges,
39 | }
40 | }
41 |
42 | func (cc *codeCatcher) Header() http.Header {
43 | if cc.headersSent {
44 | return cc.responseWriter.Header()
45 | }
46 |
47 | if cc.headerMap == nil {
48 | cc.headerMap = make(http.Header)
49 | }
50 |
51 | return cc.headerMap
52 | }
53 |
54 | func (cc *codeCatcher) getCode() int {
55 | return cc.code
56 | }
57 |
58 | // isFilteredCode returns whether the codeCatcher received a response code
59 | // among the ones it is watching, and for which the response should be deferred
60 | // to the fail2ban handler.
61 | func (cc *codeCatcher) isFilteredCode() bool {
62 | return cc.caughtFilteredCode
63 | }
64 |
65 | func (cc *codeCatcher) Write(buf []byte) (int, error) {
66 | // If WriteHeader was already called from the caller, this is a NOOP.
67 | // Otherwise, cc.code is actually a 200 here.
68 | cc.WriteHeader(cc.code)
69 |
70 | if cc.caughtFilteredCode {
71 | // We don't care about the contents of the response,
72 | // since we want to serve the forbidden page,
73 | // so we just save them for later if needed.
74 | cc.bytes = append(cc.bytes, buf...)
75 |
76 | return len(buf), nil
77 | }
78 |
79 | fmt.Printf("Write: buf: %q, code: %d", buf, cc.code)
80 |
81 | i, err := cc.responseWriter.Write(buf)
82 | if err != nil {
83 | return i, fmt.Errorf("failed to write to response: %w", err)
84 | }
85 |
86 | return i, nil
87 | }
88 |
89 | // WriteHeader is, in the specific case of 1xx status codes, a direct call to
90 | // the wrapped ResponseWriter, without marking headers as sent, allowing so
91 | // further calls.
92 | func (cc *codeCatcher) WriteHeader(code int) {
93 | if cc.headersSent || cc.caughtFilteredCode {
94 | return
95 | }
96 |
97 | fmt.Printf("Write header: code: %d", code)
98 |
99 | // Handling informational headers.
100 | if code >= 100 && code <= 199 {
101 | // Multiple informational status codes can be used,
102 | // so here the copy is not appending the values to not repeat them.
103 | for k, v := range cc.Header() {
104 | cc.responseWriter.Header()[k] = v
105 | }
106 |
107 | cc.responseWriter.WriteHeader(code)
108 |
109 | return
110 | }
111 |
112 | cc.code = code
113 | for _, block := range cc.httpCodeRanges {
114 | if cc.code >= block[0] && cc.code <= block[1] {
115 | cc.caughtFilteredCode = true
116 | // it will be up to the caller to send the headers,
117 | // so it is out of our hands now.
118 | return
119 | }
120 | }
121 |
122 | // The copy is not appending the values,
123 | // to not repeat them in case any informational status code has been written.
124 | for k, v := range cc.Header() {
125 | cc.responseWriter.Header()[k] = v
126 | }
127 |
128 | cc.responseWriter.WriteHeader(cc.code)
129 | cc.headersSent = true
130 | }
131 |
132 | // Hijack hijacks the connection.
133 | func (cc *codeCatcher) Hijack() (net.Conn, *bufio.ReadWriter, error) {
134 | if hj, ok := cc.responseWriter.(http.Hijacker); ok {
135 | conn, rw, err := hj.Hijack()
136 | if err != nil {
137 | return nil, nil, fmt.Errorf("failed to hijack connection: %w", err)
138 | }
139 |
140 | return conn, rw, nil
141 | }
142 |
143 | return nil, nil, fmt.Errorf("%T is not a http.Hijacker", cc.responseWriter)
144 | }
145 |
146 | // Flush sends any buffered data to the client.
147 | func (cc *codeCatcher) Flush() {
148 | // If WriteHeader was already called from the caller, this is a NOOP.
149 | // Otherwise, cc.code is actually a 200 here.
150 | cc.WriteHeader(cc.code)
151 |
152 | fmt.Printf("Flush: code: %d, caughtFilteredCode: %t", cc.code, cc.caughtFilteredCode)
153 |
154 | // We don't care about the contents of the response,
155 | // since we want to serve the forbidden page,
156 | // so we just don't flush.
157 | // (e.g., To prevent superfluous WriteHeader on request with a
158 | // `Transfert-Encoding: chunked` header).
159 | if cc.caughtFilteredCode && !cc.allowedRequest {
160 | return
161 | }
162 |
163 | if flusher, ok := cc.responseWriter.(http.Flusher); ok {
164 | flusher.Flush()
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/pkg/response/status/http_code_range.go:
--------------------------------------------------------------------------------
1 | package status
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 | "strings"
7 | )
8 |
9 | // Source: https://github.com/traefik/traefik/blob/05d2c86074a21d482945b9994d85e3b66de0480d/pkg/types/http_code_range.go
10 |
11 | // HTTPCodeRanges holds HTTP code ranges.
12 | type HTTPCodeRanges [][2]int
13 |
14 | // NewHTTPCodeRanges creates HTTPCodeRanges from a given []string.
15 | // Break out the http status code ranges into a low int and high int
16 | // for ease of use at runtime.
17 | func NewHTTPCodeRanges(strBlocks []string) (HTTPCodeRanges, error) {
18 | blocks := make(HTTPCodeRanges, 0, len(strBlocks))
19 |
20 | for _, block := range strBlocks {
21 | codes := strings.Split(block, "-")
22 | // if only a single HTTP code was configured, assume the best and create the correct configuration on the user's behalf
23 | if len(codes) == 1 {
24 | codes = append(codes, codes[0])
25 | }
26 |
27 | lowCode, err := strconv.Atoi(codes[0])
28 | if err != nil {
29 | return nil, fmt.Errorf("failed to parse HTTP code: %w", err)
30 | }
31 |
32 | highCode, err := strconv.Atoi(codes[1])
33 | if err != nil {
34 | return nil, fmt.Errorf("failed to parse HTTP code: %w", err)
35 | }
36 |
37 | blocks = append(blocks, [2]int{lowCode, highCode})
38 | }
39 |
40 | return blocks, nil
41 | }
42 |
43 | // Contains tests whether the passed status code is within one of its HTTP code ranges.
44 | func (h HTTPCodeRanges) Contains(statusCode int) bool {
45 | for ranges := range h {
46 | if statusCode >= h[ranges][0] && statusCode <= h[ranges][1] {
47 | return true
48 | }
49 | }
50 |
51 | return false
52 | }
53 |
--------------------------------------------------------------------------------
/pkg/response/status/http_code_range_test.go:
--------------------------------------------------------------------------------
1 | package status
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestContains(t *testing.T) {
10 | t.Parallel()
11 |
12 | tests := []struct {
13 | name string
14 | ranges HTTPCodeRanges
15 | statusCode int
16 | expected assert.BoolAssertionFunc
17 | }{
18 | {
19 | name: "empty",
20 | ranges: HTTPCodeRanges{},
21 | statusCode: 200,
22 | expected: assert.False,
23 | },
24 | {
25 | name: "single",
26 | ranges: HTTPCodeRanges{{200, 200}},
27 | statusCode: 200,
28 | expected: assert.True,
29 | },
30 | {
31 | name: "single out of range high",
32 | ranges: HTTPCodeRanges{{200, 200}},
33 | statusCode: 201,
34 | expected: assert.False,
35 | },
36 | {
37 | name: "single out of range low",
38 | ranges: HTTPCodeRanges{{200, 200}},
39 | statusCode: 199,
40 | expected: assert.False,
41 | },
42 | }
43 |
44 | for _, test := range tests {
45 | t.Run(test.name, func(t *testing.T) {
46 | t.Parallel()
47 |
48 | test.expected(t, test.ranges.Contains(test.statusCode))
49 | })
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/pkg/response/status/status.go:
--------------------------------------------------------------------------------
1 | // Package status is a middleware that denies requests from the status of the answer
2 | package status
3 |
4 | import (
5 | "fmt"
6 | "net/http"
7 | "strings"
8 |
9 | "github.com/tomMoulard/fail2ban/pkg/data"
10 | "github.com/tomMoulard/fail2ban/pkg/fail2ban"
11 | )
12 |
13 | type status struct {
14 | next http.Handler
15 | codeRanges HTTPCodeRanges
16 | f2b *fail2ban.Fail2Ban
17 | }
18 |
19 | func New(next http.Handler, statusCode string, f2b *fail2ban.Fail2Ban) (*status, error) {
20 | codeRanges, err := NewHTTPCodeRanges(strings.Split(statusCode, ","))
21 | if err != nil {
22 | return nil, fmt.Errorf("failed to create HTTP code ranges: %w", err)
23 | }
24 |
25 | return &status{
26 | next: next,
27 | codeRanges: codeRanges,
28 | f2b: f2b,
29 | }, nil
30 | }
31 |
32 | func (s *status) ServeHTTP(w http.ResponseWriter, r *http.Request) {
33 | fmt.Printf("status handler")
34 |
35 | data := data.GetData(r)
36 | if data == nil {
37 | fmt.Print("data is nil")
38 |
39 | return
40 | }
41 |
42 | fmt.Printf("data: %+v", data)
43 |
44 | catcher := newCodeCatcher(w, s.codeRanges)
45 | s.next.ServeHTTP(catcher, r)
46 |
47 | fmt.Printf("catcher: %+v", *catcher)
48 |
49 | if !catcher.isFilteredCode() { // if this is not a status code of concern: Return and do not increment fail counter.
50 | w.WriteHeader(catcher.getCode())
51 |
52 | return
53 | }
54 |
55 | catcher.allowedRequest = s.f2b.ShouldAllow(data.RemoteIP)
56 | if !catcher.allowedRequest {
57 | fmt.Printf("IP %s is banned", data.RemoteIP)
58 | w.WriteHeader(http.StatusForbidden)
59 |
60 | return
61 | }
62 |
63 | fmt.Printf("IP %s is allowed", data.RemoteIP)
64 | w.WriteHeader(catcher.getCode())
65 |
66 | if _, err := w.Write(catcher.bytes); err != nil {
67 | fmt.Printf("failed to write to response: %v", err)
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/pkg/response/status/status_test.go:
--------------------------------------------------------------------------------
1 | package status
2 |
3 | import (
4 | "bytes"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 | "time"
9 |
10 | "github.com/stretchr/testify/assert"
11 | "github.com/stretchr/testify/require"
12 | "github.com/tomMoulard/fail2ban/pkg/data"
13 | "github.com/tomMoulard/fail2ban/pkg/fail2ban"
14 | "github.com/tomMoulard/fail2ban/pkg/ipchecking"
15 | "github.com/tomMoulard/fail2ban/pkg/rules"
16 | utime "github.com/tomMoulard/fail2ban/pkg/utils/time"
17 | )
18 |
19 | func TestStatus(t *testing.T) {
20 | t.Parallel()
21 |
22 | body := "Hello, world!"
23 |
24 | tests := []struct {
25 | name string
26 | codeRanges string
27 | ips map[string]ipchecking.IPViewed
28 | respStatusCode int
29 | expectedStatus int
30 | expectedIPViewed map[string]ipchecking.IPViewed
31 | expectedBody string
32 | }{
33 | {
34 | name: "already denied", // should not happen as it should already be blocked
35 | codeRanges: "400-499",
36 | respStatusCode: http.StatusBadRequest,
37 | ips: map[string]ipchecking.IPViewed{
38 | "192.0.2.1": {
39 | Viewed: utime.Now(),
40 | Count: 42,
41 | Denied: true,
42 | },
43 | },
44 | expectedIPViewed: map[string]ipchecking.IPViewed{
45 | "192.0.2.1": {
46 | Viewed: utime.Now(),
47 | Count: 43,
48 | Denied: true,
49 | },
50 | },
51 | expectedStatus: http.StatusForbidden,
52 | },
53 | {
54 | name: "is being denied",
55 | codeRanges: "400-499",
56 | respStatusCode: http.StatusBadRequest,
57 | ips: map[string]ipchecking.IPViewed{
58 | "192.0.2.1": {
59 | Viewed: utime.Now(),
60 | Count: 42,
61 | Denied: false,
62 | },
63 | },
64 | expectedIPViewed: map[string]ipchecking.IPViewed{
65 | "192.0.2.1": {
66 | Viewed: utime.Now(),
67 | Count: 43,
68 | Denied: true,
69 | },
70 | },
71 | expectedStatus: http.StatusForbidden,
72 | },
73 | {
74 | name: "not denied in limits",
75 | codeRanges: "400-499",
76 | respStatusCode: http.StatusBadRequest,
77 | ips: map[string]ipchecking.IPViewed{},
78 | expectedIPViewed: map[string]ipchecking.IPViewed{
79 | "192.0.2.1": {
80 | Viewed: utime.Now(),
81 | Count: 1,
82 | Denied: false,
83 | },
84 | },
85 | expectedStatus: http.StatusBadRequest,
86 | expectedBody: body,
87 | },
88 | {
89 | name: "not denied out of limits",
90 | codeRanges: "400-499",
91 | respStatusCode: http.StatusOK,
92 | ips: map[string]ipchecking.IPViewed{},
93 | expectedIPViewed: map[string]ipchecking.IPViewed{},
94 | expectedStatus: http.StatusOK,
95 | expectedBody: body,
96 | },
97 | }
98 |
99 | for _, test := range tests {
100 | t.Run(test.name, func(t *testing.T) {
101 | t.Parallel()
102 |
103 | next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
104 | assert.NotZero(t, test.respStatusCode)
105 | w.WriteHeader(test.respStatusCode)
106 | t.Logf("status code: %d", test.respStatusCode)
107 |
108 | _, err := w.Write([]byte(body))
109 | assert.NoError(t, err)
110 | })
111 |
112 | f2b := fail2ban.New(rules.RulesTransformed{
113 | MaxRetry: 1,
114 | Findtime: 300 * time.Second,
115 | Bantime: 300 * time.Second,
116 | })
117 | f2b.IPs = test.ips
118 | d, err := New(next, test.codeRanges, f2b)
119 | require.NoError(t, err)
120 |
121 | recorder := &httptest.ResponseRecorder{}
122 | req := httptest.NewRequest(http.MethodGet, "https://example.com/foo", nil)
123 | req, err = data.ServeHTTP(recorder, req)
124 | require.NoError(t, err)
125 |
126 | var b bytes.Buffer
127 | recorder = &httptest.ResponseRecorder{Body: &b}
128 | d.ServeHTTP(recorder, req)
129 | t.Logf("recorder: %+v", recorder)
130 |
131 | require.Equal(t, len(test.expectedIPViewed), len(f2b.IPs))
132 |
133 | // workaround for time.Now() not matching between expected and actual
134 | for k, v := range test.expectedIPViewed {
135 | assert.Contains(t, f2b.IPs, k)
136 |
137 | // copy timestamp, as it will not match otherwise. Then compare
138 | v.Viewed = f2b.IPs[k].Viewed
139 | assert.Equal(t, v, f2b.IPs[k])
140 | }
141 |
142 | assert.Equal(t, test.expectedStatus, recorder.Code)
143 | require.NotNil(t, recorder.Body)
144 | assert.Equal(t, test.expectedBody, recorder.Body.String())
145 | })
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/pkg/rules/rules.go:
--------------------------------------------------------------------------------
1 | // Package rules contains the rules for the fail2ban plugin.
2 | package rules
3 |
4 | import (
5 | "fmt"
6 | "log"
7 | "regexp"
8 | "time"
9 | )
10 |
11 | // Urlregexp struct.
12 | type Urlregexp struct {
13 | Regexp string `yaml:"regexp"`
14 | Mode string `yaml:"mode"`
15 | }
16 |
17 | // Rules struct fail2ban config.
18 | type Rules struct {
19 | Bantime string `yaml:"bantime"` // exprimate in a smart way: 3m
20 | Enabled bool `yaml:"enabled"` // enable or disable the jail
21 | Findtime string `yaml:"findtime"` // exprimate in a smart way: 3m
22 | Maxretry int `yaml:"maxretry"`
23 | Urlregexps []Urlregexp `yaml:"urlregexps"`
24 | StatusCode string `yaml:"statuscode"`
25 | }
26 |
27 | // RulesTransformed transformed Rules struct.
28 | type RulesTransformed struct {
29 | Bantime time.Duration
30 | Findtime time.Duration
31 | URLRegexpAllow []*regexp.Regexp
32 | URLRegexpBan []*regexp.Regexp
33 | MaxRetry int
34 | Enabled bool
35 | StatusCode string
36 | }
37 |
38 | // TransformRule morph a Rules object into a RulesTransformed.
39 | func TransformRule(r Rules) (RulesTransformed, error) {
40 | bantime, err := time.ParseDuration(r.Bantime)
41 | if err != nil {
42 | return RulesTransformed{}, fmt.Errorf("failed to parse bantime duration: %w", err)
43 | }
44 |
45 | findtime, err := time.ParseDuration(r.Findtime)
46 | if err != nil {
47 | return RulesTransformed{}, fmt.Errorf("failed to parse findtime duration: %w", err)
48 | }
49 |
50 | var regexpAllow []*regexp.Regexp
51 |
52 | var regexpBan []*regexp.Regexp
53 |
54 | for _, rg := range r.Urlregexps {
55 | re, err := regexp.Compile(rg.Regexp)
56 | if err != nil {
57 | return RulesTransformed{}, fmt.Errorf("failed to compile regexp %q: %w", rg.Regexp, err)
58 | }
59 |
60 | switch rg.Mode {
61 | case "allow":
62 | regexpAllow = append(regexpAllow, re)
63 | case "block":
64 | regexpBan = append(regexpBan, re)
65 | default:
66 | log.Printf("mode %q is not known, the rule %q cannot not be applied", rg.Mode, rg.Regexp)
67 | }
68 | }
69 |
70 | rules := RulesTransformed{
71 | Bantime: bantime,
72 | Findtime: findtime,
73 | URLRegexpAllow: regexpAllow,
74 | URLRegexpBan: regexpBan,
75 | MaxRetry: r.Maxretry,
76 | Enabled: r.Enabled,
77 | StatusCode: r.StatusCode,
78 | }
79 |
80 | return rules, nil
81 | }
82 |
--------------------------------------------------------------------------------
/pkg/rules/rules_test.go:
--------------------------------------------------------------------------------
1 | package rules
2 |
3 | import "testing"
4 |
5 | func TestTransformRules(t *testing.T) {
6 | t.Parallel()
7 |
8 | tests := []struct {
9 | name string
10 | send Rules
11 | expect RulesTransformed
12 | err error
13 | }{
14 | {
15 | name: "dummy",
16 | send: Rules{
17 | Bantime: "300s",
18 | Findtime: "120s",
19 | Enabled: true,
20 | },
21 | expect: RulesTransformed{},
22 | },
23 | }
24 | for _, test := range tests {
25 | t.Run(test.name, func(t *testing.T) {
26 | t.Parallel()
27 |
28 | got, e := TransformRule(test.send)
29 | if e != nil && (test.err == nil || e.Error() != test.err.Error()) {
30 | t.Errorf("TransformRule_err: wanted %q got %q",
31 | test.err, e)
32 | }
33 |
34 | if test.expect.Bantime == got.Bantime {
35 | t.Errorf("TransformRule: wanted '%+v' got '%+v'",
36 | test.expect, got)
37 | }
38 | })
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/pkg/url/allow/allow.go:
--------------------------------------------------------------------------------
1 | // Package allow is a middleware that force allows requests from a list of regexps.
2 | package allow
3 |
4 | import (
5 | "fmt"
6 | "net/http"
7 | "regexp"
8 |
9 | "github.com/tomMoulard/fail2ban/pkg/chain"
10 | )
11 |
12 | type allow struct {
13 | regs []*regexp.Regexp
14 | }
15 |
16 | func New(regs []*regexp.Regexp) *allow {
17 | return &allow{regs: regs}
18 | }
19 |
20 | func (a *allow) ServeHTTP(w http.ResponseWriter, r *http.Request) (*chain.Status, error) {
21 | for _, reg := range a.regs {
22 | if reg.MatchString(r.URL.String()) {
23 | fmt.Printf("url %s not allowed", r.URL.String())
24 |
25 | return &chain.Status{Break: true}, nil
26 | }
27 | }
28 |
29 | fmt.Printf("url %s not is allowed", r.URL.String())
30 |
31 | return nil, nil
32 | }
33 |
--------------------------------------------------------------------------------
/pkg/url/allow/allow_test.go:
--------------------------------------------------------------------------------
1 | package allow
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "regexp"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 | "github.com/tomMoulard/fail2ban/pkg/chain"
12 | "github.com/tomMoulard/fail2ban/pkg/data"
13 | )
14 |
15 | func TestAllow(t *testing.T) {
16 | t.Parallel()
17 |
18 | tests := []struct {
19 | name string
20 | regs []*regexp.Regexp
21 | expectedStatus *chain.Status
22 | }{
23 | {
24 | name: "allowed",
25 | regs: []*regexp.Regexp{regexp.MustCompile(`^https://example.com/foo$`)},
26 | expectedStatus: &chain.Status{
27 | Break: true,
28 | },
29 | },
30 | {
31 | name: "denied",
32 | },
33 | }
34 |
35 | for _, test := range tests {
36 | t.Run(test.name, func(t *testing.T) {
37 | t.Parallel()
38 |
39 | a := New(test.regs)
40 |
41 | recorder := &httptest.ResponseRecorder{}
42 | req := httptest.NewRequest(http.MethodGet, "https://example.com/foo", nil)
43 | req, err := data.ServeHTTP(recorder, req)
44 | require.NoError(t, err)
45 |
46 | got, err := a.ServeHTTP(recorder, req)
47 | require.NoError(t, err)
48 | assert.Equal(t, test.expectedStatus, got)
49 | })
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/pkg/url/deny/deny.go:
--------------------------------------------------------------------------------
1 | // Package deny is a middleware that force denies requests from a list of IP addresses.
2 | package deny
3 |
4 | import (
5 | "errors"
6 | "fmt"
7 | "net/http"
8 | "regexp"
9 |
10 | "github.com/tomMoulard/fail2ban/pkg/chain"
11 | "github.com/tomMoulard/fail2ban/pkg/data"
12 | "github.com/tomMoulard/fail2ban/pkg/fail2ban"
13 | "github.com/tomMoulard/fail2ban/pkg/ipchecking"
14 | "github.com/tomMoulard/fail2ban/pkg/utils/time"
15 | )
16 |
17 | type deny struct {
18 | regs []*regexp.Regexp
19 |
20 | f2b *fail2ban.Fail2Ban
21 | }
22 |
23 | func New(regs []*regexp.Regexp, f2b *fail2ban.Fail2Ban) *deny {
24 | return &deny{
25 | regs: regs,
26 | f2b: f2b,
27 | }
28 | }
29 |
30 | func (d *deny) ServeHTTP(w http.ResponseWriter, r *http.Request) (*chain.Status, error) {
31 | data := data.GetData(r)
32 | if data == nil {
33 | return nil, errors.New("failed to get data from request context")
34 | }
35 |
36 | fmt.Printf("data: %+v", data)
37 |
38 | d.f2b.MuIP.Lock()
39 | defer d.f2b.MuIP.Unlock()
40 |
41 | ip := d.f2b.IPs[data.RemoteIP]
42 |
43 | for _, reg := range d.regs {
44 | if reg.MatchString(r.URL.String()) {
45 | d.f2b.IPs[data.RemoteIP] = ipchecking.IPViewed{
46 | Viewed: time.Now(),
47 | Count: ip.Count + 1,
48 | Denied: true,
49 | }
50 |
51 | fmt.Printf("Url (%q) was matched by regexpBan: %q", r.URL.String(), reg.String())
52 |
53 | return &chain.Status{Return: true}, nil
54 | }
55 | }
56 |
57 | return nil, nil
58 | }
59 |
--------------------------------------------------------------------------------
/pkg/url/deny/deny_test.go:
--------------------------------------------------------------------------------
1 | package deny
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "regexp"
7 | "testing"
8 |
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 | "github.com/tomMoulard/fail2ban/pkg/chain"
12 | "github.com/tomMoulard/fail2ban/pkg/data"
13 | "github.com/tomMoulard/fail2ban/pkg/fail2ban"
14 | "github.com/tomMoulard/fail2ban/pkg/ipchecking"
15 | "github.com/tomMoulard/fail2ban/pkg/rules"
16 | "github.com/tomMoulard/fail2ban/pkg/utils/time"
17 | )
18 |
19 | func TestDeny(t *testing.T) {
20 | t.Parallel()
21 |
22 | tests := []struct {
23 | name string
24 | regs []*regexp.Regexp
25 | expectedStatus *chain.Status
26 | expectedIPViewed map[string]ipchecking.IPViewed
27 | }{
28 | {
29 | name: "denied",
30 | regs: []*regexp.Regexp{regexp.MustCompile(`^https://example.com/foo$`)},
31 | expectedStatus: &chain.Status{
32 | Return: true,
33 | },
34 | expectedIPViewed: map[string]ipchecking.IPViewed{
35 | "192.0.2.1": {
36 | Viewed: time.Now(),
37 | Count: 1,
38 | Denied: true,
39 | },
40 | },
41 | },
42 | {
43 | name: "not denied",
44 | expectedIPViewed: map[string]ipchecking.IPViewed{},
45 | },
46 | }
47 |
48 | for _, test := range tests {
49 | t.Run(test.name, func(t *testing.T) {
50 | t.Parallel()
51 |
52 | f2b := fail2ban.New(rules.RulesTransformed{})
53 | d := New(test.regs, f2b)
54 |
55 | recorder := &httptest.ResponseRecorder{}
56 | req := httptest.NewRequest(http.MethodGet, "https://example.com/foo", nil)
57 | req, err := data.ServeHTTP(recorder, req)
58 | require.NoError(t, err)
59 |
60 | got, err := d.ServeHTTP(recorder, req)
61 | require.NoError(t, err)
62 | assert.Equal(t, test.expectedStatus, got)
63 | require.Equal(t, len(test.expectedIPViewed), len(f2b.IPs))
64 |
65 | // workaround for time.Now() not matching between expected and actual
66 | for k, v := range test.expectedIPViewed {
67 | assert.Contains(t, f2b.IPs, k)
68 |
69 | // copy timestamp, as it will not match otherwise. Then compare
70 | v.Viewed = f2b.IPs[k].Viewed
71 | assert.Equal(t, v, f2b.IPs[k])
72 | }
73 | })
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/pkg/utils/time/time-test.go:
--------------------------------------------------------------------------------
1 | //go:build TEST
2 |
3 | // Package time is a wrapper over the stdlib time package.
4 | package time
5 |
6 | import "time"
7 |
8 | func Now() time.Time {
9 | return time.Date(2021, 10, 21, 14, 44, 38, 0, time.UTC) // The first commit here !
10 | }
11 |
--------------------------------------------------------------------------------
/pkg/utils/time/time.go:
--------------------------------------------------------------------------------
1 | //go:build !TEST
2 |
3 | // Package time is a wrapper over the stdlib time package.
4 | package time
5 |
6 | import "time"
7 |
8 | func Now() time.Time {
9 | return time.Now()
10 | }
11 |
--------------------------------------------------------------------------------
/tests/test-ipfile.txt:
--------------------------------------------------------------------------------
1 | 192.168.0.0
2 | 255.0.0.0
3 | 42.42.42.42
4 | 13.38.70.00
5 |
--------------------------------------------------------------------------------