From 3ecd79cb64d274f303e34ad3decd469b2dbbfd3b Mon Sep 17 00:00:00 2001 From: Matteo Di Lorenzi Date: Fri, 7 Aug 2026 15:10:54 +0200 Subject: [PATCH 1/2] feat(adblock): implement device-based enforcement and migration scripts --- packages/ns-api/files/ns.threatshield | 41 +++++++++++--- packages/ns-threat_shield/Makefile | 4 ++ packages/ns-threat_shield/README.md | 9 +++- .../files/36_ns-threat_shield | 13 +++++ .../files/adblock-devices-migrate.py | 54 +++++++++++++++++++ .../files/configure-adblock-devices.py | 40 ++++++++++++++ 6 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 packages/ns-threat_shield/files/36_ns-threat_shield create mode 100644 packages/ns-threat_shield/files/adblock-devices-migrate.py create mode 100755 packages/ns-threat_shield/files/configure-adblock-devices.py diff --git a/packages/ns-api/files/ns.threatshield b/packages/ns-api/files/ns.threatshield index fa061d7d0..76005727b 100644 --- a/packages/ns-api/files/ns.threatshield +++ b/packages/ns-api/files/ns.threatshield @@ -736,12 +736,37 @@ def dns_list_zones(e_uci): zones.append(zone['name']) return { 'data': zones } +def dns_get_enforced_zones(e_uci): + # the user selection is stored in ns_tsdns_zones: adb_nftdevforce holds the + # devices derived from it and can't be mapped back to zones without ambiguity + zones = list(e_uci.get('adblock', 'global', 'ns_tsdns_zones', list=True, default=[])) + if zones: + return zones + # before the switch to device-based enforcement the selection was stored inside + # adb_nftdevforce: keep reading it until the migration has run + return list(e_uci.get('adblock', 'global', 'adb_nftdevforce', list=True, default=['lan'])) + +def dns_zones_to_devices(e_uci, zones): + # adb_nftdevforce is rendered as an nft iifname match, so it needs the devices + # of the selected zones and not the zone names + devices = [] + for zone in zones: + for device in utils.get_all_devices_by_zone(e_uci, zone, exclude_aliases=True): + if device not in devices: + devices.append(device) + return sorted(devices) + +def dns_set_enforced_devices(e_uci, zones): + devices = dns_zones_to_devices(e_uci, zones) + if devices: + e_uci.set('adblock', 'global', 'adb_nftdevforce', devices) + elif e_uci.get('adblock', 'global', 'adb_nftdevforce', list=True, default=[]): + # no device in the selected zones: adblock skips the enforcement altogether + e_uci.delete('adblock', 'global', 'adb_nftdevforce') + def dns_list_settings(e_uci): ts_enabled = e_uci.get('adblock', 'global', 'ts_enabled', default='0') - try: - zones = list(e_uci.get_all('adblock', 'global', 'adb_nftdevforce')) - except: - zones = ['lan'] + zones = dns_get_enforced_zones(e_uci) try: ports = list(e_uci.get_all('adblock', 'global', 'adb_nftportforce')) except: @@ -750,13 +775,15 @@ def dns_list_settings(e_uci): def dns_edit_settings(e_uci, payload): if payload['enabled']: - if 'zones' in payload and 'wan' in payload['zones']: - raise ValidationError('zones', 'wan_zone_not_allowed', payload['zones']) + zones = payload.get('zones', ['lan']) + if 'wan' in zones: + raise ValidationError('zones', 'wan_zone_not_allowed', zones) e_uci.set('adblock', 'global', 'ts_enabled', '1') e_uci.set('adblock', 'global', 'adb_enabled', '1') e_uci.set('adblock', 'global', 'adb_triggerdelay', '30') e_uci.set('adblock', 'global', 'adb_nftforce', '1') - e_uci.set('adblock', 'global', 'adb_nftdevforce', payload.get('zones', ['lan'])) + e_uci.set('adblock', 'global', 'ns_tsdns_zones', zones) + dns_set_enforced_devices(e_uci, zones) e_uci.set('adblock', 'global', 'adb_nftportforce', payload.get('ports', ['53', '853'])) else: e_uci.set('adblock', 'global', 'ts_enabled', '0') diff --git a/packages/ns-threat_shield/Makefile b/packages/ns-threat_shield/Makefile index f077c9d00..2e71ee913 100644 --- a/packages/ns-threat_shield/Makefile +++ b/packages/ns-threat_shield/Makefile @@ -56,9 +56,13 @@ define Package/ns-threat_shield/install $(INSTALL_DATA) ./files/banip.nethesis.feeds $(1)/etc/banip $(INSTALL_BIN) ./files/adjust-banip.py $(1)/usr/libexec/ns-api/post-commit/ $(INSTALL_BIN) ./files/configure-banip-wans.py $(1)/usr/libexec/ns-api/pre-commit/ + $(INSTALL_BIN) ./files/configure-adblock-devices.py $(1)/usr/libexec/ns-api/pre-commit/ + $(INSTALL_DIR) $(1)/usr/libexec + $(INSTALL_BIN) ./files/adblock-devices-migrate.py $(1)/usr/libexec/ts-dns-migrate-devices $(INSTALL_DIR) $(1)/etc/uci-defaults $(INSTALL_BIN) ./files/banip-defaults $(1)/etc/uci-defaults/99-nethsec-banip $(INSTALL_BIN) ./files/35_ns-threat_shield $(1)/etc/uci-defaults/35_ns-threat_shield + $(INSTALL_BIN) ./files/36_ns-threat_shield $(1)/etc/uci-defaults/36_ns-threat_shield gzip -9n $(1)/usr/share/threat_shield/nethesis-dns.sources gzip -9n $(1)/usr/share/threat_shield/community-dns.sources endef diff --git a/packages/ns-threat_shield/README.md b/packages/ns-threat_shield/README.md index 6943b0ebc..1773bbd5f 100644 --- a/packages/ns-threat_shield/README.md +++ b/packages/ns-threat_shield/README.md @@ -52,9 +52,16 @@ ts-ip Threat shield DNS (`ts-dns`) is a special configuration for [adblock](https://github.com/openwrt/packages/tree/master/net/adblock). The `ts-dns` is invoked every time adblock is started or reloaded. -The package adds a new option to `adblock`: +The package adds new options to `adblock`: - `ts_enabled`: if set to `1`, it enables the download of enterprise categories and community free categories. +- `ns_tsdns_zones`: the firewall zones where the local DNS enforcement is applied. + +Since adblock 4.5.5 the enforcement is rendered as nft rules matching on `iifname`, so the +`adb_nftdevforce` option needs network devices and not zone names. It is therefore a derived +value: it is computed from `ns_tsdns_zones` by the API and kept aligned with the network setup +by the `configure-adblock-devices` pre-commit hook, so that adding an interface to an enforced +zone does not leave its DNS traffic unfiltered. Do not edit `adb_nftdevforce` by hand. If `ts_enabled` is set to 1: diff --git a/packages/ns-threat_shield/files/36_ns-threat_shield b/packages/ns-threat_shield/files/36_ns-threat_shield new file mode 100644 index 000000000..468a2eca7 --- /dev/null +++ b/packages/ns-threat_shield/files/36_ns-threat_shield @@ -0,0 +1,13 @@ +#!/bin/sh + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +# Rebuild the adblock local DNS enforcement on systems upgraded from adblock 4.1.5, +# where adb_nftdevforce ended up holding firewall zone names instead of devices. + +set -e + +/usr/libexec/ts-dns-migrate-devices diff --git a/packages/ns-threat_shield/files/adblock-devices-migrate.py b/packages/ns-threat_shield/files/adblock-devices-migrate.py new file mode 100644 index 000000000..09e528f76 --- /dev/null +++ b/packages/ns-threat_shield/files/adblock-devices-migrate.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +# this script is supposed to be run by the 36_ns-threat_shield uci defaults +# +# Up to adblock 4.1.5 the local DNS enforcement was configured through adb_zonelist, +# holding firewall zone names, and adblock turned it into uci redirect sections +# letting fw4 resolve the zones. Since 4.5.5 the enforcement is rendered as nft rules +# matching on iifname, so adb_nftdevforce needs device names: the zone names stored +# there produce rules that never match and DNS traffic is no longer redirected. +# +# Move the selection to ns_tsdns_zones and rebuild adb_nftdevforce out of it. + +import subprocess + +from euci import EUci +from nethsec import utils + + +def migrate_zones(): + e_uci = EUci() + + if list(e_uci.get('adblock', 'global', 'ns_tsdns_zones', list=True, default=[])): + # already migrated + return False + + zones = list(e_uci.get('adblock', 'global', 'adb_nftdevforce', list=True, default=[])) + if not zones: + return False + + devices = [] + for zone in zones: + for device in utils.get_all_devices_by_zone(e_uci, zone, exclude_aliases=True): + if device not in devices: + devices.append(device) + + if not devices: + # the stored values are not zone names, or the zones have no interface: + # leave the configuration untouched rather than clearing it + return False + + e_uci.set('adblock', 'global', 'ns_tsdns_zones', zones) + e_uci.set('adblock', 'global', 'adb_nftdevforce', sorted(devices)) + e_uci.commit('adblock') + return True + + +if __name__ == "__main__": + if migrate_zones(): + subprocess.run(["/etc/init.d/adblock", "restart"], capture_output=True) diff --git a/packages/ns-threat_shield/files/configure-adblock-devices.py b/packages/ns-threat_shield/files/configure-adblock-devices.py new file mode 100755 index 000000000..8191acfb9 --- /dev/null +++ b/packages/ns-threat_shield/files/configure-adblock-devices.py @@ -0,0 +1,40 @@ +#!/usr/bin/python + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +# This script keeps the adblock local DNS enforcement aligned with the network setup: +# adb_nftdevforce is rendered as an nft iifname match, so it must list the devices of +# the zones selected in ns_tsdns_zones. Without this, adding an interface to an +# enforced zone would silently leave its DNS traffic unfiltered. + +# The changes variable is already within the scope from the caller +if 'adblock' in changes or 'firewall' in changes or 'network' in changes: + import syslog + from euci import EUci + from nethsec import utils + + uci = EUci() + zones = list(uci.get('adblock', 'global', 'ns_tsdns_zones', list=True, default=[])) + + if zones: + devices = [] + for zone in zones: + for device in utils.get_all_devices_by_zone(uci, zone, exclude_aliases=True): + if device not in devices: + devices.append(device) + # keep a stable order, the value is compared before being rewritten + devices = sorted(devices) + + current = list(uci.get('adblock', 'global', 'adb_nftdevforce', list=True, default=[])) + if devices != current: + if devices: + uci.set('adblock', 'global', 'adb_nftdevforce', devices) + else: + uci.delete('adblock', 'global', 'adb_nftdevforce') + uci.save('adblock') + # adblock is reloaded by the uci reload_config at the end of the commit + if 'adblock' not in changes: + changes['adblock'] = {} From e813586280c772adcef6cac9fb8780c88ebd2ce7 Mon Sep 17 00:00:00 2001 From: Matteo Di Lorenzi Date: Tue, 18 Aug 2026 18:35:40 +0200 Subject: [PATCH 2/2] refactor(adblock): streamline device retrieval and update migration scripts --- packages/ns-api/files/ns.threatshield | 22 +++++---------- packages/ns-threat_shield/Makefile | 4 +-- ...6_ns-threat_shield => 96_ns-threat_shield} | 4 +++ .../files/adblock-devices-migrate.py | 28 +++++++++++-------- .../files/configure-adblock-devices.py | 6 ++-- 5 files changed, 31 insertions(+), 33 deletions(-) rename packages/ns-threat_shield/files/{36_ns-threat_shield => 96_ns-threat_shield} (61%) diff --git a/packages/ns-api/files/ns.threatshield b/packages/ns-api/files/ns.threatshield index 76005727b..e25d718b9 100644 --- a/packages/ns-api/files/ns.threatshield +++ b/packages/ns-api/files/ns.threatshield @@ -736,24 +736,12 @@ def dns_list_zones(e_uci): zones.append(zone['name']) return { 'data': zones } -def dns_get_enforced_zones(e_uci): - # the user selection is stored in ns_tsdns_zones: adb_nftdevforce holds the - # devices derived from it and can't be mapped back to zones without ambiguity - zones = list(e_uci.get('adblock', 'global', 'ns_tsdns_zones', list=True, default=[])) - if zones: - return zones - # before the switch to device-based enforcement the selection was stored inside - # adb_nftdevforce: keep reading it until the migration has run - return list(e_uci.get('adblock', 'global', 'adb_nftdevforce', list=True, default=['lan'])) - def dns_zones_to_devices(e_uci, zones): # adb_nftdevforce is rendered as an nft iifname match, so it needs the devices # of the selected zones and not the zone names - devices = [] + devices = set() for zone in zones: - for device in utils.get_all_devices_by_zone(e_uci, zone, exclude_aliases=True): - if device not in devices: - devices.append(device) + devices.update(utils.get_all_devices_by_zone(e_uci, zone, exclude_aliases=True)) return sorted(devices) def dns_set_enforced_devices(e_uci, zones): @@ -766,7 +754,11 @@ def dns_set_enforced_devices(e_uci, zones): def dns_list_settings(e_uci): ts_enabled = e_uci.get('adblock', 'global', 'ts_enabled', default='0') - zones = dns_get_enforced_zones(e_uci) + # the user selection is stored in ns_tsdns_zones: adb_nftdevforce holds the devices + # derived from it and can't be mapped back to zones without ambiguity. Installations + # predating the device-based enforcement are converted by 96_ns-threat_shield, so the + # option is always set at this point + zones = list(e_uci.get('adblock', 'global', 'ns_tsdns_zones', list=True, default=['lan'])) try: ports = list(e_uci.get_all('adblock', 'global', 'adb_nftportforce')) except: diff --git a/packages/ns-threat_shield/Makefile b/packages/ns-threat_shield/Makefile index 2e71ee913..7a465ab7e 100644 --- a/packages/ns-threat_shield/Makefile +++ b/packages/ns-threat_shield/Makefile @@ -43,6 +43,7 @@ define Package/ns-threat_shield/install $(INSTALL_DIR) $(1)/usr/share/ns-plug/hooks/unregister $(INSTALL_DIR) $(1)/usr/libexec/ns-api/post-commit $(INSTALL_DIR) $(1)/usr/libexec/ns-api/pre-commit + $(INSTALL_DIR) $(1)/usr/libexec $(INSTALL_BIN) ./files/ts-dns $(1)/usr/sbin/ts-dns $(INSTALL_BIN) ./files/ts-ip $(1)/usr/sbin/ts-ip $(INSTALL_BIN) ./files/20_threat_shield $(1)/etc/uci-defaults @@ -57,12 +58,11 @@ define Package/ns-threat_shield/install $(INSTALL_BIN) ./files/adjust-banip.py $(1)/usr/libexec/ns-api/post-commit/ $(INSTALL_BIN) ./files/configure-banip-wans.py $(1)/usr/libexec/ns-api/pre-commit/ $(INSTALL_BIN) ./files/configure-adblock-devices.py $(1)/usr/libexec/ns-api/pre-commit/ - $(INSTALL_DIR) $(1)/usr/libexec $(INSTALL_BIN) ./files/adblock-devices-migrate.py $(1)/usr/libexec/ts-dns-migrate-devices $(INSTALL_DIR) $(1)/etc/uci-defaults $(INSTALL_BIN) ./files/banip-defaults $(1)/etc/uci-defaults/99-nethsec-banip $(INSTALL_BIN) ./files/35_ns-threat_shield $(1)/etc/uci-defaults/35_ns-threat_shield - $(INSTALL_BIN) ./files/36_ns-threat_shield $(1)/etc/uci-defaults/36_ns-threat_shield + $(INSTALL_BIN) ./files/96_ns-threat_shield $(1)/etc/uci-defaults/96_ns-threat_shield gzip -9n $(1)/usr/share/threat_shield/nethesis-dns.sources gzip -9n $(1)/usr/share/threat_shield/community-dns.sources endef diff --git a/packages/ns-threat_shield/files/36_ns-threat_shield b/packages/ns-threat_shield/files/96_ns-threat_shield similarity index 61% rename from packages/ns-threat_shield/files/36_ns-threat_shield rename to packages/ns-threat_shield/files/96_ns-threat_shield index 468a2eca7..c2b56406f 100644 --- a/packages/ns-threat_shield/files/36_ns-threat_shield +++ b/packages/ns-threat_shield/files/96_ns-threat_shield @@ -7,6 +7,10 @@ # Rebuild the adblock local DNS enforcement on systems upgraded from adblock 4.1.5, # where adb_nftdevforce ended up holding firewall zone names instead of devices. +# +# This must run after 95-adblock-housekeeping: on systems coming from adblock 4.1.5 +# the selection still lives in adb_zonelist, and it is that script which copies it +# into adb_nftdevforce set -e diff --git a/packages/ns-threat_shield/files/adblock-devices-migrate.py b/packages/ns-threat_shield/files/adblock-devices-migrate.py index 09e528f76..0a5ca8fbe 100644 --- a/packages/ns-threat_shield/files/adblock-devices-migrate.py +++ b/packages/ns-threat_shield/files/adblock-devices-migrate.py @@ -5,7 +5,7 @@ # SPDX-License-Identifier: GPL-2.0-only # -# this script is supposed to be run by the 36_ns-threat_shield uci defaults +# this script is supposed to be run by the 96_ns-threat_shield uci defaults # # Up to adblock 4.1.5 the local DNS enforcement was configured through adb_zonelist, # holding firewall zone names, and adblock turned it into uci redirect sections @@ -18,7 +18,7 @@ import subprocess from euci import EUci -from nethsec import utils +from nethsec import firewall, utils def migrate_zones(): @@ -32,19 +32,23 @@ def migrate_zones(): if not zones: return False - devices = [] - for zone in zones: - for device in utils.get_all_devices_by_zone(e_uci, zone, exclude_aliases=True): - if device not in devices: - devices.append(device) - - if not devices: - # the stored values are not zone names, or the zones have no interface: - # leave the configuration untouched rather than clearing it + if not all(firewall.zone_exists(e_uci, zone) for zone in zones): + # the stored values are device names already, or zones that no longer exist: + # they can't be mapped back to a selection, leave the configuration untouched return False + devices = set() + for zone in zones: + devices.update(utils.get_all_devices_by_zone(e_uci, zone, exclude_aliases=True)) + + # record the selection even when it yields no device, so the API keeps reporting + # the zones the user picked instead of falling back to the default one e_uci.set('adblock', 'global', 'ns_tsdns_zones', zones) - e_uci.set('adblock', 'global', 'adb_nftdevforce', sorted(devices)) + if devices: + e_uci.set('adblock', 'global', 'adb_nftdevforce', sorted(devices)) + else: + # no device in the selected zones: adblock skips the enforcement altogether + e_uci.delete('adblock', 'global', 'adb_nftdevforce') e_uci.commit('adblock') return True diff --git a/packages/ns-threat_shield/files/configure-adblock-devices.py b/packages/ns-threat_shield/files/configure-adblock-devices.py index 8191acfb9..7515f8dd7 100755 --- a/packages/ns-threat_shield/files/configure-adblock-devices.py +++ b/packages/ns-threat_shield/files/configure-adblock-devices.py @@ -20,11 +20,9 @@ zones = list(uci.get('adblock', 'global', 'ns_tsdns_zones', list=True, default=[])) if zones: - devices = [] + devices = set() for zone in zones: - for device in utils.get_all_devices_by_zone(uci, zone, exclude_aliases=True): - if device not in devices: - devices.append(device) + devices.update(utils.get_all_devices_by_zone(uci, zone, exclude_aliases=True)) # keep a stable order, the value is compared before being rewritten devices = sorted(devices)