├── .github └── workflows │ ├── go-cross.yml │ └── main.yml ├── .gitignore ├── .golangci.yml ├── .traefik.yml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── Makefile ├── docker ├── dev-geoblock │ └── docker-compose.yml └── traefik-config │ ├── dynamic-configuration.yml │ └── traefik.yml ├── geoblock.go ├── geoblock_test.go ├── go.mod ├── lrucache ├── lru.go ├── lru_interface.go └── lru_test.go └── readme.md /.github/workflows/go-cross.yml: -------------------------------------------------------------------------------- 1 | name: Go Matrix 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | cross: 6 | name: Go 7 | runs-on: ${{ matrix.os }} 8 | env: 9 | CGO_ENABLED: 0 10 | 11 | strategy: 12 | matrix: 13 | go-version: [1.22, 1.x] 14 | os: [ubuntu-latest, macos-latest, windows-latest] 15 | 16 | steps: 17 | # https://github.com/marketplace/actions/setup-go-environment 18 | - name: Set up Go ${{ matrix.go-version }} 19 | uses: actions/setup-go@v2 20 | with: 21 | go-version: ${{ matrix.go-version }} 22 | 23 | # https://github.com/marketplace/actions/checkout 24 | - name: Checkout code 25 | uses: actions/checkout@v2 26 | 27 | # https://github.com/marketplace/actions/cache 28 | - name: Cache Go modules 29 | uses: actions/cache@v3 30 | with: 31 | # In order: 32 | # * Module download cache 33 | # * Build cache (Linux) 34 | # * Build cache (Mac) 35 | # * Build cache (Windows) 36 | path: | 37 | ~/go/pkg/mod 38 | ~/.cache/go-build 39 | ~/Library/Caches/go-build 40 | %LocalAppData%\go-build 41 | key: ${{ runner.os }}-${{ matrix.go-version }}-go-${{ hashFiles('**/go.sum') }} 42 | restore-keys: | 43 | ${{ runner.os }}-${{ matrix.go-version }}-go- 44 | 45 | - name: Test 46 | run: go test -v -cover ./... 47 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - v* 9 | pull_request: 10 | 11 | jobs: 12 | main: 13 | name: Main Process 14 | runs-on: ubuntu-latest 15 | env: 16 | GO_VERSION: 1.22 17 | GOLANGCI_LINT_VERSION: v1.58.1 18 | YAEGI_VERSION: v0.16.1 19 | CGO_ENABLED: 0 20 | defaults: 21 | run: 22 | working-directory: ${{ github.workspace }}/go/src/github.com/${{ github.repository }} 23 | 24 | steps: 25 | # https://github.com/marketplace/actions/setup-go-environment 26 | - name: Set up Go ${{ env.GO_VERSION }} 27 | uses: actions/setup-go@v2 28 | with: 29 | go-version: ${{ env.GO_VERSION }} 30 | 31 | # https://github.com/marketplace/actions/checkout 32 | - name: Check out code 33 | uses: actions/checkout@v2 34 | with: 35 | path: go/src/github.com/${{ github.repository }} 36 | fetch-depth: 0 37 | 38 | # https://github.com/marketplace/actions/cache 39 | - name: Cache Go modules 40 | uses: actions/cache@v3 41 | with: 42 | path: ${{ github.workspace }}/go/pkg/mod 43 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 44 | restore-keys: | 45 | ${{ runner.os }}-go- 46 | 47 | # https://golangci-lint.run/usage/install#other-ci 48 | - name: Install golangci-lint ${{ env.GOLANGCI_LINT_VERSION }} 49 | run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_LINT_VERSION} 50 | 51 | - name: Install Yaegi ${{ env.YAEGI_VERSION }} 52 | run: curl -sfL https://raw.githubusercontent.com/traefik/yaegi/master/install.sh | bash -s -- -b $(go env GOPATH)/bin ${YAEGI_VERSION} 53 | 54 | - name: Setup GOPATH 55 | run: go env -w GOPATH=${{ github.workspace }}/go 56 | 57 | - name: Check and get dependencies 58 | run: | 59 | go mod tidy 60 | git diff --exit-code go.mod 61 | # git diff --exit-code go.sum 62 | go mod download 63 | go mod vendor 64 | # git diff --exit-code ./vendor/ 65 | 66 | - name: Lint and Tests 67 | run: make 68 | 69 | - name: Run tests with Yaegi 70 | run: make yaegi_test 71 | env: 72 | GOPATH: ${{ github.workspace }}/go 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # inspired by: https://github.com/golangci/golangci-lint/blob/master/.golangci.yml 2 | 3 | run: 4 | go: "1.22" 5 | 6 | linters-settings: 7 | # dupl: 8 | # threshold: 100 9 | funlen: 10 | lines: 100 11 | statements: 50 12 | lll: 13 | line-length: 125 14 | goconst: 15 | min-len: 2 16 | min-occurrences: 3 17 | misspell: 18 | locale: US 19 | 20 | linters: 21 | disable-all: true 22 | enable: 23 | - asciicheck 24 | # - bidichk 25 | # - bodyclose 26 | # - contextcheck 27 | # - depguard 28 | - dogsled 29 | # - dupl 30 | # - durationcheck 31 | - errcheck 32 | # - errname 33 | - funlen 34 | - gochecknoinits 35 | - goconst 36 | - gocritic 37 | - gocyclo 38 | - gofmt 39 | - goimports 40 | - mnd 41 | - goprintffuncname 42 | - gosec 43 | - gosimple 44 | - govet 45 | - importas 46 | - ineffassign 47 | - lll 48 | - makezero 49 | - misspell 50 | - nakedret 51 | - nilerr 52 | # - nilnil 53 | # - noctx 54 | - nolintlint 55 | - predeclared 56 | - revive 57 | - staticcheck 58 | - stylecheck 59 | # - tagliatelle 60 | # - thelper 61 | - typecheck 62 | - unconvert 63 | - unparam 64 | - unused 65 | - wastedassign 66 | - whitespace 67 | 68 | # don't enable: 69 | # - prealloc 70 | -------------------------------------------------------------------------------- /.traefik.yml: -------------------------------------------------------------------------------- 1 | displayName: GeoBlock 2 | type: middleware 3 | 4 | import: github.com/PascalMinder/geoblock 5 | 6 | summary: "block request based on their country of origin" 7 | 8 | testData: 9 | silentStartUp: false 10 | allowLocalRequests: false 11 | logLocalRequests: false 12 | logAllowedRequests: false 13 | logApiRequests: true 14 | api: "https://get.geojs.io/v1/ip/country/{ip}" 15 | apiTimeoutMs: 150 16 | cacheSize: 15 17 | forceMonthlyUpdate: true 18 | allowUnknownCountries: false 19 | unknownCountryApiResponse: "nil" 20 | countries: 21 | - CH 22 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "streetsidesoftware.code-spell-checker", 4 | "stkb.rewrap", 5 | "golang.go" 6 | ] 7 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [] 7 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.formatOnPaste": true, 4 | "[go]": { 5 | "editor.tabSize": 4, 6 | "editor.detectIndentation": false, 7 | "editor.snippetSuggestions": "none", 8 | "editor.formatOnSave": true, 9 | "editor.codeActionsOnSave": { 10 | "source.organizeImports": "explicit" 11 | } 12 | }, 13 | "go.autocompleteUnimportedPackages": true, 14 | "go.formatTool": "goimports", 15 | "go.lintTool": "golangci-lint", 16 | "go.buildOnSave": "workspace", 17 | "go.lintOnSave": "workspace", 18 | "go.vetOnSave": "package", 19 | "gopls": { 20 | "usePlaceholders": true, // add parameter placeholders when completing a function 21 | // Experimental settings 22 | "completeUnimported": true, // autocomplete unimported packages 23 | "deepCompletion": true // enable deep completion 24 | }, 25 | "go.useLanguageServer": true, 26 | "cSpell.words": [ 27 | "geoblock" 28 | ] 29 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020 Containous SAS 190 | Copyright 2020 Traefik Labs 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: lint test vendor clean 2 | 3 | export GO111MODULE=on 4 | 5 | default: lint test 6 | 7 | lint: 8 | golangci-lint run 9 | 10 | test: 11 | go test -v -cover ./... 12 | 13 | yaegi_test: 14 | yaegi test -v . 15 | 16 | vendor: 17 | go mod vendor 18 | 19 | clean: 20 | rm -rf ./vendor -------------------------------------------------------------------------------- /docker/dev-geoblock/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | traefik: 3 | image: traefik:v3.1 4 | 5 | volumes: 6 | - /var/run/docker.sock:/var/run/docker.sock 7 | - ./../traefik-config/traefik.yml:/etc/traefik/traefik.yml 8 | - ./../traefik-config/dynamic-configuration.yml:/etc/traefik/dynamic-configuration.yml 9 | - ./../../../geoblock:/plugins-local/src/github.com/PascalMinder/geoblock 10 | - ./../log:/log 11 | - ./../log/geoblock:/geoblock 12 | 13 | labels: 14 | - "traefik.http.routers.dash.rule=Host(`dash.localhost`)" 15 | - "traefik.http.routers.dash.service=api@internal" 16 | 17 | ports: 18 | - "80:80" 19 | 20 | hello: 21 | image: containous/whoami 22 | labels: 23 | - traefik.enable=true 24 | - traefik.http.routers.hello.entrypoints=http 25 | - traefik.http.routers.hello.rule=Host(`hello.localhost`) 26 | - traefik.http.services.hello.loadbalancer.server.port=80 27 | - traefik.http.routers.hello.middlewares=my-plugin@file 28 | 29 | whoami: 30 | image: jwilder/whoami 31 | labels: 32 | - traefik.enable=true 33 | - traefik.http.routers.whoami.entrypoints=http 34 | - traefik.http.routers.whoami.rule=Host(`whoami.localhost`) 35 | - traefik.http.services.whoami.loadbalancer.server.port=8000 36 | - traefik.http.routers.whoami.middlewares=my-plugin2@file 37 | -------------------------------------------------------------------------------- /docker/traefik-config/dynamic-configuration.yml: -------------------------------------------------------------------------------- 1 | http: 2 | middlewares: 3 | my-plugin: 4 | plugin: 5 | geoblock: 6 | silentStartUp: false 7 | allowLocalRequests: true 8 | logLocalRequests: true 9 | logAllowedRequests: true 10 | logApiRequests: true 11 | api: "https://get.geojs.io/v1/ip/country/{ip}" 12 | cacheSize: 15 13 | forceMonthlyUpdate: true 14 | allowUnknownCountries: false 15 | unknownCountryApiResponse: "nil" 16 | logFilePath: "/geoblock/geoblockB.log" 17 | countries: 18 | - GB 19 | - IS 20 | my-plugin2: 21 | plugin: 22 | geoblock: 23 | silentStartUp: false 24 | allowLocalRequests: true 25 | logLocalRequests: true 26 | logAllowedRequests: true 27 | logApiRequests: true 28 | api: "https://get.geojs.io/v1/ip/country/{ip}" 29 | cacheSize: 15 30 | forceMonthlyUpdate: true 31 | allowUnknownCountries: false 32 | unknownCountryApiResponse: "nil" 33 | logFilePath: "/geoblock/geoblockA.log" 34 | countries: 35 | - GB 36 | - IS 37 | -------------------------------------------------------------------------------- /docker/traefik-config/traefik.yml: -------------------------------------------------------------------------------- 1 | log: 2 | level: INFO 3 | 4 | experimental: 5 | localPlugins: 6 | geoblock: 7 | moduleName: github.com/PascalMinder/geoblock 8 | 9 | # API and dashboard configuration 10 | api: 11 | dashboard: true 12 | insecure: true 13 | 14 | entryPoints: 15 | http: 16 | address: ":80" 17 | forwardedHeaders: 18 | insecure: true 19 | 20 | providers: 21 | docker: 22 | endpoint: "unix:///var/run/docker.sock" 23 | exposedByDefault: false 24 | file: 25 | filename: /etc/traefik/dynamic-configuration.yml 26 | -------------------------------------------------------------------------------- /geoblock.go: -------------------------------------------------------------------------------- 1 | // Package geoblock a Traefik plugin to block requests based on their country of origin. 2 | package geoblock 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | "log" 10 | "net" 11 | "net/http" 12 | "os" 13 | "path/filepath" 14 | "strings" 15 | "time" 16 | 17 | lru "github.com/PascalMinder/geoblock/lrucache" 18 | ) 19 | 20 | const ( 21 | xForwardedFor = "X-Forwarded-For" 22 | xRealIP = "X-Real-IP" 23 | countryHeader = "X-IPCountry" 24 | numberOfHoursInMonth = 30 * 24 25 | unknownCountryCode = "AA" 26 | countryCodeLength = 2 27 | defaultDeniedRequestHTTPStatusCode = 403 28 | filePermissions = fs.FileMode(0666) 29 | ) 30 | 31 | // Config the plugin configuration. 32 | type Config struct { 33 | SilentStartUp bool `yaml:"silentStartUp"` 34 | AllowLocalRequests bool `yaml:"allowLocalRequests"` 35 | LogLocalRequests bool `yaml:"logLocalRequests"` 36 | LogAllowedRequests bool `yaml:"logAllowedRequests"` 37 | LogAPIRequests bool `yaml:"logApiRequests"` 38 | API string `yaml:"api"` 39 | APITimeoutMs int `yaml:"apiTimeoutMs"` 40 | IgnoreAPITimeout bool `yaml:"ignoreApiTimeout"` 41 | IPGeolocationHTTPHeaderField string `yaml:"ipGeolocationHttpHeaderField"` 42 | XForwardedForReverseProxy bool `yaml:"xForwardedForReverseProxy"` 43 | CacheSize int `yaml:"cacheSize"` 44 | ForceMonthlyUpdate bool `yaml:"forceMonthlyUpdate"` 45 | AllowUnknownCountries bool `yaml:"allowUnknownCountries"` 46 | UnknownCountryAPIResponse string `yaml:"unknownCountryApiResponse"` 47 | BlackListMode bool `yaml:"blacklist"` 48 | Countries []string `yaml:"countries,omitempty"` 49 | AllowedIPAddresses []string `yaml:"allowedIPAddresses,omitempty"` 50 | AddCountryHeader bool `yaml:"addCountryHeader"` 51 | HTTPStatusCodeDeniedRequest int `yaml:"httpStatusCodeDeniedRequest"` 52 | LogFilePath string `yaml:"logFilePath"` 53 | RedirectURLIfDenied string `yaml:"redirectUrlIfDenied"` 54 | } 55 | 56 | type ipEntry struct { 57 | Country string 58 | Timestamp time.Time 59 | } 60 | 61 | // CreateConfig creates the default plugin configuration. 62 | func CreateConfig() *Config { 63 | return &Config{} 64 | } 65 | 66 | // GeoBlock a Traefik plugin. 67 | type GeoBlock struct { 68 | next http.Handler 69 | silentStartUp bool 70 | allowLocalRequests bool 71 | logLocalRequests bool 72 | logAllowedRequests bool 73 | logAPIRequests bool 74 | apiURI string 75 | apiTimeoutMs int 76 | ignoreAPITimeout bool 77 | iPGeolocationHTTPHeaderField string 78 | xForwardedForReverseProxy bool 79 | forceMonthlyUpdate bool 80 | allowUnknownCountries bool 81 | unknownCountryCode string 82 | blackListMode bool 83 | countries []string 84 | allowedIPAddresses []net.IP 85 | allowedIPRanges []*net.IPNet 86 | privateIPRanges []*net.IPNet 87 | addCountryHeader bool 88 | httpStatusCodeDeniedRequest int 89 | database *lru.LRUCache 90 | logFile *os.File 91 | redirectURLIfDenied string 92 | name string 93 | infoLogger *log.Logger 94 | } 95 | 96 | // New created a new GeoBlock plugin. 97 | func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { 98 | infoLogger := log.New(io.Discard, "INFO: GeoBlock: ", log.Ldate|log.Ltime) 99 | 100 | // check geolocation API uri 101 | if len(config.API) == 0 || !strings.Contains(config.API, "{ip}") { 102 | return nil, fmt.Errorf("no api uri given") 103 | } 104 | 105 | // check if at least one allowed country is provided 106 | if len(config.Countries) == 0 { 107 | return nil, fmt.Errorf("no allowed country code provided") 108 | } 109 | 110 | // set default API timeout if non is given 111 | if config.APITimeoutMs == 0 { 112 | config.APITimeoutMs = 750 113 | } 114 | 115 | // set default HTTP status code for denied requests if non other is supplied 116 | deniedRequestHTTPStatusCode, err := getHTTPStatusCodeDeniedRequest(config.HTTPStatusCodeDeniedRequest) 117 | if err != nil { 118 | return nil, err 119 | } 120 | config.HTTPStatusCodeDeniedRequest = deniedRequestHTTPStatusCode 121 | 122 | // build allowed IP and IP ranges lists 123 | allowedIPAddresses, allowedIPRanges := parseAllowedIPAddresses(config.AllowedIPAddresses, infoLogger) 124 | 125 | infoLogger.SetOutput(os.Stdout) 126 | 127 | // output configuration of the middleware instance 128 | if !config.SilentStartUp { 129 | printConfiguration(config, infoLogger) 130 | } 131 | 132 | // create LRU cache for IP lookup 133 | cache, err := lru.NewLRUCache(config.CacheSize) 134 | if err != nil { 135 | infoLogger.Fatal(err) 136 | } 137 | 138 | // create custom log target if needed 139 | logFile, err := initializeLogFile(config.LogFilePath, infoLogger) 140 | if err != nil { 141 | infoLogger.Printf("Error initializing log file: %v\n", err) 142 | } 143 | 144 | // Set up a goroutine to close the file when the context is done 145 | if logFile != nil { 146 | go func(logger *log.Logger) { 147 | <-ctx.Done() // Wait for context cancellation 148 | logger.SetOutput(os.Stdout) 149 | logFile.Close() 150 | logger.Printf("Log file closed for middleware: %s\n", name) 151 | }(infoLogger) 152 | } 153 | 154 | return &GeoBlock{ 155 | next: next, 156 | silentStartUp: config.SilentStartUp, 157 | allowLocalRequests: config.AllowLocalRequests, 158 | logLocalRequests: config.LogLocalRequests, 159 | logAllowedRequests: config.LogAllowedRequests, 160 | logAPIRequests: config.LogAPIRequests, 161 | apiURI: config.API, 162 | apiTimeoutMs: config.APITimeoutMs, 163 | ignoreAPITimeout: config.IgnoreAPITimeout, 164 | iPGeolocationHTTPHeaderField: config.IPGeolocationHTTPHeaderField, 165 | xForwardedForReverseProxy: config.XForwardedForReverseProxy, 166 | forceMonthlyUpdate: config.ForceMonthlyUpdate, 167 | allowUnknownCountries: config.AllowUnknownCountries, 168 | unknownCountryCode: config.UnknownCountryAPIResponse, 169 | blackListMode: config.BlackListMode, 170 | countries: config.Countries, 171 | allowedIPAddresses: allowedIPAddresses, 172 | allowedIPRanges: allowedIPRanges, 173 | privateIPRanges: initPrivateIPBlocks(), 174 | database: cache, 175 | addCountryHeader: config.AddCountryHeader, 176 | httpStatusCodeDeniedRequest: config.HTTPStatusCodeDeniedRequest, 177 | logFile: logFile, 178 | redirectURLIfDenied: config.RedirectURLIfDenied, 179 | name: name, 180 | infoLogger: infoLogger, 181 | }, nil 182 | } 183 | 184 | func (a *GeoBlock) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 185 | requestIPAddresses, err := a.collectRemoteIP(req) 186 | if err != nil { 187 | // if one of the ip addresses could not be parsed, return status forbidden 188 | a.infoLogger.Println(err) 189 | rw.WriteHeader(http.StatusForbidden) 190 | return 191 | } 192 | 193 | // only keep the first IP address, which should be the client (if the proxy behaves itself), to check if allowed or denied 194 | if a.xForwardedForReverseProxy { 195 | requestIPAddresses = requestIPAddresses[:1] 196 | } 197 | 198 | for _, requestIPAddress := range requestIPAddresses { 199 | if !a.allowDenyIPAddress(requestIPAddress, req) { 200 | if len(a.redirectURLIfDenied) != 0 { 201 | rw.Header().Set("Location", a.redirectURLIfDenied) 202 | rw.WriteHeader(http.StatusFound) 203 | return 204 | } 205 | 206 | rw.WriteHeader(a.httpStatusCodeDeniedRequest) 207 | return 208 | } 209 | } 210 | 211 | a.next.ServeHTTP(rw, req) 212 | } 213 | 214 | func (a *GeoBlock) allowDenyIPAddress(requestIPAddr *net.IP, req *http.Request) bool { 215 | // check if the request IP address is a local address and if those are allowed 216 | if isPrivateIP(*requestIPAddr, a.privateIPRanges) { 217 | if a.allowLocalRequests { 218 | if a.logLocalRequests { 219 | a.infoLogger.Printf("%s: request allowed [%s] since local IP addresses are allowed", a.name, requestIPAddr) 220 | } 221 | return true 222 | } 223 | 224 | if a.logLocalRequests { 225 | a.infoLogger.Printf("%s: request denied [%s] since local IP addresses are denied", a.name, requestIPAddr) 226 | } 227 | return false 228 | } 229 | 230 | // check if the request IP address is explicitly allowed 231 | if ipInSlice(*requestIPAddr, a.allowedIPAddresses) { 232 | if a.addCountryHeader { 233 | ok, countryCode := a.cachedRequestIP(requestIPAddr, req) 234 | if ok && len(countryCode) > 0 { 235 | req.Header.Set(countryHeader, countryCode) 236 | } 237 | } 238 | if a.logAllowedRequests { 239 | a.infoLogger.Printf("%s: request allowed [%s] since the IP address is explicitly allowed", a.name, requestIPAddr) 240 | } 241 | return true 242 | } 243 | 244 | // check if the request IP address is contained within one of the explicitly allowed IP address ranges 245 | for _, ipRange := range a.allowedIPRanges { 246 | if ipRange.Contains(*requestIPAddr) { 247 | if a.addCountryHeader { 248 | ok, countryCode := a.cachedRequestIP(requestIPAddr, req) 249 | if ok && len(countryCode) > 0 { 250 | req.Header.Set(countryHeader, countryCode) 251 | } 252 | } 253 | if a.logLocalRequests { 254 | a.infoLogger.Printf("%s: request allowed [%s] since the IP address is explicitly allowed", a.name, requestIPAddr) 255 | } 256 | return true 257 | } 258 | } 259 | 260 | // check if the GeoIP database contains an entry for the request IP address 261 | allowed, countryCode := a.allowDenyCachedRequestIP(requestIPAddr, req) 262 | 263 | if a.addCountryHeader && len(countryCode) > 0 { 264 | req.Header.Set(countryHeader, countryCode) 265 | } 266 | 267 | return allowed 268 | } 269 | 270 | func (a *GeoBlock) allowDenyCachedRequestIP(requestIPAddr *net.IP, req *http.Request) (bool, string) { 271 | ipAddressString := requestIPAddr.String() 272 | cacheEntry, ok := a.database.Get(ipAddressString) 273 | 274 | var entry ipEntry 275 | var err error 276 | if !ok { 277 | entry, err = a.createNewIPEntry(req, ipAddressString) 278 | if err != nil && !(os.IsTimeout(err) && a.ignoreAPITimeout) { 279 | return false, "" 280 | } else if os.IsTimeout(err) && a.ignoreAPITimeout { 281 | a.infoLogger.Printf("%s: request allowed [%s] due to API timeout", a.name, requestIPAddr) 282 | // TODO: this was previously an immediate response to the client 283 | return true, "" 284 | } 285 | } else { 286 | entry = cacheEntry.(ipEntry) 287 | } 288 | 289 | if a.logAPIRequests { 290 | a.infoLogger.Println("Loaded from database: ", entry) 291 | } 292 | 293 | // check if existing entry was made more than a month ago, if so update the entry 294 | if time.Since(entry.Timestamp).Hours() >= numberOfHoursInMonth && a.forceMonthlyUpdate { 295 | entry, err = a.createNewIPEntry(req, ipAddressString) 296 | if err != nil { 297 | return false, "" 298 | } 299 | } 300 | 301 | // check if we are in black/white-list mode and allow/deny based on country code 302 | isAllowed := (stringInSlice(entry.Country, a.countries) != a.blackListMode) || 303 | (entry.Country == unknownCountryCode && a.allowUnknownCountries) 304 | 305 | if !isAllowed { 306 | a.infoLogger.Printf("%s: request denied [%s] for country [%s]", a.name, requestIPAddr, entry.Country) 307 | return false, entry.Country 308 | } 309 | 310 | if a.logAllowedRequests { 311 | a.infoLogger.Printf("%s: request allowed [%s] for country [%s]", a.name, requestIPAddr, entry.Country) 312 | } 313 | 314 | return true, entry.Country 315 | } 316 | 317 | func (a *GeoBlock) cachedRequestIP(requestIPAddr *net.IP, req *http.Request) (bool, string) { 318 | ipAddressString := requestIPAddr.String() 319 | cacheEntry, ok := a.database.Get(ipAddressString) 320 | 321 | var entry ipEntry 322 | var err error 323 | if !ok { 324 | entry, err = a.createNewIPEntry(req, ipAddressString) 325 | if err != nil { 326 | return false, "" 327 | } 328 | } else { 329 | entry = cacheEntry.(ipEntry) 330 | } 331 | 332 | if a.logAPIRequests { 333 | a.infoLogger.Println("Loaded from database: ", entry) 334 | } 335 | 336 | // check if existing entry was made more than a month ago, if so update the entry 337 | if time.Since(entry.Timestamp).Hours() >= numberOfHoursInMonth && a.forceMonthlyUpdate { 338 | entry, err = a.createNewIPEntry(req, ipAddressString) 339 | if err != nil { 340 | return false, "" 341 | } 342 | } 343 | 344 | return true, entry.Country 345 | } 346 | 347 | func (a *GeoBlock) collectRemoteIP(req *http.Request) ([]*net.IP, error) { 348 | var ipList []*net.IP 349 | 350 | splitFn := func(c rune) bool { 351 | return c == ',' 352 | } 353 | 354 | xForwardedForValue := req.Header.Get(xForwardedFor) 355 | xForwardedForIPs := strings.FieldsFunc(xForwardedForValue, splitFn) 356 | 357 | xRealIPValue := req.Header.Get(xRealIP) 358 | xRealIPList := strings.FieldsFunc(xRealIPValue, splitFn) 359 | 360 | for _, value := range xForwardedForIPs { 361 | value = strings.Trim(value, " ") 362 | ipAddress, err := parseIP(value) 363 | if err != nil { 364 | return ipList, fmt.Errorf("parsing failed: %s", err) 365 | } 366 | 367 | ipList = append(ipList, &ipAddress) 368 | } 369 | 370 | for _, value := range xRealIPList { 371 | value = strings.Trim(value, " ") 372 | ipAddress, err := parseIP(value) 373 | if err != nil { 374 | return ipList, fmt.Errorf("parsing failed: %s", err) 375 | } 376 | 377 | ipList = append(ipList, &ipAddress) 378 | } 379 | 380 | return ipList, nil 381 | } 382 | 383 | func (a *GeoBlock) createNewIPEntry(req *http.Request, ipAddressString string) (ipEntry, error) { 384 | var entry ipEntry 385 | 386 | country, err := a.getCountryCode(req, ipAddressString) 387 | if err != nil { 388 | return entry, err 389 | } 390 | 391 | entry = ipEntry{Country: country, Timestamp: time.Now()} 392 | a.database.Add(ipAddressString, entry) 393 | 394 | if a.logAPIRequests { 395 | a.infoLogger.Println("Added to database: ", entry) 396 | } 397 | 398 | return entry, nil 399 | } 400 | 401 | func (a *GeoBlock) getCountryCode(req *http.Request, ipAddressString string) (string, error) { 402 | if len(a.iPGeolocationHTTPHeaderField) != 0 { 403 | country, err := a.readIPGeolocationHTTPHeader(req, a.iPGeolocationHTTPHeaderField) 404 | if err == nil { 405 | return country, nil 406 | } 407 | 408 | if a.logAPIRequests { 409 | a.infoLogger.Print("Failed to read country from HTTP header field [", 410 | a.iPGeolocationHTTPHeaderField, 411 | "], continuing with API lookup.") 412 | } 413 | } 414 | 415 | country, err := a.callGeoJS(ipAddressString) 416 | if err != nil { 417 | if !(os.IsTimeout(err) || a.ignoreAPITimeout) { 418 | a.infoLogger.Println(err) 419 | } 420 | return "", err 421 | } 422 | 423 | return country, nil 424 | } 425 | 426 | func (a *GeoBlock) callGeoJS(ipAddress string) (string, error) { 427 | geoJsClient := http.Client{ 428 | Timeout: time.Millisecond * time.Duration(a.apiTimeoutMs), 429 | } 430 | 431 | apiURI := strings.Replace(a.apiURI, "{ip}", ipAddress, 1) 432 | 433 | req, err := http.NewRequest(http.MethodGet, apiURI, nil) 434 | if err != nil { 435 | return "", err 436 | } 437 | 438 | res, err := geoJsClient.Do(req) 439 | if err != nil { 440 | return "", err 441 | } 442 | 443 | if res.Body != nil { 444 | defer res.Body.Close() 445 | } 446 | 447 | body, err := io.ReadAll(res.Body) 448 | if err != nil { 449 | return "", err 450 | } 451 | 452 | sb := string(body) 453 | countryCode := strings.TrimSuffix(sb, "\n") 454 | 455 | // api response for unknown country 456 | if len([]rune(countryCode)) == len(a.unknownCountryCode) && countryCode == a.unknownCountryCode { 457 | return unknownCountryCode, nil 458 | } 459 | 460 | // this could possible cause a DoS attack 461 | if len([]rune(countryCode)) != countryCodeLength { 462 | return "", fmt.Errorf("API response has more or less than 2 characters") 463 | } 464 | 465 | if a.logAPIRequests { 466 | a.infoLogger.Printf("Country [%s] for ip %s fetched from %s", countryCode, ipAddress, apiURI) 467 | } 468 | 469 | return countryCode, nil 470 | } 471 | 472 | func (a *GeoBlock) readIPGeolocationHTTPHeader(req *http.Request, name string) (string, error) { 473 | countryCode := req.Header.Get(name) 474 | 475 | if len([]rune(countryCode)) != countryCodeLength { 476 | return "", fmt.Errorf("API response has more or less than 2 characters") 477 | } 478 | 479 | return countryCode, nil 480 | } 481 | 482 | func stringInSlice(a string, list []string) bool { 483 | for _, b := range list { 484 | if b == a { 485 | return true 486 | } 487 | } 488 | 489 | return false 490 | } 491 | 492 | func ipInSlice(a net.IP, list []net.IP) bool { 493 | for _, b := range list { 494 | if b.Equal(a) { 495 | return true 496 | } 497 | } 498 | return false 499 | } 500 | 501 | func parseIP(addr string) (net.IP, error) { 502 | ipAddress := net.ParseIP(addr) 503 | 504 | if ipAddress == nil { 505 | return nil, fmt.Errorf("unable parse IP address from address [%s]", addr) 506 | } 507 | 508 | return ipAddress, nil 509 | } 510 | 511 | // https://stackoverflow.com/questions/41240761/check-if-ip-address-is-in-private-network-space 512 | func initPrivateIPBlocks() []*net.IPNet { 513 | var privateIPBlocks []*net.IPNet 514 | 515 | for _, cidr := range []string{ 516 | "127.0.0.0/8", // IPv4 loopback 517 | "10.0.0.0/8", // RFC1918 518 | "172.16.0.0/12", // RFC1918 519 | "192.168.0.0/16", // RFC1918 520 | "169.254.0.0/16", // RFC3927 link-local 521 | "::1/128", // IPv6 loopback 522 | "fe80::/10", // IPv6 link-local 523 | "fc00::/7", // IPv6 unique local addr 524 | } { 525 | _, block, err := net.ParseCIDR(cidr) 526 | if err != nil { 527 | panic(fmt.Errorf("parse error on %q: %v", cidr, err)) 528 | } 529 | privateIPBlocks = append(privateIPBlocks, block) 530 | } 531 | 532 | return privateIPBlocks 533 | } 534 | 535 | func isPrivateIP(ip net.IP, privateIPBlocks []*net.IPNet) bool { 536 | if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { 537 | return true 538 | } 539 | 540 | for _, block := range privateIPBlocks { 541 | if block.Contains(ip) { 542 | return true 543 | } 544 | } 545 | 546 | return false 547 | } 548 | 549 | func getHTTPStatusCodeDeniedRequest(code int) (int, error) { 550 | if code != 0 { 551 | // check if given status code is valid 552 | if len(http.StatusText(code)) == 0 { 553 | return 0, fmt.Errorf("invalid denied request status code supplied") 554 | } 555 | 556 | return code, nil 557 | } 558 | 559 | return defaultDeniedRequestHTTPStatusCode, nil 560 | } 561 | 562 | func parseAllowedIPAddresses(entries []string, logger *log.Logger) ([]net.IP, []*net.IPNet) { 563 | var allowedIPAddresses []net.IP 564 | var allowedIPRanges []*net.IPNet 565 | 566 | for _, ipAddressEntry := range entries { 567 | ipAddressEntry = strings.Trim(ipAddressEntry, " ") 568 | // Attempt to parse as CIDR 569 | ip, ipBlock, err := net.ParseCIDR(ipAddressEntry) 570 | if err == nil { 571 | allowedIPAddresses = append(allowedIPAddresses, ip) 572 | allowedIPRanges = append(allowedIPRanges, ipBlock) 573 | continue 574 | } 575 | 576 | // Attempt to parse as a single IP address 577 | ipAddress := net.ParseIP(ipAddressEntry) 578 | if ipAddress == nil { 579 | logger.Fatal("Invalid IP address provided:", ipAddressEntry) 580 | } 581 | allowedIPAddresses = append(allowedIPAddresses, ipAddress) 582 | } 583 | 584 | return allowedIPAddresses, allowedIPRanges 585 | } 586 | 587 | func printConfiguration(config *Config, logger *log.Logger) { 588 | logger.Printf("allow local IPs: %t", config.AllowLocalRequests) 589 | logger.Printf("log local requests: %t", config.LogLocalRequests) 590 | logger.Printf("log allowed requests: %t", config.LogAllowedRequests) 591 | logger.Printf("log api requests: %t", config.LogAPIRequests) 592 | if len(config.IPGeolocationHTTPHeaderField) == 0 { 593 | logger.Printf("use custom HTTP header field for country lookup: %t", false) 594 | } else { 595 | logger.Printf("use custom HTTP header field for country lookup: %t [%s]", true, config.IPGeolocationHTTPHeaderField) 596 | } 597 | logger.Printf("API uri: %s", config.API) 598 | logger.Printf("API timeout: %d", config.APITimeoutMs) 599 | logger.Printf("ignore API timeout: %t", config.IgnoreAPITimeout) 600 | logger.Printf("cache size: %d", config.CacheSize) 601 | logger.Printf("force monthly update: %t", config.ForceMonthlyUpdate) 602 | logger.Printf("allow unknown countries: %t", config.AllowUnknownCountries) 603 | logger.Printf("unknown country api response: %s", config.UnknownCountryAPIResponse) 604 | logger.Printf("blacklist mode: %t", config.BlackListMode) 605 | logger.Printf("add country header: %t", config.AddCountryHeader) 606 | logger.Printf("countries: %v", config.Countries) 607 | logger.Printf("Denied request status code: %d", config.HTTPStatusCodeDeniedRequest) 608 | logger.Printf("Log file path: %s", config.LogFilePath) 609 | if len(config.RedirectURLIfDenied) != 0 { 610 | logger.Printf("Redirect URL on denied requests: %s", config.RedirectURLIfDenied) 611 | } 612 | } 613 | 614 | func initializeLogFile(logFilePath string, logger *log.Logger) (*os.File, error) { 615 | if len(logFilePath) == 0 { 616 | return nil, nil 617 | } 618 | 619 | writeable, err := isFolder(logFilePath) 620 | if err != nil { 621 | logger.Println(err) 622 | return nil, err 623 | } else if !writeable { 624 | logger.Println("Specified log folder is not writeable") 625 | return nil, fmt.Errorf("folder is not writeable: %s", logFilePath) 626 | } 627 | 628 | logFile, err := os.OpenFile(logFilePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, filePermissions) 629 | if err != nil { 630 | logger.Printf("Failed to open log file: %v\n", err) 631 | return nil, err 632 | } 633 | 634 | logger.SetOutput(logFile) 635 | return logFile, nil 636 | } 637 | 638 | func isFolder(filePath string) (bool, error) { 639 | dirPath := filepath.Dir(filePath) 640 | info, err := os.Stat(dirPath) 641 | if err != nil { 642 | if os.IsNotExist(err) { 643 | return false, fmt.Errorf("path does not exist") 644 | } 645 | return false, fmt.Errorf("error checking path: %w", err) 646 | } 647 | 648 | if !info.IsDir() { 649 | return false, fmt.Errorf("folder does not exist") 650 | } 651 | 652 | return true, nil 653 | } 654 | -------------------------------------------------------------------------------- /geoblock_test.go: -------------------------------------------------------------------------------- 1 | package geoblock_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "strings" 11 | "testing" 12 | "time" 13 | 14 | geoblock "github.com/PascalMinder/geoblock" 15 | ) 16 | 17 | const ( 18 | xForwardedFor = "X-Forwarded-For" 19 | CountryHeader = "X-IPCountry" 20 | caExampleIP = "99.220.109.148" 21 | chExampleIP = "82.220.110.18" 22 | multiForwardedIP = "82.220.110.18,192.168.1.1,10.0.0.1" 23 | multiForwardedIPwithSpaces = "82.220.110.18, 192.168.1.1, 10.0.0.1" 24 | privateRangeIP = "192.168.1.1" 25 | invalidIP = "192.168.1.X" 26 | unknownCountry = "1.1.1.1" 27 | apiURI = "https://get.geojs.io/v1/ip/country/{ip}" 28 | ipGeolocationHTTPHeaderField = "cf-ipcountry" 29 | allowedRequest = "Allowed request" 30 | ) 31 | 32 | func TestEmptyApi(t *testing.T) { 33 | cfg := createTesterConfig() 34 | cfg.API = "" 35 | cfg.Countries = append(cfg.Countries, "CH") 36 | 37 | ctx := context.Background() 38 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 39 | 40 | _, err := geoblock.New(ctx, next, cfg, "GeoBlock") 41 | 42 | // expect error 43 | if err == nil { 44 | t.Fatal("empty API uri accepted") 45 | } 46 | } 47 | 48 | func TestMissingIpInApi(t *testing.T) { 49 | cfg := createTesterConfig() 50 | cfg.API = "https://get.geojs.io/v1/ip/country/" 51 | cfg.Countries = append(cfg.Countries, "CH") 52 | 53 | ctx := context.Background() 54 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 55 | 56 | _, err := geoblock.New(ctx, next, cfg, "GeoBlock") 57 | 58 | // expect error 59 | if err == nil { 60 | t.Fatal("missing IP block in API uri") 61 | } 62 | } 63 | 64 | func TestEmptyAllowedCountryList(t *testing.T) { 65 | cfg := createTesterConfig() 66 | 67 | ctx := context.Background() 68 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 69 | 70 | _, err := geoblock.New(ctx, next, cfg, "GeoBlock") 71 | 72 | // expect error 73 | if err == nil { 74 | t.Fatal("empty country list is not allowed") 75 | } 76 | } 77 | 78 | func TestEmptyDeniedRequestStatusCode(t *testing.T) { 79 | cfg := createTesterConfig() 80 | cfg.Countries = append(cfg.Countries, "CH") 81 | 82 | ctx := context.Background() 83 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 84 | 85 | _, err := geoblock.New(ctx, next, cfg, "GeoBlock") 86 | 87 | if err != nil { 88 | t.Fatal("no error expected for empty denied request status code") 89 | } 90 | } 91 | 92 | func TestInvalidDeniedRequestStatusCode(t *testing.T) { 93 | cfg := createTesterConfig() 94 | cfg.Countries = append(cfg.Countries, "CH") 95 | cfg.HTTPStatusCodeDeniedRequest = 1 96 | 97 | ctx := context.Background() 98 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 99 | 100 | _, err := geoblock.New(ctx, next, cfg, "GeoBlock") 101 | 102 | // expect error 103 | if err == nil { 104 | t.Fatal("invalid denied request status code supplied") 105 | } 106 | } 107 | 108 | func TestAllowedCountry(t *testing.T) { 109 | cfg := createTesterConfig() 110 | cfg.Countries = append(cfg.Countries, "CH") 111 | 112 | ctx := context.Background() 113 | next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("Allowed request")) }) 114 | 115 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 116 | if err != nil { 117 | t.Fatal(err) 118 | } 119 | 120 | recorder := httptest.NewRecorder() 121 | 122 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 123 | if err != nil { 124 | t.Fatal(err) 125 | } 126 | 127 | req.Header.Add(xForwardedFor, chExampleIP) 128 | 129 | handler.ServeHTTP(recorder, req) 130 | 131 | recorderResult := recorder.Result() 132 | 133 | assertStatusCode(t, recorderResult, http.StatusOK) 134 | 135 | body, err := io.ReadAll(recorderResult.Body) 136 | if err != nil { 137 | t.Fatal(err) 138 | } 139 | 140 | expectedBody := allowedRequest 141 | if string(body) != expectedBody { 142 | t.Fatalf("expected body %q, got %q", expectedBody, string(body)) 143 | } 144 | } 145 | 146 | func TestMultipleAllowedCountry(t *testing.T) { 147 | cfg := createTesterConfig() 148 | cfg.Countries = append(cfg.Countries, "CH", "CA") 149 | 150 | ctx := context.Background() 151 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 152 | 153 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 154 | if err != nil { 155 | t.Fatal(err) 156 | } 157 | 158 | recorder := httptest.NewRecorder() 159 | 160 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 161 | if err != nil { 162 | t.Fatal(err) 163 | } 164 | 165 | req.Header.Add(xForwardedFor, caExampleIP) 166 | 167 | handler.ServeHTTP(recorder, req) 168 | 169 | assertStatusCode(t, recorder.Result(), http.StatusOK) 170 | } 171 | 172 | func TestMultipleForwardedForIP(t *testing.T) { 173 | cfg := createTesterConfig() 174 | cfg.Countries = append(cfg.Countries, "CH") 175 | cfg.AllowLocalRequests = true 176 | 177 | ctx := context.Background() 178 | next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("Allowed request")) }) 179 | 180 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 181 | if err != nil { 182 | t.Fatal(err) 183 | } 184 | 185 | recorder := httptest.NewRecorder() 186 | 187 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 188 | if err != nil { 189 | t.Fatal(err) 190 | } 191 | 192 | req.Header.Add(xForwardedFor, multiForwardedIP) 193 | 194 | handler.ServeHTTP(recorder, req) 195 | 196 | recorderResult := recorder.Result() 197 | 198 | assertStatusCode(t, recorderResult, http.StatusOK) 199 | 200 | body, err := io.ReadAll(recorderResult.Body) 201 | if err != nil { 202 | t.Fatal(err) 203 | } 204 | 205 | expectedBody := allowedRequest 206 | if string(body) != expectedBody { 207 | t.Fatalf("expected body %q, got %q", expectedBody, string(body)) 208 | } 209 | } 210 | 211 | func TestMultipleForwardedForIPwithSpaces(t *testing.T) { 212 | cfg := createTesterConfig() 213 | cfg.Countries = append(cfg.Countries, "CH") 214 | cfg.AllowLocalRequests = true 215 | 216 | ctx := context.Background() 217 | next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("Allowed request")) }) 218 | 219 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 220 | if err != nil { 221 | t.Fatal(err) 222 | } 223 | 224 | recorder := httptest.NewRecorder() 225 | 226 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 227 | if err != nil { 228 | t.Fatal(err) 229 | } 230 | 231 | req.Header.Add(xForwardedFor, multiForwardedIPwithSpaces) 232 | 233 | handler.ServeHTTP(recorder, req) 234 | 235 | recorderResult := recorder.Result() 236 | 237 | assertStatusCode(t, recorderResult, http.StatusOK) 238 | 239 | body, err := io.ReadAll(recorderResult.Body) 240 | if err != nil { 241 | t.Fatal(err) 242 | } 243 | 244 | expectedBody := allowedRequest 245 | if string(body) != expectedBody { 246 | t.Fatalf("expected body %q, got %q", expectedBody, string(body)) 247 | } 248 | } 249 | 250 | func createMockAPIServer(t *testing.T, ipResponseMap map[string][]byte) *httptest.Server { 251 | return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 252 | t.Logf("Intercepted request: %s %s", req.Method, req.URL.String()) 253 | t.Logf("Headers: %v", req.Header) 254 | 255 | requestedIP := req.URL.String()[1:] 256 | 257 | if response, exists := ipResponseMap[requestedIP]; exists { 258 | t.Logf("Matched IP: %s", requestedIP) 259 | rw.WriteHeader(http.StatusOK) 260 | _, _ = rw.Write(response) 261 | } else { 262 | t.Errorf("Unexpected IP: %s", requestedIP) 263 | rw.WriteHeader(http.StatusNotFound) 264 | _, _ = rw.Write([]byte(`{"error": "IP not found"}`)) 265 | } 266 | })) 267 | } 268 | 269 | func TestMultipleIpAddresses(t *testing.T) { 270 | mockServer := createMockAPIServer(t, map[string][]byte{caExampleIP: []byte(`CA`), chExampleIP: []byte(`CH`)}) 271 | defer mockServer.Close() 272 | 273 | cfg := createTesterConfig() 274 | 275 | cfg.Countries = append(cfg.Countries, "CH") 276 | cfg.API = mockServer.URL + "/{ip}" 277 | 278 | ctx := context.Background() 279 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 280 | 281 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 282 | if err != nil { 283 | t.Fatal(err) 284 | } 285 | 286 | recorder := httptest.NewRecorder() 287 | 288 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 289 | if err != nil { 290 | t.Fatal(err) 291 | } 292 | 293 | req.Header.Add(xForwardedFor, strings.Join([]string{chExampleIP, caExampleIP}, ",")) 294 | 295 | handler.ServeHTTP(recorder, req) 296 | 297 | assertStatusCode(t, recorder.Result(), http.StatusForbidden) 298 | } 299 | 300 | func TestIpAddressesWithSpaces(t *testing.T) { 301 | mockServer := createMockAPIServer(t, map[string][]byte{caExampleIP: []byte(`CA`), chExampleIP: []byte(`CH`)}) 302 | defer mockServer.Close() 303 | 304 | cfg := createTesterConfig() 305 | 306 | cfg.Countries = append(cfg.Countries, "CH") 307 | cfg.API = mockServer.URL + "/{ip}" 308 | 309 | ctx := context.Background() 310 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 311 | 312 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 313 | if err != nil { 314 | t.Fatal(err) 315 | } 316 | 317 | recorder := httptest.NewRecorder() 318 | 319 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 320 | if err != nil { 321 | t.Fatal(err) 322 | } 323 | 324 | req.Header.Add(xForwardedFor, strings.Join([]string{chExampleIP + " "}, ",")) 325 | 326 | handler.ServeHTTP(recorder, req) 327 | 328 | assertStatusCode(t, recorder.Result(), http.StatusOK) 329 | } 330 | 331 | func TestMultipleIpAddressesReverse(t *testing.T) { 332 | mockServer := createMockAPIServer(t, map[string][]byte{caExampleIP: []byte(`CA`), chExampleIP: []byte(`CH`)}) 333 | defer mockServer.Close() 334 | 335 | cfg := createTesterConfig() 336 | 337 | cfg.Countries = append(cfg.Countries, "CH") 338 | cfg.API = mockServer.URL + "/{ip}" 339 | 340 | ctx := context.Background() 341 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 342 | 343 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 344 | if err != nil { 345 | t.Fatal(err) 346 | } 347 | 348 | recorder := httptest.NewRecorder() 349 | 350 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 351 | if err != nil { 352 | t.Fatal(err) 353 | } 354 | 355 | req.Header.Add(xForwardedFor, strings.Join([]string{caExampleIP, chExampleIP}, ",")) 356 | 357 | handler.ServeHTTP(recorder, req) 358 | 359 | assertStatusCode(t, recorder.Result(), http.StatusForbidden) 360 | } 361 | 362 | func TestMultipleIpAddressesProxy(t *testing.T) { 363 | mockServer := createMockAPIServer(t, map[string][]byte{caExampleIP: []byte(`CA`)}) 364 | defer mockServer.Close() 365 | 366 | cfg := createTesterConfig() 367 | 368 | cfg.Countries = append(cfg.Countries, "CA") 369 | cfg.XForwardedForReverseProxy = true 370 | cfg.API = mockServer.URL + "/{ip}" 371 | 372 | ctx := context.Background() 373 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 374 | 375 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 376 | if err != nil { 377 | t.Fatal(err) 378 | } 379 | 380 | recorder := httptest.NewRecorder() 381 | 382 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 383 | if err != nil { 384 | t.Fatal(err) 385 | } 386 | 387 | req.Header.Add(xForwardedFor, strings.Join([]string{caExampleIP, chExampleIP}, ",")) 388 | 389 | handler.ServeHTTP(recorder, req) 390 | 391 | assertStatusCode(t, recorder.Result(), http.StatusOK) 392 | } 393 | 394 | func TestMultipleIpAddressesProxyReverse(t *testing.T) { 395 | mockServer := createMockAPIServer(t, map[string][]byte{chExampleIP: []byte(`CH`)}) 396 | defer mockServer.Close() 397 | 398 | cfg := createTesterConfig() 399 | 400 | cfg.Countries = append(cfg.Countries, "CA") 401 | cfg.XForwardedForReverseProxy = true 402 | cfg.API = mockServer.URL + "/{ip}" 403 | 404 | ctx := context.Background() 405 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 406 | 407 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 408 | if err != nil { 409 | t.Fatal(err) 410 | } 411 | 412 | recorder := httptest.NewRecorder() 413 | 414 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 415 | if err != nil { 416 | t.Fatal(err) 417 | } 418 | 419 | req.Header.Add(xForwardedFor, strings.Join([]string{chExampleIP, caExampleIP}, ",")) 420 | 421 | handler.ServeHTTP(recorder, req) 422 | 423 | assertStatusCode(t, recorder.Result(), http.StatusForbidden) 424 | } 425 | 426 | func TestAllowedUnknownCountry(t *testing.T) { 427 | cfg := createTesterConfig() 428 | 429 | cfg.Countries = append(cfg.Countries, "CH") 430 | cfg.AllowUnknownCountries = true 431 | 432 | ctx := context.Background() 433 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 434 | 435 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 436 | if err != nil { 437 | t.Fatal(err) 438 | } 439 | 440 | recorder := httptest.NewRecorder() 441 | 442 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 443 | if err != nil { 444 | t.Fatal(err) 445 | } 446 | 447 | req.Header.Add(xForwardedFor, unknownCountry) 448 | 449 | handler.ServeHTTP(recorder, req) 450 | 451 | assertStatusCode(t, recorder.Result(), http.StatusOK) 452 | } 453 | 454 | func TestDenyUnknownCountry(t *testing.T) { 455 | cfg := createTesterConfig() 456 | cfg.Countries = append(cfg.Countries, "CH") 457 | 458 | ctx := context.Background() 459 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 460 | 461 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 462 | if err != nil { 463 | t.Fatal(err) 464 | } 465 | 466 | recorder := httptest.NewRecorder() 467 | 468 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 469 | if err != nil { 470 | t.Fatal(err) 471 | } 472 | 473 | req.Header.Add(xForwardedFor, unknownCountry) 474 | 475 | handler.ServeHTTP(recorder, req) 476 | 477 | assertStatusCode(t, recorder.Result(), http.StatusForbidden) 478 | } 479 | 480 | func TestAllowedCountryCacheLookUp(t *testing.T) { 481 | cfg := createTesterConfig() 482 | cfg.Countries = append(cfg.Countries, "CH") 483 | 484 | ctx := context.Background() 485 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 486 | 487 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 488 | if err != nil { 489 | t.Fatal(err) 490 | } 491 | 492 | recorder := httptest.NewRecorder() 493 | 494 | for i := 0; i < 2; i++ { 495 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 496 | if err != nil { 497 | t.Fatal(err) 498 | } 499 | 500 | req.Header.Add(xForwardedFor, chExampleIP) 501 | 502 | handler.ServeHTTP(recorder, req) 503 | 504 | assertStatusCode(t, recorder.Result(), http.StatusOK) 505 | } 506 | } 507 | 508 | func TestDeniedCountry(t *testing.T) { 509 | cfg := createTesterConfig() 510 | cfg.Countries = append(cfg.Countries, "CH") 511 | 512 | ctx := context.Background() 513 | next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("Allowed request")) }) 514 | 515 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 516 | if err != nil { 517 | t.Fatal(err) 518 | } 519 | 520 | recorder := httptest.NewRecorder() 521 | 522 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 523 | if err != nil { 524 | t.Fatal(err) 525 | } 526 | 527 | req.Header.Add(xForwardedFor, caExampleIP) 528 | 529 | handler.ServeHTTP(recorder, req) 530 | 531 | recorderResult := recorder.Result() 532 | 533 | assertStatusCode(t, recorderResult, http.StatusForbidden) 534 | 535 | body, err := io.ReadAll(recorderResult.Body) 536 | if err != nil { 537 | t.Fatal(err) 538 | } 539 | 540 | expectedBody := "" 541 | if string(body) != expectedBody { 542 | t.Fatalf("expected body %q, got %q", expectedBody, string(body)) 543 | } 544 | } 545 | 546 | func TestDeniedCountryWithRedirect(t *testing.T) { 547 | cfg := createTesterConfig() 548 | cfg.Countries = append(cfg.Countries, "CH") 549 | cfg.RedirectURLIfDenied = "https://google.com" 550 | 551 | ctx := context.Background() 552 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 553 | 554 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 555 | if err != nil { 556 | t.Fatal(err) 557 | } 558 | 559 | recorder := httptest.NewRecorder() 560 | 561 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 562 | if err != nil { 563 | t.Fatal(err) 564 | } 565 | 566 | req.Header.Add(xForwardedFor, caExampleIP) 567 | 568 | handler.ServeHTTP(recorder, req) 569 | 570 | result := recorder.Result() 571 | assertStatusCode(t, result, http.StatusFound) 572 | assertResponseHeader(t, result, "Location", cfg.RedirectURLIfDenied) 573 | } 574 | 575 | func TestCustomDeniedRequestStatusCode(t *testing.T) { 576 | cfg := createTesterConfig() 577 | cfg.Countries = append(cfg.Countries, "CH") 578 | cfg.HTTPStatusCodeDeniedRequest = 418 579 | 580 | ctx := context.Background() 581 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 582 | 583 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 584 | if err != nil { 585 | t.Fatal(err) 586 | } 587 | 588 | recorder := httptest.NewRecorder() 589 | 590 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 591 | if err != nil { 592 | t.Fatal(err) 593 | } 594 | 595 | req.Header.Add(xForwardedFor, caExampleIP) 596 | 597 | handler.ServeHTTP(recorder, req) 598 | 599 | assertStatusCode(t, recorder.Result(), http.StatusTeapot) 600 | } 601 | 602 | func TestAllowBlacklistMode(t *testing.T) { 603 | cfg := createTesterConfig() 604 | cfg.BlackListMode = true 605 | cfg.Countries = append(cfg.Countries, "CH") 606 | 607 | ctx := context.Background() 608 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 609 | 610 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 611 | if err != nil { 612 | t.Fatal(err) 613 | } 614 | 615 | recorder := httptest.NewRecorder() 616 | 617 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 618 | if err != nil { 619 | t.Fatal(err) 620 | } 621 | 622 | req.Header.Add(xForwardedFor, caExampleIP) 623 | 624 | handler.ServeHTTP(recorder, req) 625 | 626 | assertStatusCode(t, recorder.Result(), http.StatusOK) 627 | } 628 | 629 | func TestDenyBlacklistMode(t *testing.T) { 630 | cfg := createTesterConfig() 631 | cfg.BlackListMode = true 632 | cfg.Countries = append(cfg.Countries, "CH") 633 | 634 | ctx := context.Background() 635 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 636 | 637 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 638 | if err != nil { 639 | t.Fatal(err) 640 | } 641 | 642 | recorder := httptest.NewRecorder() 643 | 644 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 645 | if err != nil { 646 | t.Fatal(err) 647 | } 648 | 649 | req.Header.Add(xForwardedFor, chExampleIP) 650 | 651 | handler.ServeHTTP(recorder, req) 652 | 653 | assertStatusCode(t, recorder.Result(), http.StatusForbidden) 654 | } 655 | 656 | func TestAllowLocalIP(t *testing.T) { 657 | cfg := createTesterConfig() 658 | cfg.Countries = append(cfg.Countries, "CH") 659 | cfg.AllowLocalRequests = true 660 | 661 | ctx := context.Background() 662 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 663 | 664 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 665 | if err != nil { 666 | t.Fatal(err) 667 | } 668 | 669 | recorder := httptest.NewRecorder() 670 | 671 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 672 | if err != nil { 673 | t.Fatal(err) 674 | } 675 | 676 | req.Header.Add(xForwardedFor, privateRangeIP) 677 | 678 | handler.ServeHTTP(recorder, req) 679 | 680 | assertStatusCode(t, recorder.Result(), http.StatusOK) 681 | } 682 | 683 | func TestPrivateIPRange(t *testing.T) { 684 | cfg := createTesterConfig() 685 | cfg.Countries = append(cfg.Countries, "CH") 686 | 687 | ctx := context.Background() 688 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 689 | 690 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 691 | if err != nil { 692 | t.Fatal(err) 693 | } 694 | 695 | recorder := httptest.NewRecorder() 696 | 697 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 698 | if err != nil { 699 | t.Fatal(err) 700 | } 701 | 702 | req.Header.Add(xForwardedFor, privateRangeIP) 703 | 704 | handler.ServeHTTP(recorder, req) 705 | 706 | assertStatusCode(t, recorder.Result(), http.StatusForbidden) 707 | } 708 | 709 | func TestInvalidIp(t *testing.T) { 710 | cfg := createTesterConfig() 711 | cfg.Countries = append(cfg.Countries, "CH") 712 | 713 | ctx := context.Background() 714 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 715 | 716 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 717 | if err != nil { 718 | t.Fatal(err) 719 | } 720 | 721 | recorder := httptest.NewRecorder() 722 | 723 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 724 | if err != nil { 725 | t.Fatal(err) 726 | } 727 | 728 | req.Header.Add(xForwardedFor, invalidIP) 729 | 730 | handler.ServeHTTP(recorder, req) 731 | 732 | assertStatusCode(t, recorder.Result(), http.StatusForbidden) 733 | } 734 | 735 | func TestInvalidApiResponse(t *testing.T) { 736 | // set up our fake api server 737 | var apiStub = httptest.NewServer(http.HandlerFunc(apiHandlerInvalid)) 738 | 739 | cfg := createTesterConfig() 740 | cfg.API = apiStub.URL + "/{ip}" 741 | cfg.Countries = append(cfg.Countries, "CH") 742 | 743 | ctx := context.Background() 744 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 745 | 746 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 747 | if err != nil { 748 | t.Fatal(err) 749 | } 750 | 751 | recorder := httptest.NewRecorder() 752 | 753 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 754 | if err != nil { 755 | t.Fatal(err) 756 | } 757 | 758 | // the country is allowed, but the api response is faulty. 759 | // therefore the request should be blocked 760 | req.Header.Add(xForwardedFor, chExampleIP) 761 | 762 | handler.ServeHTTP(recorder, req) 763 | 764 | assertStatusCode(t, recorder.Result(), http.StatusForbidden) 765 | } 766 | 767 | func TestApiResponseTimeoutAllowed(t *testing.T) { 768 | // set up our fake api server 769 | var apiStub = httptest.NewServer(http.HandlerFunc(apiTimeout)) 770 | 771 | cfg := createTesterConfig() 772 | cfg.API = apiStub.URL + "/{ip}" 773 | cfg.Countries = append(cfg.Countries, "CH") 774 | cfg.APITimeoutMs = 5 775 | cfg.IgnoreAPITimeout = true 776 | 777 | ctx := context.Background() 778 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 779 | 780 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 781 | if err != nil { 782 | t.Fatal(err) 783 | } 784 | 785 | recorder := httptest.NewRecorder() 786 | 787 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 788 | if err != nil { 789 | t.Fatal(err) 790 | } 791 | 792 | // the country is allowed, but the api response is faulty. 793 | // therefore the request should be blocked 794 | req.Header.Add(xForwardedFor, chExampleIP) 795 | 796 | handler.ServeHTTP(recorder, req) 797 | 798 | assertStatusCode(t, recorder.Result(), http.StatusOK) 799 | } 800 | 801 | func TestApiResponseTimeoutNotAllowed(t *testing.T) { 802 | // set up our fake api server 803 | var apiStub = httptest.NewServer(http.HandlerFunc(apiTimeout)) 804 | 805 | cfg := createTesterConfig() 806 | cfg.API = apiStub.URL + "/{ip}" 807 | cfg.Countries = append(cfg.Countries, "CH") 808 | cfg.APITimeoutMs = 5 809 | cfg.IgnoreAPITimeout = false 810 | 811 | ctx := context.Background() 812 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 813 | 814 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 815 | if err != nil { 816 | t.Fatal(err) 817 | } 818 | 819 | recorder := httptest.NewRecorder() 820 | 821 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 822 | if err != nil { 823 | t.Fatal(err) 824 | } 825 | 826 | // the country is allowed, but the api response is faulty. 827 | // therefore the request should be blocked 828 | req.Header.Add(xForwardedFor, chExampleIP) 829 | 830 | handler.ServeHTTP(recorder, req) 831 | 832 | assertStatusCode(t, recorder.Result(), http.StatusForbidden) 833 | } 834 | 835 | func TestExplicitlyAllowedIP(t *testing.T) { 836 | cfg := createTesterConfig() 837 | cfg.Countries = append(cfg.Countries, "CH") 838 | cfg.AllowedIPAddresses = append(cfg.AllowedIPAddresses, caExampleIP) 839 | cfg.LogLocalRequests = true 840 | 841 | ctx := context.Background() 842 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 843 | 844 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 845 | if err != nil { 846 | t.Fatal(err) 847 | } 848 | 849 | recorder := httptest.NewRecorder() 850 | 851 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 852 | if err != nil { 853 | t.Fatal(err) 854 | } 855 | 856 | req.Header.Add(xForwardedFor, caExampleIP) 857 | 858 | handler.ServeHTTP(recorder, req) 859 | 860 | assertStatusCode(t, recorder.Result(), http.StatusOK) 861 | } 862 | 863 | func TestExplicitlyAllowedIPWithIPCountryHeader(t *testing.T) { 864 | // set up our fake api server 865 | apiHandler := &CountryCodeHandler{ResponseCountryCode: "CA"} 866 | var apiStub = httptest.NewServer(apiHandler) 867 | 868 | cfg := createTesterConfig() 869 | cfg.API = apiStub.URL + "/{ip}" 870 | cfg.Countries = append(cfg.Countries, "CH") 871 | cfg.AllowedIPAddresses = append(cfg.AllowedIPAddresses, caExampleIP) 872 | cfg.LogLocalRequests = true 873 | cfg.AddCountryHeader = true 874 | 875 | ctx := context.Background() 876 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 877 | 878 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 879 | if err != nil { 880 | t.Fatal(err) 881 | } 882 | 883 | recorder := httptest.NewRecorder() 884 | 885 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 886 | if err != nil { 887 | t.Fatal(err) 888 | } 889 | 890 | req.Header.Add(xForwardedFor, caExampleIP) 891 | 892 | handler.ServeHTTP(recorder, req) 893 | 894 | assertStatusCode(t, recorder.Result(), http.StatusOK) 895 | assertRequestHeader(t, req, CountryHeader, "CA") 896 | } 897 | 898 | func TestExplicitlyAllowedIPNoMatch(t *testing.T) { 899 | cfg := createTesterConfig() 900 | cfg.Countries = append(cfg.Countries, "CA") 901 | cfg.AllowedIPAddresses = append(cfg.AllowedIPAddresses, caExampleIP) 902 | 903 | ctx := context.Background() 904 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 905 | 906 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 907 | if err != nil { 908 | t.Fatal(err) 909 | } 910 | 911 | recorder := httptest.NewRecorder() 912 | 913 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 914 | if err != nil { 915 | t.Fatal(err) 916 | } 917 | 918 | req.Header.Add(xForwardedFor, chExampleIP) 919 | 920 | handler.ServeHTTP(recorder, req) 921 | 922 | assertStatusCode(t, recorder.Result(), http.StatusForbidden) 923 | } 924 | 925 | func TestExplicitlyAllowedIPRangeIPV6(t *testing.T) { 926 | cfg := createTesterConfig() 927 | cfg.Countries = append(cfg.Countries, "CA") 928 | cfg.AllowedIPAddresses = append(cfg.AllowedIPAddresses, "2a00:00c0:2:3::567:8001/128") 929 | cfg.AllowedIPAddresses = append(cfg.AllowedIPAddresses, "8.8.8.8") 930 | 931 | ctx := context.Background() 932 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 933 | 934 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 935 | if err != nil { 936 | t.Fatal(err) 937 | } 938 | 939 | recorder := httptest.NewRecorder() 940 | 941 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 942 | if err != nil { 943 | t.Fatal(err) 944 | } 945 | 946 | req.Header.Add(xForwardedFor, "2a00:00c0:2:3::567:8001") 947 | 948 | handler.ServeHTTP(recorder, req) 949 | 950 | assertStatusCode(t, recorder.Result(), http.StatusOK) 951 | } 952 | 953 | func TestExplicitlyAllowedIPRangeIPV6NoMatch(t *testing.T) { 954 | cfg := createTesterConfig() 955 | cfg.Countries = append(cfg.Countries, "CA") 956 | cfg.AllowedIPAddresses = append(cfg.AllowedIPAddresses, "2a00:00c0:2:3::567:8001/128") 957 | cfg.AllowedIPAddresses = append(cfg.AllowedIPAddresses, "8.8.8.8") 958 | 959 | ctx := context.Background() 960 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 961 | 962 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 963 | if err != nil { 964 | t.Fatal(err) 965 | } 966 | 967 | recorder := httptest.NewRecorder() 968 | 969 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 970 | if err != nil { 971 | t.Fatal(err) 972 | } 973 | 974 | req.Header.Add(xForwardedFor, "2a00:00c0:2:3::567:8002") 975 | 976 | handler.ServeHTTP(recorder, req) 977 | 978 | assertStatusCode(t, recorder.Result(), http.StatusForbidden) 979 | } 980 | 981 | func TestExplicitlyAllowedIPRangeIPV4(t *testing.T) { 982 | cfg := createTesterConfig() 983 | cfg.Countries = append(cfg.Countries, "CA") 984 | cfg.AllowedIPAddresses = append(cfg.AllowedIPAddresses, "178.90.234.0/27") 985 | cfg.AllowedIPAddresses = append(cfg.AllowedIPAddresses, "8.8.8.8") 986 | 987 | ctx := context.Background() 988 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 989 | 990 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 991 | if err != nil { 992 | t.Fatal(err) 993 | } 994 | 995 | recorder := httptest.NewRecorder() 996 | 997 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 998 | if err != nil { 999 | t.Fatal(err) 1000 | } 1001 | 1002 | req.Header.Add(xForwardedFor, "178.90.234.30") 1003 | 1004 | handler.ServeHTTP(recorder, req) 1005 | 1006 | assertStatusCode(t, recorder.Result(), http.StatusOK) 1007 | } 1008 | 1009 | func TestExplicitlyAllowedIPRangeIPV4NoMatch(t *testing.T) { 1010 | cfg := createTesterConfig() 1011 | cfg.Countries = append(cfg.Countries, "CA") 1012 | cfg.AllowedIPAddresses = append(cfg.AllowedIPAddresses, "178.90.234.0/27") 1013 | cfg.AllowedIPAddresses = append(cfg.AllowedIPAddresses, "8.8.8.8") 1014 | 1015 | ctx := context.Background() 1016 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 1017 | 1018 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 1019 | if err != nil { 1020 | t.Fatal(err) 1021 | } 1022 | 1023 | recorder := httptest.NewRecorder() 1024 | 1025 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 1026 | if err != nil { 1027 | t.Fatal(err) 1028 | } 1029 | 1030 | req.Header.Add(xForwardedFor, "178.90.234.55") 1031 | 1032 | handler.ServeHTTP(recorder, req) 1033 | 1034 | assertStatusCode(t, recorder.Result(), http.StatusForbidden) 1035 | } 1036 | 1037 | func TestCountryHeader(t *testing.T) { 1038 | cfg := createTesterConfig() 1039 | cfg.AddCountryHeader = true 1040 | cfg.Countries = append(cfg.Countries, "CA") 1041 | 1042 | ctx := context.Background() 1043 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 1044 | 1045 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 1046 | if err != nil { 1047 | t.Fatal(err) 1048 | } 1049 | 1050 | recorder := httptest.NewRecorder() 1051 | 1052 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 1053 | if err != nil { 1054 | t.Fatal(err) 1055 | } 1056 | 1057 | req.Header.Add(xForwardedFor, caExampleIP) 1058 | 1059 | handler.ServeHTTP(recorder, req) 1060 | 1061 | assertRequestHeader(t, req, CountryHeader, "CA") 1062 | } 1063 | 1064 | func TestIpGeolocationHttpField(t *testing.T) { 1065 | cfg := createTesterConfig() 1066 | cfg.Countries = append(cfg.Countries, "CA") 1067 | cfg.AddCountryHeader = true 1068 | cfg.IPGeolocationHTTPHeaderField = ipGeolocationHTTPHeaderField 1069 | 1070 | ctx := context.Background() 1071 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 1072 | 1073 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 1074 | if err != nil { 1075 | t.Fatal(err) 1076 | } 1077 | 1078 | recorder := httptest.NewRecorder() 1079 | 1080 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 1081 | if err != nil { 1082 | t.Fatal(err) 1083 | } 1084 | 1085 | // we only want to listen to the ipGeolocationHTTPHeader field, 1086 | // therefore we just give another countries IP address to test it. 1087 | req.Header.Add(xForwardedFor, chExampleIP) 1088 | req.Header.Add(ipGeolocationHTTPHeaderField, "CA") 1089 | 1090 | handler.ServeHTTP(recorder, req) 1091 | 1092 | assertRequestHeader(t, req, CountryHeader, "CA") 1093 | assertStatusCode(t, recorder.Result(), http.StatusOK) 1094 | } 1095 | 1096 | func TestIpGeolocationHttpFieldContentInvalid(t *testing.T) { 1097 | apiHandler := &CountryCodeHandler{ResponseCountryCode: "CA"} 1098 | 1099 | // set up our fake api server 1100 | var apiStub = httptest.NewServer(apiHandler) 1101 | 1102 | tempDir, err := os.MkdirTemp("", "logtest") 1103 | if err != nil { 1104 | t.Fatalf("Failed to create temporary directory: %v", err) 1105 | } 1106 | defer os.RemoveAll(tempDir) 1107 | 1108 | cfg := createTesterConfig() 1109 | cfg.API = apiStub.URL + "/{ip}" 1110 | cfg.Countries = append(cfg.Countries, "CA") 1111 | cfg.IPGeolocationHTTPHeaderField = ipGeolocationHTTPHeaderField 1112 | cfg.LogFilePath = tempDir + "/info.log" 1113 | cfg.LogAllowedRequests = true 1114 | 1115 | ctx := context.Background() 1116 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 1117 | 1118 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 1119 | if err != nil { 1120 | t.Fatal(err) 1121 | } 1122 | 1123 | recorder := httptest.NewRecorder() 1124 | 1125 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 1126 | if err != nil { 1127 | t.Fatal(err) 1128 | } 1129 | 1130 | req.Header.Add(xForwardedFor, chExampleIP) 1131 | 1132 | handler.ServeHTTP(recorder, req) 1133 | 1134 | assertStatusCode(t, recorder.Result(), http.StatusOK) 1135 | 1136 | content, err := os.ReadFile(cfg.LogFilePath) 1137 | if err != nil { 1138 | t.Fatalf("Failed to read log file: %v", err) 1139 | } 1140 | 1141 | if len(content) == 0 { 1142 | t.Fatalf("Empty custom log file.") 1143 | } 1144 | } 1145 | 1146 | func TestCustomLogFile(t *testing.T) { 1147 | apiHandler := &CountryCodeHandler{ResponseCountryCode: "CA"} 1148 | 1149 | // set up our fake api server 1150 | var apiStub = httptest.NewServer(apiHandler) 1151 | 1152 | cfg := createTesterConfig() 1153 | cfg.API = apiStub.URL + "/{ip}" 1154 | cfg.Countries = append(cfg.Countries, "CA") 1155 | cfg.IPGeolocationHTTPHeaderField = ipGeolocationHTTPHeaderField 1156 | 1157 | ctx := context.Background() 1158 | next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}) 1159 | 1160 | handler, err := geoblock.New(ctx, next, cfg, "GeoBlock") 1161 | if err != nil { 1162 | t.Fatal(err) 1163 | } 1164 | 1165 | recorder := httptest.NewRecorder() 1166 | 1167 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 1168 | if err != nil { 1169 | t.Fatal(err) 1170 | } 1171 | 1172 | req.Header.Add(xForwardedFor, caExampleIP) 1173 | req.Header.Add(ipGeolocationHTTPHeaderField, "") 1174 | 1175 | handler.ServeHTTP(recorder, req) 1176 | 1177 | assertStatusCode(t, recorder.Result(), http.StatusOK) 1178 | } 1179 | 1180 | func assertStatusCode(t *testing.T, req *http.Response, expected int) { 1181 | t.Helper() 1182 | 1183 | if received := req.StatusCode; received != expected { 1184 | t.Errorf("invalid status code: %d <> %d", expected, received) 1185 | } 1186 | } 1187 | 1188 | func assertRequestHeader(t *testing.T, req *http.Request, key string, expected string) { 1189 | t.Helper() 1190 | 1191 | if received := req.Header.Get(key); received != expected { 1192 | t.Errorf("header value mismatch: %s: %s <> %s", key, expected, received) 1193 | } 1194 | } 1195 | 1196 | func assertResponseHeader(t *testing.T, response *http.Response, key string, expected string) { 1197 | t.Helper() 1198 | 1199 | if received := response.Header.Get(key); received != expected { 1200 | t.Errorf("header value mismatch: %s: %s <> %s", key, expected, received) 1201 | } 1202 | } 1203 | 1204 | type CountryCodeHandler struct { 1205 | ResponseCountryCode string 1206 | } 1207 | 1208 | func (h *CountryCodeHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) { 1209 | w.WriteHeader(http.StatusOK) 1210 | 1211 | _, err := w.Write([]byte(h.ResponseCountryCode)) 1212 | if err != nil { 1213 | fmt.Println("Error on write") 1214 | } 1215 | } 1216 | 1217 | func apiHandlerInvalid(w http.ResponseWriter, _ *http.Request) { 1218 | fmt.Fprintf(w, "Invalid Response") 1219 | } 1220 | 1221 | func apiTimeout(w http.ResponseWriter, _ *http.Request) { 1222 | // Add waiting time for response 1223 | time.Sleep(20 * time.Millisecond) 1224 | 1225 | w.WriteHeader(http.StatusOK) 1226 | 1227 | _, err := w.Write([]byte("")) 1228 | if err != nil { 1229 | fmt.Println("Error on write") 1230 | } 1231 | } 1232 | 1233 | func createTesterConfig() *geoblock.Config { 1234 | cfg := geoblock.CreateConfig() 1235 | 1236 | cfg.API = apiURI 1237 | cfg.APITimeoutMs = 750 1238 | cfg.AllowLocalRequests = false 1239 | cfg.AllowUnknownCountries = false 1240 | cfg.CacheSize = 10 1241 | cfg.Countries = make([]string, 0) 1242 | cfg.ForceMonthlyUpdate = true 1243 | cfg.LogAPIRequests = false 1244 | cfg.LogAllowedRequests = false 1245 | cfg.LogLocalRequests = false 1246 | cfg.UnknownCountryAPIResponse = "nil" 1247 | 1248 | return cfg 1249 | } 1250 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/PascalMinder/geoblock 2 | 3 | go 1.22 4 | -------------------------------------------------------------------------------- /lrucache/lru.go: -------------------------------------------------------------------------------- 1 | // Package lrucache provides a very basic LRU cache implementation. 2 | package lrucache 3 | 4 | import ( 5 | "container/list" 6 | "errors" 7 | "sync" 8 | ) 9 | 10 | // LRU struct to represent the LRU cache 11 | type LRUCache struct { 12 | lock sync.RWMutex 13 | size int 14 | evictList *list.List 15 | items map[interface{}]*list.Element 16 | } 17 | 18 | // Entry struct containing key value pair to represent a cache entry 19 | type cacheEntry struct { 20 | key interface{} 21 | value interface{} 22 | } 23 | 24 | // New constructs a new cache instance 25 | func NewLRUCache(size int) (*LRUCache, error) { 26 | // no use for a cache with one entry 27 | if size <= 1 { 28 | return nil, errors.New("cache size must be bigger than 1") 29 | } 30 | 31 | c := &LRUCache{ 32 | size: size, 33 | evictList: list.New(), 34 | items: make(map[interface{}]*list.Element), 35 | } 36 | 37 | return c, nil 38 | } 39 | 40 | func (c *LRUCache) Add(key, value interface{}) (evicted bool) { 41 | c.lock.Lock() 42 | defer c.lock.Unlock() 43 | 44 | // check for existing entry 45 | if e, ok := c.items[key]; ok { 46 | c.evictList.MoveToFront(e) 47 | e.Value.(*cacheEntry).value = value 48 | 49 | return false 50 | } 51 | 52 | // add the new entry 53 | ent := &cacheEntry{key, value} 54 | entry := c.evictList.PushFront(ent) 55 | c.items[key] = entry 56 | 57 | // remove last element if number of entries exceed limit 58 | evict := c.evictList.Len() > c.size 59 | if evict { 60 | c.removeOldest() 61 | } 62 | 63 | return evict 64 | } 65 | 66 | func (c *LRUCache) Get(key interface{}) (value interface{}, ok bool) { 67 | c.lock.Lock() 68 | defer c.lock.Unlock() 69 | 70 | e, ok := c.items[key] 71 | 72 | if ok { 73 | // update recent-ness 74 | c.evictList.MoveToFront(e) 75 | 76 | if e.Value.(*cacheEntry) == nil { 77 | return nil, false 78 | } 79 | 80 | return e.Value.(*cacheEntry).value, true 81 | } 82 | 83 | return 84 | } 85 | 86 | func (c *LRUCache) Contains(key interface{}) (ok bool) { 87 | c.lock.RLock() 88 | 89 | _, ok = c.items[key] 90 | 91 | c.lock.RUnlock() 92 | 93 | return ok 94 | } 95 | 96 | func (c *LRUCache) Remove(key interface{}) (present bool) { 97 | c.lock.Lock() 98 | defer c.lock.Unlock() 99 | 100 | e, ok := c.items[key] 101 | 102 | if ok { 103 | c.removeElement(e) 104 | 105 | return true 106 | } 107 | 108 | return false 109 | } 110 | 111 | func (c *LRUCache) Keys() []interface{} { 112 | c.lock.RLock() 113 | 114 | keys := make([]interface{}, len(c.items)) 115 | 116 | i := 0 117 | for e := c.evictList.Front(); e != nil; e = e.Next() { 118 | keys[i] = e.Value.(*cacheEntry).key 119 | i++ 120 | } 121 | 122 | c.lock.RUnlock() 123 | 124 | return keys 125 | } 126 | 127 | func (c *LRUCache) Length() int { 128 | c.lock.RLock() 129 | defer c.lock.RUnlock() 130 | return c.evictList.Len() 131 | } 132 | 133 | func (c *LRUCache) Purge() { 134 | c.lock.Lock() 135 | 136 | for k := range c.items { 137 | delete(c.items, k) 138 | } 139 | c.evictList.Init() 140 | 141 | c.lock.Unlock() 142 | } 143 | 144 | func (c *LRUCache) removeOldest() { 145 | if e := c.evictList.Back(); e != nil { 146 | c.removeElement(e) 147 | } 148 | } 149 | 150 | func (c *LRUCache) removeElement(entry *list.Element) { 151 | c.evictList.Remove(entry) 152 | 153 | e := entry.Value.(*cacheEntry) 154 | delete(c.items, e.key) 155 | } 156 | -------------------------------------------------------------------------------- /lrucache/lru_interface.go: -------------------------------------------------------------------------------- 1 | // The lru package provides a very basic LRU cache implementation 2 | package lrucache 3 | 4 | // Cache defines the interface for the LRU cache 5 | type Cache interface { 6 | // Add a new value to the cache and updates the recent-ness. Returns true if an eviction occurred. 7 | Add(key, value interface{}) bool 8 | 9 | // Return a key's value if found in the cache and updates the recent-ness. 10 | Get(key interface{}) (value interface{}, ok bool) 11 | 12 | // Check if a key exists without updating the recent-ness. 13 | Contains(key interface{}) (ok bool) 14 | 15 | // Remove a key from the cache. 16 | Remove(key interface{}) bool 17 | 18 | // Return a slice which all keys ordered by newest to oldest. 19 | Keys() []interface{} 20 | 21 | // Return number of entry in the cache. 22 | Length() int 23 | 24 | // Remove all entries form the cache. 25 | Purge() 26 | } 27 | -------------------------------------------------------------------------------- /lrucache/lru_test.go: -------------------------------------------------------------------------------- 1 | package lrucache 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestNewLRUCache(t *testing.T) { 9 | cache, err := NewLRUCache(10) 10 | 11 | if err != nil { 12 | t.Errorf("NewLRUCache() error = %v, want %v", err, nil) 13 | } 14 | 15 | if cache.Length() != 0 { 16 | t.Errorf("NewLRUCache() length = %v, want %v", cache.Length(), 0) 17 | } 18 | } 19 | 20 | func TestNewLRUCacheInvalidSize(t *testing.T) { 21 | expectedError := "cache size must be bigger than 1" 22 | _, err := NewLRUCache(1) 23 | 24 | if err.Error() != expectedError { 25 | t.Errorf("NewLRUCache() length = %v, want %v", err.Error(), expectedError) 26 | } 27 | } 28 | 29 | func TestLRUCacheAddElement(t *testing.T) { 30 | cache, err := NewLRUCache(10) 31 | 32 | if err != nil { 33 | t.Errorf("NewLRUCache() error = %v, want %v", err, nil) 34 | } 35 | 36 | cache.Add("Apple", 2.20) 37 | 38 | if cache.Length() != 1 { 39 | t.Errorf("NewLRUCache() error = %v, want %v", cache.Length(), 1) 40 | } 41 | } 42 | 43 | func TestLRUCacheAddElementEviction(t *testing.T) { 44 | cache, err := NewLRUCache(10) 45 | 46 | if err != nil { 47 | t.Errorf("NewLRUCache() error = %v, want %v", err, nil) 48 | } 49 | 50 | for i := 0; i < 11; i++ { 51 | cache.Add("Apple_"+fmt.Sprint(i), 2.0+(0.1*float32(i))) 52 | } 53 | 54 | if cache.Length() != 10 { 55 | t.Errorf("NewLRUCache() error = %v, want %v", cache.Length(), 1) 56 | } 57 | } 58 | 59 | func TestLRUCacheAddElementExisting(t *testing.T) { 60 | cache, err := NewLRUCache(10) 61 | 62 | if err != nil { 63 | t.Errorf("NewLRUCache() error = %v, want %v", err, nil) 64 | } 65 | 66 | cache.Add("Apple", 2.20) 67 | 68 | e, ok := cache.Get("Apple") 69 | 70 | if ok != true { 71 | t.Errorf("NewLRUCache() existing element = %v, want %v", ok, true) 72 | } 73 | 74 | if e != 2.20 { 75 | t.Errorf("NewLRUCache() existing element = %v, want %v", e, 2.20) 76 | } 77 | 78 | evicted := cache.Add("Apple", 2.50) 79 | 80 | if evicted != false { 81 | t.Errorf("NewLRUCache() existing element = %v, want %v", evicted, true) 82 | } 83 | 84 | e, ok = cache.Get("Apple") 85 | 86 | if ok != true { 87 | t.Errorf("NewLRUCache() existing element = %v, want %v", ok, true) 88 | } 89 | 90 | if e != 2.50 { 91 | t.Errorf("NewLRUCache() existing element = %v, want %v", e, 2.50) 92 | } 93 | } 94 | 95 | func TestLRUCacheGetElementNotExisting(t *testing.T) { 96 | cache, err := NewLRUCache(10) 97 | 98 | if err != nil { 99 | t.Errorf("NewLRUCache() error = %v, want %v", err, nil) 100 | } 101 | 102 | cache.Add("Apple", 2.20) 103 | 104 | e, ok := cache.Get("Pear") 105 | 106 | if ok != false { 107 | t.Errorf("NewLRUCache() existing element = %v, want %v", ok, true) 108 | } 109 | 110 | if e != nil { 111 | t.Errorf("NewLRUCache() element = %v, want %v", e, nil) 112 | } 113 | } 114 | 115 | func TestLRUCacheContainsElement(t *testing.T) { 116 | cache, err := NewLRUCache(10) 117 | 118 | if err != nil { 119 | t.Errorf("NewLRUCache() error = %v, want %v", err, nil) 120 | } 121 | 122 | cache.Add("Apple", 2.20) 123 | 124 | ok := cache.Contains("Pear") 125 | 126 | if ok != false { 127 | t.Errorf("NewLRUCache() existing element = %v, want %v", ok, true) 128 | } 129 | 130 | ok = cache.Contains("Apple") 131 | 132 | if ok != true { 133 | t.Errorf("NewLRUCache() element = %v, want %v", ok, true) 134 | } 135 | } 136 | 137 | func TestLRUCacheRemove(t *testing.T) { 138 | cache, err := NewLRUCache(10) 139 | 140 | if err != nil { 141 | t.Errorf("NewLRUCache() error = %v, want %v", err, nil) 142 | } 143 | 144 | cache.Add("Apple", 2.20) 145 | cache.Add("Pear", 3.20) 146 | 147 | ok := cache.Contains("Apple") 148 | 149 | if ok != true { 150 | t.Errorf("NewLRUCache() existing element = %v, want %v", ok, true) 151 | } 152 | 153 | ok = cache.Contains("Pear") 154 | 155 | if ok != true { 156 | t.Errorf("NewLRUCache() element = %v, want %v", ok, true) 157 | } 158 | 159 | cache.Remove("Apple") 160 | 161 | ok = cache.Contains("Apple") 162 | 163 | if ok != false { 164 | t.Errorf("NewLRUCache() existing element = %v, want %v", ok, true) 165 | } 166 | 167 | ok = cache.Contains("Pear") 168 | 169 | if ok != true { 170 | t.Errorf("NewLRUCache() element = %v, want %v", ok, true) 171 | } 172 | } 173 | 174 | func TestLRUCacheKeys(t *testing.T) { 175 | cache, err := NewLRUCache(10) 176 | 177 | if err != nil { 178 | t.Errorf("NewLRUCache() error = %v, want %v", err, nil) 179 | } 180 | 181 | for i := 0; i < 15; i++ { 182 | cache.Add("Apple_"+fmt.Sprint(i), 2.0+(0.1*float32(i))) 183 | } 184 | 185 | if cacheLen := cache.Length(); cacheLen != 10 { 186 | t.Errorf("NewLRUCache() existing element = %v, want %v", cacheLen, 10) 187 | } 188 | 189 | keys := cache.Keys() 190 | 191 | for i := 0; i < 10; i++ { 192 | want := "Apple_" + fmt.Sprint(14-i) 193 | if keys[i] != want { 194 | t.Errorf("NewLRUCache() existing element = %v, want %v", keys[i], want) 195 | } 196 | } 197 | } 198 | 199 | func TestLRUCachePurge(t *testing.T) { 200 | cache, err := NewLRUCache(10) 201 | 202 | if err != nil { 203 | t.Errorf("NewLRUCache() error = %v, want %v", err, nil) 204 | } 205 | 206 | for i := 0; i < 15; i++ { 207 | cache.Add("Apple_"+fmt.Sprint(i), 2.0+(0.1*float32(i))) 208 | } 209 | 210 | if cacheLen := cache.Length(); cacheLen != 10 { 211 | t.Errorf("NewLRUCache() existing element = %v, want %v", cacheLen, 10) 212 | } 213 | 214 | cache.Purge() 215 | 216 | if cacheLen := cache.Length(); cacheLen != 0 { 217 | t.Errorf("NewLRUCache() existing element = %v, want %v", cacheLen, 10) 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # GeoBlock 2 | 3 | Simple plugin for [Traefik](https://github.com/containous/traefik) to block or allow requests based on their country of origin. Uses [GeoJs.io](https://www.geojs.io/). 4 | 5 | ## Configuration 6 | 7 | It is possible to install the [plugin locally](https://traefik.io/blog/using-private-plugins-in-traefik-proxy-2-5/) or to install it through [Traefik Pilot](https://pilot.traefik.io/plugins). 8 | 9 | ### Configuration as local plugin 10 | 11 | Depending on your setup, the installation steps might differ from the one described here. This example assumes that your Traefik instance runs in a Docker container and uses the [official image](https://hub.docker.com/_/traefik/). 12 | 13 | Download the latest release of the plugin and save it to a location the Traefik container can reach. Below is an example of a possible setup. Notice how the plugin source is mapped into the container (`/plugin/geoblock:/plugins-local/src/github.com/PascalMinder/geoblock/`) via a volume bind mount: 14 | 15 | #### `docker-compose.yml` 16 | 17 | ````yml 18 | version: "3.7" 19 | 20 | services: 21 | traefik: 22 | image: traefik 23 | 24 | volumes: 25 | - /var/run/docker.sock:/var/run/docker.sock 26 | - /docker/config/traefik/traefik.yml:/etc/traefik/traefik.yml 27 | - /docker/config/traefik/dynamic-configuration.yml:/etc/traefik/dynamic-configuration.yml 28 | - /docker/config/traefik/plugin/geoblock:/plugins-local/src/github.com/PascalMinder/geoblock/ 29 | 30 | ports: 31 | - "80:80" 32 | 33 | hello: 34 | image: containous/whoami 35 | labels: 36 | - traefik.enable=true 37 | - traefik.http.routers.hello.entrypoints=http 38 | - traefik.http.routers.hello.rule=Host(`hello.localhost`) 39 | - traefik.http.services.hello.loadbalancer.server.port=80 40 | - traefik.http.routers.hello.middlewares=my-plugin@file 41 | ```` 42 | 43 | To complete the setup, the Traefik configuration must be extended with the plugin. For this you must create the `traefik.yml` and the dynamic-configuration.yml` files if not present already. 44 | 45 | ````yml 46 | log: 47 | level: INFO 48 | 49 | experimental: 50 | localPlugins: 51 | geoblock: 52 | moduleName: github.com/PascalMinder/geoblock 53 | ```` 54 | 55 | #### `dynamic-configuration.yml` 56 | 57 | ````yml 58 | http: 59 | middlewares: 60 | geoblock-ch: 61 | plugin: 62 | geoblock: 63 | silentStartUp: false 64 | allowLocalRequests: true 65 | logLocalRequests: false 66 | logAllowedRequests: false 67 | logApiRequests: true 68 | api: "https://get.geojs.io/v1/ip/country/{ip}" 69 | apiTimeoutMs: 750 # optional 70 | cacheSize: 15 71 | forceMonthlyUpdate: true 72 | allowUnknownCountries: false 73 | unknownCountryApiResponse: "nil" 74 | countries: 75 | - CH 76 | ```` 77 | 78 | ### Traefik Plugin registry 79 | 80 | This procedure will install the plugin via the [Traefik Plugin registry](https://plugins.traefik.io/install). 81 | 82 | Add the following to your `traefik-config.yml` 83 | 84 | ```yml 85 | experimental: 86 | plugins: 87 | geoblock: 88 | moduleName: "github.com/PascalMinder/geoblock" 89 | version: "v0.2.5" 90 | 91 | # other stuff you might have in your traefik-config 92 | entryPoints: 93 | http: 94 | address: ":80" 95 | https: 96 | address: ":443" 97 | 98 | providers: 99 | docker: 100 | endpoint: "unix:///var/run/docker.sock" 101 | exposedByDefault: false 102 | file: 103 | filename: "/etc/traefik/dynamic-configuration.yml" 104 | ``` 105 | 106 | In your dynamic configuration add the following: 107 | 108 | ```yml 109 | http: 110 | middlewares: 111 | my-GeoBlock: 112 | plugin: 113 | geoblock: 114 | silentStartUp: false 115 | allowLocalRequests: true 116 | logLocalRequests: false 117 | logAllowedRequests: false 118 | logApiRequests: false 119 | api: "https://get.geojs.io/v1/ip/country/{ip}" 120 | apiTimeoutMs: 500 121 | cacheSize: 25 122 | forceMonthlyUpdate: true 123 | allowUnknownCountries: false 124 | unknownCountryApiResponse: "nil" 125 | countries: 126 | - CH 127 | ``` 128 | 129 | And some example docker file for traefik: 130 | 131 | ```yml 132 | version: "3" 133 | networks: 134 | proxy: 135 | external: true # specifies that this network has been created outside of Compose, raises an error if it doesn’t exist 136 | services: 137 | traefik: 138 | image: traefik:latest 139 | container_name: traefik 140 | restart: unless-stopped 141 | security_opt: 142 | - no-new-privileges:true 143 | networks: 144 | proxy: 145 | aliases: 146 | - traefik 147 | ports: 148 | - 80:80 149 | - 443:443 150 | volumes: 151 | - "/etc/timezone:/etc/timezone:ro" 152 | - "/etc/localtime:/etc/localtime:ro" 153 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 154 | - "/a/docker/config/traefik/data/traefik.yml:/etc/traefik/traefik.yml:ro" 155 | - "/a/docker/config/traefik/data/dynamic-configuration.yml:/etc/traefik/dynamic-configuration.yml" 156 | ``` 157 | 158 | This configuration might not work. It's just to give you an idea how to configure it. 159 | 160 | ## Full plugin sample configuration 161 | 162 | - `allowLocalRequests`: If set to true, will not block request from [Private IP Ranges](https://de.wikipedia.org/wiki/Private_IP-Adresse) 163 | - `logLocalRequests`: If set to true, will log every connection from any IP in the private IP range 164 | - `api`: API URI used for querying the country associated with the connecting IP 165 | - `countries`: list of allowed countries 166 | - `blackListMode`: set to `false` so the plugin is running in `whitelist mode` 167 | 168 | ````yml 169 | my-GeoBlock: 170 | plugin: 171 | GeoBlock: 172 | silentStartUp: false 173 | allowLocalRequests: false 174 | logLocalRequests: false 175 | logAllowedRequests: false 176 | logApiRequests: false 177 | api: "https://get.geojs.io/v1/ip/country/{ip}" 178 | apiTimeoutMs: 750 # optional 179 | cacheSize: 15 180 | forceMonthlyUpdate: false 181 | allowUnknownCountries: false 182 | unknownCountryApiResponse: "nil" 183 | blackListMode: false 184 | addCountryHeader: false 185 | countries: 186 | - AF # Afghanistan 187 | - AL # Albania 188 | - DZ # Algeria 189 | - AS # American Samoa 190 | - AD # Andorra 191 | - AO # Angola 192 | - AI # Anguilla 193 | - AQ # Antarctica 194 | - AG # Antigua and Barbuda 195 | - AR # Argentina 196 | - AM # Armenia 197 | - AW # Aruba 198 | - AU # Australia 199 | - AT # Austria 200 | - AZ # Azerbaijan 201 | - BS # Bahamas (the) 202 | - BH # Bahrain 203 | - BD # Bangladesh 204 | - BB # Barbados 205 | - BY # Belarus 206 | - BE # Belgium 207 | - BZ # Belize 208 | - BJ # Benin 209 | - BM # Bermuda 210 | - BT # Bhutan 211 | - BO # Bolivia (Plurinational State of) 212 | - BQ # Bonaire, Sint Eustatius and Saba 213 | - BA # Bosnia and Herzegovina 214 | - BW # Botswana 215 | - BV # Bouvet Island 216 | - BR # Brazil 217 | - IO # British Indian Ocean Territory (the) 218 | - BN # Brunei Darussalam 219 | - BG # Bulgaria 220 | - BF # Burkina Faso 221 | - BI # Burundi 222 | - CV # Cabo Verde 223 | - KH # Cambodia 224 | - CM # Cameroon 225 | - CA # Canada 226 | - KY # Cayman Islands (the) 227 | - CF # Central African Republic (the) 228 | - TD # Chad 229 | - CL # Chile 230 | - CN # China 231 | - CX # Christmas Island 232 | - CC # Cocos (Keeling) Islands (the) 233 | - CO # Colombia 234 | - KM # Comoros (the) 235 | - CD # Congo (the Democratic Republic of the) 236 | - CG # Congo (the) 237 | - CK # Cook Islands (the) 238 | - CR # Costa Rica 239 | - HR # Croatia 240 | - CU # Cuba 241 | - CW # Curaçao 242 | - CY # Cyprus 243 | - CZ # Czechia 244 | - CI # Côte d'Ivoire 245 | - DK # Denmark 246 | - DJ # Djibouti 247 | - DM # Dominica 248 | - DO # Dominican Republic (the) 249 | - EC # Ecuador 250 | - EG # Egypt 251 | - SV # El Salvador 252 | - GQ # Equatorial Guinea 253 | - ER # Eritrea 254 | - EE # Estonia 255 | - SZ # Eswatini 256 | - ET # Ethiopia 257 | - FK # Falkland Islands (the) [Malvinas] 258 | - FO # Faroe Islands (the) 259 | - FJ # Fiji 260 | - FI # Finland 261 | - FR # France 262 | - GF # French Guiana 263 | - PF # French Polynesia 264 | - TF # French Southern Territories (the) 265 | - GA # Gabon 266 | - GM # Gambia (the) 267 | - GE # Georgia 268 | - DE # Germany 269 | - GH # Ghana 270 | - GI # Gibraltar 271 | - GR # Greece 272 | - GL # Greenland 273 | - GD # Grenada 274 | - GP # Guadeloupe 275 | - GU # Guam 276 | - GT # Guatemala 277 | - GG # Guernsey 278 | - GN # Guinea 279 | - GW # Guinea-Bissau 280 | - GY # Guyana 281 | - HT # Haiti 282 | - HM # Heard Island and McDonald Islands 283 | - VA # Holy See (the) 284 | - HN # Honduras 285 | - HK # Hong Kong 286 | - HU # Hungary 287 | - IS # Iceland 288 | - IN # India 289 | - ID # Indonesia 290 | - IR # Iran (Islamic Republic of) 291 | - IQ # Iraq 292 | - IE # Ireland 293 | - IM # Isle of Man 294 | - IL # Israel 295 | - IT # Italy 296 | - JM # Jamaica 297 | - JP # Japan 298 | - JE # Jersey 299 | - JO # Jordan 300 | - KZ # Kazakhstan 301 | - KE # Kenya 302 | - KI # Kiribati 303 | - KP # Korea (the Democratic People's Republic of) 304 | - KR # Korea (the Republic of) 305 | - KW # Kuwait 306 | - KG # Kyrgyzstan 307 | - LA # Lao People's Democratic Republic (the) 308 | - LV # Latvia 309 | - LB # Lebanon 310 | - LS # Lesotho 311 | - LR # Liberia 312 | - LY # Libya 313 | - LI # Liechtenstein 314 | - LT # Lithuania 315 | - LU # Luxembourg 316 | - MO # Macao 317 | - MG # Madagascar 318 | - MW # Malawi 319 | - MY # Malaysia 320 | - MV # Maldives 321 | - ML # Mali 322 | - MT # Malta 323 | - MH # Marshall Islands (the) 324 | - MQ # Martinique 325 | - MR # Mauritania 326 | - MU # Mauritius 327 | - YT # Mayotte 328 | - MX # Mexico 329 | - FM # Micronesia (Federated States of) 330 | - MD # Moldova (the Republic of) 331 | - MC # Monaco 332 | - MN # Mongolia 333 | - ME # Montenegro 334 | - MS # Montserrat 335 | - MA # Morocco 336 | - MZ # Mozambique 337 | - MM # Myanmar 338 | - NA # Namibia 339 | - NR # Nauru 340 | - NP # Nepal 341 | - NL # Netherlands (the) 342 | - NC # New Caledonia 343 | - NZ # New Zealand 344 | - NI # Nicaragua 345 | - NE # Niger (the) 346 | - NG # Nigeria 347 | - NU # Niue 348 | - NF # Norfolk Island 349 | - MP # Northern Mariana Islands (the) 350 | - NO # Norway 351 | - OM # Oman 352 | - PK # Pakistan 353 | - PW # Palau 354 | - PS # Palestine, State of 355 | - PA # Panama 356 | - PG # Papua New Guinea 357 | - PY # Paraguay 358 | - PE # Peru 359 | - PH # Philippines (the) 360 | - PN # Pitcairn 361 | - PL # Poland 362 | - PT # Portugal 363 | - PR # Puerto Rico 364 | - QA # Qatar 365 | - MK # Republic of North Macedonia 366 | - RO # Romania 367 | - RU # Russian Federation (the) 368 | - RW # Rwanda 369 | - RE # Réunion 370 | - BL # Saint Barthélemy 371 | - SH # Saint Helena, Ascension and Tristan da Cunha 372 | - KN # Saint Kitts and Nevis 373 | - LC # Saint Lucia 374 | - MF # Saint Martin (French part) 375 | - PM # Saint Pierre and Miquelon 376 | - VC # Saint Vincent and the Grenadines 377 | - WS # Samoa 378 | - SM # San Marino 379 | - ST # Sao Tome and Principe 380 | - SA # Saudi Arabia 381 | - SN # Senegal 382 | - RS # Serbia 383 | - SC # Seychelles 384 | - SL # Sierra Leone 385 | - SG # Singapore 386 | - SX # Sint Maarten (Dutch part) 387 | - SK # Slovakia 388 | - SI # Slovenia 389 | - SB # Solomon Islands 390 | - SO # Somalia 391 | - ZA # South Africa 392 | - GS # South Georgia and the South Sandwich Islands 393 | - SS # South Sudan 394 | - ES # Spain 395 | - LK # Sri Lanka 396 | - SD # Sudan (the) 397 | - SR # Suriname 398 | - SJ # Svalbard and Jan Mayen 399 | - SE # Sweden 400 | - CH # Switzerland 401 | - SY # Syrian Arab Republic 402 | - TW # Taiwan (Province of China) 403 | - TJ # Tajikistan 404 | - TZ # Tanzania, United Republic of 405 | - TH # Thailand 406 | - TL # Timor-Leste 407 | - TG # Togo 408 | - TK # Tokelau 409 | - TO # Tonga 410 | - TT # Trinidad and Tobago 411 | - TN # Tunisia 412 | - TR # Turkey 413 | - TM # Turkmenistan 414 | - TC # Turks and Caicos Islands (the) 415 | - TV # Tuvalu 416 | - UG # Uganda 417 | - UA # Ukraine 418 | - AE # United Arab Emirates (the) 419 | - GB # United Kingdom of Great Britain and Northern Ireland (the) 420 | - UM # United States Minor Outlying Islands (the) 421 | - US # United States of America (the) 422 | - UY # Uruguay 423 | - UZ # Uzbekistan 424 | - VU # Vanuatu 425 | - VE # Venezuela (Bolivarian Republic of) 426 | - VN # Viet Nam 427 | - VG # Virgin Islands (British) 428 | - VI # Virgin Islands (U.S.) 429 | - WF # Wallis and Futuna 430 | - EH # Western Sahara 431 | - YE # Yemen 432 | - ZM # Zambia 433 | - ZW # Zimbabwe 434 | - AX # Åland Islands 435 | ```` 436 | 437 | ## Configuration options 438 | 439 | ### Silent start-up: `silentStartUp` 440 | 441 | If set to true, the configuration is not written to the output upon the start-up of the plugin. 442 | 443 | ### Allow local requests: `allowLocalRequests` 444 | 445 | If set to true, will not block request from [Private IP Ranges](https://en.wikipedia.org/wiki/Private_network). 446 | 447 | ### Log local requests: `logLocalRequests` 448 | 449 | If set to true, will show a log message when some one accesses the service over a private ip address. 450 | 451 | ### Log allowed requests `logAllowedRequests` 452 | 453 | If set to true, will show a log message with the IP and the country of origin if a request is allowed. 454 | 455 | ### Log API requests `logApiRequests` 456 | 457 | If set to true, will show a log message for every API hit. 458 | 459 | ### API `api` 460 | 461 | Defines the API URL for the IP to Country resolution. The IP to fetch can be added with `{ip}` to the URL. 462 | 463 | ### API Timeout `apiTimeoutMs` 464 | 465 | Timeout for the call to the api uri. 466 | 467 | ### Ignore the API timeout error `ignoreAPITimeout` 468 | 469 | If the `ignoreAPITimeout` option is set to `true`, a request is allowed even if the API could not be reached. 470 | 471 | ### Set custom HTTP header field to retrieve the country code from `ipGeolocationHttpHeaderField` 472 | 473 | Allow setting the name of a custom HTTP header field to retrieve the country code from. E.g. `cf-ipcountry` for Cloudflare. 474 | 475 | ### Cache size `cacheSize` 476 | 477 | Defines the max size of the [LRU](https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU)) (least recently used) cache. 478 | 479 | ### Force monthly update `forceMonthlyUpdate` 480 | 481 | Even if an IP stays in the cache for a period of a month (about 30 x 24 hours), it must be fetch again after a month. 482 | 483 | ### Allow unknown countries `allowUnknownCountries` 484 | 485 | Some IP addresses have no country associated with them. If this option is set to true, all IPs with no associated country are also allowed. 486 | 487 | ### Unknown country api response `unknownCountryApiResponse` 488 | 489 | The API uri can be customized. This options allows to customize the response string of the API when a IP with no associated country is requested. 490 | 491 | ### Black list mode `blackListMode` 492 | 493 | When set to `true` the filter logic is inverted, i.e. requests originating from countries listed in the [`countries`](#countries-countries) list are **blocked**. Default: `false`. 494 | 495 | ### Countries `countries` 496 | 497 | A list of country codes from which connections to the service should be allowed. Logic can be inverted by using the [`blackListMode`](#black-list-mode-blacklistmode). 498 | 499 | ### Allowed IP addresses `allowedIPAddresses` 500 | 501 | A list of explicitly allowed IP addresses or IP address ranges. IP addresses and ranges added to this list will always be allowed. 502 | 503 | ```yaml 504 | allowedIPAddresses: 505 | - 192.0.2.10 # single IPv4 address 506 | - 203.0.113.0/24 # IPv4 range in CIDR format 507 | - 2001:db8:1234:/48 # IPv6 range in CIDR format 508 | ``` 509 | 510 | ### Add Header to request with Country Code: `addCountryHeader` 511 | 512 | If set to `true`, adds the X-IPCountry header to the HTTP request header. The header contains the two letter country code returned by cache or API request. 513 | 514 | ### Customize denied request status code `httpStatusCodeDeniedRequest` 515 | 516 | Allows customizing the HTTP status code returned if the request was denied. 517 | 518 | ### Define a custom log file `logFilePath` 519 | 520 | Allows to define a target for the logs of the middleware. The path must look like the following: `logFilePath: "/log/geoblock.log"`. Make sure the folder is writeable. 521 | 522 | ### Define a custom log file `XForwardedForReverseProxy` 523 | 524 | Basically tells GeoBlock to only allow/deny a request based on the first IP address in the X-ForwardedFor HTTP header. This is useful for servers behind e.g. a Cloudflare proxy. 525 | 526 | ### Define a custom log file `redirectUrlIfDenied` 527 | 528 | Allows returning a HTTP 301 status code, which indicates that the requested resource has been moved. The URL which can be specified is used to redirect the client to. So instead of "blocking" the client, the client will be redirected to the configured URL. 529 | --------------------------------------------------------------------------------