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..."; \
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/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/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 ###
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"
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
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
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/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
}
-
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
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()