Compare commits

...

5 Commits

208
README.md
View File

@ -4,29 +4,78 @@ Update a firewall rule that relies on dynamic DNS names
# What
* This script assumes exclusive ownership of the `firewalld` direct rules file `/etc/firewalld/direct.xml` or whereever configured
Script `update-firewall-source.py`, UFS for short, assists `firewalld` in writing `iptables`-compatible rules, the so-called _direct_ rules.
* List of address can be empty, direct file will then be removed
* After every execution script will trigger systemd firewalld service restart
* No subnet, will simply not be validated
* Include example systemd unit file and install instructions
* firewall-cmd --check-config
* default location for config file and default name for config file
* we should deduplicate list
* is intended to help with just docker-user
* man page https://ipset.netfilter.org/iptables.man.html we do iptables
* added in order
* related, established? needed?
* section names as comments?
* Comment max 256 chars
UFS focuses on environments where the following is true:
1. You're on a Red Hat Enterprise Linux or a derivative operating system
2. You want to keep using its default firewall management tool `firewalld`
3. You want to use Docker
4. You want published Docker ports to not be accessible from everywhere
## Why
By installing a moderately modern version of Docker Engine it will very kindly take control of some aspects of firewall rules. If you don't do anything with what Docker gives you the end result is that all ports you publish via Docker (and by extension `docker compose`) are quite literally published to the entire Internet. All sources addresses can access published ports on your machine which may not necessarily be desired.
On the one hand Docker expects you to add custom rules for container access to an `iptables` chain called `DOCKER-USER`. On the other hand Docker - more specifically its way to handle rules - does not care for how to limit access to host ports.
UFS handles both container ports and host ports. It largely follows suggestions outlined by [John Michael Carr's August 2017 unrouted.io blog post "Docker meet firewall - finally an answer"](https://unrouted.io/2017/08/15/docker-firewall/).
## How
`update-firewall-source.py` uses a `config.ini` file that may in its simplest form look somewhat like this:
```
[My home]
addr = some.dyndns.host.net
ports = 22, 80, 443
[deny-all]
target = DROP
addr =
ports =
proto =
state =
```
Over in the ['examples' directory](examples) you will find systemd `.service` and `.timer` example files to regularly execute UFS.
Its systemd journal output will look somewhat like this:
```
systemd[1]: Starting firewalld direct rules generator...
python[961809]: Generating rules from section '[My home]' ...
python[961809]: Verifying address ['some.dyndns.host.net'] ...
python[961809]: For 'some.dyndns.host.net' found records: ['1.2.3.4', '2606:4700:20::681a:804']
python[961809]: Adding IPv4 address '1.2.3.4' ...
python[961809]: For section '[My home]' option 'do_ipv6' equals false. Skipping IPv6 handling of
python[961809]: 2606:4700:20::681a:804' ...
python[961809]: Writing new firewalld direct config ...
python[961809]: Restarting systemd firewalld.service unit ...
python[961809]: Done
systemd[1]: update-firewall-source.service: Succeeded.
systemd[1]: Started firewalld direct rules generator.
```
## Tying it together
A Docker Engine installation nowadays adds the `iptables` chain `DOCKER-USER` which is all well and good. Adding rules to it makes sure that Docker's published ports can only be accessed from where you want.
If you want to cover both Docker containers and the host OS, however, that doesn't fly. UFS adds a chain named `FILTERS`. This chain is called from both `DOCKER-USER` (anything accessing a Docker published port goes this route) **_and_** from the `INPUT` chain (anything headed for the host operating system goes that way).
You only maintain the `FILTERS` chain and don't have to worry about whether an application is unknowingly accessible via public Internet - no matter if that app is a container or a `dnf` package. Even better: UFS does management for you, you just give it a `config.ini` file.
Find more in-depth info on how `ip(6)tables` evolves with UFS down in the ["iptables behind the scenes" section](#iptables-behind-the-scenes).
# Prep
Python dependencies aside make sure that your OS has headers and static libraries for D-Bus GLib bindings installed as well as generic D-Bus development files. On a Rocky Linux 8 installation for example these come via:
Aside from Python dependencies make sure that your OS has headers and static libraries for D-Bus GLib bindings installed as well as generic D-Bus development files. On a Rocky Linux 8 installation for example these come via:
```
dnf -y install dbus-glib-devel dbus-devel
```
This script assumes write access to `firewalld` direct rules file `/etc/firewalld/direct.xml` or whereever else you've configured this file to live. Typically that means you're going to want to run UFS as `root`.
# Config structure
Package configuration happens via a `config.ini` file that follows INI-style syntax. Copy [examples/config.ini.example](examples/config.ini.example) to `config.ini` to get started:
@ -106,7 +155,7 @@ proto =
state =
do_ipv6 = true
```
If a packet has traversed rules this far without being accepted it will be dropped. Note that if any of your custom `[sections]` use `do_ipv6 = true` your final `DROP` rule should do the same. Otherwise you'll just get `DROP` rule in `iptables` but not in `ip6tables`.
If a packet has traversed rules this far without being accepted it will be dropped. Note that if any of your custom `[sections]` use `do_ipv6 = true` your final `DROP` rule should do the same. Otherwise you'll just get a `DROP` rule in `iptables` but not in `ip6tables`.
# Options
@ -122,12 +171,25 @@ In `[DEFAULT]` section the following settings are called globals. They're only v
A custom `[section]` has the following options. We're calling them locals most of which are optional.
* `target`, __*mandatory*__, defaults to `ACCEPT`, can be any valid `iptables` target. Must not be empty nor unset. A string specifying the fate of a packet that matched this rule. See "TARGETS" section in [iptables man page](https://ipset.netfilter.org/iptables.man.html). You're most likely going to want to stick to either `ACCEPT` or `DROP`. By default matching packets are accepted. We do not do our own validation of what you write here. By default (see [Globals](#globals)) `do_config_check` equals to true in which case we let `firewalld` do a config check to catch nonsense rules.
* `target`, __*mandatory*__, defaults to `ACCEPT`, can be any valid `iptables` target. Must not be empty nor unset. A string specifying the fate of a packet that matched this rule. See "TARGETS" section in [iptables man page](https://ipset.netfilter.org/iptables.man.html). You're most likely going to want to stick to either `ACCEPT` or `DROP`. By default matching packets are accepted. We do not do our own validation of what you write here. `firewalld` will try its best to get your files loaded into `ip(6)tables`. It will complain via its systemd journal if that fails for example because of a bogus target.
* `addr`, __*optional*__, defaults to an empty string: A comma-separated list of any combination of IPv4 addresses, IPv6 addresses and domain names. When `update-firewall-source.py` constructs `firewalld` rules these addresses are allowed to access the server. If left undefined `addr` defaults to an empty list meaning rules apply to any and all source address.
```
# Valid example:
target = DROP
```
* `addr`, __*optional*__, defaults to an empty string: A comma-separated list of any combination of IPv4 addresses, IPv6 addresses and domain names. When `update-firewall-source.py` constructs `firewalld` rules these addresses are allowed to access the server. If left undefined `addr` defaults to an empty list meaning rules apply to any and all source addresses.
Subnets are unsupported, both as subnet masks (`142.251.36.195/255.255.255.248`) and in [CIDR](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing) notation (`142.251.36.195/29`). Do not single- nor double-quote list entries. Do feel free to separate entries with comma-space instead of just a comma.
```
# Valid example:
addr = 2606:4700:20::681a:804, lowendtalk.com
# Also valid (this is the default):
addr =
```
* `ports`, __*optional*__, defaults to `80, 443`: A comma-separated list of ports that should be accessible from `addr`. If empty `addr` may access all ports. See [iptables-extensions man page, section "multiport"](https://ipset.netfilter.org/iptables-extensions.man.html#lbBM) for syntax reference. All port-based rules use `iptables ... --match multiport` even if you're only allowing access to a single port. In essence construct your ports list with any combination of single ports (`80, 443, 8080`) and port ranges (`6660:7000, 61000:65535`).
```
@ -189,10 +251,118 @@ A custom `[section]` has the following options. We're calling them locals most o
* `state`, __*optional*__, defaults to `NEW`: Comma-separated list of connection tracking states against which a packet is matched. Most of the time your rules will want to use the default `NEW`. The final `DROP` rule present in the example `config.ini` file at [examples/config.ini.example](examples/config.ini.example) is one occasion where you'll want to deviate and unset `state` to an empty value. See ["state" extension man page in iptables docs](https://ipset.netfilter.org/iptables-extensions.man.html#lbCC) for reference.
* `do_ipv6`, __*optional*__, defaults to `false`: Decide if you want `firewalld` to generate `ip6tables` rules in addition to `iptables` rules. A default install of Docker Engine will have its IPv6 support disabled in `/etc/docker/daemon.json` in which case `ip6tables` will not have a `DOCKER-USER` or similar Docker-related chains. In this default setup having `update-firewall-source.py` generate an otherwise unused `DOCKER-USER` chain and adding rules to it clutters your rule set. Consider setting this to `true` if and when your Docker install uses IPv6.
```
# Valid example:
state =
```
* `do_ipv6`, __*optional*__, defaults to `false`: Decide if you want `firewalld` to generate `ip6tables` rules in addition to `iptables` rules. A default install of Docker Engine will have its IPv6 support disabled in `/etc/docker/daemon.json`. You may still want your machine to handle incoming IPv6 traffic. If your machine truly doesn't use IPv6 feel free to leave this at `false`. Otherwise `update-firewall-source.py` generates unused rules that clutter your rule set.
If this is `true` IPv6 addresses found or resolved in `addr` in a `[section]` will be discarded.
```
# Valid example:
do_ipv6 = true
```
## iptables behind the scenes
In an `iptables` rule set in the `filter` table by default you'll see something like this. We're only focusing on `INPUT` and `FORWARD` chains here as these are the ones relevant to Docker.
```
Chain INPUT (policy ACCEPT 140 packets, 10115 bytes)
num pkts bytes target prot opt in out source destination
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
...
```
Once you install a moderately modern Docker Engine it changes things up to this:
```
Chain INPUT (policy ACCEPT 1027 packets, 78370 bytes)
num pkts bytes target prot opt in out source destination
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
1 0 0 DOCKER-USER all -- * * 0.0.0.0/0 0.0.0.0/0
2 0 0 DOCKER-ISOLATION-STAGE-1 all -- * * 0.0.0.0/0 0.0.0.0/0
3 0 0 ACCEPT all -- * docker0 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
4 0 0 DOCKER all -- * docker0 0.0.0.0/0 0.0.0.0/0
5 0 0 ACCEPT all -- docker0 !docker0 0.0.0.0/0 0.0.0.0/0
6 0 0 ACCEPT all -- docker0 docker0 0.0.0.0/0 0.0.0.0/0
...
Chain DOCKER-USER (1 references)
num pkts bytes target prot opt in out source destination
1 0 0 RETURN all -- * * 0.0.0.0/0 0.0.0.0/0
```
With UFS you're extending this into a configuration where UFS' `FILTERS` chain is called from both Docker's `DOCKER-USER` chain **_and_** the host OS `INPUT` chain. Ideally `FILTERS` ends with a `DROP` target to make sure nothing accesses your services that's not supposed to access them.
```
Chain INPUT (policy ACCEPT 56 packets, 4596 bytes)
num pkts bytes target prot opt in out source destination
1 2910 207K ACCEPT all -- lo * 0.0.0.0/0 0.0.0.0/0
2 1309 111K FILTERS all -- * * 0.0.0.0/0 0.0.0.0/0
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
num pkts bytes target prot opt in out source destination
1 0 0 DOCKER-ISOLATION-STAGE-1 all -- * * 0.0.0.0/0 0.0.0.0/0
2 0 0 DOCKER-USER all -- * * 0.0.0.0/0 0.0.0.0/0
3 0 0 ACCEPT all -- * docker0 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
4 0 0 DOCKER all -- * docker0 0.0.0.0/0 0.0.0.0/0
5 0 0 ACCEPT all -- docker0 !docker0 0.0.0.0/0 0.0.0.0/0
6 0 0 ACCEPT all -- docker0 docker0 0.0.0.0/0 0.0.0.0/0
...
Chain FILTERS (2 references)
num pkts bytes target prot opt in out source destination
1 1267 107K ACCEPT all -- * * 0.0.0.0/0 0.0.0.0/0 state RELATED,ESTABLISHED
2 0 0 ACCEPT tcp -- * * 1.2.3.4 0.0.0.0/0 state NEW multiport dports 22,80,443 /* My home */
3 1 162 DROP all -- * * 0.0.0.0/0 0.0.0.0/0 /* deny-all */
Chain DOCKER-USER (1 references)
num pkts bytes target prot opt in out source destination
1 0 0 FILTERS all -- ens3 * 0.0.0.0/0 0.0.0.0/0
2 0 0 RETURN all -- * * 0.0.0.0/0 0.0.0.0/0
...
```
## Rule comments
The `[section]` name is used as `ip(6)tables` rule comment. The `[section]` name is truncated to the first 256 characters to fit into an `ip(6)tables` comment if needed.
Example:
```
[My home]
addr = some.dyndns.host.net
ports = 22, 80, 443
[deny-all]
target = DROP
addr =
ports =
proto =
state =
```
Results in `ip(6)tables` rules:
```
Chain FILTERS (2 references)
num pkts bytes target prot opt in out source destination
1 1267 107K ACCEPT all -- * * 0.0.0.0/0 0.0.0.0/0 state RELATED,ESTABLISHED
2 0 0 ACCEPT tcp -- * * 1.2.3.4 0.0.0.0/0 state NEW multiport dports 22,80,443 /* My home */
3 0 0 DROP all -- * * 0.0.0.0/0 0.0.0.0/0 /* deny-all */
```
# Development
## Conventional Commits