openwrt — OpenWrt fw4 via UCI

Blocks on an OpenWrt router by way of fw4, configured through UCI rather than by writing firewall rules directly. Two config ipset sections and the config rule sections that drop on them are added to /etc/config/firewall; fw4 renders each ipset into an nftables set in its own inet fw4 table.

The kur drives either the machine it runs on or a router across the network. Leave host unset and it runs uci, fw4, and nft locally; set it and everything goes over ubus JSON-RPC instead. Remote is the usual choice, since OpenWrt ships no perl and so cannot host a kur of its own.

[kur.sshd]
backend   = "openwrt"
ports     = [ "22" ]
protocols = [ "tcp" ]

[kur.sshd.options]
host     = "192.0.2.1"
user     = "blocker"
password = "hunter2"
zone     = "wan"
kill     = 1

Router-side setup — required first for remote mode

Local mode needs none of this. Remote mode needs all of it, once per router.

apk add uhttpd-mod-ubus rpcd-mod-file

# on releases still using opkg
opkg update && opkg install uhttpd-mod-ubus rpcd-mod-file
{
    "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" ]
            }
        }
    }
}
config login
	option username 'blocker'
	option password '$p$blocker'
	list read 'net-firewall-blockerhelper'
	list write 'net-firewall-blockerhelper'

$p$blocker means the password of the system user of that name, out of /etc/shadow; a crypt(3) hash or a plain string may be given instead.

/usr/share/rpcd/acl.d/ survives a reboot but is not carried across a sysupgrade — add the file's path to /etc/sysupgrade.conf, or remote mode starts answering permission denied after the next firmware upgrade. /etc/config/rpcd, and so the login, is kept by default.

What it creates

In /etc/config/firewall, with S4/S6 being <prefix>_<name>_4 and <prefix>_<name>_6:

fw4 turns that into interval sets in the inet fw4 table, which is what lets one pair of sets hold single addresses and ranges alike.

Requirements

Ports, protocols, and names

Options

| option | default | what | |--------------|------------------|--------------------------------------------------------------| | type | drop | drop silently drops; reject sends ICMP unreachable | | zone | * | the firewall zone the rules apply to, becoming their src | | reorder | 1 | move the rules to the front of the firewall config | | kill | 0 | conntrack(8) away existing connections for a banned IP | | host | (unset) | the router to drive over ubus; unset means run locally | | user | root | the ubus login (remote only) | | password | (required with host) | that login's password (remote only) | | http_proto | http | http or https (remote only) | | http_port | 80, or 443 with https | the port uhttpd listens on (remote only) | | timeout | 30 | HTTP timeout in seconds (remote only) | | insecure | 0 | skip certificate verification, for a self-signed router |

zone

* — the default — is every zone, which is rarely what is wanted on a router. wan is the usual answer.

kill

Severs the connections a ban alone leaves talking (see security). conntrack -D -s <ip>, one call per configured protocol via -p, everything for the address when no protocols are configured. conntrack's exit status is deliberately ignored, since it is non-zero whenever there was nothing to delete — which also means a missing package or a denying ACL is a silent no-op rather than an error. Ban an address with a connection open and watch conntrack -L -s <ip> empty out if you want to know it works.

What each operation runs

Shown as the local commands. Remote mode does the same work as ubus calls: uci add/set/delete/order/commit and file exec.

| operation | commands | |--------------|---------------------------------------------------------------------------------| | init | cleanup (failure ok): uci -q delete each section; then uci set/add_list per section, uci reorder unless off, the ban list written into the ipset entry lists, uci commit firewall, fw4 reload | | ban | nft add element inet fw4 <S4\|S6> { <ip> }, then the conntrack kills if enabled — a range ban is the same call | | unban | nft delete element inet fw4 <S4\|S6> { <ip> } — likewise for a range, and an already absent element is tolerated | | list | no command — the kur's own ban book | | check | uci -q get firewall.<section> for each set and rule section, plus nft list table inet fw4 | | flush | nft flush set inet fw4 S4, then S6 — config and rules stay | | re_init | teardown (best effort), init, re-add every banned IP and range | | teardown | uci -q delete each rule and ipset section, uci commit firewall, fw4 reload, then nft delete set inet fw4 S4/S6 |

The sets are deleted by name at teardown because fw4 reload is incremental: it drops rules that left the config but strands a set whose section is gone. fw4 restart would clear them, along with every other set in the table — including any other kur's.

Where the bans actually live

Banning touches the live nftables set and nothing else. That is the whole point: uci commit writes the overlay filesystem, which on most routers is flash with a finite erase budget, so a commit per ban would wear it out.

Persistence is therefore the kur's job rather than the router's. The clay tablet is the source of truth, and a kur restart re-bans every row of it through the backend. What does reach /etc/config/firewall, and so flash, is one commit at init, at re-init, and at teardown — and since a re-init commits the ban book into the ipset entry lists on its way through init, a re-init is what writes the current residents to flash. Ereshkigal never commits on its own beyond that.

An fw4 reload keeps set contents. An fw4 restart, an /etc/init.d/firewall restart, and a reboot all empty them, and what comes back is whatever was committed last, seeded by fw4 from the entry lists.

self_heal

check asks three things: that every UCI section is still there, that nft list table inet fw4 shows each set defined and referenced by a rule, and that a set whose family currently has residents is not empty. That last one is what catches a firewall restart or a reboot, which leave the config pristine and only the sets empty.

Contents are checked for emptiness only, never against the book — nftables merges and splits intervals in an interval set, so what comes back is often not what went in.

In remote mode each probe is several HTTP round trips (one per section, one for the table listing), paid on every ban and unban with self_heal on. On a slow or distant router, self_heal = 0 plus a periodic ereshkigal re-init trades prompt healing for a much cheaper ban path.

Gotchas