NAME

Net::Firewall::BlockerHelper::backends::openwrt - OpenWrt fw4 backend for Net::Firewall::BlockerHelper.

VERSION

Version 0.2.0

SYNOPSIS

use Net::Firewall::BlockerHelper;

# driving the local router
my $fw_helper = Net::Firewall::BlockerHelper->new(
        backend => 'openwrt',
        ports => ['22'],
        protocols => ['tcp'],
        name => 'ssh',
        options => { zone => 'wan' },
    );

# driving a remote router over ubus JSON-RPC
my $fw_helper = Net::Firewall::BlockerHelper->new(
        backend => 'openwrt',
        ports => ['22'],
        protocols => ['tcp'],
        name => 'ssh',
        options => {
            zone     => 'wan',
            host     => '192.0.2.1',
            user     => 'blocker',
            password => 'hunter2',
        },
    );

$fw_helper->init_backend;
$fw_helper->ban(ban => '1.2.3.4');
$fw_helper->ban_cidr(ban => '1.2.3.0/24');

# write the current ban list to flash so it survives a reboot
$fw_helper->commit;

$fw_helper->teardown;

DESCRIPTION

Blocks IPs on OpenWrt via fw4, configured through UCI rather than by writing firewall rules directly.

Two config ipset sections are added to /etc/config/firewall, one per address family (<prefix>_<name>_4 and <prefix>_<name>_6), along with the config rule sections that drop or reject traffic matching them. fw4 renders each ipset into an nftables set inside its own inet fw4 table. Because the sets are declared with match 'src_net', they are interval sets and hold both single addresses and CIDR ranges, so this backend supports CIDR bans.

Banning does not touch UCI. An IP or range is added straight to the live set with nft add element, which takes effect immediately and needs no reload. This matters on a router: an fw4 reload rebuilds the whole ruleset and is far too expensive to run per ban.

Persisting the ban list is therefore a separate, explicit step. See "commit".

The backend runs in one of two modes. With no host option it drives the local machine, running uci, fw4, and nft directly, which is the mode to use when this is running on the router itself. With host set it drives a remote router over ubus JSON-RPC, which is the mode to use from a management host, and is usually what is wanted given OpenWrt ships no perl.

Local mode requires uci, fw4, and nft in the PATH of a process with sufficient privileges.

Remote mode requires uhttpd-mod-ubus and rpcd-mod-file on the router, LWP::UserAgent and JSON locally, and an rpcd ACL granting the configured user the calls this makes. Nothing grants file exec by default, so without an ACL every nft and fw4 call comes back as ubus permission denied. Drop the following in /usr/share/rpcd/acl.d/net-firewall-blockerhelper.json and run /etc/init.d/rpcd reload.

{
    "net-firewall-blockerhelper": {
        "description": "Net::Firewall::BlockerHelper openwrt backend",
        "read": {
            "ubus": { "uci": [ "get" ] },
            "uci": [ "firewall" ]
        },
        "write": {
            "ubus": {
                "uci": [ "add", "set", "delete", "order", "commit" ],
                "file": [ "exec" ]
            },
            "uci": [ "firewall" ],
            "file": {
                "/usr/sbin/nft": [ "exec" ],
                "/sbin/fw4": [ "exec" ]
            }
        }
    }
}

/usr/share/rpcd/acl.d/ is part of the root filesystem, which on OpenWrt is an overlay whose upper layer lives in flash, so the file survives a reboot. It is not carried across a sysupgrade, as it is not in the default keep list, nor across a factory reset. Add its path to /etc/sysupgrade.conf to keep it through firmware upgrades; without it, remote mode starts failing with permission denied after the upgrade.

ENABLING KILL SUPPORT

Blocking an address only stops new connections. Anything already established keeps flowing, because the block rules match the source but the existing traffic has already been accepted and is tracked. The kill option drops those connection tracking entries too, which is what actually cuts an attacker off mid session.

It is off by default and needs a package that OpenWrt does not install on its own. To turn it on:

  1. Install the conntrack package on the router. Note the name: the userspace tool is packaged as conntrack, not as conntrack-tools, which is the name of the upstream project rather than of anything installable here.

    apk add conntrack
    
    # on releases still using opkg
    opkg update && opkg install conntrack

    The kernel side, kmod-nf-conntrack and kmod-nf-conntrack6, is already present on any router running fw4, as fw4 depends on it.

  2. Pass the option.

    my $fw_helper = Net::Firewall::BlockerHelper->new(
            backend   => 'openwrt',
            ports     => ['22'],
            protocols => ['tcp'],
            name      => 'ssh',
            options   => { zone => 'wan', kill => 1 },
        );
  3. In remote mode only, add the binary to the file section of the rpcd ACL shown above and reload rpcd. Confirm the path first, as it is not the same on every build:

    command -v conntrack        # usually /usr/sbin/conntrack
    
    "file": {
        "/usr/sbin/nft": [ "exec" ],
        "/sbin/fw4": [ "exec" ],
        "/usr/sbin/conntrack": [ "exec" ]
    }

Skipping any of that is not an error. conntrack returns a non-zero exit status whenever it has nothing to delete, which is the common case, so its status is ignored and a missing binary or a denying ACL is indistinguishable from a ban with no established connections. The bans still work; the kill just quietly does nothing. If it matters, verify it rather than assume, by banning an address with a connection open and watching conntrack -L -s <address> empty out.

The kill is scoped to what is actually being blocked. With protocols configured, one conntrack call is made per protocol, so blocking only udp will not drop tcp entries. With none, everything is being blocked and every entry for the address goes. Protocols conntrack cannot filter by are skipped.

NOTES

Persistence is not automatic and this is deliberate. uci commit writes to the overlay filesystem, which on most OpenWrt devices is flash with a finite erase budget, so committing once per ban would wear it out. Bans are applied to the live nftables set only, and are lost by anything that rebuilds fw4's table until "commit" has been called. "init" and "teardown" commit on their own because they change the UCI config and there is nothing to write out otherwise.

An fw4 reload preserves the contents of the sets; an fw4 restart, an /etc/init.d/firewall restart, and a reboot all empty them. Whatever has been committed comes back by itself, as fw4 seeds each set from the entry list of its config ipset section. Anything banned since the last "commit" does not, and is recovered by "check" noticing and "re_init" putting it back, which is what the frontend's self healing does automatically.

The first "commit" rewrites /etc/config/firewall. This is uci commit doing what it always does, canonicalising the whole file rather than editing the lines it changed, and it drops every comment in the file along the way, including the block of commented out examples a stock config ships with. The configuration itself is unchanged. Nothing here can avoid it, so take a copy of the file first if those comments are wanted.

Overlapping CIDR bans do not round trip. fw4 gives the sets the auto-merge flag, so nftables folds overlapping and adjacent ranges together as they are added: banning 10.0.0.0/8 and then 10.1.0.0/16 leaves one interval, and a later unban_cidr of the /16 punches a hole in the /8 rather than being the noop it looks like. The ban list this object keeps still says the /8 is banned, because as far as it is concerned it is. Ban ranges that do not overlap and this cannot arise.

config rule sections are created with src '*' unless the zone option says otherwise, which is every zone. Setting zone to wan is almost always what is actually wanted.

fw4 defaults an unspecified proto to tcp udp, not to every protocol. When neither ports nor protocols are configured this backend therefore writes proto 'all' explicitly, so that blocking everything really does block everything.

Rules are moved to the front of /etc/config/firewall with uci reorder, because fw4 emits rules in config order and a block rule appended after an existing accept rule would never be reached. This puts them ahead of the zone sections as well. fw4 parses the whole config before emitting anything, so that is expected to be harmless, but it is worth knowing about if the config is being read by hand. Set the reorder option to 0 to leave the ordering alone.

Everything that touches a set at runtime, meaning every ban and unban and the whole of "check", assumes fw4 names the nftables set after the name of the config ipset section it came from and puts it in its own inet fw4 table. Should that ever not hold, bans would fail with nft reporting no such set.

The kill option shells out to conntrack(8), from the conntrack package, which is not installed by default. Without it the kill is a silent noop, as conntrack's exit status is intentionally ignored. See "ENABLING KILL SUPPORT".

METHODS

new

Initiates the object.

- options :: Backend specific options that will be passed to the backend unchecked
        outside of making sure it is a hash ref if defined. See below for further info.
    - Default :: {}

- ports :: A array of ports to block. Checked to make sure they are ints within the
        range 1 to 65535 or a valid service name via getservbyname. All ports will be
        blocked if non are specified. Duplicates are removed.
    - Default :: []

- protocols :: A array of protocols to block. This is checked against
        /etc/protocols via the function getprotobyname. Duplicates will be
        discarded. If no protocols are given, all traffic sourced from the
        sets is blocked, unless ports are given, in which case it defaults
        to tcp and udp. Ports are only attached to port-capable protocols
        (tcp/udp/sctp); other protocols are blocked without a port.
    - Default :: [], or ['tcp','udp'] when ports are given

- prefix :: Prefix to use. Must match the regex /^[a-zA-Z0-9]+$/
    - default :: kur

- name :: Name of this specific instance. This must be specified. The
        prefix and the name joined by an underscore must come to 250
        characters or fewer, leaving room for the suffixes appended to
        derive the set and rule names within the nftables identifier
        limit.
    - default :: undef

The options hash accepts the following.

- type :: The drop method to use. 'drop' silently drops. 'reject' sends
        the family-appropriate ICMP unreachable back.
    - Default :: drop

- zone :: The firewall zone the rules apply to, becoming the rule's src.
        '*' is every zone. Must match /^[a-zA-Z0-9_*-]+$/.
    - Default :: *

- reorder :: Move the rule sections to the front of the firewall config so
        they are evaluated before any existing accept rules.
    - Default :: 1

- kill :: Use conntrack(8) to drop existing connection tracking entries
        for the banned IP, scoped to the configured protocols. Requires
        the conntrack package, and in remote mode an ACL allowing it. See
        L</ENABLING KILL SUPPORT>.
    - Default :: 0

- host :: The router to drive over ubus JSON-RPC. Leaving this undef runs
        everything locally instead.
    - Default :: undef

- user :: The ubus user to authenticate as. Remote mode only.
    - Default :: root

- password :: The password for that user. Required in remote mode.
    - Default :: undef

- http_proto :: 'http' or 'https'. Remote mode only.
    - Default :: http

- http_port :: The port uhttpd is listening on. Remote mode only.
    - Default :: 80, or 443 when http_proto is https

- timeout :: HTTP timeout in seconds. Remote mode only.
    - Default :: 30

- insecure :: Skip TLS certificate verification, which routers with a self
        signed certificate will need. Remote mode only.
    - Default :: 0

All errors are considered fatal, meaning if new fails it will die.

init

Initiates the backend. A best effort delete of this instance's UCI sections is run first to clear any stale copy of them, then the two config ipset sections and the config rule sections are created, the rules are moved to the front of the config unless the reorder option says otherwise, the changes are committed via "commit", and fw4 reload is run to bring the new ruleset up.

Existing bans are not re-applied here. See "re_init" for that.

$backend->init;

commit

Writes the current ban list into the UCI ipset sections and commits the staged firewall config to persistent storage.

Banning and unbanning only touch the live nftables sets, so a ban is in effect the moment it is made but does not survive a firewall restart or a reboot. This is what makes it survive. It is separate, and not run automatically, because uci commit writes to the overlay filesystem, which on most OpenWrt devices is flash; committing per ban would wear it out. Call it as often as the durability of the ban list warrants and no more.

"init" and "teardown" call this themselves, as both change the config and would otherwise leave it staged and unwritten.

Note that "flush" does not commit, so the emptied ban list is not persisted until this is called.

This is the only backend that implements this method.

$fw_helper->ban(ban => '1.2.3.4');
$fw_helper->ban(ban => '5.6.7.8');
$fw_helper->commit;

ban

Bans an IP. The value of ban is validated as being a IPv4 or IPv6 address and lowercased, then added to the family-appropriate set via nft add element. If the kill option is set, conntrack(8) is then used to drop existing connection tracking entries for the IP. Banning an already banned IP is a noop.

The UCI config is not touched and nothing is committed, so the ban is in effect immediately but is lost by a firewall restart or a reboot until "commit" is called.

$backend->ban(ban => $ip);

unban

Unbans an IP. The value of ban is validated as being a IPv4 or IPv6 address and lowercased, then removed from the family-appropriate set via nft delete element. Unbanning an IP that is not banned is a noop.

As with "ban", nothing is committed; call "commit" to persist the change.

$backend->unban(ban => $ip);

ban_cidr

Bans a CIDR range by adding it to the family-appropriate set via nft add element. The value of ban is validated as being a IPv4 or IPv6 CIDR range and lowercased. Banning an already banned range is a noop.

The sets are interval sets, so ranges share the sets the single IP bans use and need no separate setup. Host bits do not need zeroing; nftables takes the range as given.

$backend->ban_cidr(ban => '1.2.3.0/24');

unban_cidr

Unbans a CIDR range by removing it from the family-appropriate set via nft delete element. The value of ban is validated as being a IPv4 or IPv6 CIDR range and lowercased. Unbanning a range that is not banned is a noop.

$backend->unban_cidr(ban => '1.2.3.0/24');

list

List banned IPs. Returns an array of the currently banned single IPs from the retained in-memory state; the sets are not queried. CIDR range bans are not included; for those see "list_cidr".

my @banned = $backend->list;

list_cidr

List banned CIDR ranges. Returns an array of the currently banned ranges from the retained in-memory state; the sets are not queried.

my @banned_cidrs = $backend->list_cidr;

re_init

Tears down and re-initiates the backend, recreating the UCI sections and reloading fw4, then re-adds every previously banned IP and CIDR range to the relevant set via nft add element. The teardown is best effort as a partially or fully wiped setup is exactly what this needs to recover from.

This is what recovers the live sets after a firewall restart or a reboot, which empty them of everything that had not been committed.

$backend->re_init;

teardown

Tears down the setup for the backend by deleting this instance's config rule and config ipset sections, committing that, running fw4 reload to drop the rules, and then deleting the two sets outright.

The commit here is a bare one and does not go through "commit", as the ipset sections the ban list would be written into have just been deleted. The ban list itself is kept, so a following "re_init" restores it.

The sets need deleting explicitly because fw4 reload is incremental: it removes the rules that are no longer in the config but leaves behind a set whose config ipset section has gone, so without this each teardown would strand an orphaned set in the live ruleset. fw4 restart would clear them, but at the cost of emptying every other set in the table, including those of any other instance sharing the router, so the sets are removed by name instead. This is also why they are deleted after the reload rather than before: nftables will not remove a set a rule still refers to.

The section deletes and the set deletes are best effort, as tearing down a setup that is already partly gone is normal, but a failure to commit or to reload is raised.

$backend->teardown;

stop

Alias for "teardown", provided for parity with the fail2ban actionstop concept.

$backend->stop;

check

Verifies that the setup is still intact, at the config level, the runtime level, and the contents level. The UCI sections for each set the rules use, and for every rule, are probed; fw4's table is listed and checked for each of those sets being both defined and referenced by a rule; and any set whose family currently has bans is confirmed to actually hold something. Returns a true value if all of that holds and a false value if any of it does not. This is the equivalent of fail2ban's actioncheck.

Checking the contents as well is what catches the case that matters most here. An fw4 restart, an /etc/init.d/firewall restart, or a reboot empties the sets of everything that was not committed, while leaving the config and the rules perfectly intact, so nothing but the emptiness gives it away. Only emptiness is treated as broken, not a mismatch against the ban list, because nftables merges and splits the intervals in an interval set and what comes back is often not what went in.

An fw4 reload, as opposed to a restart, preserves set contents and so is not something this needs to catch.

if ( !$backend->check ) {
    $backend->re_init;
}

flush

Removes all currently banned IPs and CIDR ranges at once by flushing both sets, leaving the UCI config and the rules in place. This is the equivalent of fail2ban's actionflush.

Nothing is committed, so the emptied list is not persisted until "commit" is called.

$backend->flush;

ERROR CODES / FLAGS

Error handling is provided by Error::Helper. All errors are considered fatal.

1, notInited

The backend has not been inited yet.

2, invalidPortSpecified

Port is either not an int within the range 1 to 65535 or a name that can be resolved by getservbyname.

3, portsNotArray

The data passed to new for ports is not an array.

4, protocolsNotArray

The data passed to new for protocols is not an array.

5, invalidPortSpecified

Port is either not an int within the range 1 to 65535 or a name that can be resolved by getservbyname.

6, invalidPrefixSpecified

The specified prefix did not match /^[a-zA-Z0-9]+$/.

7, invalidName

The name is either undef or does not match /^[a-zA-Z0-9\-]+$/.

8, optionsNotHash

The item passed to new for options is not a hash.

9, noBanItem

No IP specified to ban or unban.

10, banItemNotIP

The item to ban is not an IP. Either wrong ref type or regexp test using Regexp::IPv4 and Regexp::IPv6 failed.

12, backendInitError

Failed to init the backend.

13, banFailed

Failed to ban the item.

14, unbanFailed

Failed to unban the item.

15, listFailed

Failed to get a list of bans.

16, reInitFailed

Failed to re_init the backend.

17, teardownFailed

Failed to teardown the backend.

18, alreadyInited

init called, but the backend has already been inited.

20, typeInvalid

The option type is not "drop" or "reject".

21, nameTooLong

The prefix and name joined by an underscore come to more than 250 characters, which does not leave room for the set and rule name suffixes within the nftables identifier limit.

23, initFailed

One of the required operations for init failed.

24, checkFailed

The backend check raised an error.

25, flushFailed

Failed to flush the bans.

26, banCidrFailed

Failed to ban the CIDR range.

27, unbanCidrFailed

Failed to unban the CIDR range.

28, cidrItemNotCidr

The item to ban is not a CIDR range. Either wrong ref type or it is not an IPv4 or IPv6 address followed by a prefix length valid for its family.

29, cidrNotSupported

The backend does not support CIDR bans. Unused here, as this backend does.

30, listCidrFailed

Failed to get a list of CIDR bans.

31, commitFailed

Failed to write the ban list out or to commit the staged UCI config.

32, invalidHost

The option host does not match /^[a-zA-Z0-9_.\-\[\]:]+$/.

33, noPassword

The option host is set, putting the backend in remote mode, but no password was given.

34, invalidZone

The option zone does not match /^[a-zA-Z0-9_*\-]+$/.

35, invalidHttpPort

The option http_port is not an int within the range 1 to 65535.

36, invalidHttpProto

The option http_proto is not "http" or "https".

AUTHOR

Zane C. Bowers-Hadley, <vvelox at vvelox.ent>

BUGS

Please report any bugs or feature requests to bug-net-firewall-blockerhelper at rt.cpan.org, or through the web interface at https://rt.cpan.org/NoAuth/ReportBug.html?Queue=Net-Firewall-BlockerHelper.

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by Zane C. Bowers-Hadley.

This is free software, licensed under:

The GNU Lesser General Public License, Version 2.1, February 1999