├── .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 | [![Build Status](https://github.com/tomMoulard/fail2ban/actions/workflows/main.yml/badge.svg)](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://github.com/tomMoulard/fail2ban/blob/main/.assets/gopher-tom_moulard.png)](https://tom.moulard.org)|[![](https://github.com/tomMoulard/fail2ban/blob/main/.assets/gopher-clement_david.png)](https://github.com/cledavid)|[![](https://github.com/tomMoulard/fail2ban/blob/main/.assets/gopher-martin_huvelle.png)](https://github.com/nitra-mfs)|[![](https://github.com/tomMoulard/fail2ban/blob/main/.assets/gopher-alexandre_bossut-lasry.png)](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 | --------------------------------------------------------------------------------