update-firewall-source

A firewalld direct rules generator

What

Script update-firewall-source.py, UFS for short, assists firewalld in writing iptables-compatible rules, the so-called direct rules.

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 source 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 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".

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 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.

Prep

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.

UFS understands the environment variable UFS_LOGLEVEL to set its log verbosity. UFS_LOGLEVEL defaults to INFO, for more verbosity change it to DEBUG, for less change it to either WARNING or even just ERROR. The example systemd .service file in 'examples' directory makes use of the following decleration:

Environment='...' 'UFS_LOGLEVEL=INFO'

Since UFS_LOGLEVEL=INFO is default anyway this particular example is redundant and serves as starting point for you.

Config structure

Package configuration happens via a config.ini file that follows INI-style syntax. Copy examples/config.ini.example to config.ini to get started:

[DEFAULT]
target = ACCEPT
addr =
ports = 80, 443
proto = tcp
state = NEW
hitcount =
do_ipv6 = false
firewalld_direct_file_abs = /etc/firewalld/direct.xml
restart_firewalld_after_change = true

[anyone-may-icmp-with-limit]
addr =
ports =
proto = icmp
state = NEW,UNTRACKED
hitcount = 120/60

[anyone-can-access-website]

# Unsetting 'proto' while having a 'ports' value results in an invalid section
# [these-guys-can-dns]
# addr = google.li, 142.251.36.195, lowendbox.com, 2606:4700:20::ac43:4775
# ports = 53
# proto =
# do_ipv6 = true

[maybe-a-webserver]
addr = 2606:4700:20::681a:804, lowendtalk.com
ports = 80, 443
do_ipv6 = true

[anyone-may-access-mail-services]
ports = 143, 993, 110, 995, 25, 465, 587
hitcount = 120/60

[deny-all]
target = DROP
addr =
ports =
proto =
state =
do_ipv6 = true

Layout

A config file can have an optional [DEFAULT] section and must have at least one [section] other than [DEFAULT]. Any [DEFAULT] option that's undefined retains its default value. Feel free to delete the entire [DEFAULT] section from your file. A setting changed in [DEFAULT] section affects all sections. A setting changed only in a custom [section] overwrites it for only the section.

Custom sections such as [maybe-a-webserver] in above example file are treated as organizational helper constructs. You can but don't have to group IP address rules by sections. Technically nothing's stopping you from adding all IP allow list entries into a single section.

Example explanation

Setting restart_firewalld_after_change controls if you want the firewalld systemd unit to be restarted

In above example file note that [anyone-can-access-website] makes [maybe-a-webserver] irrelevant, the latter one could easily be deleted. [anyone-can-access-website] does not overwrite defaults, it's an empty section. With it firewalld will create a rule that - following all default settings - allows access from any source address on TCP ports 80 and 443. In section [maybe-a-webserver] we do the same and additionally limit source addresses. Rules are added in config.ini order so the first rule permitting access to TCP ports 80 and 443 from anywhere makes the second one irrelevant.

We strongly recommend you do keep the very last example section:

[deny-all]
target = DROP
addr =
ports =
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 a DROP rule in iptables but not in ip6tables.

Options

Globals

In [DEFAULT] section the following settings are called globals. They're only valid in [DEFAULT] context. Adding them to a custom [section] (see Locals below) won't do anything, in a custom [section] the following settings are ignored.

  • firewalld_direct_file_abs, optional, defaults to /etc/firewalld/direct.xml: Location of firewalld's direct rules file. This is where new XML rule content is written.

  • restart_firewalld_after_change, optional, defaults to true: After putting a new /etc/firewalld/direct.xml file in place restart the firewalld systemd service unit.

Locals

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. 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.

    # 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 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" 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).

    # Valid example:
    ports = 80, 443, 6660:7000, 8080
    
  • proto, optional, defaults to tcp: A singular protocol that should be allowed for addr on ports. Can be set to an empty value in which case all protocols are allowed. Since firewalld direct rules use iptables syntax the list of possible protocol names is largely identical to what the iptables man page says about its --protocol argument:

    The specified protocol can be one of tcp, udp, udplite, icmp, icmpv6, esp, ah, sctp, mh or the special keyword all, or it can be a numeric value, representing one of these protocols or a different one. A protocol name from /etc/protocols is also allowed. A ! argument before the protocol inverts the test. The number zero is equivalent to all.

    Your mileage may vary depending on which specific OS flavor and version you're running.

    Implementation details:

    • proto is treated as a string, not a list. To for example allow access via both TCP and UDP create two [sections] like so:

      [tcp-rule]
      addr = 1.1.1.1
      ports =
      proto = tcp 
      
      [udp-rule]
      addr = 1.1.1.1
      ports =
      proto = udp
      

      Since proto = tcp is default you can leave it out of the top section. Side note, in this specific example you would want to set the [DEFAULT] value ports = instead of repeating it in each [section]. Alternatively set proto = to allow all protocols in which case a single [section] is enough to cover that use case:

      [permit-port-53-via-all-protocols]
      addr = 1.1.1.1
      ports = 
      proto =
      

      Make sure that when proto is unset you also unset ports. See next bullet point for details on that.

    • Unsetting proto while at the same time leaving at least one ports value in place (which is the default with ports = 80, 443) is an error.

      It will result in a rule that firewalld cannot load into ip(6)tables. It will report it as such in its systemd journal visible e.g. via journalctl -fu firewalld.service. This is because having at least one port configured will always result in adding a --match multiport which is only valid when also giving a --protocol such as --protocol tcp.

      [DEFAULT]
      ports = 80, 443
      proto = tcp
      
      [valid]
      addr = example.net
      ports = 22, 80, 443
      
      [invalid]
      addr = example.com
      proto =
      
      # Without 'ports' there will be no '--match multiport' 
      # and without /that/ you can safely unset 'proto':
      [also-valid]
      addr = example.org
      ports = 
      proto =
      
    • Protocol strings icmpv6 and icmp are treated specially. You can use either one as your proto =, UFS will internally automatically use icmpv6 for ip6tables and will use icmp for iptables rules.

  • 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 is one occasion where you'll want to deviate and unset state to an empty value. See "state" extension man page in iptables docs for reference.

    # Valid example:
    state =
    
  • hitcount, optional, defaults to an empty value: A rate-limiting feature. Set this to hits/seconds to limit the amount of matched packets to hits over the course of seconds, e.g. 10/60 sets the maximum packet rate to 10 packets over the course of 60 seconds. Any packet exceeding the rate will be dropped.

    Adding a hitcount will automatically add 2 ip(6)tables rules right before the actual rules. Rules follow the iptables "recent" extension. The first rule does --update, the second one does --set followed by the rule you specified.

    Given config section:

    [anyone-may-access-mail-services]
    ports = 143, 993, 110, 995, 25, 465, 587
    hitcount = 120/60
    

    UFS generates rules:

    target
    DROP   ... multiport dports 143,993,110,995,25,465,587 recent: UPDATE seconds: 60 hit_count: 120 ...
           ... multiport dports 143,993,110,995,25,465,587 recent: SET ...
    ACCEPT ... state NEW multiport dports 143,993,110,995,25,465,587 ...
    

    Where the first DROP target will drop packets that have exceeded their hit count, the second recent: SET simply marks all matching packets to be added into the hitcount bucket and the third one is the actual ACCEPT rule permitting access if a source's hitcount permits it.

  • 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

We use Conventional Commits.

Scopes

The following scopes are known for this project. A Conventional Commits commit message may optionally use one of the following scopes or none:

  • config: Structure or content of a config.ini file
  • dbus: Deals with functionality to restart the firewalld.service unit
  • systemd: Deals with lifecycle as a systemd unit
  • meta: Affects the project's repo layout, readme content, file names etc.
  • dns: Resolution of DNS records
  • xml: XML content handling for firewalld direct rules, includes segues into ip(6)tables territory
  • netdev: Network devices
  • debug: Deals with debuggability, concise messages to end user

Types

The following types are known for this project in addition to Conventional Commits default types fix and feat. A Conventional Commits commit message must use either one of the two default types or optionally a type from this list:

  • build: Project structure, directory layout, build instructions for roll-out
  • refactor: Keeping functionality while streamlining or otherwise improving function flow
  • test: Working on test coverage
  • docs: Documentation for project or components
Description
A firewalld direct rules generator
Readme 1.1 MiB
Languages
Python 100%