Compare commits

...

29 Commits

Author SHA1 Message Date
c7649f966c docs(xml): Typo 2022-07-16 02:24:46 +02:00
c63351da59 docs(xml): Make hitcount example ip(6)tables rules more less cluttered 2022-07-16 02:23:54 +02:00
62f44939d8 docs(xml): Give detailed example on how a hitcount rule manifests in ip(6)tables 2022-07-16 02:21:14 +02:00
262e11ba7c feat(xml): Generate rate throttling rules via 'recent' extension and its hitcount per time 2022-07-16 02:12:40 +02:00
00c43503a9 docs(config): Update example config.ini with hitcount and ICMP settings 2022-07-16 02:11:21 +02:00
4479dd486d docs(config): Explain hitcount setting 2022-07-16 02:10:23 +02:00
6b5d54ecdf docs(config): Document hitcount and ICMP use with example config 2022-07-16 02:09:56 +02:00
f4e31ceebe docs(xml): Document auto-selection of 'icmp' and 'icmpv6' per address family 2022-07-15 01:21:35 +02:00
db5b91b469 feat(xml): For clarity set 'icmp' and 'icmpv6' per address family 2022-07-15 01:15:48 +02:00
c3179fb681 refactor(debug): Cosmetics 2022-07-05 19:55:08 +02:00
357db8f1e0 refactor(debug): Remove unused exit code 11 2022-07-05 18:02:11 +02:00
4986b970d8 refactor(debug): Exit code 12 is now 6 which had become unused 2022-07-05 18:01:18 +02:00
7f7cd29cb7 refactor(debug): Exit code 13 is now 4 which had become unused 2022-07-05 18:00:25 +02:00
569e97d6d6 refactor(debug): Exit code 14 is now 3 which had become unused 2022-07-05 17:59:31 +02:00
5064a66c3e docs(debug): Explain UFS_LOGLEVEL env var 2022-07-05 17:56:07 +02:00
dd9abb5672 refactor(meta): Replace single quotes with double quotes 2022-07-05 17:49:12 +02:00
f35baa2c63 feat(debug): Inform user when firewalld XML config is unchanged 2022-07-05 17:38:04 +02:00
6273b6c99e Merge remote-tracking branch 'origin/master' 2022-07-05 17:29:19 +02:00
f0516806da Merge branch 'cleanup' 2022-07-05 17:28:00 +02:00
c4781aa615 refactor(xml): Shorten section name var 2022-07-05 17:26:24 +02:00
1bbf75d3dd refactor(debug): Dedicated function to generate stringified XML repr 2022-07-05 17:26:10 +02:00
7b6103be72 refactor(debug): Clearly identify exit code 5 reason 2022-07-05 17:26:05 +02:00
af2ac3e38d feat(debug): Store OSError human debug output 2022-07-05 17:22:19 +02:00
6f5687a98b feat(debug): Add function to uniformly render debug OSError output 2022-07-05 17:22:03 +02:00
f9d781c8f7 Merge pull request 'feat(xml): Diff XML with active config, only store if changed' (#2) from 1-only-write-xml-when-changed into master
Reviewed-on: #2
2022-07-05 15:03:17 +00:00
3beb55caae feat(xml): Diff XML with active config, only store if changed
Fixes #1
2022-07-05 16:59:49 +02:00
0406077aa5 docs(config): Rename headline 2022-07-05 06:35:41 +02:00
e24ec0a602 docs(config): Typos, formatting 2022-07-05 06:32:53 +02:00
69992c23da docs(config): Typo 2022-07-05 06:26:52 +02:00
3 changed files with 195 additions and 54 deletions

View File

@@ -1,8 +1,8 @@
# update-firewall-source
Update a firewall rule that relies on dynamic DNS names
A `firewalld` direct rules generator
# What
## What
Script `update-firewall-source.py`, UFS for short, assists `firewalld` in writing `iptables`-compatible rules, the so-called _direct_ rules.
@@ -15,9 +15,9 @@ UFS focuses on environments where the following is true:
## 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.
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 - more specifically its way to handle rules - does not care for how to limit access to host ports.
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"](https://unrouted.io/2017/08/15/docker-firewall/).
@@ -76,6 +76,14 @@ 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](examples) 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](examples/config.ini.example) to `config.ini` to get started:
@@ -102,10 +110,18 @@ 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
@@ -120,8 +136,9 @@ addr = 2606:4700:20::681a:804, lowendtalk.com
ports = 80, 443
do_ipv6 = true
[allow-anyone-to-access-mail-services]
[anyone-may-access-mail-services]
ports = 143, 993, 110, 995, 25, 465, 587
hitcount = 120/60
[deny-all]
target = DROP
@@ -248,6 +265,7 @@ A custom `[section]` has the following options. We're calling them locals most o
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](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.
@@ -256,6 +274,25 @@ A custom `[section]` has the following options. We're calling them locals most o
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](https://ipset.netfilter.org/iptables-extensions.man.html#lbBW). 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.

View File

@@ -4,10 +4,18 @@ 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
@@ -22,8 +30,9 @@ addr = 2606:4700:20::681a:804, lowendtalk.com
ports = 80, 443
do_ipv6 = true
[allow-anyone-to-access-mail-services]
[anyone-may-access-mail-services]
ports = 143, 993, 110, 995, 25, 465, 587
hitcount = 120/60
[deny-all]
target = DROP

View File

@@ -1,6 +1,6 @@
# Exit with various exit codes
import sys
# Path manipulation
# Path and env manipulation
import os
# Manipulate style and content of logs
import logging
@@ -22,23 +22,21 @@ import inflect
import dbus
# Find physical network interface via 'find' command
import subprocess
# Diff new and existing firewalld direct rules XML structure
import difflib
# Exit codes
# 1 : Config file invalid, it has no sections
# 2 : Config file invalid, sections must define at least CONST.CFG_MANDATORY
# 3 : Performing a firewalld rules check failed
# 4 : Performing a firewalld rules encountered a FileNotFoundError
# 5 : Unable to open firewalld direct rules file
# 6 : Source and destination are identical when attempting to back up firewalld direct rules file
# 3 : No physical network device found at "/sys/class/net"
# 4 : Linux find command exited non-zero trying to find a physical network device at "/sys/class/net"
# 5 : Unable to open firewalld direct rules file for reading
# 6 : Kernel sysfs export for network devices at "/sys/class/net" doesn't exist
# 7 : An option that must have a non-null value is either unset or null
# 8 : Exception while adding a chain XML element to firewalld direct rules
# 9 : Unable to open firewalld direct rules file for updating
# 10: Unable to restart systemd firewalld.service unit
# 11: Unable to add a <rule/> tag to firewalld
# 12: Kernel sysfs export for network devices at "/sys/class/net" doesn't exist
# 13: Linux find command exited non-zero trying to find a physical network device at "/sys/class/net"
# 14: No physical network device found at "/sys/class/net"
class CONST(object):
@@ -60,6 +58,7 @@ class CONST(object):
{"key": "ports", "value": "80, 443", "is_global": False, "empty_ok": True},
{"key": "proto", "value": "tcp", "is_global": False, "empty_ok": True},
{"key": "state", "value": "NEW", "is_global": False, "empty_ok": True},
{"key": "hitcount", "value": "", "is_global": False, "empty_ok": True},
{"key": "do_ipv6", "value": "false", "is_global": False, "empty_ok": False},
{"key": "firewalld_direct_abs", "value": "/etc/firewalld/direct.xml", "is_global": True, "empty_ok": False},
{"key": "restart_firewalld_after_change", "value": "true", "is_global": True, "empty_ok": False}
@@ -78,18 +77,17 @@ class CONST(object):
CFG_MANDATORY = [section_cfg["key"] for section_cfg in CFG_KNOWN_SECTION if section_cfg["is_mandatory"]]
is_systemd = any([systemd_env_var in os.environ for systemd_env_var in ["SYSTEMD_EXEC_PID", "INVOCATION_ID"]])
logging.basicConfig(
# Default for all modules is NOTSET so log everything
level="NOTSET",
format=CONST.LOG_FORMAT,
datefmt="[%X]",
handlers=[RichHandler(
show_time=False if any([systemd_env_var in os.environ for systemd_env_var in [
"SYSTEMD_EXEC_PID",
"INVOCATION_ID"]]) else True,
rich_tracebacks=True,
show_path=False,
show_level=False
show_time=False if is_systemd else True,
show_path=False if is_systemd else True,
show_level=False if is_systemd else True,
rich_tracebacks=True
)]
)
log = logging.getLogger("rich")
@@ -125,6 +123,10 @@ internal_empty_ok = [default["key"] for default in CONST.CFG_KNOWN_DEFAULTS if d
config = ConfigParser(defaults=internal_defaults,
converters={'list': lambda x: [i.strip() for i in x.split(',') if len(x) > 0]})
config.read(CONST.CFG_DEFAULT_ABS_PATH)
exit_code_desc = {
5: "Unable to open firewalld direct rules file for reading",
9: "Unable to open firewalld direct rules file for updating"
}
def print_section_header(
@@ -285,16 +287,58 @@ def add_rule_elem(
prio: int,
target: str,
/, *,
arg_section_name: str = None,
arg_section: str = None,
arg_proto: str = None,
arg_state: str = None,
arg_ports: list = None,
arg_hitcount: str = None,
arg_address: str = None,
arg_chain: str = "FILTERS",
arg_in_interface: str = None) -> bool:
global arg_fw_rule_data
if arg_proto == "icmpv6" and address_family == "ipv4":
arg_proto = "icmp"
if arg_proto == "icmp" and address_family == "ipv6":
arg_proto = "icmpv6"
if arg_hitcount:
try:
lxml.etree.SubElement(arg_fw_rule_data, "rule",
ipv=f"{address_family}",
table=f"filter",
chain=arg_chain,
priority=f"""{prio}""").text = \
f"""{"--in-interface " + arg_in_interface + " " if arg_in_interface else ""}""" \
f"""{"--protocol " + arg_proto + " " if arg_proto else ""}""" \
f"""{"--match multiport --destination-ports " + ",".join(arg_ports) + " " if arg_ports else ""}""" \
f"""{"--source " + arg_address + " " if arg_address else ""}""" \
f"""{"--match recent --name " + chr(34) + arg_section[:256] + chr(34) +
" --update --hitcount " + arg_hitcount.split("/")[0] + " --seconds " + arg_hitcount.split("/")[1] + " "
if arg_section else ""}""" \
f"""--jump DROP"""
prio += 1
lxml.etree.SubElement(arg_fw_rule_data, "rule",
ipv=f"{address_family}",
table=f"filter",
chain=arg_chain,
priority=f"""{prio}""").text = \
f"""{"--in-interface " + arg_in_interface + " " if arg_in_interface else ""}""" \
f"""{"--protocol " + arg_proto + " " if arg_proto else ""}""" \
f"""{"--match multiport --destination-ports " + ",".join(arg_ports) + " " if arg_ports else ""}""" \
f"""{"--source " + arg_address + " " if arg_address else ""}""" \
f"""{"--match recent --name " + chr(34) + arg_section[:256] + chr(34) +
" --set" if arg_section else ""}"""
prio += 1
except lxml.etree.LxmlError as le:
log.error(f"""Failed to add XML '<rule ipv=f"{address_family}" .../>'\n"""
f"Verbatim exception was:\n"
f"f{le}\n"
f"Exiting 8 ...")
sys.exit(8)
try:
lxml.etree.SubElement(arg_fw_rule_data, "rule",
ipv=f"{address_family}",
@@ -307,8 +351,7 @@ def add_rule_elem(
f"""{"--match multiport --destination-ports " + ",".join(arg_ports) + " " if arg_ports else ""}""" \
f"""{"--source " + arg_address + " " if arg_address else ""}""" \
f"""--jump {target}""" \
f"""
{" --match comment --comment " + chr(34) + arg_section_name[:256] + chr(34) if arg_section_name else ""}"""
f"""{" --match comment --comment " + chr(34) + arg_section[:256] + chr(34) if arg_section else ""}"""
except lxml.etree.LxmlError as le:
log.error(f"""Failed to add XML '<rule ipv=f"{address_family}" .../>'\n"""
f"Verbatim exception was:\n"
@@ -338,23 +381,23 @@ def get_phy_nics() -> list:
f"{cpe.cmd}\n"
f"Verbatim command output was:\n"
f"{cpe.output.rstrip()}\n"
f"Exiting 13 ...")
sys.exit(13)
f"Exiting 4 ...")
sys.exit(4)
else:
if not phy_nics_find.stdout:
log.error(f"No physical network device found at {linux_sysfs_nics_abs!r}.\n"
f"Command was:\n"
f"{phy_nics_find.args}\n"
f"Exiting 14 ...")
sys.exit(14)
f"Exiting 3 ...")
sys.exit(3)
for line in phy_nics_find.stdout.rstrip().split("\n"):
log.debug(f"Found physical network device {(phy_nic := os.path.basename(line))!r}")
phy_nics.append(phy_nic)
else:
log.error(f"Path {linux_sysfs_nics_abs!r} does not exist. This might not be a Linux-y operating system. "
f"Without that location we'll not be able to separate physical network interfaces from virtual ones. "
f"Exiting 12 ...")
sys.exit(12)
f"Exiting 6 ...")
sys.exit(6)
log.debug(f"List of identified physical network interfaces: {phy_nics}")
return phy_nics
@@ -365,7 +408,8 @@ def add_fw_rule_to_xml(
section_name: str,
target: str,
ports: list,
proto: str) -> bool:
proto: str,
hitcount: str) -> bool:
global arg_fw_rule_data
global arg_allow_sources
addr = arg_allow_sources
@@ -382,12 +426,16 @@ def add_fw_rule_to_xml(
address_family,
rules_already_added[address_family],
target,
arg_section_name=section_name,
arg_section=section_name,
arg_proto=proto,
arg_state=config_obj.get(section_name, "state"),
arg_ports=ports,
arg_hitcount=hitcount,
arg_address=address)
rules_already_added[address_family] += 1
if hitcount:
rules_already_added[address_family] += 3
else:
rules_already_added[address_family] += 1
if not len(addr["ipv4"]) and not len(addr["ipv6"]):
if address_family == "ipv4" or (address_family == "ipv6"
and
@@ -398,11 +446,15 @@ def add_fw_rule_to_xml(
address_family,
rules_already_added[address_family],
target,
arg_section_name=section_name,
arg_section=section_name,
arg_proto=proto,
arg_state=config_obj.get(section_name, "state"),
arg_ports=ports)
rules_already_added[address_family] += 1
arg_ports=ports,
arg_hitcount=hitcount)
if hitcount:
rules_already_added[address_family] += 3
else:
rules_already_added[address_family] += 1
return True
@@ -473,14 +525,34 @@ def gen_fwd_direct_scaffolding() -> lxml.builder.ElementMaker:
return fw_rule_data
def write_new_fwd_direct_xml(
config_obj: configparser.ConfigParser()) -> bool:
def ose_handler(
os_error: OSError,
human_text: str = None,
exit_code: int = None) -> None:
nl = "\n"
log.error(f"{human_text if human_text else exit_code_desc.get(exit_code)}"
f"{nl}Verbatim exception was:\n"
f"{os_error}"
f"""{nl + "Exiting " + str(exit_code) + " ..." if exit_code else ""}""")
def get_xml_str_repr() -> str:
global arg_fw_rule_data
fwd_direct_xml_str = lxml.etree.tostring(arg_fw_rule_data,
pretty_print=True,
encoding="UTF-8",
xml_declaration=True).decode()
return fwd_direct_xml_str
def write_new_fwd_direct_xml(
config_obj: configparser.ConfigParser()) -> bool:
global arg_fw_rule_data
fwd_direct_xml_str = get_xml_str_repr()
try:
with open(config_obj.get(configparser.DEFAULTSECT, "firewalld_direct_abs"), "r+") as fwd_file_handle:
log.info(f"Writing new firewalld direct config ...")
@@ -490,10 +562,7 @@ def write_new_fwd_direct_xml(
fwd_file_handle.write(fwd_direct_xml_str)
fwd_file_handle.truncate()
except OSError as ose:
log.error(f"Unable to open firewalld direct rules file for updating.\n"
f"Verbatim exception was:\n"
f"f{ose}\n"
f"Exiting 9 ...")
ose_handler(os_error=ose, exit_code=9)
sys.exit(9)
else:
return True
@@ -501,14 +570,14 @@ def write_new_fwd_direct_xml(
def restart_systemd_firewalld() -> bool:
sysbus = dbus.SystemBus()
systemd1 = sysbus.get_object('org.freedesktop.systemd1', '/org/freedesktop/systemd1')
manager = dbus.Interface(systemd1, 'org.freedesktop.systemd1.Manager')
systemd1 = sysbus.get_object("org.freedesktop.systemd1", "/org/freedesktop/systemd1")
manager = dbus.Interface(systemd1, "org.freedesktop.systemd1.Manager")
firewalld_unit = manager.LoadUnit('firewalld.service')
firewalld_proxy = sysbus.get_object('org.freedesktop.systemd1', str(firewalld_unit))
firewalld_active_state = firewalld_proxy.Get('org.freedesktop.systemd1.Unit',
'ActiveState',
dbus_interface='org.freedesktop.DBus.Properties')
firewalld_unit = manager.LoadUnit("firewalld.service")
firewalld_proxy = sysbus.get_object("org.freedesktop.systemd1", str(firewalld_unit))
firewalld_active_state = firewalld_proxy.Get("org.freedesktop.systemd1.Unit",
"ActiveState",
dbus_interface="org.freedesktop.DBus.Properties")
if firewalld_active_state == "inactive":
log.info(f"systemd firewalld.service unit is inactive, ignoring restart instruction, leaving as-is ...")
@@ -553,7 +622,31 @@ def add_firewall_shim(arg_phy_nics: list) -> None:
)
if __name__ == '__main__':
def has_xml_changed(
config_obj: configparser.ConfigParser()) -> bool:
arg_fwd_file_abs = os.path.abspath(config_obj.get(configparser.DEFAULTSECT, "firewalld_direct_file_abs"))
try:
with open(arg_fwd_file_abs, "r") as fwd_file_abs_handle:
fwd_file_abs_content = fwd_file_abs_handle.read()
fwd_direct_xml_str = get_xml_str_repr()
diff_result = difflib.Differ().compare(fwd_file_abs_content.splitlines(), fwd_direct_xml_str.splitlines())
s = difflib.SequenceMatcher(isjunk=None, a=fwd_file_abs_content, b=fwd_direct_xml_str, autojunk=False)
except OSError as ose:
ose_handler(os_error=ose, exit_code=5)
sys.exit(5)
else:
if s.ratio() < 1:
nl = "\n"
log.info(f"Changing firewalld rules. Diff as follows:\n"
f"""{nl.join(diff_result)}""")
return True
else:
log.info(f"No diff in firewalld XML config, no need to write new file.")
return False
if __name__ == "__main__":
validate_default_section(config)
if config_has_valid_section(config):
validate_config_sections(config)
@@ -579,7 +672,8 @@ if __name__ == '__main__':
section,
target=config.get(section, "target"),
ports=config.getlist(section, "ports"),
proto=config.get(section, "proto"))
proto=config.get(section, "proto"),
hitcount=config.get(section, "hitcount"))
for arg_address_family in ["ipv4", "ipv6"]:
if rules_count(arg_address_family):
add_rule_elem(
@@ -589,6 +683,7 @@ if __name__ == '__main__':
arg_state="ESTABLISHED,RELATED")
add_firewall_shim(get_phy_nics())
write_new_fwd_direct_xml(config)
if config.getboolean(configparser.DEFAULTSECT, "restart_firewalld_after_change"):
restart_systemd_firewalld()
if has_xml_changed(config):
write_new_fwd_direct_xml(config)
if config.getboolean(configparser.DEFAULTSECT, "restart_firewalld_after_change"):
restart_systemd_firewalld()