Compare commits

..

24 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
3 changed files with 162 additions and 47 deletions

View File

@@ -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
@@ -29,18 +29,14 @@ 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):
@@ -62,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}
@@ -80,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")
@@ -127,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(
@@ -287,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}",
@@ -309,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"
@@ -340,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
@@ -367,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
@@ -384,11 +426,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,
arg_hitcount=hitcount,
arg_address=address)
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"
@@ -400,10 +446,14 @@ 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_ports=ports,
arg_hitcount=hitcount)
if hitcount:
rules_already_added[address_family] += 3
else:
rules_already_added[address_family] += 1
return True
@@ -475,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 ...")
@@ -492,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
@@ -503,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 ...")
@@ -575,10 +642,11 @@ def has_xml_changed(
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__':
if __name__ == "__main__":
validate_default_section(config)
if config_has_valid_section(config):
validate_config_sections(config)
@@ -604,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(