From ef45d933115ccacefe498d6b8ed33c66d7259dae Mon Sep 17 00:00:00 2001 From: Dushnilin Date: Wed, 9 Sep 2026 21:01:43 +0300 Subject: [PATCH] fix(core,nft,singbox,luci): resolve parental domain blocking priority, client bypass and CI apt hash mismatch --- .github/workflows/backend-ci.yml | 1 + .github/workflows/shellcheck.yml | 1 + build.sh | 2 + fe-app-tachyon/locales/tachyon.ru.po | 18 +++ .../resources/view/tachyon/parental.js | 84 ++++++++-- luci-app-tachyon/po/ru/tachyon.po | 18 +++ tachyon/files/usr/lib/core/ip.uc | 75 ++++++++- tachyon/files/usr/lib/nft/apply.uc | 74 +++++++++ tachyon/files/usr/lib/service/state.uc | 2 + tachyon/files/usr/lib/singbox/generator.uc | 149 +++++++++++++++--- tests/nft_apply.sh | 24 +++ 11 files changed, 414 insertions(+), 34 deletions(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 11cc628d..33659af3 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -41,6 +41,7 @@ jobs: - name: Build ucode shell: bash run: | + sudo rm -f /etc/apt/sources.list.d/google-chrome.list sudo apt-get update sudo apt-get install -y build-essential cmake git libjson-c-dev pkg-config nodejs curl git clone --depth 1 --branch v0.0.20250529 https://github.com/jow-/ucode.git /tmp/ucode diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 1d2e572b..7804d1c6 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -43,6 +43,7 @@ jobs: - name: Install ShellCheck run: | + sudo rm -f /etc/apt/sources.list.d/google-chrome.list sudo apt-get update sudo apt-get install -y shellcheck diff --git a/build.sh b/build.sh index bc0049c7..6ecf569e 100755 --- a/build.sh +++ b/build.sh @@ -133,12 +133,14 @@ ensure_host_deps() { echo "Installing missing host dependencies: ${APT_PACKAGES[*]}" >&2 if [[ "$(id -u)" -eq 0 ]]; then + rm -f /etc/apt/sources.list.d/google-chrome.list 2>/dev/null || true apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y "${APT_PACKAGES[@]}" return 0 fi if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then + sudo rm -f /etc/apt/sources.list.d/google-chrome.list 2>/dev/null || true sudo apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "${APT_PACKAGES[@]}" return 0 diff --git a/fe-app-tachyon/locales/tachyon.ru.po b/fe-app-tachyon/locales/tachyon.ru.po index 487e4c32..e5ef37e0 100644 --- a/fe-app-tachyon/locales/tachyon.ru.po +++ b/fe-app-tachyon/locales/tachyon.ru.po @@ -6295,3 +6295,21 @@ msgstr "Скачивание и обработка списков" msgid "Applying rules and subnet lists" msgstr "Применение правил и списков подсетей" + +msgid "Always Active (24/7)" +msgstr "Всегда активно (24/7)" + +msgid "Active Hours Mode" +msgstr "Режим часов активности" + +msgid "Choose whether this schedule rule is permanently active (24/7) or restricted to specific hours." +msgstr "Выберите, действует ли расписание круглосуточно (24/7) или только в заданные часы." + +msgid "Scheduled Hours (Time window)" +msgstr "По расписанию (интервал времени)" + +msgid "Allow always" +msgstr "Разрешено всегда" + +msgid "Block always" +msgstr "Заблокировано всегда" diff --git a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/parental.js b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/parental.js index 4f1c5ace..ee5e17fb 100644 --- a/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/parental.js +++ b/luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/parental.js @@ -185,7 +185,7 @@ const DOMAIN_PRESETS = { function validateTime(sectionId, value) { if (!value) { - return _("Time is required (e.g. 22:00)"); + return true; } const str = `${value}`.trim(); if (!/^([01][0-9]|2[0-3]):[0-5][0-9]$/.test(str)) { @@ -218,8 +218,8 @@ function isScheduleCurrentlyActive(sectionId) { const enabled = uci.get(UCI_PACKAGE, sectionId, "enabled") !== "0"; if (!enabled) return false; - const startTime = uci.get(UCI_PACKAGE, sectionId, "start_time") || "00:00"; - const endTime = uci.get(UCI_PACKAGE, sectionId, "end_time") || "23:59"; + const startTime = uci.get(UCI_PACKAGE, sectionId, "start_time"); + const endTime = uci.get(UCI_PACKAGE, sectionId, "end_time"); const rawDays = uci.get(UCI_PACKAGE, sectionId, "days"); const days = normalizeDays(rawDays); @@ -230,10 +230,14 @@ function isScheduleCurrentlyActive(sectionId) { return false; } + if (!startTime && !endTime) { + return true; + } + const currentMinutes = now.getHours() * 60 + now.getMinutes(); - const [sH, sM] = startTime.split(":").map(Number); - const [eH, eM] = endTime.split(":").map(Number); + const [sH, sM] = (startTime || "00:00").split(":").map(Number); + const [eH, eM] = (endTime || "23:59").split(":").map(Number); const startMinutes = (sH || 0) * 60 + (sM || 0); const endMinutes = (eH || 0) * 60 + (eM || 0); @@ -822,22 +826,31 @@ function createParentalContent(section) { o.modalonly = false; o.cfgvalue = function (sectionId) { const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block"; + const s = uci.get(UCI_PACKAGE, sectionId, "start_time"); + const e = uci.get(UCI_PACKAGE, sectionId, "end_time"); + const is247 = !s && !e; if (action === "allow") { return ( '✅ ' + - _("Allow in interval") + + (is247 ? _("Allow always") : _("Allow in interval")) + "" ); } return ( '🚫 ' + - _("Block in interval") + + (is247 ? _("Block always") : _("Block in interval")) + "" ); }; o.textvalue = function (sectionId) { const action = uci.get(UCI_PACKAGE, sectionId, "action") || "block"; - return action === "allow" ? _("Allow in interval") : _("Block in interval"); + const s = uci.get(UCI_PACKAGE, sectionId, "start_time"); + const e = uci.get(UCI_PACKAGE, sectionId, "end_time"); + const is247 = !s && !e; + if (action === "allow") { + return is247 ? _("Allow always") : _("Allow in interval"); + } + return is247 ? _("Block always") : _("Block in interval"); }; // Schedule column in table @@ -845,24 +858,36 @@ function createParentalContent(section) { o.rawhtml = true; o.modalonly = false; o.cfgvalue = function (sectionId) { - const s = uci.get(UCI_PACKAGE, sectionId, "start_time") || "00:00"; - const e = uci.get(UCI_PACKAGE, sectionId, "end_time") || "23:59"; + const s = uci.get(UCI_PACKAGE, sectionId, "start_time"); + const e = uci.get(UCI_PACKAGE, sectionId, "end_time"); const days = formatDaysDisplay(uci.get(UCI_PACKAGE, sectionId, "days")); + if (!s && !e) { + return ( + '
⚡ ' + + _("Always Active (24/7)") + + '
' + + days + + "
" + ); + } return ( '
' + - s + + (s || "00:00") + " — " + - e + + (e || "23:59") + '
' + days + "
" ); }; o.textvalue = function (sectionId) { - const s = uci.get(UCI_PACKAGE, sectionId, "start_time") || "00:00"; - const e = uci.get(UCI_PACKAGE, sectionId, "end_time") || "23:59"; + const s = uci.get(UCI_PACKAGE, sectionId, "start_time"); + const e = uci.get(UCI_PACKAGE, sectionId, "end_time"); const days = formatDaysDisplay(uci.get(UCI_PACKAGE, sectionId, "days")); - return `${s} - ${e}, ${days}`; + if (!s && !e) { + return `${_("Always Active (24/7)")}, ${days}`; + } + return `${s || "00:00"} - ${e || "23:59"}, ${days}`; }; // Daily quota column in table @@ -1091,6 +1116,31 @@ function createParentalContent(section) { o.value("block", _("Block / Disable in scheduled hours (Standard)")); o.value("allow", _("Allow / Enable ONLY in scheduled hours (White period)")); + // Schedule Active Hours Mode (24/7 vs Specific Time Window) + o = section.option( + form.ListValue, + "_time_mode", + _("Active Hours Mode"), + _( + "Choose whether this schedule rule is permanently active (24/7) or restricted to specific hours.", + ), + ); + o.modalonly = true; + o.default = "always"; + o.value("always", _("Always Active (24/7)")); + o.value("interval", _("Scheduled Hours (Time window)")); + o.cfgvalue = function (sectionId) { + const s = uci.get(UCI_PACKAGE, sectionId, "start_time"); + const e = uci.get(UCI_PACKAGE, sectionId, "end_time"); + return s || e ? "interval" : "always"; + }; + o.write = function (sectionId, value) { + if (value === "always") { + uci.unset(UCI_PACKAGE, sectionId, "start_time"); + uci.unset(UCI_PACKAGE, sectionId, "end_time"); + } + }; + // Start Time o = section.option( form.Value, @@ -1099,8 +1149,10 @@ function createParentalContent(section) { _("Beginning of the schedule interval (e.g. 22:00)"), ); o.modalonly = true; + o.depends("_time_mode", "interval"); o.default = "22:00"; o.placeholder = "22:00"; + o.rmempty = true; o.validate = validateTime; // End Time @@ -1113,8 +1165,10 @@ function createParentalContent(section) { ), ); o.modalonly = true; + o.depends("_time_mode", "interval"); o.default = "08:00"; o.placeholder = "08:00"; + o.rmempty = true; o.validate = validateTime; // Days of week diff --git a/luci-app-tachyon/po/ru/tachyon.po b/luci-app-tachyon/po/ru/tachyon.po index 487e4c32..e5ef37e0 100644 --- a/luci-app-tachyon/po/ru/tachyon.po +++ b/luci-app-tachyon/po/ru/tachyon.po @@ -6295,3 +6295,21 @@ msgstr "Скачивание и обработка списков" msgid "Applying rules and subnet lists" msgstr "Применение правил и списков подсетей" + +msgid "Always Active (24/7)" +msgstr "Всегда активно (24/7)" + +msgid "Active Hours Mode" +msgstr "Режим часов активности" + +msgid "Choose whether this schedule rule is permanently active (24/7) or restricted to specific hours." +msgstr "Выберите, действует ли расписание круглосуточно (24/7) или только в заданные часы." + +msgid "Scheduled Hours (Time window)" +msgstr "По расписанию (интервал времени)" + +msgid "Allow always" +msgstr "Разрешено всегда" + +msgid "Block always" +msgstr "Заблокировано всегда" diff --git a/tachyon/files/usr/lib/core/ip.uc b/tachyon/files/usr/lib/core/ip.uc index 5ffed8ee..1e69fe79 100644 --- a/tachyon/files/usr/lib/core/ip.uc +++ b/tachyon/files/usr/lib/core/ip.uc @@ -1,5 +1,6 @@ #!/usr/bin/env ucode +let fs = require("fs"); let common = require("core.common"); let as_string = common.as_string; @@ -143,6 +144,77 @@ function ipv6_supported() { return common.ipv6_supported(); } +function resolve_mac_to_ips(mac) { + mac = lc(replace(trim(as_string(mac)), "-", ":")); + if (mac == "") + return []; + let matched = []; + let seen = {}; + + let lease_files = [ "/tmp/dhcp.leases", "/var/lib/misc/dnsmasq.leases", "/tmp/hosts/dhcp" ]; + for (let lpath in lease_files) { + let data = fs.readfile(lpath); + if (!data) + continue; + for (let line in split(as_string(data), "\n")) { + line = trim(line); + if (line == "" || index(line, "#") == 0) + continue; + let fields = split(line, /[ \t]+/); + if (length(fields) >= 3) { + let m = lc(replace(fields[1], "-", ":")); + let ip = fields[2]; + if (m == mac && valid_ip(ip) && !seen[ip]) { + seen[ip] = true; + push(matched, ip); + } + } + } + } + + let arp_data = fs.readfile("/proc/net/arp"); + if (arp_data) { + for (let line in split(as_string(arp_data), "\n")) { + line = trim(line); + if (line == "" || index(line, "IP address") == 0) + continue; + let fields = split(line, /[ \t]+/); + if (length(fields) >= 4) { + let ip = fields[0]; + let m = lc(replace(fields[3], "-", ":")); + if (m == mac && valid_ip(ip) && !seen[ip]) { + seen[ip] = true; + push(matched, ip); + } + } + } + } + + try { + let uci_core = require("core.uci"); + let cursor = uci_core ? uci_core.cursor() : null; + if (cursor) { + cursor.load("dhcp"); + cursor.foreach("dhcp", "host", function(host) { + let m_list = host.mac; + if (type(m_list) != "array") + m_list = [ m_list ]; + for (let m in m_list) { + if (lc(replace(trim(as_string(m)), "-", ":")) == mac) { + let ip = host.ip; + if (ip && valid_ip(ip) && !seen[ip]) { + seen[ip] = true; + push(matched, ip); + } + } + } + }); + } + } catch (e) {} + + return matched; +} + return { valid_ipv4, valid_ipv4_cidr, @@ -154,5 +226,6 @@ return { nft_ip_or_cidr, ip_family, format_ipv6_tproxy_target, - ipv6_supported + ipv6_supported, + resolve_mac_to_ips }; diff --git a/tachyon/files/usr/lib/nft/apply.uc b/tachyon/files/usr/lib/nft/apply.uc index 41943ab7..683fcd85 100644 --- a/tachyon/files/usr/lib/nft/apply.uc +++ b/tachyon/files/usr/lib/nft/apply.uc @@ -1504,6 +1504,51 @@ function nft_create_runtime_base(table, localv4_set, common_set, port_set, ip_po return false; } + let excluded_clients_list = []; + append_array(excluded_clients_list, list_option(uci_settings(), "excluded_clients")); + append_array(excluded_clients_list, list_option(uci_settings(), "excluded_ips")); + if (length(excluded_clients_list) > 0) { + let exc_v4 = []; + let exc_v6 = []; + let exc_mac = []; + for (let item in excluded_clients_list) { + let val = trim(as_string(item)); + if (val == "") continue; + let is_mac = match(val, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null; + if (is_mac) { + push(exc_mac, lc(replace(val, "-", ":"))); + for (let res_ip in core_ip.resolve_mac_to_ips(val)) { + if (core_ip.ip_family(res_ip) == 4) push(exc_v4, res_ip); + else if (core_ip.ip_family(res_ip) == 6) push(exc_v6, res_ip); + } + } else if (core_ip.ip_family(val) == 4 || (core_ip.valid_ip_cidr(val) && index(val, ":") == -1)) { + push(exc_v4, val); + } else if (core_ip.ip_family(val) == 6 || (core_ip.valid_ip_cidr(val) && index(val, ":") != -1)) { + push(exc_v6, val); + } + } + for (let mac in exc_mac) { + if (!nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ether", "saddr", mac, "counter", "return" ])) + return false; + if (!nft_add_rule(table, "dns_redirect", [ "iifname", "@" + as_string(interface_set), "ether", "saddr", mac, "counter", "return" ])) + return false; + } + if (length(exc_v4) > 0) { + if (!nft_create_ipv4_set(table, "tachyon_excluded") || + !nft_add_set_elements(table, "tachyon_excluded", join(",", exc_v4)) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "saddr", "@tachyon_excluded", "counter", "return" ]) || + !nft_add_rule(table, "dns_redirect", [ "iifname", "@" + as_string(interface_set), "ip", "saddr", "@tachyon_excluded", "counter", "return" ])) + return false; + } + if (length(exc_v6) > 0) { + if (!nft_create_ipv6_set(table, "tachyon_excluded6") || + !nft_add_set_elements(table, "tachyon_excluded6", join(",", exc_v6)) || + !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip6", "saddr", "@tachyon_excluded6", "counter", "return" ]) || + !nft_add_rule(table, "dns_redirect", [ "iifname", "@" + as_string(interface_set), "ip6", "saddr", "@tachyon_excluded6", "counter", "return" ])) + return false; + } + } + if (!nft_add_rule(table, "mangle", [ "jump", "priority_rules" ]) || !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "@" + as_string(common_set), "meta", "l4proto", "tcp", "meta", "mark", "set", fakeip_mark, "counter" ]) || !nft_add_rule(table, "mangle", [ "iifname", "@" + as_string(interface_set), "ip", "daddr", "@" + as_string(common_set), "meta", "l4proto", "udp", "meta", "mark", "set", fakeip_mark, "counter" ]) || @@ -2335,6 +2380,8 @@ function nft_runtime_signature_from_settings_and_sections(settings, sections, sc body = signature_add_value(body, "settings.exclude_ntp", bool_option(settings, "exclude_ntp", false) ? "1" : "0"); body = signature_add_value(body, "settings.game_console_optimizer", option(settings, "game_console_optimizer", "0")); body = signature_add_value(body, "settings.game_console_ips", option(settings, "game_console_ips", "")); + body = signature_add_value(body, "settings.excluded_clients", option(settings, "excluded_clients", "")); + body = signature_add_value(body, "settings.excluded_ips", option(settings, "excluded_ips", "")); for (let section in sections) body = nft_rule_signature_body(body, object_or_empty(section)); @@ -2654,6 +2701,12 @@ function source_aware_dns_values(sections, deferred_sections) { push(values, value); } } + for (let value in nft_csv_values(section_source_ip_values(section))) { + if (!seen[value]) { + seen[value] = true; + push(values, value); + } + } } if (action == "dns") { @@ -2666,6 +2719,27 @@ function source_aware_dns_values(sections, deferred_sections) { } } + let settings = uci_settings(); + let exc_items = []; + append_array(exc_items, list_option(settings, "excluded_clients")); + append_array(exc_items, list_option(settings, "excluded_ips")); + for (let item in exc_items) { + let val = trim(as_string(item)); + if (val == "") continue; + let is_mac = match(val, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null; + if (is_mac) { + for (let res_ip in core_ip.resolve_mac_to_ips(val)) { + if (!seen[res_ip]) { + seen[res_ip] = true; + push(values, res_ip); + } + } + } else if (!seen[val]) { + seen[val] = true; + push(values, val); + } + } + return values; } diff --git a/tachyon/files/usr/lib/service/state.uc b/tachyon/files/usr/lib/service/state.uc index 1ee0607e..fd95a26d 100644 --- a/tachyon/files/usr/lib/service/state.uc +++ b/tachyon/files/usr/lib/service/state.uc @@ -935,6 +935,8 @@ function nft_runtime_signature_body(settings, sections) { body = signature_add_value(body, "settings.exclude_ntp", bool_option(settings, "exclude_ntp", false) ? "1" : "0"); body = signature_add_value(body, "settings.game_console_optimizer", option(settings, "game_console_optimizer", "0")); body = signature_add_value(body, "settings.game_console_ips", option(settings, "game_console_ips", "")); + body = signature_add_value(body, "settings.excluded_clients", option(settings, "excluded_clients", "")); + body = signature_add_value(body, "settings.excluded_ips", option(settings, "excluded_ips", "")); for (let section in sections) { section = object_or_empty(section); diff --git a/tachyon/files/usr/lib/singbox/generator.uc b/tachyon/files/usr/lib/singbox/generator.uc index 21503b22..d702b43b 100644 --- a/tachyon/files/usr/lib/singbox/generator.uc +++ b/tachyon/files/usr/lib/singbox/generator.uc @@ -773,6 +773,10 @@ function schedule_has_time_window(schedule) { trim(option(schedule, "end_time", "")) != ""; } +function resolve_mac_to_ips(mac) { + return core_ip.resolve_mac_to_ips(mac); +} + function profile_source_ip_cidrs(profile) { let result = []; for (let raw in schedule_list_value(profile, "device_ip")) { @@ -780,8 +784,14 @@ function profile_source_ip_cidrs(profile) { if (device == "") continue; let is_mac = match(device, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null; - if (is_mac) + if (is_mac) { + for (let ip in resolve_mac_to_ips(device)) { + let cidr = core_ip.valid_ipv6(ip) ? ip + "/128" : ip + "/32"; + if (index(result, cidr) < 0) + push(result, cidr); + } continue; + } if (core_ip.valid_ip(device)) { let cidr = core_ip.valid_ipv6(device) ? device + "/128" : device + "/32"; if (index(result, cidr) < 0) push(result, cidr); @@ -799,8 +809,14 @@ function schedule_source_ip_cidrs(schedule) { if (device == "") continue; let is_mac = match(device, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null; - if (is_mac) + if (is_mac) { + for (let ip in resolve_mac_to_ips(device)) { + let cidr = core_ip.valid_ipv6(ip) ? ip + "/128" : ip + "/32"; + if (index(result, cidr) < 0) + push(result, cidr); + } continue; + } if (core_ip.valid_ip(device)) { let cidr = core_ip.valid_ipv6(device) ? device + "/128" : device + "/32"; if (index(result, cidr) < 0) push(result, cidr); @@ -907,10 +923,14 @@ function enabled_safesearch_profiles() { return result; } -function add_safesearch_dns_rules(config, profiles) { +function add_safesearch_dns_rules(target, profiles) { if (length(profiles) == 0) return; + let rules_arr = (type(target) == "object" && type(target.dns) == "object" && type(target.dns.rules) == "array") + ? target.dns.rules + : target; + let all_sources = []; for (let profile in profiles) { for (let cidr in profile_source_ip_cidrs(profile)) { @@ -951,7 +971,7 @@ function add_safesearch_dns_rules(config, profiles) { }; if (length(all_sources) > 0) rule.source_ip_cidr = all_sources; - push(config.dns.rules, rule); + push(rules_arr, rule); } } } @@ -965,7 +985,10 @@ function add_content_block_dns_inbound(config) { }); } -function add_content_block_dns_rules(config, schedules) { +function add_content_block_dns_rules(target, schedules) { + let rules_arr = (type(target) == "object" && type(target.dns) == "object" && type(target.dns.rules) == "array") + ? target.dns.rules + : target; let added = false; for (let schedule in schedules) { let mode = option(schedule, "mode", "block"); @@ -989,14 +1012,17 @@ function add_content_block_dns_rules(config, schedules) { rule.source_ip_cidr = sources; if (mode == "allow") rule.invert = true; - push(config.dns.rules, rule); + push(rules_arr, rule); added = true; } } return added; } -function add_content_block_route_rules(config, schedules) { +function add_content_block_route_rules(target, schedules) { + let rules_arr = (type(target) == "object" && type(target.route) == "object" && type(target.route.rules) == "array") + ? target.route.rules + : target; for (let schedule in schedules) { if (schedule_has_time_window(schedule)) continue; @@ -1012,7 +1038,7 @@ function add_content_block_route_rules(config, schedules) { rule.source_ip_cidr = sources; if (mode == "allow") rule.invert = true; - push(config.route.rules, rule); + push(rules_arr, rule); } } @@ -1029,11 +1055,25 @@ function add_content_blocking(config) { action: "hijack-dns", inbound: [ runtime_constants.DNS_BLOCK_INBOUND_TAG ] }); - add_content_block_dns_rules(config, schedules); - add_content_block_dns_rules(config, profiles); - add_safesearch_dns_rules(config, safesearch_profiles); - add_content_block_route_rules(config, schedules); - add_content_block_route_rules(config, profiles); + + let new_dns_rules = []; + add_content_block_dns_rules(new_dns_rules, schedules); + add_content_block_dns_rules(new_dns_rules, profiles); + add_safesearch_dns_rules(new_dns_rules, safesearch_profiles); + + // Place content blocking DNS rules at the front of config.dns.rules so + // queries on dns-block-in are rejected before any section FakeIP rule matches! + for (let i = length(new_dns_rules) - 1; i >= 0; i--) + unshift(config.dns.rules, new_dns_rules[i]); + + let new_route_rules = []; + add_content_block_route_rules(new_route_rules, schedules); + add_content_block_route_rules(new_route_rules, profiles); + + // Insert content blocking route rules right after hijack-dns (index 1) + // so sniffed domains from blocked clients are rejected before any section proxy route matches! + for (let i = length(new_route_rules) - 1; i >= 0; i--) + splice(config.route.rules, 1, 0, new_route_rules[i]); } function single_or_array(values) { @@ -1044,7 +1084,48 @@ function single_or_array(values) { return values; } -function source_aware_dns_sources(sections) { +function get_excluded_client_cidrs(settings) { + let result = []; + let seen = {}; + let add_cidr = function(ip) { + ip = trim(as_string(ip)); + if (ip == "") return; + let is_mac = match(ip, /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/) != null; + if (is_mac) { + for (let res_ip in resolve_mac_to_ips(ip)) { + let cidr = core_ip.valid_ipv6(res_ip) ? res_ip + "/128" : res_ip + "/32"; + if (!seen[cidr]) { + seen[cidr] = true; + push(result, cidr); + } + } + return; + } + if (core_ip.valid_ip(ip)) { + let cidr = core_ip.valid_ipv6(ip) ? ip + "/128" : ip + "/32"; + if (!seen[cidr]) { + seen[cidr] = true; + push(result, cidr); + } + } else if (core_ip.valid_ip_cidr(ip)) { + if (!seen[ip]) { + seen[ip] = true; + push(result, ip); + } + } + }; + + if (settings) { + for (let item in list_option(settings, "excluded_clients")) + add_cidr(item); + for (let item in list_option(settings, "excluded_ips")) + add_cidr(item); + } + + return result; +} + +function source_aware_dns_sources(sections, settings) { let sources = []; let seen = {}; let add_source = function(value) { @@ -1055,6 +1136,11 @@ function source_aware_dns_sources(sections) { push(sources, value); }; + if (settings) { + for (let cidr in get_excluded_client_cidrs(settings)) + add_source(cidr); + } + for (let section in sections) { let action = option(section, "action", ""); let has_dns_matchers = connections.has_dns_matchers(section); @@ -1067,6 +1153,8 @@ function source_aware_dns_sources(sections) { if (action == "bypass") { for (let ip in list_option(section, "fully_routed_ips")) add_source(ip); + for (let ip in list_option(section, "source_ip_cidr")) + add_source(ip); } if (action == "dns") { @@ -1099,12 +1187,25 @@ function add_source_aware_dns_fallback(config, source_aware_dns) { let settings = runtime_settings_cache || {}; let rewrite_ttl = int_option(settings, "dns_rewrite_ttl", "60"); - push(config.dns.rules, { + let rule = { action: "route", server: runtime_constants.DNSMASQ_DNS_SERVER_TAG, inbound: [ runtime_constants.SOURCE_DNS_INBOUND_TAG ], source_ip_cidr: single_or_array(source_aware_dns), rewrite_ttl + }; + unshift(config.dns.rules, rule); +} + +function add_excluded_clients_route_rule(config, settings) { + let excluded_cidrs = get_excluded_client_cidrs(settings); + if (length(excluded_cidrs) == 0) + return; + + splice(config.route.rules, 1, 0, { + action: "route", + outbound: runtime_constants.BYPASS_OUTBOUND_TAG, + source_ip_cidr: single_or_array(excluded_cidrs) }); } @@ -1124,7 +1225,7 @@ function generate_config(output_path, service_address, mwan3_active, supports_xh if (length(sections) == 0 && length(servers) == 0) runtime_generate_unsupported("no enabled sections"); - let source_aware_dns = source_aware_dns_sources(sections); + let source_aware_dns = source_aware_dns_sources(sections, settings); let config = base_config(settings, service_address, { mwan3_active: cli_bool(mwan3_active), source_aware_dns: length(source_aware_dns) > 0 @@ -1137,10 +1238,22 @@ function generate_config(output_path, service_address, mwan3_active, supports_xh for (let section in sections) add_outbound_for_section(config, section, taken, sections); add_service_route_rules(config, sections); - for (let section in sections) - add_route_for_section(config, section); + + // Add block and bypass sections before proxy sections so exceptions take precedence + for (let section in sections) { + let act = option(section, "action", ""); + if (act == "block" || act == "bypass") + add_route_for_section(config, section); + } + for (let section in sections) { + let act = option(section, "action", ""); + if (act != "block" && act != "bypass") + add_route_for_section(config, section); + } + add_server_routes(config, servers, sections); add_source_aware_dns_fallback(config, source_aware_dns); + add_excluded_clients_route_rule(config, settings); // Append dns_hosts predefined rules AFTER section DNS rules so that // FakeIP/section-level DNS routing takes precedence over hardcoded IPs diff --git a/tests/nft_apply.sh b/tests/nft_apply.sh index 2bb441af..52b4cbb3 100644 --- a/tests/nft_apply.sh +++ b/tests/nft_apply.sh @@ -676,6 +676,10 @@ br-lan tun0 0 [settings.game_console_ips] +[settings.excluded_clients] + +[settings.excluded_ips] + [rule.text_rule.action] bypass [rule.text_rule.ip_cidr] @@ -766,6 +770,10 @@ br-lan tun0 0 [settings.game_console_ips] +[settings.excluded_clients] + +[settings.excluded_ips] + [rule.enabled.action] bypass [rule.enabled.ip_cidr] @@ -812,12 +820,28 @@ br-lan 0 [settings.game_console_ips] +[settings.excluded_clients] + +[settings.excluded_ips] + EOF_EXPECTED expected_signature="$(md5sum "$WORK_DIR/signature-defaults-expected.txt" | awk '{print $1}')" assert_eq "$expected_signature" \ "$(nft_ucode nft-runtime-signature-fixture "$WORK_DIR/signature-defaults-fixture.json")" \ "nft runtime signature defaults" +cat >"$WORK_DIR/signature-excluded-clients-fixture.json" <<'JSON' +{ + "settings": { + "excluded_clients": "192.168.1.50 00:11:22:33:44:55" + } +} +JSON +sig_with_excluded="$(nft_ucode nft-runtime-signature-fixture "$WORK_DIR/signature-excluded-clients-fixture.json")" +if [ "$sig_with_excluded" = "$expected_signature" ]; then + fail "nft runtime signature must change when excluded_clients is set" +fi + : > "$NFT_LOG" nft_ucode nft-populate-runtime-sets-fixture "$WORK_DIR/populate-fixture.json" 0 "" TachyonTable tachyon_subnets tachyon_ports tachyon_ip_ports tachyon_interfaces localv4 0x00100000 [ ! -s "$NFT_LOG" ] || fail "disabled nft population should not call nft"