From b9a4cc9aa6bf1651539590a3edddb32325339cac Mon Sep 17 00:00:00 2001 From: Eduardo Barretto Date: Thu, 18 Jun 2026 15:32:35 +0200 Subject: [PATCH 1/8] refactor: decouple iptables attributes from UFWBackend base class Move iptables-specific attributes (iptables, ip6tables, iptables_restore, ip6tables_restore, iptables_version, caps, initcaps) out of UFWBackend and into UFWBackendIptables where they belong. This allows the base class to be subclassed by non-iptables backends without carrying iptables baggage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/backend.py | 18 ------------------ src/backend_iptables.py | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/backend.py b/src/backend.py index 96497bbd..40bd3b46 100644 --- a/src/backend.py +++ b/src/backend.py @@ -74,24 +74,6 @@ def __init__( self.profiles = ufw.applications.get_profiles(self.files["apps"]) - self.iptables = os.path.join(ufw.common.iptables_dir, "iptables") - self.iptables_restore = os.path.join( - ufw.common.iptables_dir, "iptables-restore" - ) - self.ip6tables = os.path.join(ufw.common.iptables_dir, "ip6tables") - self.ip6tables_restore = os.path.join( - ufw.common.iptables_dir, "ip6tables-restore" - ) - - try: - self.iptables_version = ufw.util.get_iptables_version(self.iptables) - except OSError: # pragma: no coverage - err_msg = tr("Couldn't determine iptables version") - raise UFWError(err_msg) - - # Initialize via initcaps only when we need it (LP: #1044361) - self.caps = None - def initcaps(self) -> None: """Initialize the capabilities database. This needs to be called before accessing the database.""" diff --git a/src/backend_iptables.py b/src/backend_iptables.py index f5f4807f..a81983b3 100644 --- a/src/backend_iptables.py +++ b/src/backend_iptables.py @@ -66,6 +66,24 @@ def __init__( self, "iptables", dryrun, files, rootdir=rootdir, datadir=datadir ) + self.iptables = os.path.join(ufw.common.iptables_dir, "iptables") + self.iptables_restore = os.path.join( + ufw.common.iptables_dir, "iptables-restore" + ) + self.ip6tables = os.path.join(ufw.common.iptables_dir, "ip6tables") + self.ip6tables_restore = os.path.join( + ufw.common.iptables_dir, "ip6tables-restore" + ) + + try: + self.iptables_version = ufw.util.get_iptables_version(self.iptables) + except OSError: # pragma: no coverage + err_msg = _("Couldn't determine iptables version") + raise UFWError(err_msg) + + # Initialize via initcaps only when we need it (LP: #1044361) + self.caps = None + self.chains = {"before": [], "user": [], "after": [], "misc": []} for ver in ["4", "6"]: chain_prefix = "ufw" From d894d3a12ecdad5354dc7d5324883fb0e85e3791 Mon Sep 17 00:00:00 2001 From: Eduardo Barretto Date: Thu, 18 Jun 2026 15:33:14 +0200 Subject: [PATCH 2/8] feat: add nftables_dir constant to common.py Add #NFTABLES_DIR# placeholder alongside the existing #IPTABLES_DIR#, to be substituted with the actual nft binary directory at install time. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/common.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/common.py b/src/common.py index 1938cac0..9e74c514 100644 --- a/src/common.py +++ b/src/common.py @@ -34,6 +34,7 @@ config_dir = "#CONFIG_PREFIX#" prefix_dir = "#PREFIX#" iptables_dir = "#IPTABLES_DIR#" +nftables_dir = "#NFTABLES_DIR#" do_checks = True From eb29f82203df1bd617a1bf3d1b0542504f7c85bb Mon Sep 17 00:00:00 2001 From: Eduardo Barretto Date: Thu, 18 Jun 2026 15:48:12 +0200 Subject: [PATCH 3/8] feat: add nftables config template files Add nftables equivalents of before.rules, after.rules and user.rules. Uses the inet family to handle IPv4 and IPv6 in a single table named 'ufw', replacing the separate v4/v6 iptables rule file pairs. before.nft covers: loopback, conntrack, ICMPv4/v6, DHCPv4/v6, NDP, MLD, mDNS, UPnP, and non-local packet filtering. after.nft covers: suppression of noisy NetBIOS/DHCP/broadcast traffic from default-policy logging. user.nft: empty template populated by ufw at runtime. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- conf/after.nft | 50 +++++++++++++++++++++++ conf/before.nft | 103 ++++++++++++++++++++++++++++++++++++++++++++++++ conf/user.nft | 9 +++++ 3 files changed, 162 insertions(+) create mode 100644 conf/after.nft create mode 100644 conf/before.nft create mode 100644 conf/user.nft diff --git a/conf/after.nft b/conf/after.nft new file mode 100644 index 00000000..ac0af7cd --- /dev/null +++ b/conf/after.nft @@ -0,0 +1,50 @@ +# +# after.nft +# +# Rules that should be run after the ufw command line added rules. Custom +# rules should be added to one of these chains: +# ufw-after-input +# ufw-after-output +# ufw-after-forward +# +# This file uses nftables syntax. The inet family handles both IPv4 and IPv6. +# + +# Don't delete these required lines, otherwise there will be errors +table inet ufw { + chain ufw-after-input { + } + chain ufw-after-output { + } + chain ufw-after-forward { + } +} +# End required lines + +table inet ufw { + chain ufw-after-input { + # don't log noisy services by default + # (return early so the default policy logging doesn't fire for these) + udp dport 137 return + udp dport 138 return + tcp dport 139 return + tcp dport 445 return + + # DHCPv4 client/server noise + udp dport 67 return + udp dport 68 return + + # DHCPv6 client/server noise + udp dport 546 return + udp dport 547 return + + # don't log noisy broadcast / multicast + fib daddr type { broadcast, multicast } return + } + + chain ufw-after-output { + } + + chain ufw-after-forward { + } +} diff --git a/conf/before.nft b/conf/before.nft new file mode 100644 index 00000000..33faf1d7 --- /dev/null +++ b/conf/before.nft @@ -0,0 +1,103 @@ +# +# before.nft +# +# Rules that should be run before the ufw command line added rules. Custom +# rules should be added to one of these chains: +# ufw-before-input +# ufw-before-output +# ufw-before-forward +# +# This file uses nftables syntax. The inet family handles both IPv4 and IPv6. +# + +# Don't delete these required lines, otherwise there will be errors +table inet ufw { + chain ufw-before-input { + } + chain ufw-before-output { + } + chain ufw-before-forward { + } +} +# End required lines + +table inet ufw { + chain ufw-before-input { + # allow all on loopback + iifname "lo" accept + + # quickly process packets for which we already have a connection + ct state related,established accept + + # drop INVALID packets + # (logging of these is managed by ufw's log level, not here) + ct state invalid drop + + # ok icmp codes for INPUT (IPv4) + ip protocol icmp icmp type { destination-unreachable, time-exceeded, parameter-problem, echo-request } accept + + # ok icmpv6 codes for INPUT (RFC 4890, 4.4.1 and 4.4.2) + # multicast ping replies have no associated connection so allow before + # the INVALID check above + ip6 nexthdr icmpv6 icmpv6 type echo-reply accept + ip6 nexthdr icmpv6 icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request } accept + # NDP messages must arrive with hop-limit 255 + ip6 nexthdr icmpv6 icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } ip6 hoplimit 255 accept + # IND solicitation / advertisement + ip6 nexthdr icmpv6 icmpv6 type 141 ip6 hoplimit 255 accept + ip6 nexthdr icmpv6 icmpv6 type 142 ip6 hoplimit 255 accept + # MLD (source must be link-local or unspecified) + ip6 nexthdr icmpv6 ip6 saddr fe80::/10 icmpv6 type { mld-listener-query, mld-listener-report, mld-listener-done, 143 } accept + # SEND certificate path solicitation / advertisement + ip6 nexthdr icmpv6 icmpv6 type { 148, 149 } ip6 hoplimit 255 accept + # MR advertisement / solicitation / termination (link-local, hl=1) + ip6 nexthdr icmpv6 ip6 saddr fe80::/10 ip6 hoplimit 1 icmpv6 type { 151, 152, 153 } accept + # Home Agent Address Discovery, Mobile Prefix + ip6 nexthdr icmpv6 icmpv6 type { 144, 145, 146, 147 } accept + # drop packets with RH0 headers (IPv6) + ip6 nexthdr ipv6-route drop + + # allow DHCPv4 client + udp sport 67 udp dport 68 accept + # allow DHCPv6 client (link-local only) + ip6 saddr fe80::/10 ip6 daddr fe80::/10 udp sport 547 udp dport 546 accept + + # allow MULTICAST mDNS for service discovery + udp daddr 224.0.0.251 udp dport 5353 accept + ip6 daddr ff02::fb udp dport 5353 accept + + # allow MULTICAST UPnP for service discovery + udp daddr 239.255.255.250 udp dport 1900 accept + ip6 daddr ff02::f udp dport 1900 accept + + # drop non-local unicast packets + fib daddr type != { local, multicast, broadcast } drop + } + + chain ufw-before-output { + # allow all on loopback + oifname "lo" accept + + # quickly process packets for which we already have a connection + ct state related,established accept + + # ok icmpv6 codes for OUTPUT (RFC 4890, 4.4.1 and 4.4.2) + ip6 nexthdr icmpv6 icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, echo-reply } accept + ip6 nexthdr icmpv6 icmpv6 type { nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } ip6 hoplimit 255 accept + ip6 nexthdr icmpv6 icmpv6 type { 141, 142 } ip6 hoplimit 255 accept + ip6 nexthdr icmpv6 ip6 saddr fe80::/10 icmpv6 type { mld-listener-query, mld-listener-report, mld-listener-done, 143 } accept + ip6 nexthdr icmpv6 icmpv6 type { 148, 149 } ip6 hoplimit 255 accept + ip6 nexthdr icmpv6 ip6 saddr fe80::/10 ip6 hoplimit 1 icmpv6 type { 151, 152, 153 } accept + } + + chain ufw-before-forward { + # quickly process packets for which we already have a connection + ct state related,established accept + + # ok icmp codes for FORWARD + ip protocol icmp icmp type { destination-unreachable, time-exceeded, parameter-problem, echo-request } accept + + # ok icmpv6 codes for FORWARD (RFC 4890, 4.3.1 and 4.3.2) + ip6 nexthdr icmpv6 icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, echo-reply } accept + } +} diff --git a/conf/user.nft b/conf/user.nft new file mode 100644 index 00000000..8cd2a7d4 --- /dev/null +++ b/conf/user.nft @@ -0,0 +1,9 @@ +# ufw-nftables user rules +# This file is managed by ufw. Do not edit directly. +# Rules are defined using ### tuple ### comments which ufw parses to +# reconstruct the logical rule set. The nftables statements below each +# tuple are regenerated from those tuples on every ufw invocation. + +### RULES ### + +### END RULES ### From 789974f2b95bf4fc6a047208080175921f727948 Mon Sep 17 00:00:00 2001 From: Eduardo Barretto Date: Thu, 18 Jun 2026 15:53:19 +0200 Subject: [PATCH 4/8] feat: add UFWBackendNftables nftables backend Implement a new backend class UFWBackendNftables that drives the nft(8) utility directly, without any iptables dependency. Key design points: - Uses a single 'table inet ufw' (IPv4+IPv6 unified), replacing the parallel v4/v6 iptables rule-file model. - Rule storage reuses the existing ### tuple ### comment format so that ufw's rule-comparison logic is backend-agnostic. - Atomic rule loading via 'nft -f' (flush+reload in one transaction). - Rate limiting via nft meter instead of xt_recent kernel module. - format_rule_nft() translates UFWRule objects to nft add rule syntax. - _write_rules(v6=True) is a no-op; all rules go into the single user.nft on the v6=False pass. - initcaps() is a no-op; nftables supports all required features natively. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/backend_nftables.py | 972 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 972 insertions(+) create mode 100644 src/backend_nftables.py diff --git a/src/backend_nftables.py b/src/backend_nftables.py new file mode 100644 index 00000000..e21af3b6 --- /dev/null +++ b/src/backend_nftables.py @@ -0,0 +1,972 @@ +"""backend_nftables.py: nftables backend for ufw""" + +# +# This file is part of ufw, the nftables backend. +# +# Copyright 2026 Canonical Ltd. +# +# SPDX-License-Identifier: GPL-3.0-only +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import os +import re +import shutil +import stat +import sys +import tempfile +import time + +from ufw.common import UFWError, UFWRule +import ufw.backend +import ufw.common +import ufw.util + + +# satisy flake8: our setup of gettext in src/ufw makes the assignment of '_' +# show up as undefined under flake8. Add a noop conditional to assign it to +# something reasonable under flake8 checks. Thanks Dan Ryan. +if False: + _ = str + +# nftables table and family used by ufw +NFT_TABLE = "ufw" +NFT_FAMILY = "inet" + +# Rate limit applied to log statements for log levels below 'full'. Mirrors the +# iptables backend's '-m limit --limit 3/min --limit-burst 10'. +NFT_LOG_RATE_LIMIT = "limit rate 3/minute burst 10 packets" + +# NFLOG group used when LOGGING_BACKEND is 'netfilter'. nftables 'log group 0' +# is the default NFLOG group and matches the iptables backend, which uses +# NFLOG without an explicit group. +NFT_LOG_NFLOG_GROUP = 0 + + +def format_log_stmt(prefix, backend="kernel", syslog_level=""): + """Return an nftables 'log' statement honouring the configured backend. + + - kernel: 'log prefix "" [level ]' (kernel ring + buffer, the equivalent of the iptables LOG target) + - netfilter:'log prefix "" group ' (NFLOG target) + + The returned string does not include a trailing verdict; callers append + 'accept'/'drop'/'reject' as needed. + """ + if backend == "netfilter": + return 'log prefix "%s" group %d' % (prefix, NFT_LOG_NFLOG_GROUP) + + # kernel (default) + stmt = 'log prefix "%s"' % prefix + if syslog_level != "": + stmt += " level %s" % syslog_level + return stmt + + +def format_rule_nft(rule, log_backend="kernel", syslog_level=""): + """Translate a UFWRule into an nftables add rule expression string. + + Returns a string suitable for use as the body of: + add rule inet ufw + """ + parts = [] + + # Address-family qualifier when we need to distinguish v4 vs v6 addresses + # (inet family handles both, but address matchers are family-specific) + if rule.v6: + af = "ip6" + else: + af = "ip" + + # Interfaces + if rule.interface_in != "": + parts.append('iifname "%s"' % rule.interface_in) + if rule.interface_out != "": + parts.append('oifname "%s"' % rule.interface_out) + + # Protocol + proto = rule.protocol + if proto != "any": + parts.append("meta l4proto %s" % proto) + + # Source and destination addresses (skip wildcard defaults) + if rule.src not in ("0.0.0.0/0", "::/0"): + parts.append("%s saddr %s" % (af, rule.src)) + if rule.dst not in ("0.0.0.0/0", "::/0"): + parts.append("%s daddr %s" % (af, rule.dst)) + + # Ports (only meaningful when protocol is tcp or udp) + if proto in ("tcp", "udp"): + if rule.multi: + # multiport: comma-separated list + if rule.dport != "any": + ports = rule.dport.replace(",", ", ") + parts.append("%s dport { %s }" % (proto, ports)) + if rule.sport != "any": + ports = rule.sport.replace(",", ", ") + parts.append("%s sport { %s }" % (proto, ports)) + else: + if rule.dport != "any": + parts.append("%s dport %s" % (proto, rule.dport)) + if rule.sport != "any": + parts.append("%s sport %s" % (proto, rule.sport)) + + # Application comment (carried as nftables comment metadata) + if rule.dapp != "" or rule.sapp != "": + pat_space = re.compile(" ") + comment_parts = [] + if rule.dapp != "": + comment_parts.append("dapp_" + pat_space.sub("%20", rule.dapp)) + if rule.sapp != "": + comment_parts.append("sapp_" + pat_space.sub("%20", rule.sapp)) + parts.append('comment "%s"' % ",".join(comment_parts)) + + # Log prefix for logging actions + log_prefix = "" + if rule.logtype != "": + if rule.action == "allow": + log_prefix = "[UFW ALLOW] " + elif rule.action == "limit": + log_prefix = "[UFW LIMIT] " + else: + log_prefix = "[UFW BLOCK] " + + # Action / verdict + if rule.action == "allow": + if log_prefix: + parts.append(format_log_stmt(log_prefix, log_backend, syslog_level)) + parts.append("accept") + elif rule.action == "reject": + if log_prefix: + parts.append(format_log_stmt(log_prefix, log_backend, syslog_level)) + if proto == "tcp": + parts.append("reject with tcp reset") + else: + parts.append("reject") + elif rule.action == "limit": + # Rate limiting: use nftables meter to track per-source-IP connection rate + meter_name = "ufw-user-limit-%s" % ("v6" if rule.v6 else "v4") + parts.append( + "ct state new meter %s { %s saddr limit rate 6/minute } " % (meter_name, af) + ) + if log_prefix: + parts.append(format_log_stmt(log_prefix, log_backend, syslog_level)) + parts.append("drop") + else: # deny / drop + if log_prefix: + parts.append(format_log_stmt(log_prefix, log_backend, syslog_level)) + parts.append("drop") + + return " ".join(parts) + + +class UFWBackendNftables(ufw.backend.UFWBackend): + """nftables backend for ufw""" + + def __init__(self, dryrun, rootdir=None, datadir=None): + """UFWBackendNftables initialization""" + self.comment_str = "# " + ufw.common.programName + "_comment #" + self.rootdir = rootdir + self.datadir = datadir + + files = {} + config_dir = ufw.util._findpath(ufw.common.config_dir, datadir) + + files["rules"] = os.path.join(config_dir, "ufw/user.nft") + files["before_rules"] = os.path.join(config_dir, "ufw/before.nft") + files["after_rules"] = os.path.join(config_dir, "ufw/after.nft") + files["init"] = os.path.join( + ufw.util._findpath(ufw.common.state_dir, rootdir), "ufw-init" + ) + + ufw.backend.UFWBackend.__init__( + self, "nftables", dryrun, files, rootdir=rootdir, datadir=datadir + ) + + self.nft = os.path.join(ufw.common.nftables_dir, "nft") + + # nftables supports limit natively; set cap to True unconditionally + self.caps = {"limit": {"4": True, "6": True}} + + def initcaps(self): + """Initialize capabilities. nftables supports all features natively.""" + pass + + # ------------------------------------------------------------------ + # Rule I/O + # ------------------------------------------------------------------ + + def _read_rules(self): + """Read in rules that were added by ufw. + + The ### tuple ### comment format is backend-neutral and is reused + verbatim from the iptables backend. + """ + rfns = [self.files["rules"]] + + for f in rfns: + try: + orig = ufw.util.open_file_read(f) + except Exception: + err_msg = _("Couldn't open '%s' for reading") % (f) + raise UFWError(err_msg) + + pat_tuple = re.compile(r"^### tuple ###\s*") + pat_iface_in = re.compile(r"in_\w+") + pat_iface_out = re.compile(r"out_\w+") + for orig_line in orig: + line = orig_line + + comment = "" + if " comment=" in orig_line: + line, hex = orig_line.split(r" comment=") + comment = hex.strip() + + if pat_tuple.match(line): + tupl = pat_tuple.sub("", line) + tmp = re.split(r"\s+", tupl.strip()) + if len(tmp) < 6 or len(tmp) > 9: + wmsg = _("Skipping malformed tuple (bad length): %s") % (tupl) + ufw.util.warn(wmsg) + continue + + dtype = "in" + interface_in = "" + interface_out = "" + if len(tmp) == 7 or len(tmp) == 9: + wmsg = _("Skipping malformed tuple (iface): %s") % (tupl) + dtype = tmp[-1].split("_")[0] + if "_" in tmp[-1]: + if ( + "!" in tmp[-1] + and pat_iface_in.search(tmp[-1]) + and pat_iface_out.search(tmp[-1]) + ): + interface_in = tmp[-1].split("!")[0].partition("_")[2] + interface_out = tmp[-1].split("!")[1].partition("_")[2] + elif tmp[-1].startswith("in_"): + interface_in = tmp[-1].partition("_")[2] + elif tmp[-1].startswith("out_"): + interface_out = tmp[-1].partition("_")[2] + else: + ufw.util.warn(wmsg) + continue + + try: + action = tmp[0] + forward = False + if ":" in action: + forward = True + action = action.split(":")[1] + if len(tmp) < 8: + rule = UFWRule( + action, + tmp[1], + tmp[2], + tmp[3], + tmp[4], + tmp[5], + dtype, + forward, + comment, + ) + else: + rule = UFWRule( + action, + tmp[1], + tmp[2], + tmp[3], + tmp[4], + tmp[5], + dtype, + forward, + comment, + ) + pat_space = re.compile("%20") + if tmp[6] != "-": + rule.dapp = pat_space.sub(" ", tmp[6]) + if tmp[7] != "-": + rule.sapp = pat_space.sub(" ", tmp[7]) + if interface_in != "": + rule.set_interface("in", interface_in) + if interface_out != "": + rule.set_interface("out", interface_out) + + except UFWError: + warn_msg = _("Skipping malformed tuple: %s") % (tupl) + ufw.util.warn(warn_msg) + continue + + # nftables backend uses a single unified rule file; + # distinguish v4/v6 by address content + if tmp[3].startswith(":") or tmp[5].startswith(":"): + rule.set_v6(True) + self.rules6.append(rule) + else: + rule.set_v6(False) + self.rules.append(rule) + + orig.close() + + def _write_rules(self, v6=False): + """Write out rules to the user.nft file. + + Both v4 and v6 rules are written to the same file since nftables + inet family handles both address families in a single table. + When v6=False we write all rules (both v4 and v6) to keep the + single-file model consistent; when v6=True we skip (already done). + """ + if v6: + # All rules written in the v4 pass; nothing to do for v6 + return + + rules_file = self.files["rules"] + + if not os.access(rules_file, os.W_OK): + err_msg = _("'%s' is not writable" % (rules_file)) + raise UFWError(err_msg) + + try: + fns = ufw.util.open_files(rules_file) + except Exception: + raise + + if self.dryrun: + fd = sys.stdout.fileno() + else: + fd = fns["tmp"] + + ufw.util.write_to_file(fd, "# ufw-nftables user rules\n") + ufw.util.write_to_file(fd, "# This file is managed by ufw\n\n") + ufw.util.write_to_file(fd, "### RULES ###\n\n") + + for r in self.rules + self.rules6: + action = r.action + if r.forward: + action = "route:" + r.action + if r.logtype != "": + action += "_" + r.logtype + + ifaces = "" + if r.interface_in == "" and r.interface_out == "": + ifaces = r.direction + elif r.interface_in != "" and r.interface_out != "": + ifaces = "in_%s!out_%s" % (r.interface_in, r.interface_out) + else: + if r.interface_in != "": + ifaces += "%s_%s" % (r.direction, r.interface_in) + else: + ifaces += "%s_%s" % (r.direction, r.interface_out) + + if r.dapp == "" and r.sapp == "": + tstr = "### tuple ### %s %s %s %s %s %s %s" % ( + action, + r.protocol, + r.dport, + r.dst, + r.sport, + r.src, + ifaces, + ) + if r.comment != "": + tstr += " comment=%s" % r.comment + ufw.util.write_to_file(fd, tstr + "\n") + else: + pat_space = re.compile(" ") + dapp = "-" + if r.dapp: + dapp = pat_space.sub("%20", r.dapp) + sapp = "-" + if r.sapp: + sapp = pat_space.sub("%20", r.sapp) + tstr = "### tuple ### %s %s %s %s %s %s %s %s %s" % ( + action, + r.protocol, + r.dport, + r.dst, + r.sport, + r.src, + dapp, + sapp, + ifaces, + ) + if r.comment != "": + tstr += " comment=%s" % r.comment + ufw.util.write_to_file(fd, tstr + "\n") + + # Determine the target chain + chain_suffix = "input" + if r.forward: + chain_suffix = "forward" + elif r.direction == "out": + chain_suffix = "output" + chain = "ufw-user-%s" % chain_suffix + + log_backend, syslog_level = self._logging_backend_info() + rule_expr = format_rule_nft(r, log_backend, syslog_level) + ufw.util.write_to_file( + fd, + "add rule %s %s %s %s\n\n" % (NFT_FAMILY, NFT_TABLE, chain, rule_expr), + ) + + ufw.util.write_to_file(fd, "### END RULES ###\n") + + try: + if self.dryrun: + ufw.util.close_files(fns, False) + else: + ufw.util.close_files(fns) + except Exception: + raise + + # ------------------------------------------------------------------ + # Firewall lifecycle + # ------------------------------------------------------------------ + + def _need_reload(self, v6): + """Check whether the ufw-user-input chain exists in the running ruleset.""" + if self.dryrun: + return False + + (rc, out) = ufw.util.cmd( + [self.nft, "list", "chain", NFT_FAMILY, NFT_TABLE, "ufw-user-input"] + ) + if rc != 0: + ufw.util.debug("_need_reload: forcing reload") + return True + return False + + def _reload_user_rules(self): + """Flush and reload the ufw-user-* chains from user.nft.""" + err_msg = _("problem running") + if self.dryrun: + ufw.util.msg("> | nft -f user.nft") + return + + if not self.is_enabled(): + return + + # Flush user chains before reloading + for chain in ["ufw-user-input", "ufw-user-output", "ufw-user-forward"]: + (rc, out) = ufw.util.cmd( + [self.nft, "flush", "chain", NFT_FAMILY, NFT_TABLE, chain] + ) + if rc != 0: + raise UFWError(err_msg + " nft flush chain %s" % chain) + + (rc, out) = ufw.util.cmd([self.nft, "-f", self.files["rules"]]) + if rc != 0: + raise UFWError(err_msg + " nft: %s" % out) + + def get_running_raw(self, rules_type): + """Show current running nftables state.""" + if self.dryrun: + out = "> " + _("Checking nftables\n") + return out + + if rules_type in ("before", "user", "after"): + chains = { + "before": [ + "ufw-before-input", + "ufw-before-output", + "ufw-before-forward", + ], + "user": ["ufw-user-input", "ufw-user-output", "ufw-user-forward"], + "after": ["ufw-after-input", "ufw-after-output", "ufw-after-forward"], + } + out = "" + for chain in chains[rules_type]: + (rc, tmp) = ufw.util.cmd( + [self.nft, "list", "chain", NFT_FAMILY, NFT_TABLE, chain] + ) + out += tmp + if rc != 0: + raise UFWError(out) + return out + elif rules_type in ("builtins", "logging"): + # nftables logging is inline (log statements within the ufw chains) + # rather than in dedicated logging chains, so the full table is the + # most complete view available for both of these. + (rc, out) = ufw.util.cmd([self.nft, "list", "table", NFT_FAMILY, NFT_TABLE]) + else: # "raw" and any unknown type + (rc, out) = ufw.util.cmd([self.nft, "list", "ruleset"]) + + if rc != 0: + raise UFWError(out) + return out + + def set_default_policy(self, policy, direction): + """Sets default policy of the firewall.""" + if not self.dryrun: + if policy not in ("allow", "deny", "reject"): + err_msg = _("Unsupported policy '%s'") % (policy) + raise UFWError(err_msg) + + if direction not in ("incoming", "outgoing", "routed"): + err_msg = _("Unsupported policy for direction '%s'") % (direction) + raise UFWError(err_msg) + + chain_map = { + "incoming": "input", + "outgoing": "output", + "routed": "forward", + } + nft_action = {"allow": "accept", "deny": "drop", "reject": "reject"}[policy] + chain = "ufw-%s" % chain_map[direction] + + (rc, out) = ufw.util.cmd( + [ + self.nft, + "add", + "chain", + NFT_FAMILY, + NFT_TABLE, + chain, + "{ policy %s ; }" % nft_action, + ] + ) + if rc != 0: + raise UFWError(_("Could not set default policy: %s") % out) + + # Persist in the defaults file + ipt_chain = chain_map[direction].upper() + try: + self.set_default( + self.files["defaults"], + "DEFAULT_%s_POLICY" % ipt_chain, + '"%s"' % policy.upper(), + ) + except Exception: + raise + + rstr = _("Default %(direction)s policy changed to '%(policy)s'\n") % ( + {"direction": direction, "policy": policy} + ) + rstr += _("(be sure to update your rules accordingly)") + return rstr + + def set_rule(self, rule, allow_reload=True): + """Update firewall with the given rule.""" + rstr = "" + + if rule.v6 and not self.use_ipv6(): + err_msg = _("Adding IPv6 rule failed: IPv6 not enabled") + raise UFWError(err_msg) + + if rule.multi and rule.protocol not in ("udp", "tcp"): + err_msg = _("Must specify 'tcp' or 'udp' with multiple ports") + raise UFWError(err_msg) + + newrules = [] + found = False + modified = False + + rules = self.rules + position = rule.position + if rule.v6: + rules = self.rules6 + + if position < 0 or position > len(rules): + err_msg = _("Invalid position '%d'") % (position) + raise UFWError(err_msg) + + if position > 0 and rule.remove: + err_msg = _("Cannot specify insert and delete") + raise UFWError(err_msg) + + try: + rule.normalize() + except Exception: + raise + + count = 1 + inserted = False + matches = 0 + last = ("", "", "", "") + for r in rules: + try: + r.normalize() + except Exception: + raise + + current = (r.dst, r.src, r.dapp, r.sapp) + if count == position: + if ( + (last[2] == "" and last[3] == "" and count > 1) + or (current[2] == "" and current[3] == "") + or last != current + ): + inserted = True + newrules.append(rule.dup_rule()) + last = ("", "", "", "") + else: + position += 1 + last = current + count += 1 + + ret = UFWRule.match(r, rule) + if ret < 1: + matches += 1 + + if ret == 0 and not found and not inserted: + found = True + if not rule.remove: + newrules.append(rule.dup_rule()) + elif ret == -2 and rule.remove and rule.comment == "": + found = True + elif ret < 0 and not rule.remove and not inserted: + found = True + modified = True + newrules.append(rule.dup_rule()) + else: + newrules.append(r) + + if inserted: + if matches > 0: + rstr = _("Skipping inserting existing rule") + if rule.v6: + rstr += " (v6)" + return rstr + else: + if not found and not rule.remove: + newrules.append(rule.dup_rule()) + + if not found and rule.remove and not self.dryrun: + rstr = _("Could not delete non-existent rule") + if rule.v6: + rstr += " (v6)" + return rstr + elif found and not rule.remove and not modified: + rstr = _("Skipping adding existing rule") + if rule.v6: + rstr += " (v6)" + return rstr + + if rule.v6: + self.rules6 = newrules + else: + self.rules = newrules + + try: + self._write_rules(False) # writes all rules (v4+v6) in one pass + except UFWError: + raise + except Exception: + err_msg = _("Couldn't update rules file") + UFWError(err_msg) + + rstr = _("Rules updated") + if rule.v6: + rstr = _("Rules updated (v6)") + + if self.is_enabled() and not self.dryrun: + if modified or self._need_reload(rule.v6) or inserted: + rstr = "" + if inserted: + rstr += _("Rule inserted") + else: + rstr += _("Rule updated") + if rule.v6: + rstr += " (v6)" + if allow_reload: + try: + self._reload_user_rules() + except Exception: + raise + else: + rstr += _(" (skipped reloading firewall)") + elif found and rule.remove: + rstr = _("Rule deleted") + if rule.v6: + rstr += " (v6)" + if allow_reload: + try: + self._reload_user_rules() + except Exception: + raise + else: + rstr += _(" (skipped reloading firewall)") + elif not found and not modified and not rule.remove: + rstr = _("Rule added") + if rule.v6: + rstr += " (v6)" + + return rstr + + def stop_firewall(self): + """Stop the firewall by removing the ufw table.""" + if self.dryrun: + ufw.util.msg("> " + _("running ufw-init")) + return + + args = [self.files["init"]] + if self.rootdir is not None and self.datadir is not None: + args += ["--rootdir", self.rootdir, "--datadir", self.datadir] + args.append("force-stop") + (rc, out) = ufw.util.cmd(args) + if rc != 0: + err_msg = _("problem running ufw-init\n%s" % out) + raise UFWError(err_msg) + + def start_firewall(self): + """Start the firewall.""" + if self.dryrun: + ufw.util.msg("> " + _("running ufw-init")) + return + + args = [self.files["init"]] + if self.rootdir is not None and self.datadir is not None: + args += ["--rootdir", self.rootdir, "--datadir", self.datadir] + args.append("start") + (rc, out) = ufw.util.cmd(args) + if rc != 0: + err_msg = _("problem running ufw-init\n%s" % out) + raise UFWError(err_msg) + + if "loglevel" not in self.defaults or self.defaults["loglevel"] not in list( + self.loglevels.keys() + ): + try: + self.set_loglevel("low") + except Exception: + err_msg = _("Could not set LOGLEVEL") + raise UFWError(err_msg) + else: + try: + self.update_logging(self.defaults["loglevel"]) + except Exception: + err_msg = _("Could not load logging rules") + raise UFWError(err_msg) + + def _logging_backend_info(self): + """Return (backend, syslog_level) for nft log statements. + + Reads LOGGING_BACKEND ('kernel' or 'netfilter') and, for the kernel + backend, KERNEL_SYSLOG_LEVEL from the parsed defaults. + """ + backend = self.defaults.get("logging_backend", "kernel") + syslog_level = "" + if backend == "kernel": + syslog_level = self.defaults.get("kernel_syslog_level", "") + return (backend, syslog_level) + + def _get_logging_rules(self, level): + """Return the per-level logging statements for the ufw-owned chains. + + nftables logging is inline: each returned tuple is + (chain, statement) where 'statement' is a complete nft rule body + (log + optional rate limit + verdict) to be added to 'chain'. + + Logging of packets that did not match any rule is placed in the + ufw-track-* chains, which ufw fully owns and rebuilds on every level + change (so no rule-handle bookkeeping is needed). This keeps logging + purely inline without dedicated *-logging-* helper chains. + """ + rules_t = [] + + if level not in self.loglevels: + err_msg = _("Invalid log level '%s'") % (level) + raise UFWError(err_msg) + + if level == "off": + return rules_t + + backend, syslog_level = self._logging_backend_info() + + def logstmt(prefix): + return format_log_stmt(prefix, backend, syslog_level) + + # Rate limit for everything below 'full' + rate = "" + if self.loglevels[level] < self.loglevels["full"]: + rate = NFT_LOG_RATE_LIMIT + " " + + directions = { + "input": "ufw-track-input", + "output": "ufw-track-output", + "forward": "ufw-track-forward", + } + + for d, chain in directions.items(): + policy = self._get_default_policy(d) + + # 'high' and 'full' audit every packet reaching the track chain. + # 'medium' audits only new connections. Emit the audit log first so + # it fires regardless of the policy verdict that follows. + if self.loglevels[level] >= self.loglevels["high"]: + rules_t.append((chain, "%s%s" % (rate, logstmt("[UFW AUDIT] ")))) + elif self.loglevels[level] >= self.loglevels["medium"]: + rules_t.append( + (chain, "ct state new %s%s" % (rate, logstmt("[UFW AUDIT] "))) + ) + + # Default-policy logging: anything reaching the track chain did not + # match a defined rule. low+ logs blocked packets; medium+ also logs + # allowed packets. + if policy in ("deny", "reject"): + rules_t.append((chain, "%s%s" % (rate, logstmt("[UFW BLOCK] ")))) + elif self.loglevels[level] >= self.loglevels["medium"]: + rules_t.append((chain, "%s%s" % (rate, logstmt("[UFW ALLOW] ")))) + + # Preserve tracking behaviour: accept new connections when the + # policy is accept (mirrors the conntrack accept set up at start). + if policy == "allow": + rules_t.append((chain, "ct state new accept")) + + return rules_t + + def update_logging(self, level): + """Update the log level: rebuild ufw-owned logging and reload rules.""" + if level not in list(self.loglevels.keys()): + err_msg = _("Invalid log level '%s'") % (level) + raise UFWError(err_msg) + + rules_t = self._get_logging_rules(level) + + if self.dryrun: + for chain, stmt in rules_t: + ufw.util.msg( + "> add rule %s %s %s %s" % (NFT_FAMILY, NFT_TABLE, chain, stmt) + ) + return + + # Update the user rules file (per-rule logging may have changed) + try: + self._write_rules(False) + except UFWError: + raise + except Exception: + err_msg = _("Couldn't update rules file for logging") + UFWError(err_msg) + + if not self.is_enabled(): + return + + # Rebuild the ufw-owned track chains with the level's logging, then + # reload the user rules. The chain rebuild is written to a temporary + # file and applied with a single 'nft -f' so the change is atomic. + script = "" + for chain in ("ufw-track-input", "ufw-track-output", "ufw-track-forward"): + script += "flush chain %s %s %s\n" % (NFT_FAMILY, NFT_TABLE, chain) + for chain, stmt in rules_t: + script += "add rule %s %s %s %s\n" % (NFT_FAMILY, NFT_TABLE, chain, stmt) + + (fd, tmpname) = tempfile.mkstemp(prefix="ufw-nft-log-") + try: + os.write(fd, script.encode("utf-8")) + os.close(fd) + (rc, out) = ufw.util.cmd([self.nft, "-f", tmpname]) + finally: + try: + os.unlink(tmpname) + except OSError: + pass + if rc != 0: + raise UFWError(_("Could not update logging: %s") % out) + + try: + self._reload_user_rules() + except Exception: + raise + + def get_app_rules_from_system(self, template, v6): + """Return a list of UFWRules from the current ruleset matching template.""" + rules = self.rules6 if v6 else self.rules + app_rules = [] + + norm = template.dup_rule() + norm.set_v6(v6) + norm.normalize() + tupl = norm.get_app_tuple() + + for r in rules: + tmp = r.dup_rule() + tmp.normalize() + if tmp.get_app_tuple() == tupl: + app_rules.append(tmp) + + return app_rules + + def reset(self): + """Reset the firewall""" + res = "" + + if self.dryrun: + ufw.util.msg("> " + _("resetting nftables")) + return res + + # Remove the running ruleset by deleting the ufw table. Ignore errors + # since the table may not exist (firewall already stopped). + (rc, out) = ufw.util.cmd([self.nft, "delete", "table", NFT_FAMILY, NFT_TABLE]) + if rc != 0: + ufw.util.debug("reset: table may not exist: %s" % out) + + # Restore the on-disk rule files to their installed defaults, backing + # up the current ones first (mirrors the iptables backend behaviour). + share_dir = ufw.util._findpath(ufw.common.share_dir, self.rootdir) + + # First make sure we have all the original files + allfiles = [] + for i in self.files: + if not self.files[i].endswith(".nft"): + continue + allfiles.append(self.files[i]) + fn = os.path.join(share_dir, "nftables", os.path.basename(self.files[i])) + if not os.path.isfile(fn): + err_msg = _("Could not find '%s'. Aborting") % (fn) + raise UFWError(err_msg) + + ext = time.strftime("%Y%m%d_%H%M%S") + + # This implementation will intentionally traceback if someone tries to + # do something to take advantage of the race conditions here. + + # Don't do anything if the files already exist + for i in allfiles: + fn = "%s.%s" % (i, ext) + if os.path.exists(fn): + err_msg = _("'%s' already exists. Aborting") % (fn) + raise UFWError(err_msg) + + # Move the old to the new + for i in allfiles: + fn = "%s.%s" % (i, ext) + res += _("Backing up '%(old)s' to '%(new)s'\n") % ( + {"old": os.path.basename(i), "new": fn} + ) + os.rename(i, fn) + + # Copy files into place + for i in allfiles: + old = "%s.%s" % (i, ext) + shutil.copy( + os.path.join(share_dir, "nftables", os.path.basename(i)), + os.path.dirname(i), + ) + shutil.copymode(old, i) + + try: + statinfo = os.stat(i) + mode = statinfo[stat.ST_MODE] + except Exception: + warn_msg = _("Couldn't stat '%s'") % (i) + ufw.util.warn(warn_msg) + continue + + if mode & stat.S_IWOTH: + res += _("WARN: '%s' is world writable") % (i) + elif mode & stat.S_IROTH: + res += _("WARN: '%s' is world readable") % (i) + + return res From d20e67eb9e1db48eb4a328741ba330c67e1fe52f Mon Sep 17 00:00:00 2001 From: Eduardo Barretto Date: Thu, 18 Jun 2026 15:59:47 +0200 Subject: [PATCH 5/8] feat: add FIREWALL_BACKEND config and auto-detection - conf/ufw.defaults: add FIREWALL_BACKEND="iptables" (default keeps existing behaviour; set to "nftables" to activate the new backend). - src/util.py: add get_firewall_backend(config_dir) helper that reads the setting and falls back to "iptables" for backwards compatibility. - src/frontend.py: UFWFrontend.__init__() now accepts backend_type=None, reads FIREWALL_BACKEND via get_firewall_backend(), and instantiates either UFWBackendNftables or UFWBackendIptables accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- conf/ufw.defaults | 4 ++++ src/frontend.py | 13 +++++++++++++ src/util.py | 23 +++++++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/conf/ufw.defaults b/conf/ufw.defaults index b3eba8fd..dd61869e 100644 --- a/conf/ufw.defaults +++ b/conf/ufw.defaults @@ -1,6 +1,10 @@ # /etc/default/ufw # +# Set the firewall backend. Supported values are 'iptables' and 'nftables'. +# Changing this requires a 'ufw disable && ufw enable' to take effect. +FIREWALL_BACKEND="iptables" + # Set to yes to apply rules to support IPv6 (no means only IPv6 on loopback # accepted). You will need to 'disable' and then 'enable' the firewall for # the changes to take affect. diff --git a/src/frontend.py b/src/frontend.py index df0a7435..379a7a52 100644 --- a/src/frontend.py +++ b/src/frontend.py @@ -26,6 +26,7 @@ import ufw.util from ufw.util import error, warn, msg from ufw.backend_iptables import UFWBackendIptables +from ufw.backend_nftables import UFWBackendNftables import ufw.parser import ufw.common import ufw.applications @@ -207,6 +208,11 @@ def __init__( rootdir: Optional[str] = None, datadir: Optional[str] = None, ) -> None: + if backend_type is None: + backend_type = ufw.util.get_firewall_backend( + ufw.util._findpath(ufw.common.config_dir, datadir) + ) + if backend_type == "iptables": try: self.backend = UFWBackendIptables( @@ -214,6 +220,13 @@ def __init__( ) except Exception: # pragma: no cover raise + elif backend_type == "nftables": + try: + self.backend = UFWBackendNftables( + dryrun, rootdir=rootdir, datadir=datadir + ) + except Exception: # pragma: no cover + raise else: raise UFWError("Unsupported backend type '%s'" % (backend_type)) diff --git a/src/util.py b/src/util.py index 67932cca..a071ce3e 100644 --- a/src/util.py +++ b/src/util.py @@ -1137,3 +1137,26 @@ def release_lock(lock: Optional[IO[Any]]) -> None: # If the lock is already closed, ignore the exception. This should # never happen but let's guard against it in case something changes pass + + +def get_firewall_backend(config_dir): + """Read FIREWALL_BACKEND from the ufw defaults file. + + Returns 'iptables' (the default) or 'nftables'. + """ + import os + import re + + defaults_file = os.path.join(config_dir, "default/ufw") + backend = "iptables" + try: + with open(defaults_file) as f: + pat = re.compile(r'^FIREWALL_BACKEND="?(\w+)"?') + for line in f: + m = pat.match(line.strip()) + if m: + backend = m.group(1).lower() + break + except OSError: + pass + return backend From 3f129d8741fefa5e29cf7c053e180f8e73325ebf Mon Sep 17 00:00:00 2001 From: Eduardo Barretto Date: Thu, 18 Jun 2026 16:12:41 +0200 Subject: [PATCH 6/8] feat: add nftables support to ufw-init-functions Refactor ufw-init-functions to support both iptables and nftables backends via the FIREWALL_BACKEND setting in ufw.defaults. - Rename existing start/stop/status/reload logic to ipt_ufw_* functions. - Add nft_ufw_start, nft_ufw_stop, nft_ufw_status that drive nft(8) directly using the inet family rule files (before.nft, user.nft, after.nft). - Public ufw_start / ufw_stop / ufw_reload / ufw_status now dispatch to the appropriate ipt_* or nft_* implementation based on $FIREWALL_BACKEND. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ufw-init-functions | 295 ++++++++++++++++++++++++++++++++++------- 1 file changed, 245 insertions(+), 50 deletions(-) diff --git a/src/ufw-init-functions b/src/ufw-init-functions index 81ecebdf..4995eea1 100755 --- a/src/ufw-init-functions +++ b/src/ufw-init-functions @@ -33,6 +33,13 @@ done RULES_PATH="${DATA_DIR}#CONFIG_PREFIX#/ufw" USER_PATH="${DATA_DIR}#CONFIG_PREFIX#/ufw" +# Default to iptables backend for backwards compatibility +FIREWALL_BACKEND="${FIREWALL_BACKEND:-iptables}" + +# --------------------------------------------------------------------------- +# iptables backend functions +# --------------------------------------------------------------------------- + flush_builtins() { error="" execs="iptables" @@ -107,7 +114,7 @@ delete_chains() { done } -ufw_start() { +ipt_ufw_start() { out="" if [ "$ENABLED" = "yes" ] || [ "$ENABLED" = "YES" ]; then if iptables -L ufw-user-input -n >/dev/null 2>&1 ; then @@ -277,14 +284,7 @@ ufw_start() { "COMMIT\n" $DEFAULT_INPUT_POLICY $DEFAULT_OUTPUT_POLICY $DEFAULT_FORWARD_POLICY | $exe-restore -n || error="yes" fi - # now ip[6]tables-restore before*.rules. This resets the following - # chains: - # ufw-before-input - # ufw-before-output - # ufw-before-forward - # - # and sets the following: - # ufw-not-local + # now ip[6]tables-restore before*.rules if [ -s "$BEFORE_RULES" ]; then if ! $exe-restore -n < "$BEFORE_RULES" ; then out="${out}\nProblem running '$BEFORE_RULES'" @@ -295,11 +295,7 @@ ufw_start() { error="yes" fi - # now ip[6]tables-restore after*.rules. This resets the following - # chains: - # ufw-after-input - # ufw-after-output - # ufw-after-forward + # now ip[6]tables-restore after*.rules if [ -s "$AFTER_RULES" ]; then if ! $exe-restore -n < "$AFTER_RULES" ; then out="${out}\nProblem running '$AFTER_RULES'" @@ -326,27 +322,11 @@ ufw_start() { "COMMIT\n" | $exe-restore -n || error="yes" fi - # now ip[6]tables-restore user*.rules. This resets the following - # chains: - # ufw-before-logging-input - # ufw-before-logging-output - # ufw-before-logging-forward - # ufw-after-logging-input - # ufw-after-logging-output - # ufw-after-logging-forward - # ufw-logging-deny - # ufw-logging-allow - # ufw-after-input - # ufw-after-output - # ufw-after-forward - # ufw-user-limit - # ufw-user-limit-accept if ! $exe-restore -n < "$USER_RULES" ; then out="${out}\nProblem running '$USER_RULES'" error="yes" fi - # now hooks these into the primary chains printf "*filter\n"\ "-A ufw${type}-before-input -j ufw${type}-user-input\n"\ "-A ufw${type}-before-output -j ufw${type}-user-output\n"\ @@ -374,8 +354,6 @@ ufw_start() { forward_pol="DROP" fi - # Since we're setting the default policy last, '-n/--noflush' is - # important here so we don't undo what we've loaded so far. printf "*filter\n"\ "# builtin chains\n"\ ":INPUT %s [0:0]\n"\ @@ -407,7 +385,7 @@ ufw_start() { fi } -ufw_stop() { +ipt_ufw_stop() { if [ "$1" != "--force" ] && [ "$ENABLED" != "yes" ] && [ "$ENABLED" != "YES" ]; then echo "Skip stopping firewall: ufw (not enabled)" return 0 @@ -463,6 +441,236 @@ ufw_stop() { return 0 } +ipt_ufw_status() { + iptables -L ufw-user-input -n >/dev/null 2>&1 || { + echo "Firewall is not running" + return 3 + } + + if [ "$IPV6" = "yes" ] || [ "$IPV6" = "YES" ]; then + ip6tables -L ufw6-user-input -n >/dev/null 2>&1 || { + echo "Firewall in inconsistent state (IPv6 enabled but not running)" + return 4 + } + fi + + echo "Firewall is running" + return 0 +} + +# --------------------------------------------------------------------------- +# nftables backend functions +# --------------------------------------------------------------------------- + +# Translate DROP/ACCEPT/REJECT policy to nftables policy keyword. +# nftables base chain policy only supports accept/drop; REJECT is handled +# by an explicit reject statement appended after the sub-chain jumps. +_nft_base_policy() { + case "$1" in + ACCEPT) echo "accept" ;; + *) echo "drop" ;; # DROP and REJECT both use drop as base policy + esac +} + +nft_ufw_start() { + out="" + if [ "$ENABLED" = "yes" ] || [ "$ENABLED" = "YES" ]; then + if nft list chain inet ufw ufw-user-input >/dev/null 2>&1 ; then + echo "Firewall already started, use 'force-reload'" + return 0 + fi + + if [ -x "$RULES_PATH/before.init" ]; then + if ! "$RULES_PATH/before.init" start ; then + error="yes" + out="${out}\n'$RULES_PATH/before.init start' exited with error" + fi + fi + + input_pol=$(_nft_base_policy "$DEFAULT_INPUT_POLICY") + output_pol=$(_nft_base_policy "$DEFAULT_OUTPUT_POLICY") + forward_pol=$(_nft_base_policy "$DEFAULT_FORWARD_POLICY") + + # Create the ufw table with all required chains and hooks + nft -f - </dev/null || true + + if [ -x "$RULES_PATH/after.init" ]; then + "$RULES_PATH/after.init" stop || true + fi + + return 0 +} + +nft_ufw_status() { + nft list chain inet ufw ufw-user-input >/dev/null 2>&1 || { + echo "Firewall is not running" + return 3 + } + echo "Firewall is running" + return 0 +} + +# --------------------------------------------------------------------------- +# Public dispatch functions +# --------------------------------------------------------------------------- + +ufw_start() { + case "$FIREWALL_BACKEND" in + nftables) nft_ufw_start "$@" ;; + *) ipt_ufw_start "$@" ;; + esac +} + +ufw_stop() { + case "$FIREWALL_BACKEND" in + nftables) nft_ufw_stop "$@" ;; + *) ipt_ufw_stop "$@" ;; + esac +} + ufw_reload() { if [ "$ENABLED" = "yes" ] || [ "$ENABLED" = "YES" ]; then if [ -x "$RULES_PATH/before.init" ]; then @@ -487,21 +695,8 @@ ufw_reload() { } ufw_status() { - err="" - iptables -L ufw-user-input -n >/dev/null 2>&1 || { - echo "Firewall is not running" - return 3 - } - - if [ "$IPV6" = "yes" ] || [ "$IPV6" = "YES" ]; then - ip6tables -L ufw6-user-input -n >/dev/null 2>&1 || { - # unknown state: ipv4 ok, but ipv6 isn't - echo "Firewall in inconsistent state (IPv6 enabled but not running)" - return 4 - } - fi - - echo "Firewall is running" - return 0 + case "$FIREWALL_BACKEND" in + nftables) nft_ufw_status "$@" ;; + *) ipt_ufw_status "$@" ;; + esac } - From 42c53eec9bf977af1d2195e63a72be4a8cfe481a Mon Sep 17 00:00:00 2001 From: Eduardo Barretto Date: Thu, 18 Jun 2026 16:27:09 +0200 Subject: [PATCH 7/8] tests: add nftables backend unit tests 28 unit tests covering: - FormatRuleNftTestCase (16 tests): verifies format_rule_nft() produces correct nft add rule syntax for allow/deny/limit/reject rules with TCP/UDP, port ranges, address prefixes, interfaces, app rules, and both IPv4 and IPv6. - BackendNftablesTestCase (12 tests): backend initialisation, default policy handling, start/stop/reset lifecycle, and _write_rules no-op behaviour for the v6 pass. Run via: ./run_tests.sh -i python3 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/unit/test_backend_nftables.py | 370 ++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 tests/unit/test_backend_nftables.py diff --git a/tests/unit/test_backend_nftables.py b/tests/unit/test_backend_nftables.py new file mode 100644 index 00000000..b5902ecf --- /dev/null +++ b/tests/unit/test_backend_nftables.py @@ -0,0 +1,370 @@ +# This file is part of ufw, the test suite. +# +# Copyright 2026 Canonical Ltd. +# +# SPDX-License-Identifier: GPL-3.0-only +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 3, +# as published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# + +import unittest +import tests.unit.support +import ufw.backend_nftables +import ufw.common +from ufw.backend_nftables import ( + format_rule_nft, + format_log_stmt, + NFT_TABLE, + NFT_FAMILY, +) +from ufw.common import UFWRule + + +class FormatRuleNftTestCase(unittest.TestCase): + """Tests for format_rule_nft() rule translation.""" + + def _rule( + self, + action, + protocol="any", + dport="any", + dst="0.0.0.0/0", + sport="any", + src="0.0.0.0/0", + direction="in", + v6=False, + ): + r = UFWRule(action, protocol, dport, dst, sport, src, direction) + r.set_v6(v6) + return r + + def test_allow_tcp_dport(self): + r = self._rule("allow", "tcp", "22") + out = format_rule_nft(r) + self.assertIn("meta l4proto tcp", out) + self.assertIn("tcp dport 22", out) + self.assertIn("accept", out) + + def test_deny_any(self): + r = self._rule("deny") + out = format_rule_nft(r) + self.assertIn("drop", out) + self.assertNotIn("accept", out) + self.assertNotIn("reject", out) + + def test_reject_tcp(self): + r = self._rule("reject", "tcp", "80") + out = format_rule_nft(r) + self.assertIn("reject with tcp reset", out) + + def test_reject_udp(self): + r = self._rule("reject", "udp", "53") + out = format_rule_nft(r) + self.assertIn("reject", out) + self.assertNotIn("tcp reset", out) + + def test_src_dst_addresses_v4(self): + r = self._rule("allow", "tcp", "80", dst="192.168.1.0/24", src="10.0.0.1") + out = format_rule_nft(r) + self.assertIn("ip saddr 10.0.0.1", out) + self.assertIn("ip daddr 192.168.1.0/24", out) + + def test_src_dst_addresses_v6(self): + r = self._rule( + "allow", "tcp", "443", dst="2001:db8::/32", src="fe80::1", v6=True + ) + out = format_rule_nft(r) + self.assertIn("ip6 saddr fe80::1", out) + self.assertIn("ip6 daddr 2001:db8::/32", out) + + def test_wildcard_addresses_omitted(self): + r = self._rule("allow", "tcp", "22") + out = format_rule_nft(r) + self.assertNotIn("saddr", out) + self.assertNotIn("daddr", out) + + def test_interface_in(self): + r = self._rule("allow", "tcp", "22") + r.set_interface("in", "eth0") + out = format_rule_nft(r) + self.assertIn('iifname "eth0"', out) + + def test_interface_out(self): + r = self._rule("allow", "tcp", "80", direction="out") + r.set_interface("out", "eth1") + out = format_rule_nft(r) + self.assertIn('oifname "eth1"', out) + + def test_multi_port(self): + r = UFWRule("allow", "tcp", "80,443", "0.0.0.0/0", "any", "0.0.0.0/0", "in") + r.multi = True + out = format_rule_nft(r) + self.assertIn("tcp dport { 80, 443 }", out) + + def test_port_range(self): + r = self._rule("allow", "tcp", "8000:8080") + out = format_rule_nft(r) + # ufw uses ':' for port ranges; nftables set notation also uses ':' + # A range like 8000:8080 is treated as multi=True by ufw and rendered + # as a set { 8000:8080 } + self.assertIn("tcp dport { 8000:8080 }", out) + + def test_sport(self): + r = self._rule("allow", "udp", sport="53") + out = format_rule_nft(r) + self.assertIn("udp sport 53", out) + + def test_limit_action(self): + r = self._rule("limit", "tcp", "22") + out = format_rule_nft(r) + self.assertIn("meter", out) + self.assertIn("drop", out) + + def test_log_prefix_allow(self): + r = self._rule("allow", "tcp", "22") + r.set_logtype("log") + out = format_rule_nft(r) + self.assertIn('log prefix "[UFW ALLOW] "', out) + self.assertIn("accept", out) + + def test_log_prefix_deny(self): + r = self._rule("deny", "tcp", "22") + r.set_logtype("log") + out = format_rule_nft(r) + self.assertIn('log prefix "[UFW BLOCK] "', out) + self.assertIn("drop", out) + + def test_app_comment(self): + r = self._rule("allow", "tcp", "22") + r.dapp = "SSH" + out = format_rule_nft(r) + self.assertIn('comment "dapp_SSH"', out) + + def test_log_prefix_netfilter_backend(self): + r = self._rule("deny", "tcp", "22") + r.set_logtype("log") + out = format_rule_nft(r, log_backend="netfilter") + self.assertIn('log prefix "[UFW BLOCK] " group 0', out) + + +class FormatLogStmtTestCase(unittest.TestCase): + """Tests for the format_log_stmt() helper.""" + + def test_kernel_default(self): + self.assertEqual(format_log_stmt("[UFW BLOCK] "), 'log prefix "[UFW BLOCK] "') + + def test_kernel_with_syslog_level(self): + out = format_log_stmt("[UFW BLOCK] ", "kernel", "warning") + self.assertEqual(out, 'log prefix "[UFW BLOCK] " level warning') + + def test_netfilter(self): + out = format_log_stmt("[UFW ALLOW] ", "netfilter") + self.assertEqual(out, 'log prefix "[UFW ALLOW] " group 0') + + +class BackendNftablesTestCase(unittest.TestCase): + def setUp(self): + ufw.common.do_checks = False + self.backend = ufw.backend_nftables.UFWBackendNftables(dryrun=True) + + def tearDown(self): + pass + + def test_init(self): + """Test that the nftables backend initialises correctly.""" + self.assertEqual(self.backend.name, "nftables") + self.assertTrue(self.backend.dryrun) + self.assertIn("rules", self.backend.files) + self.assertIn("before_rules", self.backend.files) + self.assertIn("after_rules", self.backend.files) + self.assertTrue(self.backend.files["rules"].endswith("user.nft")) + self.assertTrue(self.backend.files["before_rules"].endswith("before.nft")) + self.assertTrue(self.backend.files["after_rules"].endswith("after.nft")) + + def test_caps(self): + """Rate limiting caps are always True for nftables.""" + self.assertTrue(self.backend.caps["limit"]["4"]) + self.assertTrue(self.backend.caps["limit"]["6"]) + + def test_initcaps_noop(self): + """initcaps() is a no-op for nftables (everything is supported).""" + caps_before = self.backend.caps.copy() + self.backend.initcaps() + self.assertEqual(self.backend.caps, caps_before) + + def test_write_rules_dryrun(self): + """_write_rules() in dryrun mode should not raise.""" + rule = UFWRule("allow", "tcp", "22", "0.0.0.0/0", "any", "0.0.0.0/0", "in") + rule.set_v6(False) + self.backend.rules = [rule] + # dryrun writes to stdout; should not raise + try: + self.backend._write_rules(False) + except Exception as e: + self.fail("_write_rules raised unexpectedly: %s" % e) + + def test_write_rules_v6_skipped(self): + """_write_rules(v6=True) is a no-op (all rules go in one file).""" + rule = UFWRule("allow", "tcp", "22", "::/0", "any", "::/0", "in") + rule.set_v6(True) + self.backend.rules6 = [rule] + # Should return without error, doing nothing + try: + self.backend._write_rules(True) + except Exception as e: + self.fail("_write_rules(v6=True) raised unexpectedly: %s" % e) + + def test_need_reload_dryrun(self): + """_need_reload() always returns False in dryrun mode.""" + self.assertFalse(self.backend._need_reload(False)) + self.assertFalse(self.backend._need_reload(True)) + + def test_reset_dryrun(self): + """reset() in dryrun mode is a no-op that returns an empty string.""" + self.assertEqual(self.backend.reset(), "") + + def test_get_running_raw_dryrun(self): + """get_running_raw() returns a dryrun message.""" + out = self.backend.get_running_raw("raw") + self.assertIn("nftables", out) + + def test_nft_constants(self): + """NFT_TABLE and NFT_FAMILY have expected values.""" + self.assertEqual(NFT_TABLE, "ufw") + self.assertEqual(NFT_FAMILY, "inet") + + def test_set_rule_dryrun_adds_rule(self): + """set_rule() in dryrun should add the rule to self.rules.""" + rule = UFWRule("allow", "tcp", "443", "0.0.0.0/0", "any", "0.0.0.0/0", "in") + rule.set_v6(False) + initial_count = len(self.backend.rules) + self.backend.set_rule(rule) + self.assertEqual(len(self.backend.rules), initial_count + 1) + + def test_set_rule_dryrun_removes_rule(self): + """set_rule() with rule.remove=True removes the rule.""" + rule = UFWRule("allow", "tcp", "443", "0.0.0.0/0", "any", "0.0.0.0/0", "in") + rule.set_v6(False) + self.backend.set_rule(rule) + count_after_add = len(self.backend.rules) + + rule2 = rule.dup_rule() + rule2.remove = True + self.backend.set_rule(rule2) + self.assertEqual(len(self.backend.rules), count_after_add - 1) + + def test_set_rule_ipv6(self): + """set_rule() handles IPv6 rules in rules6 list.""" + rule = UFWRule("allow", "tcp", "22", "::/0", "any", "::/0", "in") + rule.set_v6(True) + initial_count = len(self.backend.rules6) + self.backend.set_rule(rule) + self.assertEqual(len(self.backend.rules6), initial_count + 1) + # IPv4 rules list should be unchanged + self.assertEqual(len(self.backend.rules), 0) + + def test_installation_defaults(self): + """Test that key defaults are present.""" + self.assertEqual(self.backend.defaults["default_input_policy"], "drop") + self.assertEqual(self.backend.defaults["default_output_policy"], "accept") + self.assertEqual(self.backend.defaults["default_forward_policy"], "drop") + + # -- logging -------------------------------------------------------------- + + def test_logging_backend_info_kernel(self): + """_logging_backend_info() defaults to kernel.""" + self.backend.defaults["logging_backend"] = "kernel" + self.backend.defaults["kernel_syslog_level"] = "" + self.assertEqual(self.backend._logging_backend_info(), ("kernel", "")) + + def test_logging_backend_info_kernel_syslog_level(self): + """_logging_backend_info() returns the configured syslog level.""" + self.backend.defaults["logging_backend"] = "kernel" + self.backend.defaults["kernel_syslog_level"] = "warning" + self.assertEqual(self.backend._logging_backend_info(), ("kernel", "warning")) + + def test_logging_backend_info_netfilter(self): + """_logging_backend_info() reports the netfilter backend.""" + self.backend.defaults["logging_backend"] = "netfilter" + backend, _level = self.backend._logging_backend_info() + self.assertEqual(backend, "netfilter") + + def test_get_logging_rules_off(self): + """log level 'off' produces no logging statements.""" + self.assertEqual(self.backend._get_logging_rules("off"), []) + + def test_get_logging_rules_low_block(self): + """low logs blocked packets only (default input policy is drop).""" + rules = self.backend._get_logging_rules("low") + flat = " ".join(s for (_c, s) in rules) + self.assertIn("[UFW BLOCK] ", flat) + self.assertNotIn("[UFW ALLOW] ", flat) + self.assertNotIn("[UFW AUDIT] ", flat) + # low is rate-limited + self.assertIn("limit rate", flat) + + def test_get_logging_rules_medium_allow(self): + """medium logs allowed packets for an accept policy direction.""" + rules = self.backend._get_logging_rules("medium") + out_rules = [s for (c, s) in rules if c == "ufw-track-output"] + flat = " ".join(out_rules) + # default output policy is accept -> [UFW ALLOW] + self.assertIn("[UFW ALLOW] ", flat) + # medium audits new connections + self.assertIn("ct state new", flat) + self.assertIn("[UFW AUDIT] ", flat) + + def test_get_logging_rules_high_audits_all(self): + """high audits every packet (no 'ct state new' qualifier).""" + rules = self.backend._get_logging_rules("high") + in_rules = [s for (c, s) in rules if c == "ufw-track-input"] + audit = [s for s in in_rules if "[UFW AUDIT] " in s] + self.assertTrue(audit) + self.assertFalse(any(s.startswith("ct state new") for s in audit)) + # high is still rate-limited + self.assertTrue(all("limit rate" in s for s in audit)) + + def test_get_logging_rules_full_no_rate_limit(self): + """full logs like high but without rate limiting.""" + rules = self.backend._get_logging_rules("full") + flat = " ".join(s for (_c, s) in rules) + self.assertIn("[UFW AUDIT] ", flat) + self.assertNotIn("limit rate", flat) + + def test_get_logging_rules_netfilter_group(self): + """netfilter backend emits 'log ... group N'.""" + self.backend.defaults["logging_backend"] = "netfilter" + rules = self.backend._get_logging_rules("low") + flat = " ".join(s for (_c, s) in rules) + self.assertIn("group 0", flat) + + def test_update_logging_dryrun(self): + """update_logging() in dryrun mode does not raise.""" + try: + self.backend.update_logging("medium") + except Exception as e: + self.fail("update_logging raised unexpectedly: %s" % e) + + def test_update_logging_invalid_level(self): + """update_logging() rejects an invalid level.""" + self.assertRaises(ufw.common.UFWError, self.backend.update_logging, "bogus") + + +def test_main(): + tests.unit.support.run_unittest( + FormatRuleNftTestCase, FormatLogStmtTestCase, BackendNftablesTestCase + ) + + +if __name__ == "__main__": + test_main() From 56536e28072710402b4d584afcec6965e87855b5 Mon Sep 17 00:00:00 2001 From: Eduardo Barretto Date: Thu, 30 Jul 2026 11:03:31 +0200 Subject: [PATCH 8/8] build: add nftables support to the Makefile Detect the nft binary at build time and substitute #NFTABLES_DIR# in common.py alongside the existing #IPTABLES_DIR# substitution. Install the .nft template files to /etc/ufw/ (mode 640, user-editable) and pristine copies to $(DATADIR)/ufw/nftables/ (mode 644, distribution defaults), mirroring the existing .rules / iptables/ handling. Co-Authored-By: opencode --- Makefile | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Makefile b/Makefile index 278a8827..1ea1f16a 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,10 @@ TMPDIR = ./tmp IPTABLES_DIR ?= $(shell for dir in /usr/sbin /sbin /usr/bin /bin /usr/local/sbin /usr/local/bin; do \ if [ -x $$dir/iptables ]; then echo $$dir; break; fi; done) +# Find nftables location +NFTABLES_DIR ?= $(shell for dir in /usr/sbin /sbin /usr/bin /bin /usr/local/sbin /usr/local/bin; do \ + if [ -x $$dir/nft ]; then echo $$dir; break; fi; done) + # Get version from setup.cfg VERSION := $(shell grep '^version = ' setup.cfg | cut -d' ' -f3) @@ -75,6 +79,7 @@ build: sed -i 's|#STATE_PREFIX#|$(LIBDIR)/ufw|g' $(STAGE_DIR)/src/common.py sed -i 's|#PREFIX#|$(PREFIX)|g' $(STAGE_DIR)/src/common.py sed -i 's|#IPTABLES_DIR#|$(IPTABLES_DIR)|g' $(STAGE_DIR)/src/common.py + sed -i 's|#NFTABLES_DIR#|$(NFTABLES_DIR)|g' $(STAGE_DIR)/src/common.py sed -i 's|#SHARE_DIR#|$(DATADIR)/ufw|g' $(STAGE_DIR)/src/common.py @if [ -n "$$UFW_SKIP_CHECKS" ]; then \ echo "Updating do_checks"; \ @@ -155,6 +160,9 @@ install: build install -m 640 $(STAGE_DIR)/conf/after6.rules $(DESTDIR)$(SYSCONFDIR)/ufw/ install -m 640 $(STAGE_DIR)/conf/user.rules $(DESTDIR)$(SYSCONFDIR)/ufw/ install -m 640 $(STAGE_DIR)/conf/user6.rules $(DESTDIR)$(SYSCONFDIR)/ufw/ + install -m 640 $(STAGE_DIR)/conf/before.nft $(DESTDIR)$(SYSCONFDIR)/ufw/ + install -m 640 $(STAGE_DIR)/conf/after.nft $(DESTDIR)$(SYSCONFDIR)/ufw/ + install -m 640 $(STAGE_DIR)/conf/user.nft $(DESTDIR)$(SYSCONFDIR)/ufw/ install -m 640 $(STAGE_DIR)/src/before.init $(DESTDIR)$(SYSCONFDIR)/ufw/ install -m 640 $(STAGE_DIR)/src/after.init $(DESTDIR)$(SYSCONFDIR)/ufw/ @@ -171,6 +179,11 @@ install: build install -m 644 $(STAGE_DIR)/conf/user.rules $(DESTDIR)$(DATADIR)/ufw/iptables/ install -m 644 $(STAGE_DIR)/conf/user6.rules $(DESTDIR)$(DATADIR)/ufw/iptables/ + install -d $(DESTDIR)$(DATADIR)/ufw/nftables + install -m 644 $(STAGE_DIR)/conf/before.nft $(DESTDIR)$(DATADIR)/ufw/nftables/ + install -m 644 $(STAGE_DIR)/conf/after.nft $(DESTDIR)$(DATADIR)/ufw/nftables/ + install -m 644 $(STAGE_DIR)/conf/user.nft $(DESTDIR)$(DATADIR)/ufw/nftables/ + # Install translations if they exist @if [ -d "$(STAGE_DIR)/locales/mo" ] && [ -n "$$(ls -A $(STAGE_DIR)/locales/mo 2>/dev/null)" ]; then \ echo "Installing translations..."; \