├── .gitignore ├── LICENSE ├── README.md └── nft-geo-filter /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ronnie P. Thomas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nft-geo-filter 2 | Allow/deny traffic in nftables using country specific IP blocks 3 | 4 | # Requirements 5 | This script requires nftables >= 0.9.0 6 | 7 | # Installation 8 | Download the script from here: 9 | https://raw.githubusercontent.com/rpthms/nft-geo-filter/master/nft-geo-filter 10 | 11 | # TL;DR 12 | Run `nft-geo-filter --table-family netdev --interface 13 | XX` to block packets from the country whose ISO-3166-1 alpha-2 country code is 14 | XX. Replace `` with the interface name in your system 15 | that's connected to the internet (Eg:- eth0). 16 | 17 | # Description 18 | This script will download IPv4 or/and IPv6 blocks for the specified countries 19 | from one of the supported IP blocks provider and add them to sets in the 20 | specified table. You have to provide 2 letter ISO-3166-1 alpha-2 country codes 21 | of the countries you want to filter as positional arguments to this script. 22 | 23 | nft-geo-filter supports 2 IP Blocks provider at this point: 24 | 25 | * **ipverse.net** - http://ipverse.net/ 26 | * **ipdeny.com** - https://www.ipdeny.com/ipblocks/ 27 | 28 | You can specify which table holds the sets and the filtering rules using the 29 | `--table-family` and `--table-name` flags. `--table-name` specifies the name of 30 | the table. nft-geo-filter requires its own private table, so make sure that the 31 | table name that you provide is not being used by any other table in your 32 | ruleset. `--table-family` specifies the family of the nftables table which will 33 | store the filter sets and the filtering rule. The family must be one of the 34 | following options: 35 | 36 | * ip 37 | * ip6 38 | * inet 39 | * netdev 40 | 41 | By using a separate table, this script can create it's own chains and add its 42 | own filtering rules without needing the admin to make any changes to their 43 | nftables config, like you were required to do in the previous version of this 44 | script. **Do not add any rules to the chains inside nft-geo-filter's private 45 | table**, because they will be removed when you re-run the script to update the 46 | filter sets. 47 | 48 | **The default action of this script is to block traffic** from the IP blocks of 49 | the provided countries and allow everything else. To invert this behaviour and 50 | only allow traffic from the IP blocks of the specified countries (with a few 51 | exceptions, see the "Allow mode exceptions" section below), use the `--allow` 52 | flag. 53 | 54 | Running nft-geo-filter without specifying any optional flags will end up 55 | creating IP sets and filtering rules to block traffic from those IPs, inside a 56 | table called 'geo-filter' of the 'inet' family. But **it is recommended to use 57 | a 'netdev' table to drop packets** much more effeciently than the other 58 | families. Refer to the 'netdev' section below. 59 | 60 | # IPv4 or IPv6? 61 | 62 | The filter sets that are added to the table is determined by the table's family 63 | that you specify using `--table-family`: 64 | 65 | Table Family | Filter Sets 66 | -------------|------------ 67 | ip|Only the IPv4 set 68 | ip6|Only the IPv6 set 69 | inet|Both IPv4 and IPv6 sets 70 | netdev|Both IPv4 and IPv6 sets by default. Use the --no-ipv6 flag to only use the IPv4 set or the --no-ipv4 flag to only use the IPv6 set. 71 | 72 | # Netdev 73 | Using the netdev table to drop packets is more efficient than dropping them in 74 | the tables of other families (by a factor of 2x according to the nftables wiki: 75 | https://wiki.nftables.org/wiki-nftables/index.php/Nftables_families#netdev). 76 | This is because the netdev rules are applied very early in the packet path (as 77 | soon as the NIC passes the packets to the networking stack). 78 | 79 | To use a netdev table, you need to set the `--table-family` to `netdev` and 80 | provide the name of the interface that's connected to the internet by using the 81 | `--interface` flag. The interface is needed because netdev tables work on a 82 | per-interface basis. 83 | 84 | # Allow mode implicit exceptions 85 | When you use `--allow`, certain rules are automatically added along with the 86 | regular filtering rules to ensure that your regular traffic is not impeded. 87 | These rules ensure that: 88 | 89 | 1. Traffic from private IPv4 address ranges and link-local IPv6 address ranges 90 | are allowed to pass through. 91 | 2. Traffic from the localhost is allowed to pass through. 92 | 3. Non-IP traffic such as ARP is not blocked when using the netdev table. 93 | 94 | # Allow outgoing connections to denied IPs 95 | In case you want to make connections to IP addresses that are being denied by 96 | the filtering sets, you can use the `--allow-established` flag. This will add a 97 | rule to the filter-chain to allow packets from all established and related 98 | connections (i.e the first packet of the connection should originate from your 99 | host). Initial packets from the denied IPs will always be denied. 100 | 101 | This flag is really handy when combined with `--allow`, which lets you limit 102 | the incoming connections to certain countries while letting you create outgoing 103 | connections to any country without any restrictions. Check the example titled 104 | 'Only allow incoming packets from Monaco but still allow outgoing connections 105 | to any country' in the section below to get an idea about the 106 | `--allow-established` flag. 107 | 108 | # Manual exceptions 109 | You can create exceptions for a few IP addresses so that they pass through the 110 | filtering sets that were set up. To do that provide a comma separated list of 111 | IPs that need to be exempted from filtering to the `--exceptions` flag. This 112 | will create rules that would explicitly allow packets from the specified IP 113 | addresses, even if the filtering sets would block them. Check the "Usage 114 | examples" section below to see how the `--exceptions` flag can be used. 115 | 116 | # What do I need to add to my nftables config? 117 | **Nothing!** Since this script creates a separate nftables table to filter your 118 | traffic, it will not cause your current nftables config to break. The 119 | "filter-chain" chain created by this script has a high priority of -190 to 120 | ensure that: 121 | * Conntrack operations happen before this script's rule matching begins 122 | (Connection tracking operations uses a higher priority of -200) 123 | * Filtering rules of this script are applied before your own 124 | rules (Most people won't be using a filter chain with such a high priority) 125 | 126 | # Other options 127 | By default, nft-geo-filter uses `/usr/sbin/nft` as the path to the nft binary. 128 | If your distro stores nft in a different location, specify that location using 129 | the `--nft-location` argument. 130 | 131 | You can also add counters to your filtering rules to see how many packets have 132 | been dropped/accepted. Just add the `--counter` argument when calling the 133 | script. 134 | 135 | Filtering rules can also log the packets that are accepted or droped by them, by 136 | using the `--log-accept` or the `--log-drop` arguments. You can optionally provide 137 | a prefix to the log messages for easier identification, using the `--log-accept-prefix`, 138 | `--log-drop-prefix` arguments and change the log severity level from 'warn' by using 139 | the `--log-accept-level` and `--log-drop-level` arguments. 140 | 141 | # Help text 142 | Run `nft-geo-filter -h` to get the following help text: 143 | ``` 144 | usage: nft-geo-filter [-h] [-v] [--version] [-l LOCATION] [-a] [--allow-established] [-c] 145 | [--provider {ipdeny.com,ipverse.net}] [-f {ip,ip6,inet,netdev}] [-n NAME] 146 | [-i INTERFACE] [--no-ipv4 | --no-ipv6] [-p] [--log-accept-prefix PREFIX] 147 | [--log-accept-level {emerg,alert,crit,err,warn,notice,info,debug}] [-o] 148 | [--log-drop-prefix PREFIX] 149 | [--log-drop-level {emerg,alert,crit,err,warn,notice,info,debug}] 150 | [-e ADDRESSES] 151 | country [country ...] 152 | 153 | Filter traffic in nftables using country IP blocks 154 | 155 | positional arguments: 156 | country 2 letter ISO-3166-1 alpha-2 country codes to allow/block. Check your IP 157 | blocks provider to find the list of supported countries. 158 | 159 | optional arguments: 160 | -h, --help show this help message and exit 161 | -v, --verbose show verbose output 162 | --version show program's version number and exit 163 | 164 | -l LOCATION, --nft-location LOCATION 165 | Location of the nft binary. Default is /usr/sbin/nft 166 | -a, --allow By default, all the IPs in the filter sets will be denied and every other 167 | IP will be allowed to pass the filtering chain. Provide this argument to 168 | reverse this behaviour. 169 | --allow-established Allow packets from denied IPs, but only if they are a part of an 170 | established connection i.e the initial packet originated from your host. 171 | Initial packets from the denied IPs will still be dropped. This flag can 172 | be useful when using the allow mode, so that outgoing connections to 173 | addresses outside the filter set can still be made. 174 | -c, --counter Add the counter statement to the filtering rules 175 | --provider {ipdeny.com,ipverse.net} 176 | Specify the country IP blocks provider. Default is ipverse.net 177 | 178 | Table: 179 | Provide the name and the family of the table in which the set of filtered addresses will be 180 | created. This script will create a new nftables table, so make sure the provided table name 181 | is unique and not being used by any other table in the ruleset. An 'inet' table called 'geo- 182 | filter' will be used by default 183 | 184 | -f {ip,ip6,inet,netdev}, --table-family {ip,ip6,inet,netdev} 185 | Specify the table's family. Default is inet 186 | -n NAME, --table-name NAME 187 | Specify the table's name. Default is geo-filter 188 | 189 | Netdev arguments: 190 | If you're using a netdev table, you need to provide the name of the interface which is 191 | connected to the internet because netdev tables work on a per-interface basis. You can also 192 | choose to only store v4 or only store v6 addresses inside the netdev table sets by providing 193 | the '--no-ipv6' or '--no-ipv4' arguments. Both v4 and v6 addresses are stored by default 194 | 195 | -i INTERFACE, --interface INTERFACE 196 | Specify the ingress interface for the netdev table 197 | --no-ipv4 Don't create a set for v4 addresses in the netdev table 198 | --no-ipv6 Don't create a set for v6 addresses in the netdev table 199 | 200 | Logging statement: 201 | You can optionally add the logging statement to the filtering rules added by this script. 202 | That way, you'll be able to see the IP addresses of the packets that are accepted or dropped 203 | by the filtering rules in the kernel log (which can be read via the systemd journal or 204 | syslog). You can also add an optional prefix to the log messages and change the log message 205 | severity level. 206 | 207 | -p, --log-accept Add the log statement to the accept filtering rules 208 | --log-accept-prefix PREFIX 209 | Add a prefix to the accept log messages for easier identification. No 210 | prefix is used by default. 211 | --log-accept-level {emerg,alert,crit,err,warn,notice,info,debug} 212 | Set the accept log message severity level. Default is 'warn'. 213 | -o, --log-drop Add the log statement to the drop filtering rules 214 | --log-drop-prefix PREFIX 215 | Add a prefix to the drop log messages for easier identification. No 216 | prefix is used by default. 217 | --log-drop-level {emerg,alert,crit,err,warn,notice,info,debug} 218 | Set the drop log message severity level. Default is 'warn'. 219 | 220 | IP Exceptions: 221 | You can add exceptions for certain IPs by passing a comma separated list of IPs or 222 | subnets/prefixes to the '--exceptions' option. The IP addresses passed to this option will be 223 | explicitly allowed in the filtering chain created by this script. Both IPv4 and IPv6 224 | addresses can be passed. Use this option to allow a few IP addresses that would otherwise be 225 | denied by your filtering sets. 226 | 227 | -e ADDRESSES, --exceptions ADDRESSES 228 | ``` 229 | 230 | # Usage examples 231 | All you have to do is run this script with the appropriate flags. There's no 232 | need to create a table or set manually in your nftables config for the 233 | filtering operation to work. Take a look at the following examples to 234 | understand how the script works. I'm using the IP address blocks from Monaco in 235 | the following examples: 236 | 237 | * Use a netdev table to block packets from Monaco (on the enp1s0 interface)\ 238 | **Command to run**: `nft-geo-filter --table-family netdev --interface enp1s0 MC`\ 239 | **Resulting ruleset**: 240 | ``` 241 | table netdev geo-filter { 242 | set filter-v4 { 243 | type ipv4_addr 244 | flags interval 245 | auto-merge 246 | elements = { 37.44.224.0/22, 80.94.96.0/20, 247 | 82.113.0.0/19, 87.238.104.0/21, 248 | 87.254.224.0/19, 88.209.64.0/18, 249 | 91.199.109.0/24, 176.114.96.0/20, 250 | 185.47.116.0/22, 185.162.120.0/22, 251 | 185.250.4.0/22, 188.191.136.0/21, 252 | 194.9.12.0/23, 195.20.192.0/23, 253 | 195.78.0.0/19, 213.133.72.0/21, 254 | 213.137.128.0/19 } 255 | } 256 | 257 | set filter-v6 { 258 | type ipv6_addr 259 | flags interval 260 | auto-merge 261 | elements = { 2a01:8fe0::/32, 262 | 2a07:9080::/29, 263 | 2a0b:8000::/29 } 264 | } 265 | 266 | chain filter-chain { 267 | type filter hook ingress device "enp1s0" priority -190; policy accept; 268 | ip saddr @filter-v4 drop 269 | ip6 saddr @filter-v6 drop 270 | } 271 | } 272 | ``` 273 | 274 | * Use a netdev table to only block IPv4 packets from Monaco (on the enp1s0 interface)\ 275 | **Command to run**: `nft-geo-filter --table-family netdev --interface enp1s0 --no-ipv6 MC`\ 276 | **Resulting ruleset**: 277 | ``` 278 | table netdev geo-filter { 279 | set filter-v4 { 280 | type ipv4_addr 281 | flags interval 282 | auto-merge 283 | elements = { 37.44.224.0/22, 80.94.96.0/20, 284 | 82.113.0.0/19, 87.238.104.0/21, 285 | 87.254.224.0/19, 88.209.64.0/18, 286 | 91.199.109.0/24, 176.114.96.0/20, 287 | 185.47.116.0/22, 185.162.120.0/22, 288 | 185.250.4.0/22, 188.191.136.0/21, 289 | 194.9.12.0/23, 195.20.192.0/23, 290 | 195.78.0.0/19, 213.133.72.0/21, 291 | 213.137.128.0/19 } 292 | } 293 | 294 | chain filter-chain { 295 | type filter hook ingress device "enp1s0" priority -190; policy accept; 296 | ip saddr @filter-v4 drop 297 | } 298 | } 299 | ``` 300 | 301 | * Only allow packets from Monaco using a netdev table (on the enp1s0 interface)\ 302 | **Command to run**: `nft-geo-filter --table-family netdev --interface enp1s0 --allow MC`\ 303 | **Resulting ruleset**: 304 | ``` 305 | table netdev geo-filter { 306 | set filter-v4 { 307 | type ipv4_addr 308 | flags interval 309 | auto-merge 310 | elements = { 37.44.224.0/22, 80.94.96.0/20, 311 | 82.113.0.0/19, 87.238.104.0/21, 312 | 87.254.224.0/19, 88.209.64.0/18, 313 | 91.199.109.0/24, 176.114.96.0/20, 314 | 185.47.116.0/22, 185.162.120.0/22, 315 | 185.250.4.0/22, 188.191.136.0/21, 316 | 194.9.12.0/23, 195.20.192.0/23, 317 | 195.78.0.0/19, 213.133.72.0/21, 318 | 213.137.128.0/19 } 319 | } 320 | 321 | set filter-v6 { 322 | type ipv6_addr 323 | flags interval 324 | auto-merge 325 | elements = { 2a01:8fe0::/32, 326 | 2a07:9080::/29, 327 | 2a0b:8000::/29 } 328 | } 329 | 330 | chain filter-chain { 331 | type filter hook ingress device "enp1s0" priority -190; policy drop; 332 | ip6 saddr fe80::/10 accept 333 | ip saddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } accept 334 | meta protocol != { ip, ip6 } accept 335 | ip saddr @filter-v4 accept 336 | ip6 saddr @filter-v6 accept 337 | } 338 | } 339 | ``` 340 | 341 | * Use an ip table named 'monaco-filter' to block IPv4 packets from Monaco and count the blocked packets\ 342 | **Command to run**: `nft-geo-filter --table-family ip --table-name monaco-filter --counter MC`\ 343 | **Resulting ruleset**: 344 | ``` 345 | table ip monaco-filter { 346 | set filter-v4 { 347 | type ipv4_addr 348 | flags interval 349 | auto-merge 350 | elements = { 37.44.224.0/22, 80.94.96.0/20, 351 | 82.113.0.0/19, 87.238.104.0/21, 352 | 87.254.224.0/19, 88.209.64.0/18, 353 | 91.199.109.0/24, 176.114.96.0/20, 354 | 185.47.116.0/22, 185.162.120.0/22, 355 | 185.250.4.0/22, 188.191.136.0/21, 356 | 194.9.12.0/23, 195.20.192.0/23, 357 | 195.78.0.0/19, 213.133.72.0/21, 358 | 213.137.128.0/19 } 359 | } 360 | 361 | chain filter-chain { 362 | type filter hook prerouting priority -190; policy accept; 363 | ip saddr @filter-v4 counter packets 0 bytes 0 drop 364 | } 365 | } 366 | ``` 367 | 368 | * Use an ip6 table named 'monaco-filter-v6' to block IPv6 packets from Monaco\ 369 | **Command to run**: `nft-geo-filter --table-family ip6 --table-name monaco-filter-v6 MC`\ 370 | **Resulting ruleset**: 371 | ``` 372 | table ip6 monaco-filter-v6 { 373 | set filter-v6 { 374 | type ipv6_addr 375 | flags interval 376 | auto-merge 377 | elements = { 2a01:8fe0::/32, 378 | 2a07:9080::/29, 379 | 2a0b:8000::/29 } 380 | } 381 | 382 | chain filter-chain { 383 | type filter hook prerouting priority -190; policy accept; 384 | ip6 saddr @filter-v6 drop 385 | } 386 | } 387 | ``` 388 | 389 | * Only allow packets from Monaco using an inet table\ 390 | **Command to run**: `nft-geo-filter --allow MC`\ 391 | **Resulting ruleset**: 392 | ``` 393 | table inet geo-filter { 394 | set filter-v4 { 395 | type ipv4_addr 396 | flags interval 397 | auto-merge 398 | elements = { 37.44.224.0/22, 80.94.96.0/20, 399 | 82.113.0.0/19, 87.238.104.0/21, 400 | 87.254.224.0/19, 88.209.64.0/18, 401 | 91.199.109.0/24, 176.114.96.0/20, 402 | 185.47.116.0/22, 185.162.120.0/22, 403 | 185.250.4.0/22, 188.191.136.0/21, 404 | 194.9.12.0/23, 195.20.192.0/23, 405 | 195.78.0.0/19, 213.133.72.0/21, 406 | 213.137.128.0/19 } 407 | } 408 | 409 | set filter-v6 { 410 | type ipv6_addr 411 | flags interval 412 | auto-merge 413 | elements = { 2a01:8fe0::/32, 414 | 2a07:9080::/29, 415 | 2a0b:8000::/29 } 416 | } 417 | 418 | chain filter-chain { 419 | type filter hook prerouting priority -190; policy drop; 420 | ip6 saddr { ::1, fe80::/10 } accept 421 | ip saddr { 10.0.0.0/8, 127.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } accept 422 | ip saddr @filter-v4 accept 423 | ip6 saddr @filter-v6 accept 424 | } 425 | } 426 | ``` 427 | 428 | * Block all packets from Monaco using an inet table (default operation)\ 429 | **Command to run**: `nft-geo-filter MC`\ 430 | **Resulting ruleset**: 431 | ``` 432 | table inet geo-filter { 433 | set filter-v4 { 434 | type ipv4_addr 435 | flags interval 436 | auto-merge 437 | elements = { 37.44.224.0/22, 80.94.96.0/20, 438 | 82.113.0.0/19, 87.238.104.0/21, 439 | 87.254.224.0/19, 88.209.64.0/18, 440 | 91.199.109.0/24, 176.114.96.0/20, 441 | 185.47.116.0/22, 185.162.120.0/22, 442 | 185.250.4.0/22, 188.191.136.0/21, 443 | 194.9.12.0/23, 195.20.192.0/23, 444 | 195.78.0.0/19, 213.133.72.0/21, 445 | 213.137.128.0/19 } 446 | } 447 | 448 | set filter-v6 { 449 | type ipv6_addr 450 | flags interval 451 | auto-merge 452 | elements = { 2a01:8fe0::/32, 453 | 2a07:9080::/29, 454 | 2a0b:8000::/29 } 455 | } 456 | 457 | chain filter-chain { 458 | type filter hook prerouting priority -190; policy accept; 459 | ip saddr @filter-v4 drop 460 | ip6 saddr @filter-v6 drop 461 | } 462 | } 463 | ``` 464 | 465 | * Block all packets from Monaco using an inet table named 'monaco-filter' and log the dropped packets\ 466 | **Command to run**: `nft-geo-filter --table-name monaco-filter --log-drop MC`\ 467 | **Resulting ruleset**: 468 | ``` 469 | table inet monaco-filter { 470 | set filter-v4 { 471 | type ipv4_addr 472 | flags interval 473 | auto-merge 474 | elements = { 37.44.224.0/22, 80.94.96.0/20, 475 | 82.113.0.0/19, 87.238.104.0/21, 476 | 87.254.224.0/19, 88.209.64.0/18, 477 | 91.199.109.0/24, 176.114.96.0/20, 478 | 185.47.116.0/22, 185.162.120.0/22, 479 | 185.250.4.0/22, 188.191.136.0/21, 480 | 194.9.12.0/23, 195.20.192.0/23, 481 | 195.78.0.0/19, 213.133.72.0/21, 482 | 213.137.128.0/19 } 483 | } 484 | 485 | set filter-v6 { 486 | type ipv6_addr 487 | flags interval 488 | auto-merge 489 | elements = { 2a01:8fe0::/32, 490 | 2a07:9080::/29, 491 | 2a0b:8000::/29 } 492 | } 493 | 494 | chain filter-chain { 495 | type filter hook prerouting priority -190; policy accept; 496 | ip saddr @filter-v4 log drop 497 | ip6 saddr @filter-v6 log drop 498 | } 499 | } 500 | ``` 501 | 502 | * Block all packets from Monaco and log them using the 'MC-Block ' log prefix and the 'info' log level\ 503 | **Command to run**: `nft-geo-filter --log-drop --log-drop-prefix 'MC-Block ' --log-drop-level info MC`\ 504 | **Resulting ruleset**: 505 | ``` 506 | table inet geo-filter { 507 | set filter-v4 { 508 | type ipv4_addr 509 | flags interval 510 | auto-merge 511 | elements = { 37.44.224.0/22, 80.94.96.0/20, 512 | 82.113.0.0/19, 87.238.104.0/21, 513 | 87.254.224.0/19, 88.209.64.0/18, 514 | 91.199.109.0/24, 176.114.96.0/20, 515 | 185.47.116.0/22, 185.162.120.0/22, 516 | 185.250.4.0/22, 188.191.136.0/21, 517 | 194.9.12.0/23, 195.20.192.0/23, 518 | 195.78.0.0/19, 213.133.72.0/21, 519 | 213.137.128.0/19 } 520 | } 521 | 522 | set filter-v6 { 523 | type ipv6_addr 524 | flags interval 525 | auto-merge 526 | elements = { 2a01:8fe0::/32, 527 | 2a07:9080::/29, 528 | 2a0b:8000::/29 } 529 | } 530 | 531 | chain filter-chain { 532 | type filter hook prerouting priority -190; policy accept; 533 | ip saddr @filter-v4 log prefix "MC-Block " level info drop 534 | ip6 saddr @filter-v6 log prefix "MC-Block " level info drop 535 | } 536 | } 537 | ``` 538 | 539 | * Only allow packets from Monaco but create exceptions for Cloudflare's DNS service\ 540 | **Command to run**: `nft-geo-filter --exceptions 1.0.0.1,1.1.1.1,2606:4700:4700::1001,2606:4700:4700::1111 --allow MC`\ 541 | **Resulting ruleset**: 542 | ``` 543 | table inet geo-filter { 544 | set filter-v4 { 545 | type ipv4_addr 546 | flags interval 547 | auto-merge 548 | elements = { 37.44.224.0/22, 80.94.96.0/20, 549 | 82.113.0.0/19, 87.238.104.0/21, 550 | 87.254.224.0/19, 88.209.64.0/18, 551 | 91.199.109.0/24, 176.114.96.0/20, 552 | 185.47.116.0/22, 185.162.120.0/22, 553 | 185.250.4.0/22, 188.191.136.0/21, 554 | 194.9.12.0/23, 195.20.192.0/23, 555 | 195.78.0.0/19, 213.133.72.0/21, 556 | 213.137.128.0/19 } 557 | } 558 | 559 | set filter-v6 { 560 | type ipv6_addr 561 | flags interval 562 | auto-merge 563 | elements = { 2a01:8fe0::/32, 564 | 2a07:9080::/29, 565 | 2a0b:8000::/29 } 566 | } 567 | 568 | chain filter-chain { 569 | type filter hook prerouting priority -190; policy drop; 570 | ip saddr { 1.0.0.1, 1.1.1.1 } accept 571 | ip6 saddr { 2606:4700:4700::1001, 2606:4700:4700::1111 } accept 572 | ip6 saddr { ::1, fe80::/10 } accept 573 | ip saddr { 10.0.0.0/8, 127.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } accept 574 | ip saddr @filter-v4 accept 575 | ip6 saddr @filter-v6 accept 576 | } 577 | } 578 | ``` 579 | 580 | * Block all packets from Monaco except the packets from `80.94.96.0/24` and `2a07:9080:100:100::/64`\ 581 | **Command to run**: `nft-geo-filter --exceptions 80.94.96.0/24,2a07:9080:100:100::/64 MC`\ 582 | **Resulting ruleset**: 583 | ``` 584 | table inet geo-filter { 585 | set filter-v4 { 586 | type ipv4_addr 587 | flags interval 588 | auto-merge 589 | elements = { 37.44.224.0/22, 80.94.96.0/20, 590 | 82.113.0.0/19, 87.238.104.0/21, 591 | 87.254.224.0/19, 88.209.64.0/18, 592 | 91.199.109.0/24, 176.114.96.0/20, 593 | 185.47.116.0/22, 185.162.120.0/22, 594 | 185.250.4.0/22, 188.191.136.0/21, 595 | 194.9.12.0/23, 195.20.192.0/23, 596 | 195.78.0.0/19, 213.133.72.0/21, 597 | 213.137.128.0/19 } 598 | } 599 | 600 | set filter-v6 { 601 | type ipv6_addr 602 | flags interval 603 | auto-merge 604 | elements = { 2a01:8fe0::/32, 605 | 2a07:9080::/29, 606 | 2a0b:8000::/29 } 607 | } 608 | 609 | chain filter-chain { 610 | type filter hook prerouting priority -190; policy accept; 611 | ip saddr { 80.94.96.0/24 } accept 612 | ip6 saddr { 2a07:9080:100:100::/64 } accept 613 | ip saddr @filter-v4 drop 614 | ip6 saddr @filter-v6 drop 615 | } 616 | } 617 | ``` 618 | 619 | * Only allow incoming packets from Monaco but still allow outgoing connections to any country\ 620 | **Command to run**: `nft-geo-filter --allow --allow-established MC`\ 621 | **Resulting ruleset**: 622 | ``` 623 | table inet geo-filter { 624 | set filter-v4 { 625 | type ipv4_addr 626 | flags interval 627 | auto-merge 628 | elements = { 37.44.224.0/22, 80.94.96.0/20, 629 | 82.113.0.0/19, 87.238.104.0/21, 630 | 87.254.224.0/19, 88.209.64.0/18, 631 | 91.199.109.0/24, 176.114.96.0/20, 632 | 185.47.116.0/22, 185.162.120.0/22, 633 | 185.250.4.0/22, 188.191.136.0/21, 634 | 194.9.12.0/23, 195.20.192.0/23, 635 | 195.78.0.0/19, 213.133.72.0/21, 636 | 213.137.128.0/19 } 637 | } 638 | 639 | set filter-v6 { 640 | type ipv6_addr 641 | flags interval 642 | auto-merge 643 | elements = { 2a01:8fe0::/32, 644 | 2a07:9080::/29, 645 | 2a0b:8000::/29 } 646 | } 647 | 648 | chain filter-chain { 649 | type filter hook prerouting priority -190; policy drop; 650 | ct state established,related accept 651 | ip6 saddr { ::1, fe80::/10 } accept 652 | ip saddr { 10.0.0.0/8, 127.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } accept 653 | ip saddr @filter-v4 accept 654 | ip6 saddr @filter-v6 accept 655 | } 656 | } 657 | ``` 658 | 659 | * Download IP blocks from ipdeny.com instead of ipverse.net to block packets from Monaco\ 660 | **Command to run**: `nft-geo-filter --provider ipdeny.com MC`\ 661 | **Resulting ruleset**: 662 | ``` 663 | table inet geo-filter { 664 | set filter-v4 { 665 | type ipv4_addr 666 | flags interval 667 | auto-merge 668 | elements = { 37.44.224.0/22, 80.94.96.0/20, 669 | 82.113.0.0/19, 87.238.104.0/21, 670 | 87.254.224.0/19, 88.209.64.0/18, 671 | 91.199.109.0/24, 91.213.192.0/24, 672 | 176.114.96.0/20, 185.47.116.0/22, 673 | 185.162.120.0/22, 185.193.108.0/22, 674 | 185.250.4.0/22, 188.191.136.0/21, 675 | 193.34.228.0/23, 193.35.2.0/23, 676 | 194.9.12.0/23, 195.20.192.0/23, 677 | 195.78.0.0/19, 213.133.72.0/21 } 678 | } 679 | 680 | set filter-v6 { 681 | type ipv6_addr 682 | flags interval 683 | auto-merge 684 | elements = { 2a01:8fe0::/32, 685 | 2a06:92c0::/32, 686 | 2a07:9080::/29, 687 | 2a0b:8000::/29, 688 | 2a0f:b980::/29 } 689 | } 690 | 691 | chain filter-chain { 692 | type filter hook prerouting priority -190; policy accept; 693 | ip saddr @filter-v4 drop 694 | ip6 saddr @filter-v6 drop 695 | } 696 | } 697 | ``` 698 | 699 | # Run nft-geo-filter as a service 700 | nft-geo-filter can also be run via a cronjob or a systemd timer to keep your 701 | filtering sets updated. When nft-geo-filter is executed, it will check if the 702 | target sets already exist. It they do, the script will flush the existing 703 | contents of the filtering sets after downloading the IP blocks and then add the 704 | updated IP blocks to the sets. If any changes need to be made to the filtering 705 | rules, the script will make them as well. 706 | 707 | * Taking Monaco as an example again, to update the filtering sets in an 'ip' 708 | table called 'monaco-filter' when you boot your system and then every 12 709 | hours thereafter, your systemd timer and service units would look something 710 | like this (provided you have stored the nft-geo-filter script in 711 | /usr/local/bin): 712 | 713 | **nft-geo-filter.timer** 714 | ``` 715 | [Unit] 716 | Description=nftables Country Filter Timer 717 | 718 | [Timer] 719 | OnBootSec=1min 720 | OnUnitActiveSec=12h 721 | 722 | [Install] 723 | WantedBy=timers.target 724 | ``` 725 | 726 | **nft-geo-filter.service** 727 | ``` 728 | [Unit] 729 | Description=nftables Country Filter 730 | 731 | [Service] 732 | Type=oneshot 733 | ExecStart=/usr/local/bin/nft-geo-filter --table-family ip --table-name monaco-filter MC 734 | ``` 735 | 736 | * A cronjob that runs the same nft-geo-filter command provided above at 3:00 a.m. 737 | every day would look like this: 738 | ``` 739 | 0 3 * * * /usr/local/bin/nft-geo-filter --table-family ip --table-name monaco-filter MC 740 | ``` 741 | -------------------------------------------------------------------------------- /nft-geo-filter: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # Python script for filtering traffic in nftables using country IP blocks 4 | 5 | import argparse 6 | import ipaddress 7 | import json 8 | import logging 9 | import os 10 | import shutil 11 | import subprocess 12 | import sys 13 | import tempfile 14 | import textwrap 15 | import urllib.request 16 | import urllib.error 17 | 18 | #Temp File Header 19 | FILE_HEADER = ('table {} {} {{\n' 20 | 'set {} {{\n' 21 | 'type {}\n' 22 | 'flags interval\n' 23 | 'auto-merge\n') 24 | 25 | SUPPORTED_PROVIDERS = ('ipdeny.com', 'ipverse.net') 26 | 27 | class GeoFilter: 28 | def __init__(self, args): 29 | self.nft = args.nft_location 30 | self.allow = args.allow 31 | self.table_family = args.table_family 32 | self.table_name = args.table_name 33 | self.interface = args.interface 34 | self.country_codes = [c.lower() for c in args.country] 35 | self.no_ipv4 = args.no_ipv4 36 | self.no_ipv6 = args.no_ipv6 37 | self.counter = args.counter 38 | self.log_accept = args.log_accept 39 | self.log_accept_prefix = args.log_accept_prefix 40 | self.log_accept_level = args.log_accept_level 41 | self.log_drop = args.log_drop 42 | self.log_drop_prefix = args.log_drop_prefix 43 | self.log_drop_level = args.log_drop_level 44 | self.verbosity = args.verbose 45 | self.provider = args.provider 46 | 47 | self.reset_dormancy = True 48 | self.working_dir = tempfile.mkdtemp() 49 | self.logger = self.configure_logging() 50 | 51 | self.policy = "drop" if self.allow else "accept" 52 | 53 | def __enter__(self): 54 | return self 55 | 56 | def __exit__(self, exc_type, exc_value, traceback): 57 | self.delete_working_dir() 58 | 59 | def configure_logging(self): 60 | """Configure the logger object for this class""" 61 | logger = logging.getLogger('GeoFilter') 62 | 63 | if self.verbosity > 1: 64 | log_level = logging.DEBUG 65 | elif self.verbosity == 1: 66 | log_level = logging.INFO 67 | else: 68 | log_level = logging.WARNING 69 | 70 | logger.setLevel(log_level) 71 | 72 | # Create a StreamHandler to log messages to the console 73 | sh = logging.StreamHandler() 74 | sh.setLevel(logging.DEBUG) 75 | 76 | # Log format 77 | formatter = logging.Formatter('%(levelname)s - %(funcName)s - %(message)s') 78 | 79 | sh.setFormatter(formatter) 80 | logger.addHandler(sh) 81 | return logger 82 | 83 | def delete_working_dir(self): 84 | self.logger.info("Deleting the working directory") 85 | shutil.rmtree(self.working_dir) 86 | 87 | def show_subprocess_run_error(self, err): 88 | self.logger.error("Failed to run: {}".format(err.args)) 89 | self.logger.error("Command exit status: {}\n".format(err.returncode)) 90 | self.logger.error("Command stdout: \n{}".format(err.stdout.decode("utf-8"))) 91 | self.logger.error("Command stderr: \n{}".format(err.stderr.decode("utf-8"))) 92 | 93 | def add_table(self): 94 | nft_command_tmpl = "{} add table {} {}" 95 | nft_command = nft_command_tmpl.format(self.nft, self.table_family, self.table_name) 96 | 97 | self.logger.info("Adding a {} table: {}".format(self.table_family, self.table_name)) 98 | self.logger.debug("Running command: {}".format(nft_command)) 99 | try: 100 | subprocess.run(nft_command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 101 | except subprocess.CalledProcessError as err: 102 | self.logger.error("Failed to add the {} table: {}".format(self.table_family, self.table_name)) 103 | self.show_subprocess_run_error(err) 104 | raise 105 | 106 | def set_table_as_dormant(self, is_dormant): 107 | if is_dormant: 108 | nft_dormant_command_tmpl = "{} add table {} {} {{ flags dormant; }}" 109 | else: 110 | nft_dormant_command_tmpl = "{} add table {} {}" 111 | nft_dormant_command = nft_dormant_command_tmpl.format(self.nft, self.table_family, self.table_name) 112 | 113 | self.logger.info("{} is dormant: {}".format(self.table_name, is_dormant)) 114 | self.logger.debug("Running command: {}".format(nft_dormant_command)) 115 | try: 116 | subprocess.run(nft_dormant_command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 117 | except subprocess.CalledProcessError as err: 118 | if is_dormant: 119 | self.logger.error("Failed to add the dormant flag to the {} table".format(self.table_name)) 120 | else: 121 | self.logger.error("Failed to remove the dormant flag from the {} table".format(self.table_name)) 122 | self.show_subprocess_run_error(err) 123 | raise 124 | 125 | def add_chain(self): 126 | if self.table_family == "netdev": 127 | nft_command_tmpl = "{} -- add chain {} {} filter-chain {{ type filter hook ingress device {} priority -190; policy {}; }}" 128 | nft_command = nft_command_tmpl.format(self.nft, self.table_family, self.table_name, self.interface, self.policy) 129 | else: 130 | nft_command_tmpl = "{} -- add chain {} {} filter-chain {{ type filter hook prerouting priority -190; policy {}; }}" 131 | nft_command = nft_command_tmpl.format(self.nft, self.table_family, self.table_name, self.policy) 132 | 133 | self.logger.info("Adding the filter-chain in the {} table".format(self.table_name)) 134 | self.logger.debug("Running command: {}".format(nft_command)) 135 | try: 136 | subprocess.run(nft_command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 137 | except subprocess.CalledProcessError as err: 138 | self.logger.error("Failed to add the filter-chain to the {} table".format(self.table_name)) 139 | self.show_subprocess_run_error(err) 140 | raise 141 | 142 | def find_old_rules(self): 143 | """Get a list of all the old rules in the filter-chain and store them 144 | for deletion.""" 145 | nft_list_command = "{} -j list chain {} {} filter-chain".format(self.nft, self.table_family, self.table_name) 146 | 147 | self.logger.info("Finding old filtering rules in the filter-chain of the {} table".format(self.table_name)) 148 | self.logger.debug("Running command: {}".format(nft_list_command)) 149 | try: 150 | result = subprocess.run(nft_list_command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 151 | except subprocess.CalledProcessError as err: 152 | self.logger.error("Failed to find the handles of the old filtering rules") 153 | self.show_subprocess_run_error(err) 154 | raise 155 | 156 | json_result = json.loads(result.stdout.decode("utf-8")) 157 | self.old_rule_handles = [] 158 | 159 | # Get the handles of all the rules in the filter-chain 160 | for rule in [r for r in json_result["nftables"] if "rule" in r]: 161 | self.old_rule_handles.append(rule["rule"]["handle"]) 162 | 163 | self.logger.debug("Old filtering rule handles: {}".format(self.old_rule_handles)) 164 | 165 | def delete_old_rules(self): 166 | """Delete the old rules in the filter-chain. This should be done after the 167 | new filtering rules are added so that the filtering chain doesn't remain 168 | without rules at any point""" 169 | if self.old_rule_handles: 170 | nft_delete_tmpl = "{} delete rule {} {} filter-chain handle {}" 171 | self.logger.info("Deleting old filtering rules from {}'s filter-chain".format(self.table_name)) 172 | 173 | for handle in self.old_rule_handles: 174 | nft_delete_command = nft_delete_tmpl.format(self.nft, self.table_family, self.table_name, handle) 175 | self.logger.debug("Running command: {}".format(nft_delete_command)) 176 | try: 177 | subprocess.run(nft_delete_command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 178 | except subprocess.CalledProcessError as err: 179 | self.logger.error("Failed to delete the old filtering rules") 180 | self.show_subprocess_run_error(err) 181 | raise 182 | 183 | def create_log_statement(self, action): 184 | """ Construct the logging as specified by command line args. 185 | for a rule with the specified action""" 186 | is_accept = action == "accept" 187 | if (self.log_accept and is_accept) or (self.log_drop and not is_accept): 188 | 189 | # Extract the log parameters for this type of action 190 | log_prefix = self.log_accept_prefix if is_accept else self.log_drop_prefix 191 | log_level = self.log_accept_level if is_accept else self.log_drop_level 192 | 193 | return "log {} {}".format( 194 | "prefix \"{}\"".format(log_prefix) if log_prefix else "", 195 | "level {}".format(log_level) if log_level else "" 196 | ) 197 | else: 198 | return "" 199 | 200 | def add_filtering_rule(self, addr_family): 201 | action = "accept" if self.allow else "drop" 202 | filter_set_name = "filter-v4" if addr_family == "ip" else "filter-v6" 203 | log_addr_family = "IPv4" if addr_family == "ip" else "IPv6" 204 | 205 | nft_command_tmpl = "{} add rule {} {} filter-chain {} saddr @{} {} {} {}" 206 | nft_command = nft_command_tmpl.format(self.nft, self.table_family, self.table_name, addr_family, 207 | filter_set_name, self.counter, self.create_log_statement(action), action).split() 208 | 209 | self.logger.info("Adding a new filtering rule for {} addresses in {}'s filter-chain".format(log_addr_family, self.table_name)) 210 | self.logger.debug("Running command: {}".format(nft_command)) 211 | try: 212 | subprocess.run(nft_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 213 | except subprocess.CalledProcessError as err: 214 | self.logger.error("Failed to add the new filtering rule for the {} addresses".format(log_addr_family)) 215 | self.show_subprocess_run_error(err) 216 | raise 217 | 218 | def add_exceptions(self): 219 | ip_list = args.exceptions.split(',') 220 | 221 | try: 222 | v4_list = [addr for addr in ip_list if ipaddress.ip_network(addr, strict=False).version == 4] 223 | v6_list = [addr for addr in ip_list if ipaddress.ip_network(addr, strict=False).version == 6] 224 | except ValueError as err: 225 | self.logger.error("ValueError raised: {}".format(err)) 226 | raise 227 | 228 | if v6_list: 229 | nft_allow_v6_exceptions = "{} insert rule {} {} filter-chain ip6 saddr {{ {} }} accept".format( 230 | self.nft, self.table_family, self.table_name, ",".join(v6_list)) 231 | self.logger.info("Adding IPv6 exceptions in {}'s filter-chain".format(self.table_name)) 232 | self.logger.debug("Running command: {}".format(nft_allow_v6_exceptions)) 233 | try: 234 | subprocess.run(nft_allow_v6_exceptions.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 235 | except subprocess.CalledProcessError as err: 236 | self.logger.error("Failed to add IPv6 exceptions in {}'s filter-chain".format(self.table_name)) 237 | self.show_subprocess_run_error(err) 238 | raise 239 | 240 | if v4_list: 241 | nft_allow_v4_exceptions = "{} insert rule {} {} filter-chain ip saddr {{ {} }} accept".format( 242 | self.nft, self.table_family, self.table_name, ",".join(v4_list)) 243 | self.logger.info("Adding IPv4 exceptions in {}'s filter-chain".format(self.table_name)) 244 | self.logger.debug("Running command: {}".format(nft_allow_v4_exceptions)) 245 | try: 246 | subprocess.run(nft_allow_v4_exceptions.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 247 | except subprocess.CalledProcessError as err: 248 | self.logger.error("Failed to add IPv4 exceptions in {}'s filter-chain".format(self.table_name)) 249 | self.show_subprocess_run_error(err) 250 | raise 251 | 252 | def allow_established(self): 253 | nft_command = "{} insert rule {} {} filter-chain ct state established,related accept".format(self.nft, 254 | self.table_family, self.table_name) 255 | 256 | self.logger.info("Adding a rule to allow packets from established connections in {}'s filter-chain".format( 257 | self.table_name)) 258 | self.logger.debug("Running command: {}".format(nft_command)) 259 | try: 260 | subprocess.run(nft_command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 261 | except subprocess.CalledProcessError as err: 262 | self.logger.error("Failed to add the rule to allow packets from established connections") 263 | self.show_subprocess_run_error(err) 264 | raise 265 | 266 | def add_allow_rules(self): 267 | """Certain rules need to be added to the filter-chain when using --allow, otherwise 268 | LAN traffic and protocols such as ARP won't work""" 269 | if self.table_family == "netdev": 270 | nft_allow_non_ip = "{} insert rule {} {} filter-chain meta protocol ne {{ ip, ip6 }} accept".format( 271 | self.nft, self.table_family, self.table_name) 272 | self.logger.info("Allow non-IP traffic in {}'s filter-chain".format(self.table_name)) 273 | self.logger.debug("Running command: {}".format(nft_allow_non_ip)) 274 | try: 275 | subprocess.run(nft_allow_non_ip.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 276 | except subprocess.CalledProcessError as err: 277 | self.logger.error("Failed to add the rule to allow non-IP traffic in {}'s filter-chain".format(self.table_name)) 278 | self.show_subprocess_run_error(err) 279 | raise 280 | 281 | if self.table_family in ("ip","inet") or (self.table_family == "netdev" and not self.no_ipv4): 282 | if self.table_family == "netdev": 283 | nft_allow_private_ip = "{} insert rule {} {} filter-chain ip saddr {{ 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 }}\ 284 | accept".format(self.nft, self.table_family, self.table_name) 285 | else: 286 | nft_allow_private_ip = "{} insert rule {} {} filter-chain ip saddr {{ 10.0.0.0/8, 127.0.0.0/8, 172.16.0.0/12,\ 287 | 192.168.0.0/16 }} accept".format(self.nft, self.table_family, self.table_name) 288 | self.logger.info("Allow private IPv4 address ranges in {}'s filter-chain".format(self.table_name)) 289 | self.logger.debug("Running command: {}".format(nft_allow_private_ip)) 290 | try: 291 | subprocess.run(nft_allow_private_ip.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 292 | except subprocess.CalledProcessError as err: 293 | self.logger.error("Failed to add the rule to allow private IPv4 address ranges in {}'s filter-chain".format(self.table_name)) 294 | self.show_subprocess_run_error(err) 295 | raise 296 | 297 | if self.table_family in ("ip6","inet") or (self.table_family == "netdev" and not self.no_ipv6): 298 | if self.table_family == "netdev": 299 | nft_allow_link_local_ip6 = "{} insert rule {} {} filter-chain ip6 saddr fe80::/10 accept".format( 300 | self.nft, self.table_family, self.table_name) 301 | else: 302 | nft_allow_link_local_ip6 = "{} insert rule {} {} filter-chain ip6 saddr {{ ::1, fe80::/10 }} accept".format( 303 | self.nft, self.table_family, self.table_name) 304 | self.logger.info("Allow link local IPv6 traffic in {}'s filter-chain".format(self.table_name)) 305 | self.logger.debug("Running command: {}".format(nft_allow_link_local_ip6)) 306 | try: 307 | subprocess.run(nft_allow_link_local_ip6.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 308 | except subprocess.CalledProcessError as err: 309 | self.logger.error("Failed to add the rule to allow link local IPv6 traffic in {}'s filter-chain".format(self.table_name)) 310 | self.show_subprocess_run_error(err) 311 | raise 312 | 313 | def add_policy_logging_rule(self): 314 | """Append a final rule with same action of the policy if 315 | counter or logging with match to policy if required""" 316 | log_statement = self.create_log_statement(self.policy) 317 | if log_statement != "" or self.counter == "counter": 318 | nft_unmatched_logging = "{} add rule {} {} filter-chain {} {} {}".format( 319 | self.nft, self.table_family, self.table_name, self.counter, log_statement, self.policy) 320 | self.logger.info("Appending {} to {}'s filter-chain to attach logging/counter".format(self.policy, self.table_name)) 321 | self.logger.debug("Running command: {}".format(nft_unmatched_logging)) 322 | try: 323 | subprocess.run(nft_unmatched_logging.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 324 | except subprocess.CalledProcessError as err: 325 | self.logger.error("Failed to append a {} rule to attach logging/counter in {}'s filter-chain".format(self.policy, self.table_name)) 326 | self.show_subprocess_run_error(err) 327 | raise 328 | 329 | def does_set_exist(self, filter_set_name): 330 | nft_list_command = "{} -j list sets {}".format(self.nft, self.table_family) 331 | 332 | self.logger.info("Checking if the {} set exists in the {} table".format(filter_set_name, self.table_name)) 333 | self.logger.debug("Running command: {}".format(nft_list_command)) 334 | try: 335 | result = subprocess.run(nft_list_command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 336 | except subprocess.CalledProcessError as err: 337 | self.logger.error("Could not list the existing sets in the {} family".format(self.table_family)) 338 | self.show_subprocess_run_error(err) 339 | raise 340 | 341 | json_result = json.loads(result.stdout.decode("utf-8")) 342 | 343 | if json_result["nftables"] is not None: 344 | for nft_set in [s for s in json_result["nftables"] if "set" in s]: 345 | if (nft_set["set"]["name"] == filter_set_name and 346 | nft_set["set"]["family"] == self.table_family and 347 | nft_set["set"]["table"] == self.table_name): 348 | 349 | self.logger.info("Found set {} in {}!".format(filter_set_name, self.table_name)) 350 | return True 351 | 352 | self.logger.info("Could not find set {} in {}!".format(filter_set_name, self.table_name)) 353 | return False 354 | 355 | def flush_filter_set(self, filter_set_name): 356 | """Flush the contents of the specified set. But before that, we want to save the 357 | contents of the old set, so that we can restore it if an error occurs.""" 358 | if self.does_set_exist(filter_set_name): 359 | nft_list_command = "{} list set {} {} {}".format(self.nft, self.table_family, self.table_name, filter_set_name) 360 | try: 361 | list_result = subprocess.run(nft_list_command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 362 | except subprocess.CalledProcessError as err: 363 | self.logger.error("Could not list the {} set in the {} table".format(filter_set_name, self.table_name)) 364 | self.show_subprocess_run_error(err) 365 | raise 366 | 367 | with open("{}/old_sets".format(self.working_dir), mode="ab") as f: 368 | f.write(list_result.stdout) 369 | 370 | nft_flush_command = "{} flush set {} {} {}".format(self.nft, self.table_family, self.table_name, filter_set_name) 371 | 372 | self.logger.info('Flushing the {} set in the {} table'.format(filter_set_name, self.table_name)) 373 | self.logger.debug("Running command: {}".format(nft_flush_command)) 374 | try: 375 | subprocess.run(nft_flush_command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 376 | except subprocess.CalledProcessError as err: 377 | self.logger.error("Could not flush the {} set in the {} table".format(filter_set_name, self.table_name)) 378 | self.show_subprocess_run_error(err) 379 | raise 380 | 381 | def restore_old_sets(self): 382 | """Restore the old sets if we failed to update the existing sets of the filter table. If 383 | we were creating new sets and failed to do so, then set the filter table to dormant 384 | because we don't want to accidentally lock ourselves out of the server""" 385 | if not os.path.exists("{}/old_sets".format(self.working_dir)): 386 | self.logger.warning('No old sets detected. Setting the {} table as dormant!'.format(self.table_name)) 387 | self.reset_dormancy = False 388 | self.set_table_as_dormant(True) 389 | return 390 | 391 | nft_restore_command = "{} -f {}".format(self.nft, "{}/old_sets".format(self.working_dir)) 392 | 393 | self.logger.info('Restoring the old sets in the {} table'.format(self.table_name)) 394 | self.logger.debug("Running command: {}".format(nft_restore_command)) 395 | try: 396 | subprocess.run(nft_restore_command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 397 | except subprocess.CalledProcessError as err: 398 | self.logger.error('Could not restore the old sets in the {} table'.format(self.table_name)) 399 | self.show_subprocess_run_error(err) 400 | raise 401 | 402 | def get_ip_blocks(self, country_code, addr_family): 403 | # We need to set the table as dormant before we try to download the IP blocks, because 404 | # there is a possibility that the IP blocks provider's address might be blocked by the 405 | # filtering rule that was added when this script was previously executed. The dormant 406 | # flag is removed as soon as the download is finished. By doing this, we are disabling 407 | # the geo-filtering as little as possible. 408 | self.set_table_as_dormant(True) 409 | 410 | log_addr_family = "IPv4" if addr_family == 'ip' else "IPv6" 411 | self.logger.info('Downloading "{}" {} blocks from {}'.format(country_code, log_addr_family, self.provider)) 412 | 413 | try: 414 | if self.provider == 'ipdeny.com': 415 | if addr_family == 'ip': 416 | provider_url = 'https://www.ipdeny.com/ipblocks/data/aggregated/{}-aggregated.zone' 417 | else: 418 | provider_url = 'https://www.ipdeny.com/ipv6/ipaddresses/aggregated/{}-aggregated.zone' 419 | 420 | http_resp = urllib.request.urlopen(provider_url.format(country_code)) 421 | data = http_resp.read().decode('utf-8') 422 | 423 | self.logger.info('Building list of {} blocks for {}..'.format(log_addr_family, country_code)) 424 | ip_blocks = ',\n'.join(data.splitlines()) 425 | self.logger.debug("IP block list for {}: {}".format(country_code, ip_blocks)) 426 | elif self.provider == 'ipverse.net': 427 | if addr_family == 'ip': 428 | provider_url = 'https://raw.githubusercontent.com/ipverse/rir-ip/master/country/{}/ipv4-aggregated.txt' 429 | 430 | else: 431 | provider_url = 'https://raw.githubusercontent.com/ipverse/rir-ip/master/country/{}/ipv6-aggregated.txt' 432 | 433 | http_resp = urllib.request.urlopen(provider_url.format(country_code)) 434 | data = http_resp.read().decode('utf-8') 435 | 436 | self.logger.info('Building list of {} blocks for {}..'.format(log_addr_family, country_code)) 437 | 438 | # Delete the comments on top of the IPverse IP blocks 439 | data_list = data.splitlines() 440 | data_without_comments = [d for d in data_list if d[0] != '#'] 441 | 442 | ip_blocks = ',\n'.join(data_without_comments) 443 | self.logger.debug("IP block list for {}: {}".format(country_code, ip_blocks)) 444 | 445 | return ip_blocks 446 | except urllib.error.HTTPError as err: 447 | self.logger.error("Couldn't GET {}: {} {}".format(provider_url.format(country_code), err.code, err.reason)) 448 | self.restore_old_sets() 449 | raise 450 | except urllib.error.URLError as err: 451 | self.logger.error("Couldn't GET {}: {}".format(provider_url.format(country_code), err.reason)) 452 | self.restore_old_sets() 453 | raise 454 | finally: 455 | if self.reset_dormancy: 456 | self.set_table_as_dormant(False) 457 | 458 | def update_filter_set(self, addr_family): 459 | if addr_family == 'ip': 460 | filter_set_type = "ipv4_addr" 461 | filter_set_name = "filter-v4" 462 | log_addr_family = "IPv4" 463 | elif addr_family == 'ip6': 464 | filter_set_type = "ipv6_addr" 465 | filter_set_name = "filter-v6" 466 | log_addr_family = "IPv6" 467 | 468 | # Flush the existing filter set (if it exists) 469 | self.flush_filter_set(filter_set_name) 470 | 471 | for c in self.country_codes: 472 | ip_blocks = self.get_ip_blocks(c, addr_family) 473 | filter_set_ips = ''.join(('elements = {\n', ip_blocks, '\n}\n}\n}')) 474 | 475 | with tempfile.NamedTemporaryFile(mode='w', dir=self.working_dir, delete=False) as tmp: 476 | tmp.write(FILE_HEADER.format(self.table_family, self.table_name, filter_set_name, filter_set_type)) 477 | tmp.write(filter_set_ips) 478 | 479 | nft_command = "{} -f {}".format(self.nft, tmp.name) 480 | 481 | self.logger.info('Adding the "{}" {} blocks to the {} set in {}'.format(c, log_addr_family, filter_set_name, self.table_name)) 482 | self.logger.debug("Running command: {}".format(nft_command)) 483 | try: 484 | subprocess.run(nft_command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 485 | except subprocess.CalledProcessError as err: 486 | self.logger.error('Could not add the "{}" {} blocks to the {} set in {}'.format(c, log_addr_family, filter_set_name, self.table_name)) 487 | self.restore_old_sets() 488 | self.show_subprocess_run_error(err) 489 | raise 490 | 491 | # Add the new filtering rule 492 | self.add_filtering_rule(addr_family) 493 | 494 | if __name__ == '__main__': 495 | parser = argparse.ArgumentParser(description='Filter traffic in nftables using country IP blocks') 496 | 497 | # Version 498 | parser.add_argument("-v", "--verbose", help="show verbose output", action="count", default=0) 499 | parser.add_argument("--version", action="version", version="%(prog)s 3.0") 500 | 501 | nft_gfilter_group = parser.add_argument_group() 502 | nft_gfilter_group.add_argument("-l", "--nft-location", default="/usr/sbin/nft", metavar="LOCATION", 503 | help="Location of the nft binary. Default is /usr/sbin/nft") 504 | nft_gfilter_group.add_argument("-a", "--allow", action="store_true", 505 | help=textwrap.dedent("""By default, all the IPs in the filter sets will be denied and every other 506 | IP will be allowed to pass the filtering chain. Provide this argument to reverse this 507 | behaviour.""") 508 | ) 509 | nft_gfilter_group.add_argument("--allow-established", action="store_true", 510 | help=textwrap.dedent("""Allow packets from denied IPs, but only if they are a part of an established 511 | connection i.e the initial packet originated from your host. Initial packets from the denied IPs 512 | will still be dropped. This flag can be useful when using the allow mode, so that outgoing connections 513 | to addresses outside the filter set can still be made.""") 514 | ) 515 | nft_gfilter_group.add_argument("-c", "--counter", action="store_const", const="counter", default="", 516 | help="Add the counter statement to the filtering rules") 517 | nft_gfilter_group.add_argument("--provider", action="store", default="ipverse.net", choices=SUPPORTED_PROVIDERS, 518 | help="Specify the country IP blocks provider. Default is ipverse.net") 519 | 520 | # Table info 521 | table_group = parser.add_argument_group( 522 | title="Table", 523 | description=textwrap.dedent("""Provide the name and the family of the table in which the set of 524 | filtered addresses will be created. This script will create a new nftables table, so 525 | make sure the provided table name is unique and not being used by any other table in 526 | the ruleset. An 'inet' table called 'geo-filter' will be used by default""") 527 | ) 528 | table_group.add_argument("-f", "--table-family", choices=["ip","ip6","inet","netdev"], default="inet", 529 | help="Specify the table's family. Default is inet") 530 | table_group.add_argument("-n", "--table-name", default="geo-filter", metavar="NAME", 531 | help="Specify the table's name. Default is geo-filter") 532 | 533 | # Netdev info 534 | netdev_group = parser.add_argument_group( 535 | title="Netdev arguments", 536 | description=textwrap.dedent("""If you're using a netdev table, you need to provide the name of the 537 | interface which is connected to the internet because netdev tables work on a per-interface 538 | basis. You can also choose to only store v4 or only store v6 addresses inside the 539 | netdev table sets by providing the '--no-ipv6' or '--no-ipv4' arguments. Both v4 and v6 540 | addresses are stored by default""") 541 | ) 542 | netdev_group.add_argument("-i", "--interface", 543 | help="Specify the ingress interface for the netdev table") 544 | netdev_addr_family_group = netdev_group.add_mutually_exclusive_group() 545 | netdev_addr_family_group.add_argument("--no-ipv4", action="store_true", help="Don't create a set for v4 addresses in the netdev table") 546 | netdev_addr_family_group.add_argument("--no-ipv6", action="store_true", help="Don't create a set for v6 addresses in the netdev table") 547 | 548 | # Logging statement options 549 | log_group = parser.add_argument_group( 550 | title="Logging statement", 551 | description=textwrap.dedent("""You can optionally add the logging statement to the filtering rules added 552 | by this script. That way, you'll be able to see the IP addresses of the packets that are accepted 553 | or dropped by the filtering rules in the kernel log (which can be read via the systemd journal or 554 | syslog). You can also add an optional prefix to the log messages and change the log message 555 | severity level.""") 556 | ) 557 | log_group.add_argument("-p", "--log-accept", action="store_true", help="Add the log statement to the accept filtering rules") 558 | log_group.add_argument("--log-accept-prefix", metavar="PREFIX", help=textwrap.dedent("""Add a prefix to the accept log messages 559 | for easier identification. No prefix is used by default.""")) 560 | log_group.add_argument("--log-accept-level", choices=["emerg", "alert", "crit", "err", "warn", "notice", "info", "debug"], 561 | help="Set the acceptlog message severity level. Default is 'warn'.") 562 | log_group.add_argument("-o", "--log-drop", action="store_true", help="Add the log statement to the drop filtering rules") 563 | log_group.add_argument("--log-drop-prefix", metavar="PREFIX", help=textwrap.dedent("""Add a prefix to the drop log messages 564 | for easier identification. No prefix is used by default.""")) 565 | log_group.add_argument("--log-drop-level", choices=["emerg", "alert", "crit", "err", "warn", "notice", "info", "debug"], 566 | help="Set the drop log message severity level. Default is 'warn'.") 567 | 568 | exception_group = parser.add_argument_group( 569 | title="IP Exceptions", 570 | description=textwrap.dedent("""You can add exceptions for certain IPs by passing a comma separated list 571 | of IPs or subnets/prefixes to the '--exceptions' option. The IP addresses passed to this option will 572 | be explicitly allowed in the filtering chain created by this script. Both IPv4 and IPv6 addresses 573 | can be passed. Use this option to allow a few IP addresses that would otherwise be denied by your 574 | filtering sets.""") 575 | ) 576 | exception_group.add_argument("-e", "--exceptions", metavar="ADDRESSES") 577 | 578 | # Mandatory arguments 579 | parser.add_argument("country", nargs='+', 580 | help=textwrap.dedent("""2 letter ISO-3166-1 alpha-2 country codes to allow/block. Check 581 | your IP blocks provider to find the list of supported countries.""") 582 | ) 583 | 584 | args = parser.parse_args() 585 | 586 | if not os.geteuid() == 0: 587 | sys.exit('Need root privileges to run this script!') 588 | 589 | # Check the arguments 590 | if args.table_family == "netdev" and not args.interface: 591 | sys.exit("'netdev' family requires an 'interface'. Please provide an interface with --interface") 592 | if args.table_family == "netdev" and args.allow_established: 593 | sys.exit("Can't use '--allow-established' with the 'netdev' family. Please choose a different table family.") 594 | if (args.log_accept_prefix or args.log_accept_level) and not args.log_accept: 595 | sys.exit("Can't use '--log-accept-prefix', '--log-accept-level' without the '--log-accept' argument.") 596 | if (args.log_drop_prefix or args.log_drop_level) and not args.log_drop: 597 | sys.exit("Can't use '--log-drop-prefix', '--log-drop-level' without the '--log-drop' argument.") 598 | 599 | with GeoFilter(args) as gFilter: 600 | try: 601 | # Ensure that the target nft table and chain exists 602 | gFilter.add_table() 603 | gFilter.add_chain() 604 | 605 | # Store the handles of the old filtering rules 606 | gFilter.find_old_rules() 607 | 608 | # Start updating the filter sets! 609 | if args.table_family in ("ip","inet") or (args.table_family == "netdev" and not args.no_ipv4): 610 | gFilter.update_filter_set('ip') 611 | if args.table_family in ("ip6","inet") or (args.table_family == "netdev" and not args.no_ipv6): 612 | gFilter.update_filter_set('ip6') 613 | 614 | # If we're using --allow, need to add some extra rules 615 | if args.allow: 616 | gFilter.add_allow_rules() 617 | 618 | # If exceptions have been provided, add rules for them 619 | if args.exceptions: 620 | gFilter.add_exceptions() 621 | 622 | # If we're allowing established connections from denied IPs, add a rule for that 623 | if args.allow_established: 624 | gFilter.allow_established() 625 | 626 | # Add a final rule matching the policy if logging/counters are required 627 | gFilter.add_policy_logging_rule() 628 | 629 | # Delete the old rules 630 | gFilter.delete_old_rules() 631 | except (ValueError, subprocess.CalledProcessError, urllib.error.HTTPError, urllib.error.URLError): 632 | sys.exit(1) 633 | --------------------------------------------------------------------------------