├── .gitignore ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── ipfilter.go ├── ipfilter_test.go ├── range2CIDRs.go └── testdata ├── GeoLite2.mmdb ├── blacklist ├── 192 │ └── 168 │ │ └── 192.168.1.2 ├── 1234 │ └── abcd │ │ └── 1234=abcd==1 ├── 192.168.0.1 └── ==1 ├── blockpage.html └── whitelist └── 127.0.0.1 /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Thumbs.db 3 | _gitignore/ 4 | Vagrantfile 5 | .vagrant/ 6 | 7 | dist/builds/ 8 | dist/release/ 9 | 10 | error.log 11 | access.log 12 | 13 | /*.conf 14 | Caddyfile 15 | 16 | og_static/ -------------------------------------------------------------------------------- /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 {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ipfilter 2 | [![Go Report Card](https://goreportcard.com/badge/pyed/ipfilter)](https://goreportcard.com/report/pyed/ipfilter) 3 | 4 | This is middleware for the [Caddy](http://caddyserver.com) 5 | web server that implements black and whitelisting based on 6 | IP addresses (or CIDR ranges) or country of origin using a 7 | [MaxMind](https://dev.maxmind.com/geoip/geoip2/geolite2/) database. 8 | 9 | ## Syntax 10 | 11 | ``` 12 | ipfilter { 13 | rule 14 | ip 15 | prefix_dir 16 | database 17 | country 18 | blockpage 19 | strict 20 | } 21 | ``` 22 | 23 | You can specify zero or more `ipfilter` blocks. Each `ipfilter` block has 24 | to specify at least one `ip`, `prefix_dir` or `country` directive. If no 25 | `ipfilter` blocks are defined this middleware will allow every request. 26 | 27 | * **basepath**: A sequence of URI path prefixes to match for the filter 28 | to be active. You have to specify at least one path prefix. Use `/` to 29 | match every request. If the request doesn't match one of these prefixes 30 | the filter is ignored for purposes of determining if the request is 31 | blocked or allowed. 32 | 33 | * **rule**: Should the filter `block` (blacklist) or `allow` (whitelist) 34 | the addresses. This directive is mandatory. It is an error to use it more 35 | than once per ipfilter block. The **rule** in effect for the last `ipfilter` 36 | block to match a request determines if it is blocked or allowed. 37 | 38 | Note that if you only have `ipfilter` blocks that specify `rule allow` 39 | then any request which doesn't match those filters will be implicitly 40 | blocked. 41 | 42 | * **ip**: A sequence of IP adddresses or CIDR ranges to match. For example, 43 | `ip 1.2.3.4 192.168.0.0/24` This is optional. It can be used more than 44 | once in each `ipfilter` block rather than enumerating all IPs after a single 45 | `ip` directive. 46 | 47 | * **prefix_dir**: Specifies a directory in which to search for file names 48 | matching the IP address of the request. This is optional. It is an error 49 | to use this more than once per `ipfilter` block. 50 | 51 | You can specify a relative pathname to place it relative to the Caddy 52 | server CWD (which should be the content root dir). When putting the 53 | blacklisted directory in the web server document tree you should also add 54 | an `internal` directive to ensure those files are not visible via HTTP 55 | GET requests. For example, `internal /blacklist/`. You can also specify 56 | an absolute pathname to locate the blacklist directory outside the 57 | document tree. And the path can include environment vars. For example, 58 | `prefix_dir {$HOME}/etc/www/blacklist`. 59 | 60 | You can create the file in the root of the blacklist directory. This is 61 | known as using a "flat" namespace. For example, *blacklist/127.0.0.1* 62 | or *blacklist/2601:647:4601:fa93:1865:4b6c:d055:3f3*. However, 63 | putting thousands of files in a single directory may cause 64 | poor performance of the lookup function. So you can also, 65 | and should, use a "sharded" namespace. This involves creating 66 | the file in a subdirectory based on the first two components 67 | of the address. For example, *blacklist/127/0/127.0.0.1* or 68 | *blacklist/2601/647/2601:647:4601:fa93:1865:4b6c:d055:3f3*. 69 | 70 | **Note:** IPv6 addresses as file names can use 71 | colons or equal-signs to separate the components; e.g., 72 | *blacklist/2601/647/2601=647=4601=fa93==3f3*. Using equal-signs in 73 | place of colons in the file name may be necessary on platforms like MS 74 | Windows which assign special meaning to colons in file names. You have 75 | to use one or the other; you cannot mix them in the same file name. 76 | 77 | Note that you can also whitelist IP addresses using this mechanism 78 | by specifying `rule allow`. This may be useful when it follows a more 79 | general blocking rule (e.g., by country) and you want to selectively 80 | allow some addresses through but don't want to hardcode the addresses 81 | in the Caddy config file. 82 | 83 | This mechanism is most useful when coupled with automated monitoring of 84 | your web server activity to detect signals that your server is under 85 | attack from malware. All your monitoring software has to do is create 86 | a file in the blacklist directory. 87 | 88 | At this time the content of the file is ignored. In the future the 89 | contents will probably be read and exposed as a placeholder variable 90 | for use in conjuction with a template to be filled in via the `markdown` 91 | directive. So you should consider putting some explanatory text in the 92 | file explaining why the address was blocked. 93 | 94 | * **database**: Specifies the path to a 95 | [MaxMind](https://dev.maxmind.com/geoip/geoip2/geolite2/) database. This 96 | is required if using the **country** directive; otherwise it should 97 | be omitted. 98 | 99 | * **country**: A whitespace separated sequence of ISO two letter country 100 | codes to filter. This is optional but if used also requires a **database** 101 | directive. Note that if a country could not be found for the address it 102 | will be the empty string. This can be specified more than once per block 103 | rather than enumerating all countries on a single line. 104 | 105 | * **blockpage**: Names the file to be returned if the ipfilter 106 | matches. Note that a `http.StatusOK` (200) status is returned if the 107 | page is successfully returned to the client. This is optional. If not 108 | specified then a `http.StatusForbidden` (403) status is returned. 109 | 110 | * **strict**: Use this to disallow use of the address in the 111 | `X-Forwarded-For` request header if any. This is optional and defaults 112 | to false. If true or there is no `X-Forwarded-For` header use the address 113 | from the request remote address. 114 | 115 | ## Caddyfile examples 116 | 117 | #### Filter clients based on a given IP or range of IPs 118 | 119 | ``` 120 | ipfilter / { 121 | rule block 122 | ip 70.1.128.0/19 2001:db8::/122 9.12.20.16 123 | } 124 | ``` 125 | `caddy` will block any clients with IPs that fall into one of these two ranges `70.1.128.0/19` and `2001:db8::/122` , or a client that has an IP of `9.12.20.16` explicitly. 126 | 127 | ``` 128 | ipfilter / { 129 | rule allow 130 | blockpage default.html 131 | ip 55.3.4.20 2e80::20:f8ff:fe31:77cf 132 | } 133 | ``` 134 | `caddy` will serve only these 2 IPs, eveyone else will get `default.html` 135 | 136 | ``` 137 | ipfilter / { 138 | rule block 139 | prefix_dir blacklisted 140 | } 141 | ``` 142 | `caddy` will block any client IP that appears as a file name in the 143 | *blacklisted* directory. 144 | 145 | #### Filter clients based on their [Country ISO Code](https://en.wikipedia.org/wiki/ISO_3166-1#Current_codes) 146 | 147 | filtering with country codes requires a local copy of the Geo database, can be downloaded for free from [MaxMind](https://dev.maxmind.com/geoip/geoip2/geolite2/) 148 | ``` 149 | ipfilter / { 150 | rule allow 151 | database /data/GeoLite.mmdb 152 | country US JP 153 | } 154 | ``` 155 | with that in your `Caddyfile` caddy will only serve users from the `United States` or `Japan` 156 | 157 | ``` 158 | ipfilter /notglobal /secret { 159 | rule block 160 | database /data/GeoLite.mmdb 161 | blockpage default.html 162 | country US JP 163 | } 164 | ``` 165 | having that in your `Caddyfile` caddy will ignore any requests from `United States` or `Japan` to `/notglobal` or `/secret` and it will show `default.html` instead, `blockpage` is optional. 166 | 167 | #### Using mutiple `ipfilter` blocks 168 | 169 | The `ipfilter` blocks are evaluated for each HTTP request in the order they 170 | appear. The last rule which matches a request is used to decide if the request 171 | is allowed. So in general you will want more general rules (e.g., blacklist an 172 | entire country) to appear before more specific rules (e.g., to whitelist 173 | specific address ranges). 174 | 175 | ``` 176 | ipfilter / { 177 | rule allow 178 | ip 32.55.3.10 179 | } 180 | 181 | ipfilter /webhook { 182 | rule allow 183 | ip 192.168.1.0/24 184 | } 185 | ``` 186 | You can use as many `ipfilter` blocks as you please, the above says: block everyone but `32.55.3.10`, Unless it falls in `192.168.1.0/24` and requesting a path in `/webhook`. Note that this is slightly subtle. Any request doesn't match any of those filters is implicitly blocked. In other words, there is no need to explicitly block every address followed by "allow" filters like those above. 187 | 188 | ## Backward compatibility 189 | 190 | `ipfilter` supports [CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing). This is the recommended way of specifiying ranges. The old formats of ranging over IPs will get converted to CIDR via [range2CIDRs](https://github.com/pyed/ipfilter/blob/master/range2CIDRs.go) for the purpose of backward compatibility. 191 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pyed/ipfilter 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/caddyserver/caddy v1.0.1 7 | github.com/oschwald/maxminddb-golang v1.3.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/bifurcation/mint v0.0.0-20180715133206-93c51c6ce115 h1:fUjoj2bT6dG8LoEe+uNsKk8J+sLkDbQkJnB6Z1F02Bc= 3 | github.com/bifurcation/mint v0.0.0-20180715133206-93c51c6ce115/go.mod h1:zVt7zX3K/aDCk9Tj+VM7YymsX66ERvzCJzw8rFCX2JU= 4 | github.com/caddyserver/caddy v1.0.1 h1:oor6ep+8NoJOabpFXhvjqjfeldtw1XSzfISVrbfqTKo= 5 | github.com/caddyserver/caddy v1.0.1/go.mod h1:G+ouvOY32gENkJC+jhgl62TyhvqEsFaDiZ4uw0RzP1E= 6 | github.com/cenkalti/backoff v2.1.1+incompatible h1:tKJnvO2kl0zmb/jA5UKAt4VoEVw1qxKWjE/Bpp46npY= 7 | github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= 8 | github.com/cheekybits/genny v0.0.0-20170328200008-9127e812e1e9 h1:a1zrFsLFac2xoM6zG1u72DWJwZG3ayttYLfmLbxVETk= 9 | github.com/cheekybits/genny v0.0.0-20170328200008-9127e812e1e9/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 12 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= 13 | github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= 14 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 15 | github.com/go-acme/lego v2.5.0+incompatible h1:5fNN9yRQfv8ymH3DSsxla+4aYeQt2IgfZqHKVnK8f0s= 16 | github.com/go-acme/lego v2.5.0+incompatible/go.mod h1:yzMNe9CasVUhkquNvti5nAtPmG94USbYxYrZfTkIn0M= 17 | github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 18 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 19 | github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= 20 | github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 21 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 22 | github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwMrUAE= 23 | github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= 24 | github.com/hashicorp/golang-lru v0.0.0-20180201235237-0fb14efe8c47 h1:UnszMmmmm5vLwWzDjTFVIkfhvWF1NdrmChl8L2NUDCw= 25 | github.com/hashicorp/golang-lru v0.0.0-20180201235237-0fb14efe8c47/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= 26 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 27 | github.com/jimstudt/http-authentication v0.0.0-20140401203705-3eca13d6893a/go.mod h1:wK6yTYYcgjHE1Z1QtXACPDjcFJyBskHEdagmnq3vsP8= 28 | github.com/klauspost/cpuid v1.2.0 h1:NMpwD2G9JSFOE1/TJjGSo5zG7Yb2bTe7eq1jH+irmeE= 29 | github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= 30 | github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= 31 | github.com/lucas-clemente/aes12 v0.0.0-20171027163421-cd47fb39b79f h1:sSeNEkJrs+0F9TUau0CgWTTNEwF23HST3Eq0A+QIx+A= 32 | github.com/lucas-clemente/aes12 v0.0.0-20171027163421-cd47fb39b79f/go.mod h1:JpH9J1c9oX6otFSgdUHwUBUizmKlrMjxWnIAjff4m04= 33 | github.com/lucas-clemente/quic-clients v0.1.0/go.mod h1:y5xVIEoObKqULIKivu+gD/LU90pL73bTdtQjPBvtCBk= 34 | github.com/lucas-clemente/quic-go v0.10.2 h1:iQtTSZVbd44k94Lu0U16lLBIG3lrnjDvQongjPd4B/s= 35 | github.com/lucas-clemente/quic-go v0.10.2/go.mod h1:hvaRS9IHjFLMq76puFJeWNfmn+H70QZ/CXoxqw9bzao= 36 | github.com/lucas-clemente/quic-go-certificates v0.0.0-20160823095156-d2f86524cced h1:zqEC1GJZFbGZA0tRyNZqRjep92K5fujFtFsu5ZW7Aug= 37 | github.com/lucas-clemente/quic-go-certificates v0.0.0-20160823095156-d2f86524cced/go.mod h1:NCcRLrOTZbzhZvixZLlERbJtDtYsmMw8Jc4vS8Z0g58= 38 | github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk= 39 | github.com/mholt/caddy v1.0.0 h1:KI6RPGih2GFzWRPG8s9clKK28Ns4ZlVMKR/v7mxq6+c= 40 | github.com/mholt/caddy v1.0.0/go.mod h1:PzUpQ3yGCTuEuy0KSxEeB4TZOi3zBZ8BR/zY0RBP414= 41 | github.com/mholt/certmagic v0.5.0 h1:lYXxsLUFya/I3BgDCrfuwcMQOB+4auzI8CCzpK41tjc= 42 | github.com/mholt/certmagic v0.5.0/go.mod h1:g4cOPxcjV0oFq3qwpjSA30LReKD8AoIfwAY9VvG35NY= 43 | github.com/mholt/certmagic v0.6.2-0.20190624175158-6a42ef9fe8c2 h1:xKE9kZ5C8gelJC3+BNM6LJs1x21rivK7yxfTZMAuY2s= 44 | github.com/mholt/certmagic v0.6.2-0.20190624175158-6a42ef9fe8c2/go.mod h1:g4cOPxcjV0oFq3qwpjSA30LReKD8AoIfwAY9VvG35NY= 45 | github.com/miekg/dns v1.1.3 h1:1g0r1IvskvgL8rR+AcHzUA+oFmGcQlaIm4IqakufeMM= 46 | github.com/miekg/dns v1.1.3/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= 47 | github.com/naoina/go-stringutil v0.1.0/go.mod h1:XJ2SJL9jCtBh+P9q5btrd/Ylo8XwT/h1USek5+NqSA0= 48 | github.com/naoina/toml v0.1.1/go.mod h1:NBIhNtsFMo3G2szEBne+bO4gS192HuIYRqfvOWb4i1E= 49 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 50 | github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 51 | github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 52 | github.com/oschwald/maxminddb-golang v1.3.0 h1:oTh8IBSj10S5JNlUDg5WjJ1QdBMdeaZIkPEVfESSWgE= 53 | github.com/oschwald/maxminddb-golang v1.3.0/go.mod h1:3jhIUymTJ5VREKyIhWm66LJiQt04F0UCDdodShpjWsY= 54 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 55 | github.com/russross/blackfriday v0.0.0-20170610170232-067529f716f4 h1:S9YlS71UNJIyS61OqGAmLXv3w5zclSidN+qwr80XxKs= 56 | github.com/russross/blackfriday v0.0.0-20170610170232-067529f716f4/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= 57 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 58 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 59 | golang.org/x/crypto v0.0.0-20190123085648-057139ce5d2b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 60 | golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 61 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= 62 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 63 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 64 | golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 65 | golang.org/x/net v0.0.0-20190328230028-74de082e2cca h1:hyA6yiAgbUwuWqtscNvWAI7U1CtlaD1KilQ6iudt1aI= 66 | golang.org/x/net v0.0.0-20190328230028-74de082e2cca/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 67 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 68 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 69 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 70 | golang.org/x/sys v0.0.0-20190124100055-b90733256f2e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 71 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 72 | golang.org/x/sys v0.0.0-20190228124157-a34e9553db1e h1:ZytStCyV048ZqDsWHiYDdoI2Vd4msMcrDECFxS+tL9c= 73 | golang.org/x/sys v0.0.0-20190228124157-a34e9553db1e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 74 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 75 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 76 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 77 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 78 | gopkg.in/mcuadros/go-syslog.v2 v2.2.1/go.mod h1:l5LPIyOOyIdQquNg+oU6Z3524YwrcqEm0aKH+5zpt2U= 79 | gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= 80 | gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= 81 | gopkg.in/square/go-jose.v2 v2.2.2 h1:orlkJ3myw8CN1nVQHBFfloD+L3egixIa4FvUP6RosSA= 82 | gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= 83 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 84 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 85 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 86 | -------------------------------------------------------------------------------- /ipfilter.go: -------------------------------------------------------------------------------- 1 | package ipfilter 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | "sort" 13 | "strings" 14 | 15 | "github.com/caddyserver/caddy" 16 | "github.com/caddyserver/caddy/caddyhttp/httpserver" 17 | "github.com/oschwald/maxminddb-golang" 18 | ) 19 | 20 | // IPFilter is a middleware for filtering clients based on their ip or country's ISO code. 21 | type IPFilter struct { 22 | Next httpserver.Handler 23 | Config IPFConfig 24 | } 25 | 26 | // IPPath holds the configuration of a single ipfilter block. 27 | type IPPath struct { 28 | PathScopes []string 29 | BlockPage string 30 | CountryCodes []string 31 | PrefixDir string 32 | Nets []*net.IPNet 33 | IsBlock bool 34 | Strict bool 35 | } 36 | 37 | // IPFConfig holds the configuration for the ipfilter middleware. 38 | type IPFConfig struct { 39 | Paths []IPPath 40 | DBHandler *maxminddb.Reader // Database's handler if it gets opened. 41 | } 42 | 43 | // OnlyCountry is used to fetch only the country's code from 'mmdb'. 44 | type OnlyCountry struct { 45 | Country struct { 46 | ISOCode string `maxminddb:"iso_code"` 47 | } `maxminddb:"country"` 48 | } 49 | 50 | // Status is used to keep track of the status of the request. 51 | type Status struct { 52 | countryMatch, inRange bool 53 | } 54 | 55 | // Any returns 'true' if we have a match on a country code or an IP in range. 56 | func (s *Status) Any() bool { 57 | return s.countryMatch || s.inRange 58 | } 59 | 60 | // block will take care of blocking 61 | func block(blockPage string, w http.ResponseWriter) (int, error) { 62 | if blockPage != "" { 63 | bp, err := os.Open(blockPage) 64 | if err != nil { 65 | return http.StatusInternalServerError, err 66 | } 67 | defer bp.Close() 68 | 69 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 70 | if _, err := io.Copy(w, bp); err != nil { 71 | return http.StatusInternalServerError, err 72 | } 73 | // we wrote the blockpage, return OK. 74 | return http.StatusOK, nil 75 | } 76 | 77 | // if we don't have blockpage, return forbidden. 78 | return http.StatusForbidden, nil 79 | } 80 | 81 | // Init initializes the plugin 82 | func init() { 83 | caddy.RegisterPlugin("ipfilter", caddy.Plugin{ 84 | ServerType: "http", 85 | Action: Setup, 86 | }) 87 | } 88 | 89 | // Setup parses the ipfilter configuration and returns the middleware handler. 90 | func Setup(c *caddy.Controller) error { 91 | ifconfig, err := ipfilterParse(c) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | // Create new middleware 97 | newMiddleWare := func(next httpserver.Handler) httpserver.Handler { 98 | return &IPFilter{ 99 | Next: next, 100 | Config: ifconfig, 101 | } 102 | } 103 | // Add middleware 104 | cfg := httpserver.GetConfig(c) 105 | cfg.AddMiddleware(newMiddleWare) 106 | 107 | return nil 108 | } 109 | 110 | func getClientIP(r *http.Request, strict bool) (net.IP, error) { 111 | var ip string 112 | 113 | // Use the client ip from the 'X-Forwarded-For' header, if available. 114 | if fwdFor := r.Header.Get("X-Forwarded-For"); fwdFor != "" && !strict { 115 | ips := strings.Split(fwdFor, ", ") 116 | ip = ips[0] 117 | } else { 118 | // Otherwise, get the client ip from the request remote address. 119 | var err error 120 | ip, _, err = net.SplitHostPort(r.RemoteAddr) 121 | if err != nil { 122 | return nil, err 123 | } 124 | } 125 | 126 | // Parse the ip address string into a net.IP. 127 | parsedIP := net.ParseIP(ip) 128 | if parsedIP == nil { 129 | return nil, errors.New("unable to parse address") 130 | } 131 | 132 | return parsedIP, nil 133 | } 134 | 135 | // ShouldAllow takes a path and a request and decides if it should be allowed 136 | func (ipf IPFilter) ShouldAllow(path IPPath, r *http.Request) (bool, string, error) { 137 | allow := true 138 | scopeMatched := "" 139 | 140 | // check if we are in one of our scopes. 141 | for _, scope := range path.PathScopes { 142 | if httpserver.Path(r.URL.Path).Matches(scope) { 143 | // extract the client's IP and parse it. 144 | clientIP, err := getClientIP(r, path.Strict) 145 | if err != nil { 146 | return false, scope, err 147 | } 148 | 149 | // request status. 150 | var rs Status 151 | 152 | if len(path.CountryCodes) != 0 { 153 | // do the lookup. 154 | var result OnlyCountry 155 | if err = ipf.Config.DBHandler.Lookup(clientIP, &result); err != nil { 156 | return false, scope, err 157 | } 158 | 159 | // get only the ISOCode out of the lookup results. 160 | clientCountry := result.Country.ISOCode 161 | for _, c := range path.CountryCodes { 162 | if clientCountry == c { 163 | rs.countryMatch = true 164 | break 165 | } 166 | } 167 | } 168 | 169 | if len(path.Nets) != 0 { 170 | for _, rng := range path.Nets { 171 | if rng.Contains(clientIP) { 172 | rs.inRange = true 173 | break 174 | } 175 | } 176 | } 177 | 178 | if ipf.PrefixDirBlocked(clientIP, path) { 179 | rs.inRange = true 180 | } 181 | 182 | scopeMatched = scope 183 | if rs.Any() { 184 | // Rule matched, if the rule has IsBlock = true then we have to deny access 185 | allow = !path.IsBlock 186 | } else { 187 | // Rule did not match, if the rule has IsBlock = true then we have to allow access 188 | allow = path.IsBlock 189 | } 190 | 191 | // We only have to test the first path that matches because it is the most specific 192 | break 193 | } 194 | } 195 | 196 | // no scope match, pass-through. 197 | return allow, scopeMatched, nil 198 | } 199 | 200 | // PrefixDirBlocked takes an IP and a path and decides to allow or block based on prefix_dir. 201 | func (ipf IPFilter) PrefixDirBlocked(clientIP net.IP, path IPPath) bool { 202 | if path.PrefixDir == "" { 203 | return false 204 | } 205 | 206 | fname := clientIP.String() 207 | fname_variant := "" 208 | is_ipv6 := clientIP.To4() == nil 209 | if is_ipv6 { 210 | fname_variant = strings.ReplaceAll(fname, ":", "=") 211 | } 212 | 213 | // Check the "flat" namespace. 214 | blacklistPath := filepath.Join(path.PrefixDir, fname) 215 | if _, err := os.Stat(blacklistPath); err == nil { 216 | return true 217 | } 218 | if is_ipv6 { 219 | blacklistPath := filepath.Join(path.PrefixDir, fname_variant) 220 | if _, err := os.Stat(blacklistPath); err == nil { 221 | return true 222 | } 223 | } 224 | 225 | // Check the "sharded" namespace. 226 | c := strings.SplitN(fname, ".", 3) // shard IPv4 address 227 | if len(c) != 3 { 228 | c = strings.SplitN(fname, ":", 3) // shard IPv6 address 229 | if len(c) != 3 { 230 | // This should be a "can't happen" situation. Perhaps there is an 231 | // IP address type we don't know how to shard. But rather than 232 | // blow up below just log the problem and grant access. 233 | log.Println("ipfilter: Could not shard address:", fname) 234 | return false 235 | } 236 | } 237 | blacklistPath = filepath.Join(path.PrefixDir, c[0], c[1], fname) 238 | if _, err := os.Stat(blacklistPath); err == nil { 239 | return true 240 | } 241 | if is_ipv6 { 242 | blacklistPath = filepath.Join(path.PrefixDir, c[0], c[1], fname_variant) 243 | if _, err := os.Stat(blacklistPath); err == nil { 244 | return true 245 | } 246 | } 247 | 248 | return false 249 | } 250 | 251 | func (ipf IPFilter) ServeHTTP(w http.ResponseWriter, r *http.Request) (int, error) { 252 | allow := true 253 | matchedPath := "" 254 | blockPage := "" 255 | 256 | // Loop over all IPPaths in the config 257 | for _, path := range ipf.Config.Paths { 258 | pathAllow, pathMathedPath, err := ipf.ShouldAllow(path, r) 259 | if err != nil { 260 | return http.StatusInternalServerError, err 261 | } 262 | 263 | if len(pathMathedPath) >= len(matchedPath) { 264 | allow = pathAllow 265 | matchedPath = pathMathedPath 266 | blockPage = path.BlockPage 267 | } 268 | } 269 | 270 | if !allow { 271 | return block(blockPage, w) 272 | } 273 | return ipf.Next.ServeHTTP(w, r) 274 | } 275 | 276 | // parseIP parses a string to an IP range. 277 | func parseIP(ip string) ([]*net.IPNet, error) { 278 | // CIDR notation 279 | _, ipnet, err := net.ParseCIDR(ip) 280 | if err == nil { 281 | return []*net.IPNet{ipnet}, nil 282 | } 283 | 284 | // Singular IP 285 | parsedIP := net.ParseIP(ip) 286 | if parsedIP != nil { 287 | mask := len(parsedIP) * 8 288 | return []*net.IPNet{{ 289 | IP: parsedIP, 290 | Mask: net.CIDRMask(mask, mask), 291 | }}, nil 292 | } 293 | 294 | // for backward compatibility, convert ranges into CIDR notation. 295 | parseError := fmt.Errorf("Can't parse IP: %s", ip) 296 | // check if the ip isn't complete; 297 | // e.g. 192.168 -> Range{"192.168.0.0", "192.168.255.255"} 298 | dotSplit := strings.Split(ip, ".") 299 | if len(dotSplit) < 4 { 300 | startR := make([]string, len(dotSplit), 4) 301 | copy(startR, dotSplit) 302 | for len(dotSplit) < 4 { 303 | startR = append(startR, "0") 304 | dotSplit = append(dotSplit, "255") 305 | } 306 | start := net.ParseIP(strings.Join(startR, ".")) 307 | end := net.ParseIP(strings.Join(dotSplit, ".")) 308 | if start.To4() == nil || end.To4() == nil { 309 | return nil, parseError 310 | } 311 | 312 | return range2CIDRs(start, end), nil 313 | } 314 | 315 | // try to split on '-' to see if it is a range of ips e.g. 1.1.1.1-10 316 | splitted := strings.Split(ip, "-") 317 | if len(splitted) > 1 { // if more than one, then we got a range e.g. ["1.1.1.1", "10"] 318 | start := net.ParseIP(splitted[0]) 319 | // make sure that we got a valid IPv4 IP. 320 | if start.To4() == nil { 321 | return nil, parseError 322 | } 323 | 324 | // split the start of the range on "." and switch the last field with splitted[1], e.g 1.1.1.1 -> 1.1.1.10 325 | fields := strings.Split(start.String(), ".") 326 | fields[3] = splitted[1] 327 | end := net.ParseIP(strings.Join(fields, ".")) 328 | 329 | // parse the end range. 330 | if end.To4() == nil { 331 | return nil, parseError 332 | } 333 | 334 | return range2CIDRs(start, end), nil 335 | } 336 | 337 | // Failed to parse IP 338 | return nil, parseError 339 | } 340 | 341 | // ipfilterParseSingle parses a single ipfilter {} block from the caddy config. 342 | func ipfilterParseSingle(config *IPFConfig, c *caddy.Controller) (IPPath, error) { 343 | var cPath IPPath 344 | ruleTypeSpecified := false 345 | 346 | // Get PathScopes 347 | cPath.PathScopes = c.RemainingArgs() 348 | if len(cPath.PathScopes) == 0 { 349 | return cPath, c.ArgErr() 350 | } 351 | 352 | // Sort PathScopes by length (the longest is always the most specific so should be tested first) 353 | sort.Sort(sort.Reverse(ByLength(cPath.PathScopes))) 354 | 355 | for c.NextBlock() { 356 | value := c.Val() 357 | 358 | switch value { 359 | case "rule": 360 | if !c.NextArg() { 361 | return cPath, c.ArgErr() 362 | } 363 | if ruleTypeSpecified { 364 | return cPath, c.Err("ipfilter: Only one 'rule' directive per block allowed") 365 | } 366 | 367 | rule := c.Val() 368 | if rule == "block" { 369 | cPath.IsBlock = true 370 | } else if rule != "allow" { 371 | return cPath, c.Err("ipfilter: Rule should be 'block' or 'allow'") 372 | } 373 | ruleTypeSpecified = true 374 | case "database": 375 | if !c.NextArg() { 376 | return cPath, c.ArgErr() 377 | } 378 | // Check if a database has already been opened 379 | if config.DBHandler != nil { 380 | return cPath, c.Err("ipfilter: A database is already opened") 381 | } 382 | 383 | database := c.Val() 384 | 385 | // Open the database. 386 | var err error 387 | config.DBHandler, err = maxminddb.Open(database) 388 | if err != nil { 389 | return cPath, c.Err("ipfilter: Can't open database: " + database) 390 | } 391 | case "blockpage": 392 | if !c.NextArg() { 393 | return cPath, c.ArgErr() 394 | } 395 | 396 | // check if blockpage exists. 397 | blockpage := c.Val() 398 | if _, err := os.Stat(blockpage); os.IsNotExist(err) { 399 | return cPath, c.Err("ipfilter: No such file: " + blockpage) 400 | } 401 | cPath.BlockPage = blockpage 402 | case "country": 403 | countryCodes := c.RemainingArgs() 404 | if len(countryCodes) == 0 { 405 | return cPath, c.ArgErr() 406 | } 407 | cPath.CountryCodes = append(cPath.CountryCodes, countryCodes...) 408 | case "ip": 409 | ips := c.RemainingArgs() 410 | if len(ips) == 0 { 411 | return cPath, c.ArgErr() 412 | } 413 | 414 | for _, ip := range ips { 415 | ipRange, err := parseIP(ip) 416 | if err != nil { 417 | return cPath, c.Err("ipfilter: " + err.Error()) 418 | } 419 | 420 | cPath.Nets = append(cPath.Nets, ipRange...) 421 | } 422 | case "strict": 423 | if c.NextArg() { 424 | return cPath, c.ArgErr() 425 | } 426 | cPath.Strict = true 427 | case "prefix_dir": 428 | if !c.NextArg() || cPath.PrefixDir != "" { 429 | return cPath, c.ArgErr() 430 | } 431 | // Verify the IP address path prefix exists and is a directory. 432 | prefixDir := c.Val() 433 | if statb, err := os.Stat(prefixDir); os.IsNotExist(err) || !statb.IsDir() { 434 | return cPath, c.Err("ipfilter: No such blacklist prefix dir: " + prefixDir) 435 | } 436 | cPath.PrefixDir = prefixDir 437 | } 438 | } 439 | 440 | if !ruleTypeSpecified { 441 | return cPath, c.Err("ipfilter: There must be one 'rule' directive per block") 442 | } 443 | return cPath, nil 444 | } 445 | 446 | // ipfilterParse parses all ipfilter {} blocks to an IPFConfig 447 | func ipfilterParse(c *caddy.Controller) (IPFConfig, error) { 448 | var config IPFConfig 449 | 450 | var hasCountryCodes, hasRanges, hasPrefixDir bool 451 | 452 | for c.Next() { 453 | path, err := ipfilterParseSingle(&config, c) 454 | if err != nil { 455 | return config, err 456 | } 457 | 458 | if len(path.CountryCodes) != 0 { 459 | hasCountryCodes = true 460 | } 461 | if len(path.Nets) != 0 { 462 | hasRanges = true 463 | } 464 | if path.PrefixDir != "" { 465 | hasPrefixDir = true 466 | } 467 | 468 | config.Paths = append(config.Paths, path) 469 | } 470 | 471 | // having a database is mandatory if you are blocking by country codes. 472 | if hasCountryCodes && config.DBHandler == nil { 473 | return config, c.Err("ipfilter: Database is required to block/allow by country") 474 | } 475 | 476 | // Must specify at least one of these subdirectives. 477 | if !hasCountryCodes && !hasRanges && !hasPrefixDir { 478 | return config, c.Err("ipfilter: No IPs, Country codes, or prefix dir has been provided") 479 | } 480 | 481 | return config, nil 482 | } 483 | 484 | // ByLength sorts strings by length and alphabetically (if same length) 485 | type ByLength []string 486 | 487 | func (s ByLength) Len() int { return len(s) } 488 | func (s ByLength) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 489 | 490 | func (s ByLength) Less(i, j int) bool { 491 | if len(s[i]) < len(s[j]) { 492 | return true 493 | } else if len(s[i]) == len(s[j]) { 494 | return s[i] < s[j] // Compare alphabetically in ascending order 495 | } 496 | return false 497 | } 498 | -------------------------------------------------------------------------------- /ipfilter_test.go: -------------------------------------------------------------------------------- 1 | package ipfilter 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net" 7 | "net/http" 8 | "net/http/httptest" 9 | "reflect" 10 | "testing" 11 | 12 | "github.com/caddyserver/caddy" 13 | "github.com/caddyserver/caddy/caddyhttp/httpserver" 14 | "github.com/oschwald/maxminddb-golang" 15 | ) 16 | 17 | const ( 18 | // 'GeoLite2.mmdb' taken from 'MaxMind.com' 19 | // 'https://dev.maxmind.com/geoip/geoip2/geolite2/' 20 | BlacklistPrefix = "./testdata/blacklist" 21 | WhitelistPrefix = "./testdata/whitelist" 22 | DataBase = "./testdata/GeoLite2.mmdb" 23 | BlockPage = "./testdata/blockpage.html" 24 | Allow = "allow" 25 | Block = "block" 26 | BlockMsg = "You are not allowed here" 27 | ) 28 | 29 | func TestCountryCodes(t *testing.T) { 30 | TestCases := []struct { 31 | ipfconf IPFConfig 32 | reqIP string 33 | scope string 34 | expectedBody string 35 | expectedStatus int 36 | }{ 37 | {IPFConfig{ 38 | Paths: []IPPath{ 39 | { 40 | PathScopes: []string{"/"}, 41 | BlockPage: BlockPage, 42 | IsBlock: false, 43 | CountryCodes: []string{"JP", "SA"}, 44 | }, 45 | }, 46 | }, 47 | "8.8.8.8:_", // US 48 | "/", 49 | BlockMsg, 50 | http.StatusOK, 51 | }, 52 | 53 | {IPFConfig{ 54 | Paths: []IPPath{ 55 | { 56 | PathScopes: []string{"/private"}, 57 | BlockPage: BlockPage, 58 | IsBlock: true, 59 | CountryCodes: []string{"US", "CA"}, 60 | }, 61 | }, 62 | }, 63 | "24.53.192.20:_", // CA 64 | "/private", 65 | BlockMsg, 66 | http.StatusOK, 67 | }, 68 | 69 | {IPFConfig{ 70 | Paths: []IPPath{ 71 | { 72 | PathScopes: []string{"/testdata"}, 73 | IsBlock: true, 74 | CountryCodes: []string{"RU", "CN"}, 75 | }, 76 | }, 77 | }, 78 | "42.48.120.7:_", // CN 79 | "/", 80 | "", 81 | http.StatusOK, // pass-thru, out of scope 82 | }, 83 | 84 | {IPFConfig{ 85 | Paths: []IPPath{ 86 | { 87 | PathScopes: []string{"/"}, 88 | IsBlock: true, 89 | CountryCodes: []string{"RU", "JP", "SA"}, 90 | }, 91 | }, 92 | }, 93 | "78.95.221.163:_", // SA 94 | "/", 95 | "", 96 | http.StatusForbidden, 97 | }, 98 | 99 | {IPFConfig{ 100 | Paths: []IPPath{ 101 | { 102 | PathScopes: []string{"/onlyus"}, 103 | IsBlock: false, 104 | CountryCodes: []string{"US"}, 105 | }, 106 | }, 107 | }, 108 | "5.175.96.22:_", // RU 109 | "/onlyus", 110 | "", 111 | http.StatusForbidden, 112 | }, 113 | 114 | {IPFConfig{ 115 | Paths: []IPPath{ 116 | { 117 | PathScopes: []string{"/"}, 118 | IsBlock: false, 119 | CountryCodes: []string{"FR", "GB", "AE", "DE"}, 120 | }, 121 | }, 122 | }, 123 | "5.4.9.3:_", // DE 124 | "/", 125 | "", 126 | http.StatusOK, // Allowed 127 | }, 128 | } 129 | // open the db 130 | db, err := maxminddb.Open(DataBase) 131 | if err != nil { 132 | t.Fatalf("Error opening the database: %v", err) 133 | } 134 | defer db.Close() 135 | 136 | for _, tc := range TestCases { 137 | 138 | ipf := IPFilter{ 139 | Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) { 140 | return http.StatusOK, nil 141 | }), 142 | Config: tc.ipfconf, 143 | } 144 | 145 | // set the DBHandler 146 | ipf.Config.DBHandler = db 147 | 148 | req, err := http.NewRequest("GET", tc.scope, nil) 149 | if err != nil { 150 | t.Fatalf("Could not create HTTP request: %v", err) 151 | } 152 | 153 | req.RemoteAddr = tc.reqIP 154 | 155 | rec := httptest.NewRecorder() 156 | 157 | status, _ := ipf.ServeHTTP(rec, req) 158 | if status != tc.expectedStatus { 159 | t.Fatalf("Expected StatusCode: '%d', Got: '%d'\nTestCase: %v\n", 160 | tc.expectedStatus, status, tc) 161 | } 162 | 163 | if rec.Body.String() != tc.expectedBody { 164 | t.Fatalf("Expected Body: '%s', Got: '%s'\nTestCase: %v\n", 165 | tc.expectedBody, rec.Body.String(), tc) 166 | } 167 | } 168 | } 169 | 170 | func TestPrefixDir(t *testing.T) { 171 | TestCases := []struct { 172 | ipfconf IPFConfig 173 | reqIP string 174 | scope string 175 | expectedBody string 176 | expectedStatus int 177 | }{ 178 | // Non blacklisted address should be okay. 179 | {IPFConfig{ 180 | Paths: []IPPath{ 181 | { 182 | PathScopes: []string{"/"}, 183 | IsBlock: true, 184 | PrefixDir: BlacklistPrefix, 185 | }, 186 | }, 187 | }, 188 | "243.1.3.15:_", 189 | "/", 190 | "", 191 | http.StatusOK, 192 | }, 193 | 194 | // "Flat" blacklisted address should be forbidden. Note that IPv6 195 | // "::1" is always a "flat" address as it has no leading non-zero 196 | // components and thus can't be sharded. 197 | {IPFConfig{ 198 | Paths: []IPPath{ 199 | { 200 | PathScopes: []string{"/"}, 201 | IsBlock: true, 202 | PrefixDir: BlacklistPrefix, 203 | }, 204 | }, 205 | }, 206 | "[::1]:_", 207 | "/", 208 | "", 209 | http.StatusForbidden, 210 | }, 211 | 212 | // "Sharded" blacklisted IPv6 address should be forbidden. 213 | {IPFConfig{ 214 | Paths: []IPPath{ 215 | { 216 | PathScopes: []string{"/"}, 217 | IsBlock: true, 218 | PrefixDir: BlacklistPrefix, 219 | }, 220 | }, 221 | }, 222 | "[1234:abcd::1]:_", 223 | "/", 224 | "", 225 | http.StatusForbidden, 226 | }, 227 | 228 | // "Sharded" blacklisted IPv4 address should be forbidden. 229 | {IPFConfig{ 230 | Paths: []IPPath{ 231 | { 232 | PathScopes: []string{"/"}, 233 | IsBlock: true, 234 | PrefixDir: BlacklistPrefix, 235 | }, 236 | }, 237 | }, 238 | //"[::1]:_", 239 | "192.168.1.2:_", 240 | "/", 241 | "", 242 | http.StatusForbidden, 243 | }, 244 | 245 | // "Flat" whitelisted IPv4 address should be okay even if the 246 | // preceding rule would have blacklisted it. 247 | {IPFConfig{ 248 | Paths: []IPPath{ 249 | { 250 | PathScopes: []string{"/"}, 251 | IsBlock: true, 252 | Nets: parseCIDRs([]string{"127.0.0.1/32"}), 253 | }, 254 | { 255 | PathScopes: []string{"/"}, 256 | IsBlock: false, 257 | PrefixDir: WhitelistPrefix, 258 | }, 259 | }, 260 | }, 261 | "127.0.0.1:_", 262 | "/hello", 263 | "", 264 | http.StatusOK, 265 | }, 266 | } 267 | 268 | for _, tc := range TestCases { 269 | ipf := IPFilter{ 270 | Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) { 271 | return http.StatusOK, nil 272 | }), 273 | Config: tc.ipfconf, 274 | } 275 | req, err := http.NewRequest("GET", tc.scope, nil) 276 | if err != nil { 277 | t.Fatalf("Could not create HTTP request: %v", err) 278 | } 279 | 280 | req.RemoteAddr = tc.reqIP 281 | 282 | rec := httptest.NewRecorder() 283 | 284 | status, _ := ipf.ServeHTTP(rec, req) 285 | if status != tc.expectedStatus { 286 | t.Fatalf("Expected StatusCode: '%d', Got: '%d'\nTestCase: %v\n", 287 | tc.expectedStatus, status, tc) 288 | } 289 | 290 | if rec.Body.String() != tc.expectedBody { 291 | t.Fatalf("Expected Body: '%s', Got: '%s'\nTestCase: %v\n", 292 | tc.expectedBody, rec.Body.String(), tc) 293 | } 294 | } 295 | } 296 | func TestNets(t *testing.T) { 297 | TestCases := []struct { 298 | ipfconf IPFConfig 299 | reqIP string 300 | scope string 301 | expectedBody string 302 | expectedStatus int 303 | }{ 304 | {IPFConfig{ 305 | Paths: []IPPath{ 306 | { 307 | PathScopes: []string{"/"}, 308 | BlockPage: BlockPage, 309 | IsBlock: true, 310 | Nets: parseCIDRs([]string{"243.1.3.10/31", "243.1.3.12/30", 311 | "243.1.3.16/30", "243.1.3.20/32"}), 312 | }, 313 | }, 314 | }, 315 | "243.1.3.15:_", 316 | "/", 317 | BlockMsg, 318 | http.StatusOK, 319 | }, 320 | 321 | {IPFConfig{ 322 | Paths: []IPPath{ 323 | { 324 | PathScopes: []string{"/private"}, 325 | BlockPage: BlockPage, 326 | IsBlock: true, 327 | Nets: parseCIDRs([]string{"243.1.3.0/24", "202.33.44.0/24"}), 328 | }, 329 | }, 330 | }, 331 | "202.33.44.224:_", 332 | "/private", 333 | BlockMsg, 334 | http.StatusOK, 335 | }, 336 | 337 | {IPFConfig{ 338 | Paths: []IPPath{ 339 | { 340 | PathScopes: []string{"/"}, 341 | BlockPage: BlockPage, 342 | IsBlock: true, 343 | Nets: parseCIDRs([]string{ 344 | "243.1.3.10/31", "243.1.3.12/30", "243.1.3.16/30", "243.1.3.20/32", 345 | }), 346 | }, 347 | }, 348 | }, 349 | "243.1.3.9:_", 350 | "/", 351 | "", 352 | http.StatusOK, 353 | }, 354 | 355 | {IPFConfig{ 356 | Paths: []IPPath{ 357 | { 358 | PathScopes: []string{"/eighties"}, 359 | BlockPage: BlockPage, 360 | IsBlock: false, 361 | Nets: parseCIDRs([]string{ 362 | "243.1.3.10/31", "243.1.3.12/30", "243.1.3.16/30", "243.1.3.20/32", 363 | "80.0.0.0/8", 364 | }), 365 | }, 366 | }, 367 | }, 368 | "80.245.155.250:_", 369 | "/eighties", 370 | "", 371 | http.StatusOK, 372 | }, 373 | 374 | {IPFConfig{ 375 | Paths: []IPPath{ 376 | { 377 | PathScopes: []string{"/eighties"}, 378 | IsBlock: true, 379 | Nets: parseCIDRs([]string{ 380 | "243.1.3.10/31", "243.1.3.12/30", "243.1.3.16/30", "243.1.3.20/32", 381 | "80.0.0.0/8", 382 | }), 383 | }, 384 | }, 385 | }, 386 | "80.245.155.250:_", 387 | "/", 388 | "", 389 | http.StatusOK, 390 | }, 391 | 392 | {IPFConfig{ 393 | Paths: []IPPath{ 394 | { 395 | PathScopes: []string{"/"}, 396 | IsBlock: true, 397 | Nets: parseCIDRs([]string{ 398 | "243.1.3.10/31", "243.1.3.12/30", "243.1.3.16/30", "243.1.3.20/32", 399 | "80.0.0.0/8", "23.1.3.1/32", "23.1.3.2/31", "23.1.3.4/30", "23.1.3.8/29", 400 | "23.1.3.16/30", "23.1.3.20/32", "85.0.0.0/8", 401 | }), 402 | }, 403 | }, 404 | }, 405 | "23.1.3.9:_", 406 | "/", 407 | "", 408 | http.StatusForbidden, 409 | }, 410 | // From here on out, tests are covering single IPNets 411 | {IPFConfig{ 412 | Paths: []IPPath{ 413 | { 414 | PathScopes: []string{"/"}, 415 | BlockPage: BlockPage, 416 | IsBlock: true, 417 | Nets: parseCIDRs([]string{"8.8.8.8/32"}), 418 | }, 419 | }, 420 | }, 421 | "8.8.4.4:_", 422 | "/", 423 | "", 424 | http.StatusOK, 425 | }, 426 | 427 | {IPFConfig{ 428 | Paths: []IPPath{ 429 | { 430 | PathScopes: []string{"/"}, 431 | BlockPage: BlockPage, 432 | IsBlock: false, 433 | Nets: parseCIDRs([]string{"8.8.8.8/32"}), 434 | }, 435 | }, 436 | }, 437 | "8.8.4.4:_", 438 | "/", 439 | BlockMsg, 440 | http.StatusOK, 441 | }, 442 | 443 | {IPFConfig{ 444 | Paths: []IPPath{ 445 | { 446 | PathScopes: []string{"/private"}, 447 | BlockPage: BlockPage, 448 | IsBlock: false, 449 | Nets: parseCIDRs([]string{ 450 | "52.9.1.2/32", "52.9.1.3/32", "52.9.1.4/32", 451 | }), 452 | }, 453 | }, 454 | }, 455 | "52.9.1.3:_", 456 | "/private", 457 | "", 458 | http.StatusOK, 459 | }, 460 | 461 | {IPFConfig{ 462 | Paths: []IPPath{ 463 | { 464 | PathScopes: []string{"/private"}, 465 | BlockPage: BlockPage, 466 | IsBlock: false, 467 | Nets: parseCIDRs([]string{"99.1.8.8/32"}), 468 | }, 469 | }, 470 | }, 471 | "90.90.90.90:_", 472 | "/", 473 | "", 474 | http.StatusOK, 475 | }, 476 | 477 | {IPFConfig{ 478 | Paths: []IPPath{ 479 | { 480 | PathScopes: []string{"/private"}, 481 | IsBlock: true, 482 | Nets: parseCIDRs([]string{ 483 | "52.9.1.2/32", 484 | "52.9.1.3/32", 485 | "52.9.1.4/32", 486 | }), 487 | }, 488 | }, 489 | }, 490 | "52.9.1.3:_", 491 | "/private", 492 | "", 493 | http.StatusForbidden, 494 | }, 495 | } 496 | 497 | for _, tc := range TestCases { 498 | ipf := IPFilter{ 499 | Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) { 500 | return http.StatusOK, nil 501 | }), 502 | Config: tc.ipfconf, 503 | } 504 | req, err := http.NewRequest("GET", tc.scope, nil) 505 | if err != nil { 506 | t.Fatalf("Could not create HTTP request: %v", err) 507 | } 508 | 509 | req.RemoteAddr = tc.reqIP 510 | 511 | rec := httptest.NewRecorder() 512 | 513 | status, _ := ipf.ServeHTTP(rec, req) 514 | if status != tc.expectedStatus { 515 | t.Fatalf("Expected StatusCode: '%d', Got: '%d'\nTestCase: %v\n", 516 | tc.expectedStatus, status, tc) 517 | } 518 | 519 | if rec.Body.String() != tc.expectedBody { 520 | t.Fatalf("Expected Body: '%s', Got: '%s'\nTestCase: %v\n", 521 | tc.expectedBody, rec.Body.String(), tc) 522 | } 523 | } 524 | } 525 | 526 | func TestFwdForIPs(t *testing.T) { 527 | // These test cases provide test coverage for proxied requests support (Refer to https://github.com/pyed/ipfilter/pull/4) 528 | TestCases := []struct { 529 | ipfconf IPFConfig 530 | reqIP string 531 | fwdFor string 532 | scope string 533 | expectedStatus int 534 | }{ 535 | // Middleware should block request when filtering rule is set to 'Block', a *blocked* IP is passed in the 'X-Forwarded-For' header and the request is coming from *permitted* remote address 536 | { 537 | IPFConfig{ 538 | Paths: []IPPath{ 539 | { 540 | PathScopes: []string{"/"}, 541 | IsBlock: true, 542 | Nets: parseCIDRs([]string{"8.8.8.8/32"}), 543 | }, 544 | }, 545 | }, 546 | "8.8.4.4:_", 547 | "8.8.8.8", 548 | "/", 549 | http.StatusForbidden, 550 | }, 551 | // Middleware should allow request when filtering rule is set to 'Block', no IP is passed in the 'X-Forwarded-For' header and the request is coming from *permitted* remote address 552 | { 553 | IPFConfig{ 554 | Paths: []IPPath{ 555 | { 556 | PathScopes: []string{"/"}, 557 | IsBlock: true, 558 | Nets: parseCIDRs([]string{"8.8.8.8/32"}), 559 | }, 560 | }, 561 | }, 562 | "8.8.4.4:_", 563 | "", 564 | "/", 565 | http.StatusOK, 566 | }, 567 | // Middleware should allow request when filtering rule is set to 'Block', a *permitted* IP is passed in the 'X-Forwarded-For' header and the request is coming from *blocked* remote address 568 | { 569 | IPFConfig{ 570 | Paths: []IPPath{ 571 | { 572 | PathScopes: []string{"/"}, 573 | IsBlock: true, 574 | Nets: parseCIDRs([]string{"8.8.8.8/32"}), 575 | }, 576 | }, 577 | }, 578 | "8.8.8.8:_", 579 | "8.8.4.4", 580 | "/", 581 | http.StatusOK, 582 | }, 583 | // Middleware should allow request when filtering rule is set to 'Allow', a *permitted* IP is passed in the 'X-Forwarded-For' header and the request is coming from *blocked* remote address 584 | { 585 | IPFConfig{ 586 | Paths: []IPPath{ 587 | { 588 | PathScopes: []string{"/"}, 589 | IsBlock: false, 590 | Nets: parseCIDRs([]string{"8.8.8.8/32"}), 591 | }, 592 | }, 593 | }, 594 | "8.8.4.4:_", 595 | "8.8.8.8", 596 | "/", 597 | http.StatusOK, 598 | }, 599 | // Middleware should block request when filtering rule is set to 'Allow', no IP is passed in the 'X-Forwarded-For' header and the request is coming from *blocked* remote address 600 | { 601 | IPFConfig{ 602 | Paths: []IPPath{ 603 | { 604 | PathScopes: []string{"/"}, 605 | IsBlock: false, 606 | Nets: parseCIDRs([]string{"8.8.8.8/32"}), 607 | }, 608 | }, 609 | }, 610 | "8.8.4.4:_", 611 | "", 612 | "/", 613 | http.StatusForbidden, 614 | }, 615 | // Middleware should block request when filtering rule is set to 'Allow', a *blocked* IP is passed in the 'X-Forwarded-For' header and the request is coming from *permitted* remote address 616 | { 617 | IPFConfig{ 618 | Paths: []IPPath{ 619 | { 620 | PathScopes: []string{"/"}, 621 | IsBlock: false, 622 | Nets: parseCIDRs([]string{"8.8.8.8/32"}), 623 | }, 624 | }, 625 | }, 626 | "8.8.8.8:_", 627 | "8.8.4.4", 628 | "/", 629 | http.StatusForbidden, 630 | }, 631 | } 632 | 633 | for _, tc := range TestCases { 634 | ipf := IPFilter{ 635 | Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) { 636 | return http.StatusOK, nil 637 | }), 638 | Config: tc.ipfconf, 639 | } 640 | 641 | req, err := http.NewRequest("GET", tc.scope, nil) 642 | if err != nil { 643 | t.Fatalf("Could not create HTTP request: %v", err) 644 | } 645 | 646 | req.RemoteAddr = tc.reqIP 647 | if tc.fwdFor != "" { 648 | req.Header.Set("X-Forwarded-For", tc.fwdFor) 649 | } 650 | 651 | rec := httptest.NewRecorder() 652 | 653 | status, _ := ipf.ServeHTTP(rec, req) 654 | if status != tc.expectedStatus { 655 | t.Fatalf("Expected StatusCode: '%d', Got: '%d'\nTestCase: %v\n", 656 | tc.expectedStatus, status, tc) 657 | } 658 | } 659 | } 660 | 661 | func TestStrict(t *testing.T) { 662 | TestCases := []struct { 663 | ipfconf IPFConfig 664 | reqIP string 665 | fwdFor string 666 | scope string 667 | expectedStatus int 668 | }{ 669 | { 670 | IPFConfig{ 671 | Paths: []IPPath{ 672 | { 673 | PathScopes: []string{"/"}, 674 | IsBlock: true, 675 | Nets: parseCIDRs([]string{"8.8.8.8/32"}), 676 | Strict: true, 677 | }, 678 | }, 679 | }, 680 | "8.8.4.4:_", 681 | "8.8.8.8", 682 | "/", 683 | http.StatusOK, 684 | }, 685 | { 686 | IPFConfig{ 687 | Paths: []IPPath{ 688 | { 689 | PathScopes: []string{"/"}, 690 | IsBlock: true, 691 | Nets: parseCIDRs([]string{"8.8.8.8/32"}), 692 | Strict: true, 693 | }, 694 | }, 695 | }, 696 | "8.8.8.8:_", 697 | "8.8.8.8", 698 | "/", 699 | http.StatusForbidden, 700 | }, 701 | { 702 | IPFConfig{ 703 | Paths: []IPPath{ 704 | { 705 | PathScopes: []string{"/"}, 706 | IsBlock: true, 707 | Nets: parseCIDRs([]string{"8.8.8.8/32"}), 708 | Strict: false, 709 | }, 710 | }, 711 | }, 712 | "8.8.4.4:_", 713 | "8.8.8.8", 714 | "/", 715 | http.StatusForbidden, 716 | }, 717 | } 718 | 719 | for _, tc := range TestCases { 720 | ipf := IPFilter{ 721 | Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) { 722 | return http.StatusOK, nil 723 | }), 724 | Config: tc.ipfconf, 725 | } 726 | 727 | req, err := http.NewRequest("GET", tc.scope, nil) 728 | if err != nil { 729 | t.Fatalf("Could not create HTTP request: %v", err) 730 | } 731 | 732 | req.RemoteAddr = tc.reqIP 733 | if tc.fwdFor != "" { 734 | req.Header.Set("X-Forwarded-For", tc.fwdFor) 735 | } 736 | 737 | rec := httptest.NewRecorder() 738 | 739 | status, _ := ipf.ServeHTTP(rec, req) 740 | if status != tc.expectedStatus { 741 | t.Fatalf("Expected StatusCode: '%d', Got: '%d'\nTestCase: %v\n", 742 | tc.expectedStatus, status, tc) 743 | } 744 | } 745 | } 746 | 747 | func TestIpfilterParseSingle(t *testing.T) { 748 | tests := []struct { 749 | inputIpfilterConfig string 750 | shouldErr bool 751 | expectedPath IPPath 752 | DBHandler *maxminddb.Reader 753 | }{ 754 | // No `rule` directive is an error. 755 | {`/ { 756 | ip 10.0.0.1 757 | }`, true, IPPath{ 758 | PathScopes: []string{"/"}, 759 | IsBlock: false, 760 | Nets: parseCIDRs([]string{"10.0.0.1/32"}), 761 | }, nil, 762 | }, 763 | // Two `rule` directives is an error. 764 | {`/ { 765 | rule block 766 | ip 10.0.0.1 767 | rule allow 768 | ip 10.0.0.2 769 | }`, true, IPPath{ 770 | PathScopes: []string{"/"}, 771 | IsBlock: true, 772 | Nets: parseCIDRs([]string{"10.0.0.1/32"}), 773 | }, nil, 774 | }, 775 | {`/ { 776 | rule allow 777 | ip 10.0.0.1 778 | }`, false, IPPath{ 779 | PathScopes: []string{"/"}, 780 | IsBlock: false, 781 | Nets: parseCIDRs([]string{"10.0.0.1/32"}), 782 | }, nil, 783 | }, 784 | {fmt.Sprintf(`/blog /local { 785 | rule block 786 | ip 10.0.0.1-150 20.0.0.1-255 30.0.0.2 787 | blockpage %s 788 | }`, BlockPage), false, IPPath{ 789 | PathScopes: []string{"/local", "/blog"}, 790 | IsBlock: true, 791 | BlockPage: BlockPage, 792 | Nets: parseCIDRs([]string{ 793 | "10.0.0.1/32", "10.0.0.2/31", "10.0.0.4/30", "10.0.0.8/29", 794 | "10.0.0.16/28", "10.0.0.32/27", "10.0.0.64/26", "10.0.0.128/28", 795 | "10.0.0.144/30", "10.0.0.148/31", "10.0.0.150/32", "20.0.0.1/32", 796 | "20.0.0.2/31", "20.0.0.4/30", "20.0.0.8/29", "20.0.0.16/28", 797 | "20.0.0.32/27", "20.0.0.64/26", "20.0.0.128/25", "30.0.0.2/32"}), 798 | }, nil, 799 | }, 800 | {`/ { 801 | rule allow 802 | ip 192.168 10.0.0.20-25 8.8.4.4 182 0 803 | }`, false, IPPath{ 804 | PathScopes: []string{"/"}, 805 | IsBlock: false, 806 | Nets: parseCIDRs([]string{ 807 | "192.168.0.0/16", "10.0.0.20/30", "10.0.0.24/31", 808 | "8.8.4.4/32", "182.0.0.0/8", "0.0.0.0/8", 809 | }), 810 | }, nil, 811 | }, 812 | {fmt.Sprintf(`/private /blog /local { 813 | rule block 814 | ip 11.10.12 192.168.8.4-50 20.20.20.20 255 8.8.8.8 815 | country US JP RU FR 816 | database %s 817 | blockpage %s 818 | }`, DataBase, BlockPage), false, IPPath{ 819 | PathScopes: []string{"/private", "/local", "/blog"}, 820 | IsBlock: true, 821 | BlockPage: BlockPage, 822 | CountryCodes: []string{"US", "JP", "RU", "FR"}, 823 | Nets: parseCIDRs([]string{ 824 | "11.10.12.0/24", "192.168.8.4/30", "192.168.8.8/29", "192.168.8.16/28", 825 | "192.168.8.32/28", "192.168.8.48/31", "192.168.8.50/32", "20.20.20.20/32", 826 | "255.0.0.0/8", "8.8.8.8/32", 827 | }), 828 | }, &maxminddb.Reader{}, 829 | }, 830 | {fmt.Sprintf(`/private /blog /local /contact { 831 | rule block 832 | ip 11.10.12 192.168.8.4-50 20.20.20.20 255 8.8.8.8 833 | country US JP RU FR 834 | database %s 835 | blockpage %s 836 | }`, DataBase, BlockPage), false, IPPath{ 837 | PathScopes: []string{"/private", "/contact", "/local", "/blog"}, 838 | IsBlock: true, 839 | BlockPage: BlockPage, 840 | CountryCodes: []string{"US", "JP", "RU", "FR"}, 841 | Nets: parseCIDRs([]string{ 842 | "11.10.12.0/24", "192.168.8.4/30", "192.168.8.8/29", "192.168.8.16/28", 843 | "192.168.8.32/28", "192.168.8.48/31", "192.168.8.50/32", "20.20.20.20/32", 844 | "255.0.0.0/8", "8.8.8.8/32", 845 | }), 846 | }, &maxminddb.Reader{}, 847 | }, 848 | {`/ { 849 | rule allow 850 | ip 11. 851 | }`, true, IPPath{ 852 | PathScopes: []string{"/"}, 853 | IsBlock: false, 854 | }, nil, 855 | }, 856 | {`/ { 857 | rule allow 858 | ip 192.168.1.10- 859 | }`, true, IPPath{ 860 | PathScopes: []string{"/"}, 861 | IsBlock: false, 862 | }, nil, 863 | }, 864 | {`/ { 865 | rule allow 866 | ip 192.168.1.10- 20.20.20.20 867 | }`, true, IPPath{ 868 | PathScopes: []string{"/"}, 869 | IsBlock: false, 870 | }, nil, 871 | }, 872 | } 873 | 874 | for i, test := range tests { 875 | c := caddy.NewTestController("http", test.inputIpfilterConfig) 876 | 877 | actualConfig := IPFConfig{[]IPPath{test.expectedPath}, nil} 878 | 879 | actualPath, err := ipfilterParseSingle(&actualConfig, c) 880 | 881 | if err == nil && test.shouldErr { 882 | t.Errorf("Test %d didn't error, but it should have", i) 883 | } else if err != nil && !test.shouldErr { 884 | t.Errorf("Test %d errored, but it shouldn't have; got: '%v'", i, err) 885 | } 886 | 887 | // PathScopes 888 | if !reflect.DeepEqual(actualPath.PathScopes, test.expectedPath.PathScopes) { 889 | t.Errorf("Test %d expected 'PathScopes': %v got: %v", 890 | i, test.expectedPath.PathScopes, actualPath.PathScopes) 891 | } 892 | 893 | // Rule 894 | if actualPath.IsBlock != test.expectedPath.IsBlock { 895 | t.Errorf("Test %d expected 'IsBlock': %t, got: %t", 896 | i, test.expectedPath.IsBlock, actualPath.IsBlock) 897 | } 898 | 899 | // BlockPage 900 | if actualPath.BlockPage != test.expectedPath.BlockPage { 901 | t.Errorf("Test %d expected 'BlockPage': %s got: %s", 902 | i, test.expectedPath.BlockPage, actualPath.BlockPage) 903 | } 904 | 905 | // CountryCodes 906 | if !reflect.DeepEqual(actualPath.CountryCodes, test.expectedPath.CountryCodes) { 907 | t.Errorf("Test %d expected 'CountryCodes': %v got: %v", 908 | i, test.expectedPath.CountryCodes, actualPath.CountryCodes) 909 | } 910 | 911 | // Nets 912 | if len(actualPath.Nets) != len(test.expectedPath.Nets) { 913 | t.Errorf("Test %d expected 'Nets': %s\ngot: %s", 914 | i, test.expectedPath.Nets, actualPath.Nets) 915 | } 916 | for n := range actualPath.Nets { 917 | if actualPath.Nets[n].String() != test.expectedPath.Nets[n].String() { 918 | t.Errorf("Test %d expected : %s\ngot: %s", 919 | i, test.expectedPath.Nets[n], actualPath.Nets[n]) 920 | } 921 | } 922 | 923 | // DBHandler 924 | if actualConfig.DBHandler == nil && test.DBHandler != nil { 925 | t.Errorf("Test %d expected 'DBHandler' to NOT be a nil, got a non-nil", i) 926 | } 927 | if actualConfig.DBHandler != nil && test.DBHandler == nil { 928 | t.Errorf("Test %d expected 'DBHandler' to be nil, it is not", i) 929 | } 930 | 931 | } 932 | } 933 | 934 | func TestMultipleIpFilters(t *testing.T) { 935 | TestCases := []struct { 936 | inputIpfilterConfig string 937 | shouldErr bool 938 | reqIP string 939 | reqPath string 940 | expectedStatus int 941 | }{ 942 | { 943 | `ipfilter / { 944 | rule block 945 | ip 192.168.1.10 946 | } 947 | ipfilter /allowed { 948 | rule allow 949 | ip 192.168.1.10 950 | }`, false, "192.168.1.10:_", "/", http.StatusForbidden, 951 | }, 952 | { 953 | `ipfilter / { 954 | rule block 955 | ip 192.168.1.10 956 | } 957 | ipfilter /allowed { 958 | rule allow 959 | ip 192.168.1.10 960 | }`, false, "192.168.1.10:_", "/allowed", http.StatusOK, 961 | }, 962 | { 963 | `ipfilter / { 964 | rule block 965 | ip 192.168.1.10 966 | } 967 | ipfilter /allowed { 968 | rule allow 969 | ip 192.168.1.10 970 | }`, false, "212.168.23.13:_", "/", http.StatusOK, 971 | }, 972 | { 973 | `ipfilter / { 974 | rule block 975 | ip 192.168.1.10 976 | } 977 | ipfilter /allowed { 978 | rule allow 979 | ip 192.168.1.10 980 | }`, false, "212.168.23.13:_", "/allowed", http.StatusForbidden, 981 | }, 982 | { 983 | fmt.Sprintf(`ipfilter / { 984 | rule allow 985 | ip 192.168.1.10 986 | } 987 | ipfilter /allowed { 988 | rule allow 989 | country US 990 | database %s 991 | }`, DataBase), false, "8.8.8.8:_", "/allowed", http.StatusOK, 992 | }, 993 | { 994 | fmt.Sprintf(`ipfilter /local { 995 | rule allow 996 | ip 192.168.1 997 | } 998 | ipfilter /private { 999 | rule allow 1000 | ip 192.168.1.10-15 1001 | } 1002 | ipfilter /notglobal /secret { 1003 | rule block 1004 | country RU 1005 | database %s 1006 | } 1007 | ipfilter / { 1008 | rule allow 1009 | ip 212.222.222.1 1010 | }`, DataBase), false, "192.168.1.9:_", "/private", http.StatusForbidden, 1011 | }, 1012 | { 1013 | fmt.Sprintf(`ipfilter /local { 1014 | rule allow 1015 | ip 192.168.1 1016 | } 1017 | ipfilter /private { 1018 | rule allow 1019 | ip 192.168.1.10-15 1020 | } 1021 | ipfilter /notglobal /secret { 1022 | rule block 1023 | country RU 1024 | database %s 1025 | } 1026 | ipfilter / { 1027 | rule allow 1028 | ip 212.222.222.1 1029 | }`, DataBase), false, "212.222.222.1:_", "/list", http.StatusOK, 1030 | }, 1031 | { 1032 | fmt.Sprintf(`ipfilter /local { 1033 | rule allow 1034 | ip 192.168.1 1035 | } 1036 | ipfilter /private { 1037 | rule allow 1038 | ip 192.168.1.10-15 1039 | } 1040 | ipfilter /notglobal /secret { 1041 | rule block 1042 | country RU 1043 | database %s 1044 | } 1045 | ipfilter / { 1046 | rule allow 1047 | ip 212.222.222.1 1048 | }`, DataBase), false, "5.175.96.22:_", "/secret", http.StatusForbidden, 1049 | }, 1050 | { 1051 | fmt.Sprintf(`ipfilter /local { 1052 | rule allow 1053 | ip 192.168.1 1054 | } 1055 | ipfilter /private { 1056 | rule allow 1057 | ip 192.168.1.10-15 1058 | } 1059 | ipfilter /notglobal /secret { 1060 | rule block 1061 | country RU 1062 | database %s 1063 | } 1064 | ipfilter / { 1065 | rule allow 1066 | ip 212.222.222.1 1067 | }`, DataBase), false, "192.168.1.14:_", "/local", http.StatusOK, 1068 | }, 1069 | { 1070 | fmt.Sprintf(`ipfilter /local { 1071 | rule allow 1072 | ip 192.168.1 1073 | } 1074 | ipfilter /private { 1075 | rule allow 1076 | ip 192.168.1.10-15 1077 | } 1078 | ipfilter /notglobal /secret { 1079 | rule block 1080 | country RU 1081 | database %s 1082 | } 1083 | ipfilter / { 1084 | rule allow 1085 | ip 212.222.222.1 1086 | }`, DataBase), false, "192.168.1.16:_", "/private", http.StatusForbidden, 1087 | }, 1088 | } 1089 | 1090 | for i, tc := range TestCases { 1091 | // Parse the text config 1092 | c := caddy.NewTestController("http", tc.inputIpfilterConfig) 1093 | config, err := ipfilterParse(c) 1094 | 1095 | if err != nil && !tc.shouldErr { 1096 | t.Errorf("Test %d failed, error generated while it should not: %v", i, err) 1097 | } else if err == nil && tc.shouldErr { 1098 | t.Errorf("Test %d failed, no error generated while it should", i) 1099 | } else if err != nil { 1100 | continue 1101 | } 1102 | 1103 | ipf := IPFilter{ 1104 | Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) { 1105 | return http.StatusOK, nil 1106 | }), 1107 | Config: config, 1108 | } 1109 | 1110 | req, err := http.NewRequest("GET", tc.reqPath, nil) 1111 | if err != nil { 1112 | t.Fatalf("Could not create HTTP request: %v", err) 1113 | } 1114 | 1115 | req.RemoteAddr = tc.reqIP 1116 | 1117 | rec := httptest.NewRecorder() 1118 | 1119 | status, err := ipf.ServeHTTP(rec, req) 1120 | if err != nil { 1121 | t.Fatalf("Test %d failed. Error generated:\n%v", i, err) 1122 | } 1123 | if status != tc.expectedStatus { 1124 | t.Fatalf("Test %d failed. Expected StatusCode: '%d', Got: '%d'\nTestCase: %v\n", 1125 | i, tc.expectedStatus, status, tc) 1126 | } 1127 | } 1128 | } 1129 | 1130 | func TestIPv6(t *testing.T) { 1131 | TestCases := []struct { 1132 | inputIpfilterConfig string 1133 | shouldErr bool 1134 | reqIP string 1135 | reqPath string 1136 | expectedStatus int 1137 | }{ 1138 | { 1139 | `ipfilter / { 1140 | rule allow 1141 | ip 2001:db8:1234::/48 1142 | }`, false, "[2001:db8:1234:0000:0000:0000:0000:0000]:_", "/", http.StatusOK, 1143 | }, 1144 | { 1145 | `ipfilter / { 1146 | rule allow 1147 | ip 2001:db8:1234::/48 1148 | }`, false, "[2001:db8:1234:ffff:ffff:ffff:ffff:ffff]:_", "/", http.StatusOK, 1149 | }, 1150 | { 1151 | `ipfilter / { 1152 | rule allow 1153 | ip 2001:db8:1234::/48 1154 | }`, false, "[2001:db8:1244:0000:0000:0000:0000:0000]:_", "/", http.StatusForbidden, 1155 | }, 1156 | { 1157 | `ipfilter / { 1158 | rule allow 1159 | ip 8.8.8.8 2001:db8:85a3:8d3:1319:8a2e:370:7348 8.8.4.4 1160 | }`, false, "[2001:db8:85a3:8d3:1319:8a2e:370:7338]:_", "/", http.StatusForbidden, 1161 | }, 1162 | { 1163 | `ipfilter / { 1164 | rule allow 1165 | ip 8.8.8.8 2001:db8:85a3:8d3:1319:8a2e:370:7348 8.8.4.4 1166 | }`, false, "[2001:db8:85a3:8d3:1319:8a2e:370:7348]:_", "/", http.StatusOK, 1167 | }, 1168 | { 1169 | `ipfilter / { 1170 | rule allow 1171 | ip 2001:db8:85a3::8a2e:370:7334 10.0.0 192.168.1.5-40 1172 | }`, false, "192.168.1.33:_", "/", http.StatusOK, 1173 | }, 1174 | { 1175 | `ipfilter / { 1176 | rule allow 1177 | ip 2001:db8:85a3::8a2e:370:7334/64 10.0.0 1178 | }`, false, "10.0.0.5:_", "/", http.StatusOK, 1179 | }, 1180 | } 1181 | 1182 | for i, tc := range TestCases { 1183 | // Parse the text config 1184 | c := caddy.NewTestController("http", tc.inputIpfilterConfig) 1185 | config, err := ipfilterParse(c) 1186 | 1187 | if err != nil && !tc.shouldErr { 1188 | t.Errorf("Test %d failed, error generated while it should not: %v", i, err) 1189 | } else if err == nil && tc.shouldErr { 1190 | t.Errorf("Test %d failed, no error generated while it should", i) 1191 | } else if err != nil { 1192 | continue 1193 | } 1194 | 1195 | ipf := IPFilter{ 1196 | Next: httpserver.HandlerFunc(func(w http.ResponseWriter, r *http.Request) (int, error) { 1197 | return http.StatusOK, nil 1198 | }), 1199 | Config: config, 1200 | } 1201 | 1202 | req, err := http.NewRequest("GET", tc.reqPath, nil) 1203 | if err != nil { 1204 | t.Fatalf("Could not create HTTP request: %v", err) 1205 | } 1206 | 1207 | req.RemoteAddr = tc.reqIP 1208 | 1209 | rec := httptest.NewRecorder() 1210 | 1211 | status, err := ipf.ServeHTTP(rec, req) 1212 | if err != nil { 1213 | t.Fatalf("Test %d failed. Error generated:\n%v", i, err) 1214 | } 1215 | if status != tc.expectedStatus { 1216 | t.Fatalf("Test %d failed. Expected StatusCode: '%d', Got: '%d'\nTestCase: %v\n", 1217 | i, tc.expectedStatus, status, tc) 1218 | } 1219 | } 1220 | 1221 | } 1222 | 1223 | // parseCIDRs takes a slice of IPs as strings and returns them parsed via net.ParseCIDR as []*net.IPNet 1224 | func parseCIDRs(ips []string) []*net.IPNet { 1225 | ipnets := make([]*net.IPNet, len(ips)) 1226 | for i, ip := range ips { 1227 | _, ipnet, err := net.ParseCIDR(ip) 1228 | if err != nil { 1229 | log.Fatalf("ParseCIDR can't parse: %s\nError: %s", ip, err) 1230 | } 1231 | 1232 | ipnets[i] = ipnet 1233 | } 1234 | 1235 | return ipnets 1236 | } 1237 | -------------------------------------------------------------------------------- /range2CIDRs.go: -------------------------------------------------------------------------------- 1 | package ipfilter 2 | 3 | // https://groups.google.com/forum/m/#!topic/golang-nuts/rJvVwk4jwjQ 4 | 5 | import ( 6 | "fmt" 7 | "net" 8 | ) 9 | 10 | var allFF = net.ParseIP("255.255.255.255").To4() 11 | 12 | func range2CIDRs(a1, a2 net.IP) (r []*net.IPNet) { 13 | // Warn users that they're using an old method of ranging 14 | fmt.Println("ipfilter Warning: You are using an old method of ranging over IPs, it's highly recommended to switch over to CIDR notation, For more: https://caddyserver.com/docs/ipfilter") 15 | maxLen := 32 16 | a1 = a1.To4() 17 | a2 = a2.To4() 18 | for cmp(a1, a2) <= 0 { 19 | l := 32 20 | for l > 0 { 21 | m := net.CIDRMask(l-1, maxLen) 22 | if cmp(a1, first(a1, m)) != 0 || cmp(last(a1, m), a2) > 0 { 23 | break 24 | } 25 | l-- 26 | } 27 | r = append(r, &net.IPNet{IP: a1, Mask: net.CIDRMask(l, maxLen)}) 28 | a1 = last(a1, net.CIDRMask(l, maxLen)) 29 | if cmp(a1, allFF) == 0 { 30 | break 31 | } 32 | a1 = next(a1) 33 | } 34 | return r 35 | } 36 | 37 | func next(ip net.IP) net.IP { 38 | n := len(ip) 39 | out := make(net.IP, n) 40 | copy := false 41 | for n > 0 { 42 | n-- 43 | if copy { 44 | out[n] = ip[n] 45 | continue 46 | } 47 | if ip[n] < 255 { 48 | out[n] = ip[n] + 1 49 | copy = true 50 | continue 51 | } 52 | out[n] = 0 53 | } 54 | return out 55 | } 56 | 57 | func cmp(ip1, ip2 net.IP) int { 58 | l := len(ip1) 59 | for i := 0; i < l; i++ { 60 | if ip1[i] == ip2[i] { 61 | continue 62 | } 63 | if ip1[i] < ip2[i] { 64 | return -1 65 | } 66 | return 1 67 | } 68 | return 0 69 | } 70 | 71 | func first(ip net.IP, mask net.IPMask) net.IP { 72 | return ip.Mask(mask) 73 | } 74 | 75 | func last(ip net.IP, mask net.IPMask) net.IP { 76 | n := len(ip) 77 | out := make(net.IP, n) 78 | for i := 0; i < n; i++ { 79 | out[i] = ip[i] | ^mask[i] 80 | } 81 | return out 82 | } 83 | -------------------------------------------------------------------------------- /testdata/GeoLite2.mmdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyed/ipfilter/44576102099e4428e7e429987a97a45122c4a656/testdata/GeoLite2.mmdb -------------------------------------------------------------------------------- /testdata/blacklist/1234/abcd/1234=abcd==1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyed/ipfilter/44576102099e4428e7e429987a97a45122c4a656/testdata/blacklist/1234/abcd/1234=abcd==1 -------------------------------------------------------------------------------- /testdata/blacklist/192.168.0.1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyed/ipfilter/44576102099e4428e7e429987a97a45122c4a656/testdata/blacklist/192.168.0.1 -------------------------------------------------------------------------------- /testdata/blacklist/192/168/192.168.1.2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyed/ipfilter/44576102099e4428e7e429987a97a45122c4a656/testdata/blacklist/192/168/192.168.1.2 -------------------------------------------------------------------------------- /testdata/blacklist/==1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyed/ipfilter/44576102099e4428e7e429987a97a45122c4a656/testdata/blacklist/==1 -------------------------------------------------------------------------------- /testdata/blockpage.html: -------------------------------------------------------------------------------- 1 | You are not allowed here -------------------------------------------------------------------------------- /testdata/whitelist/127.0.0.1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyed/ipfilter/44576102099e4428e7e429987a97a45122c4a656/testdata/whitelist/127.0.0.1 --------------------------------------------------------------------------------