diff --git a/packages/ns-api/files/ns.threatshield b/packages/ns-api/files/ns.threatshield index fa061d7d0..e25d718b9 100644 --- a/packages/ns-api/files/ns.threatshield +++ b/packages/ns-api/files/ns.threatshield @@ -736,12 +736,29 @@ def dns_list_zones(e_uci): zones.append(zone['name']) return { 'data': zones } +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 = set() + for zone in zones: + 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): + 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'] + # 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: @@ -750,13 +767,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..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 @@ -56,9 +57,12 @@ 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_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/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/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/96_ns-threat_shield b/packages/ns-threat_shield/files/96_ns-threat_shield new file mode 100644 index 000000000..c2b56406f --- /dev/null +++ b/packages/ns-threat_shield/files/96_ns-threat_shield @@ -0,0 +1,17 @@ +#!/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. +# +# 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 + +/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..0a5ca8fbe --- /dev/null +++ b/packages/ns-threat_shield/files/adblock-devices-migrate.py @@ -0,0 +1,58 @@ +#!/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 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 +# 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 firewall, 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 + + 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) + 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 + + +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..7515f8dd7 --- /dev/null +++ b/packages/ns-threat_shield/files/configure-adblock-devices.py @@ -0,0 +1,38 @@ +#!/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 = set() + for zone in zones: + 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) + + 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'] = {}