From 3ecd79cb64d274f303e34ad3decd469b2dbbfd3b Mon Sep 17 00:00:00 2001 From: Matteo Di Lorenzi Date: Fri, 7 Aug 2026 15:10:54 +0200 Subject: [PATCH] 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'] = {}