#!/bin/sh /etc/rc.common
# Copyright (C) 2018-2024 Ycarus (Yannick Chabanois) <ycarus@zugaina.org> for OpenMPTCProuter

START=98
USE_PROCD=1
EXTRA_COMMANDS="reload_rules bypass_asn"

. /usr/lib/unbound/iptools.sh

# dnsmasq writes one nftset= line per UCI ipset entry; its config parser has a
# ~1024-byte line buffer, so cap how many domains go into each entry.
#
# A flat domain-COUNT cap is not actually a safe proxy for that byte limit:
# the generated line is "nftset=" + "/<domain>" per domain + a trailing
# "/4#inet#fw4#<set4>,6#inet#fw4#<set6>" tag (plus any DSCP set names added
# on top), so its real size depends on domain-name length and interface-name
# length too, not just domain count. 30 domains of ~30-31 bytes each (a
# perfectly ordinary length -- e.g. "d25xi40x97liuc.cloudfront.net" already
# appears in this tree's own DSCP domain lists) lands EXACTLY at 1024 bytes,
# i.e. right on top of the crash threshold with zero margin, not under it.
# _BYPASS_MAX_DOMAINS_PER_DNSIPSET is kept only as a generous sanity backstop
# against unbounded uci list growth; _dnsipset_add() below tracks the actual
# size of each slot's line and is what actually decides when to spill into a
# new omr_dst_bypass_<family>_d<N> section.
_BYPASS_MAX_DOMAINS_PER_DNSIPSET=200
# Budget for the whole generated line, under the ~1024-byte buffer: "nftset="
# (7 bytes), "/<domain>" per domain, and ",4#inet#fw4#<set>" per set name
# (the first one starts with "/"), i.e. 12 bytes plus the name. The set
# names used to be left out of the count, with a 700-byte domain budget as
# the margin for them; DSCP set names (~38 bytes each) were then added on
# top of a full slot and pushed the line past 1024 bytes.
_BYPASS_MAX_DNSIPSET_LINE_BYTES=900

# Zone list cache: computing it needs a full config_load of the (large)
# firewall config, and callers invoke this in per-proto/per-interface loops.
_omr_fw_zones=""

# Networks of the wan* firewall zone(s), cached the same way and for the
# same reason: _intf_rule needs to know whether the interface it is
# generating marks for is one traffic can come *in* on (see the reply-path
# chain below), and it runs once per network interface.
_omr_wan_networks=""

# TLD list for wildcard domain expansion: fetch it from IANA at most once
# per run — callers need it once per wildcard domain, and each failed
# attempt costs the full curl timeout when the WAN is not up yet. The last
# list fetched is kept for a run that cannot get it: without it a wildcard
# rule expanded to nothing, which looked like a removed rule and flushed
# its sets (see _omr_fed_lost).
_omr_tlds_tried=""
_OMR_TLDS="/var/run/omr-bypass-tlds"
_fetch_tlds() {
	[ -n "$_omr_tlds_tried" ] && return
	_omr_tlds_tried=1
	tlds=$(curl --max-time 4 -s https://data.iana.org/TLD/tlds-alpha-by-domain.txt)
	if printf '%s\n' "$tlds" | grep -q -i -x 'com'; then
		printf '%s\n' "$tlds" > "$_OMR_TLDS" 2>/dev/null
	else
		tlds="$(cat "$_OMR_TLDS" 2>/dev/null)"
	fi
}

# _wildcard_merge <name.> <domain>...: the domains given plus the ones the
# earlier expansions of <name.> found, kept for the next run. One dig per
# TLD with a one second timeout: a run on a busy link, or with no WAN up,
# lost some or all of them, and each lost domain's set was flushed (see
# _omr_fed_lost). With nothing now nor kept (no DNS since the boot), the
# <name.><tld> the dnsmasq sections had before this run.
_OMR_WILDCARDS="/var/run/omr-bypass-wildcards"
_wildcard_merge() {
	local prefix="$1" cached
	shift
	cached="$(awk -v p="$prefix" '$1 == p { $1 = ""; print; exit }' "$_OMR_WILDCARDS" 2>/dev/null)"
	set -- $(printf '%s\n' "$@" $cached | awk 'NF && !s[$0]++')
	if [ "$#" -eq 0 ]; then
		awk -v p="$prefix" 'index($1, p) == 1 && substr($1, length(p) + 1) !~ /[.]/ && !s[$1]++ { printf "%s%s", sep, $1; sep = " " }' \
			/tmp/omr-bypass-fed.before 2>/dev/null
		return 0
	fi
	{
		awk -v p="$prefix" '$1 != p' "$_OMR_WILDCARDS" 2>/dev/null
		echo "$prefix $*"
	} > "$_OMR_WILDCARDS.new" 2>/dev/null && mv -f "$_OMR_WILDCARDS.new" "$_OMR_WILDCARDS"
	echo "$*"
}

# _expand_wildcard_domain <name.>: the <name.><tld> that resolve, for the
# country codes and the generic TLDs of up to three letters (and .info), on
# one line. The three copies this replaces kept words 12 to 50 of the IANA
# list (past its "# Version" line, AAA to about ACCOUNTANT: never .com or a
# country) or counted the header's words as TLDs.
_expand_wildcard_domain() {
	local domain="$1" tld domainlist=""
	for tld in $(printf '%s\n' "$tlds" | sed '/^#/d' | grep -i -x -E '[a-z]{2,3}|info'); do
		domainlist="${domainlist} ${domain}${tld}"
	done
	[ -n "$domainlist" ] || return 0
	echo $domainlist  `# Get the list of valid domains, pass it to awk` \
		| awk '{print tolower($0)}'   `# awk lowercases the whole string and passes it to ` \
		| xargs -n8 -P12              `# xargs sends 8 arguments at a time to` \
			dig a +timeout=1 +tries=1 +retry=1 +nocmd +noall +answer `# dig, which passes results (if any) to` \
		| awk '{print $1}'            `# awk, which outputs queried domain to` \
		| sed -e 's/.$//'             `# sed, which trims off the trailing dot (google.com. -> google.com)` \
		| grep -i -F -- "$domain"     `# grep, only keep wanted domain` \
		| awk '{for (i=1;i<=NF;i++) if (!a[$i]++) printf("%s%s",$i,FS)}{printf("\n")}' # deduplicate
}

_get_firewall_zones() {
	if [ -n "$_omr_fw_zones" ]; then
		echo $_omr_fw_zones
		return
	fi
	config_load firewall
	all_zones=""
	get_all_zones() {
		config_get name "$1" name
		case "$(echo "$name" | tr 'A-Z' 'a-z')" in
			wan*|vpn*|*openwisp*) ;;
			*) all_zones="$all_zones $name" ;;
		esac
	}
	config_foreach get_all_zones zone
	echo $all_zones
}

_get_wan_networks() {
	if [ -n "$_omr_wan_networks" ]; then
		echo $_omr_wan_networks
		return
	fi
	config_load firewall
	wan_networks=""
	get_wan_networks() {
		config_get name "$1" name
		config_get networks "$1" network
		case "$(echo "$name" | tr 'A-Z' 'a-z')" in
			wan*) wan_networks="$wan_networks $networks" ;;
		esac
	}
	config_foreach get_wan_networks zone
	echo $wan_networks
}

_add_proto() {
	protoname=$1
	[ -z "$protoname" ] && return
	if ! dd if=/proc/net/xt_ndpi/proto bs=4096 2>/dev/null | grep -q "$protoname"; then
		echo "add_custom $protoname" >/proc/net/xt_ndpi/proto
	fi
	allurls=$(dd if=/proc/net/xt_ndpi/host_proto bs=4096 2>/dev/null)
	hosts=$(uci -q get omr-bypass.$protoname.url)
	for url in $hosts; do
		if ! echo "$allurls" | grep -qi "^${protoname}:.*${url}"; then
			echo "$protoname:$url" >/proc/net/xt_ndpi/host_proto
		fi
	done
	ips=$(uci -q get omr-bypass.$protoname.ip)
	for ip in $ips; do
		if ! echo "$allurls" | grep -qi "^${protoname}:.*${ip}"; then
			echo "$protoname:$ip" >/proc/net/xt_ndpi/ip_proto
		fi
	done
}

_uci_add_list_unique() {
	local key="$1" val="$2"
	uci -q get "$key" 2>/dev/null | tr ' ' '\n' | grep -qxF "$val" || uci -q add_list "$key"="$val"
}

# True if <value> is one of the entries of the list <option>. "grep -w" is not
# enough for domains: netflix.com matched www.netflix.com, so it was never
# added when www.netflix.com came first.
_uci_list_has() {
	case " $(uci -q get "$1" 2>/dev/null) " in
		*" $2 "*) return 0 ;;
	esac
	return 1
}

# _uci_batch: what "uci -q batch" does with the commands on stdin, for the
# set, add_list and delete commands this script generates. "uci batch"
# unloads every config after each command, so each line parsed the whole
# config again, and its delta, which every line made longer. The skeleton of
# 15 interfaces and 8 zones (14,000 lines, a 450 KB firewall config) was
# still running after 17 minutes at boot, and procd held the reboot until it
# was done. Here the deletes are done in one uci cursor, and what is left is
# merged with one "uci import -m" per config: import reads a value with the
# same parser as batch, so the value text is passed on as it is. Commands
# that only apply to what an earlier one left (a set after a delete of the
# same section or option) are dropped when a later delete undoes them, as
# batch would. A line of any other form falls back to "uci batch".
_uci_batch() {
	local tmp="/tmp/omr-bypass-uci.$$" f
	rm -f "$tmp".*
	cat > "$tmp.in"
	[ -s "$tmp.in" ] || { rm -f "$tmp".*; return 0; }
	if ! awk -v out="$tmp" '
		function parse() {
			sub(/^[ \t]+/, ""); sub(/[ \t]+$/, "")
			if ($0 == "") return 0
			cmd = $1; r = substr($0, length(cmd) + 1); sub(/^[ \t]+/, "", r)
			eq = index(r, "="); hasv = (eq > 0)
			v = hasv ? substr(r, eq + 1) : ""
			if (hasv) r = substr(r, 1, eq - 1)
			n = split(r, k, ".")
			if (n < 2 || n > 3 || k[1] !~ /^[A-Za-z0-9_-]+$/ || k[2] !~ /^[A-Za-z0-9_]+$/ || (n == 3 && k[3] !~ /^[A-Za-z0-9_]+$/)) return -1
			s = k[1] "." k[2]; o = s "." k[3]
			empty = (v == "" || v == "\"\"" || v == "\047\047")
			if (cmd == "delete" && !hasv) return n == 2 ? 1 : 2
			# an empty value deletes the option
			if (cmd == "set" && n == 3 && hasv) return empty ? 2 : 3
			if (cmd == "set" && n == 2 && hasv && !empty) {
				t = v; gsub(/^["\047]|["\047]$/, "", t)
				return t ~ /^[A-Za-z0-9_-]+$/ ? 4 : -1
			}
			if (cmd == "add_list" && n == 3 && hasv && !empty) return 5
			return -1
		}
		NR == FNR {
			c = parse()
			if (c < 0) { bad = 1; exit 1 }
			if (c == 1) dels[s] = FNR
			if (c == 2) delo[o] = FNR
			next
		}
		{
			c = parse()
			if (c <= 0) next
			if (c == 1 || c == 2) {
				x = (c == 1) ? s : o
				if (!(x in done)) { print x > (out ".del"); done[x] = 1 }
				if (c == 1) { delete type[s]; if (cur[k[1]] == k[2]) cur[k[1]] = "" }
				next
			}
			if ((s in dels) && FNR < dels[s]) next
			if (c != 4 && (o in delo) && FNR < delo[o]) next
			f = out ".imp." k[1]
			if (c == 4) {
				type[s] = t
				print "config " t " " k[2] > f
				cur[k[1]] = k[2]
				next
			}
			if (!(s in type)) {
				# a section this input has not created (yet): its
				# type is not known here, batch sets it, before the
				# import, which only comes later for it
				if (!(s in dels)) print > (out ".left")
				next
			}
			if (cur[k[1]] != k[2]) { print "config " type[s] " " k[2] > f; cur[k[1]] = k[2] }
			print "\t" (c == 3 ? "option" : "list") " " k[3] " " v > f
		}
		END { if (bad) exit 1 }
	' "$tmp.in" "$tmp.in"; then
		uci -q batch < "$tmp.in" 2>/dev/null
		rm -f "$tmp".*
		return 0
	fi
	if [ -s "$tmp.del" ]; then
		ucode -e '
			import { cursor } from "uci";
			import { stdin } from "fs";
			let c = cursor(), cfgs = {};
			for (let l = stdin.read("line"); length(l); l = stdin.read("line")) {
				let k = split(trim(l), ".");
				c.delete(k[0], k[1], k[2]);
				cfgs[k[0]] = true;
			}
			for (let p in cfgs)
				c.save(p);
		' < "$tmp.del" 2>/dev/null || sed 's/^/delete /' "$tmp.del" | uci -q batch
	fi
	[ -s "$tmp.left" ] && uci -q batch < "$tmp.left"
	for f in "$tmp".imp.*; do
		[ -f "$f" ] || continue
		uci -q -S -m import "${f##*.imp.}" < "$f" 2>/dev/null
	done
	rm -f "$tmp".*
	return 0
}

# Domain/mac/ip/port/proto/category bypass entries store the network
# interface's own UCI section name (e.g. "wan1", from the LuCI interface
# picker) in their "interface" option. _intf_rule resolves that to the
# underlying device (e.g. "eth1") when it builds the ipset/firewall/mark-id
# skeleton for that interface, caching the mapping in
# omr-bypass.ifmap_<section>.device. Without resolving through that same
# cache here, a domain assigned to "wan1" builds sections named
# omr_dst_bypass_wan1_* while the only marking/routing infrastructure that
# actually exists is omr_dst_bypass_eth1_* -- the bypass silently never
# matches (issue #4340). Special pseudo-values pass through unchanged.
_map_intf_device() {
	local raw="$1"
	case "$raw" in
		""|all|none|default|srv_vpn1) printf '%s' "$raw"; return ;;
	esac
	local dev
	dev=$(uci -q get "omr-bypass.ifmap_${raw}.device" 2>/dev/null)
	printf '%s' "${dev:-$raw}"
}

# Output WAN lists (issue: "Output interface" as an ordered failover list)
#
# A rule's "interface" may be a list: the first usable WAN is used, the
# next ones are its failover order, "default" means the MPTCP master. The
# old single "failback" option is appended to the list. A rule with one
# value keeps working exactly as before. A rule with several gets a WAN
# group: a pseudo-interface "grp_<hash of the list>" with its own bypass
# mark 0x4539<id> (id 9001-9899, kept in omr-bypass.<key>.id). The skeleton
# below builds its sets/fw4 rules/proxy exemptions like for any WAN, and
# /usr/lib/omr-bypass/wan-failover keeps "fwmark 0x4539<id> lookup <table
# of the first usable WAN>" up to date, so a failover never touches fw4 or
# dnsmasq.
_rule_intf_list() {
	local sec="$1" raw fb v out=""
	config_get raw "$sec" interface
	config_get fb "$sec" failback
	for v in $raw $fb; do
		# goes into eval'd variable names, uci batch lines and nft rules:
		# only what a uci section name can be
		case "$v" in
			""|*[!A-Za-z0-9_]*)
				logger -t "omr-bypass" "Rule $sec: invalid interface '$v' ignored"
				continue
				;;
		esac
		case "$v" in all) v="default" ;; esac
		case " $out " in *" $v "*) continue ;; esac
		out="${out:+$out }$v"
	done
	# "none" (block) and an empty value (DSCP only) do not combine with a
	# list: the first value decides, as it always did.
	case "$out" in
		none*) out="none" ;;
	esac
	case "$raw" in "") out="" ;; esac
	printf '%s' "$out"
}

_wan_group_key() {
	printf 'grp_%s' "$(printf '%s' "$1" | md5sum | cut -c1-8)"
}

# _rule_dscp <var> <section>: the section's DSCP class, or empty when it is
# not one of the names the LuCI page offers (or a 0-63 number). The value goes
# into eval'd variable names, uci batch lines and nft rules.
_rule_dscp() {
	local __rdscp
	config_get __rdscp "$2" dscp
	case "$__rdscp" in
		""|cs[0-7]|af[1-4][1-3]|ef|le|[0-9]|[1-5][0-9]|6[0-3]) ;;
		*)
			logger -t "omr-bypass" "Rule $2: invalid DSCP class '$__rdscp' ignored"
			__rdscp=""
			;;
	esac
	eval "$1=\"\$__rdscp\""
}

# _rule_proto <var> <section>: the nDPI protocol name of a rule. It goes into
# an sqlite query and into /etc/firewall.omr-bypass, which fw4 runs as a
# shell script.
_rule_proto() {
	local __rproto
	config_get __rproto "$2" proto
	case "$__rproto" in
		*[!A-Za-z0-9_.-]*)
			logger -t "omr-bypass" "Rule $2: invalid protocol '$__rproto' ignored"
			__rproto=""
			;;
	esac
	eval "$1=\"\$__rproto\""
}

# Port rules and MAC addresses are written into /etc/firewall.omr-bypass too
_valid_port_spec() {
	case "$1" in
		""|*[!0-9:,\ -]*) return 1 ;;
	esac
	return 0
}

_valid_port() {
	case "$1" in
		""|*[!0-9]*|0*|??????*) return 1 ;;
	esac
	[ "$1" -le 65535 ]
}

# _rule_ports <var> <section> <ports>: the ports and port ranges of a rule,
# one per word, a range as a-b. "80,443" was one dest_port entry, which fw4
# refuses: it then dropped the shared rule of that WAN and protocol, and with
# it every port rule of the WAN. A value that is not a port is logged and
# left out.
_rule_ports() {
	local __rp="" __p __a __b IFS=", 	"
	for __p in $3; do
		[ -n "$__p" ] || continue
		case "$__p" in *:*) __p="${__p%%:*}-${__p#*:}" ;; esac
		__a="${__p%%-*}"
		__b="${__p#*-}"
		if _valid_port "$__a" && _valid_port "$__b" && [ "$__a" -le "$__b" ]; then
			[ "$__a" = "$__b" ] && __p="$__a"
			__rp="${__rp:+$__rp }$__p"
		else
			logger -t "omr-bypass" "Rule $2: invalid port '$__p' ignored"
		fi
	done
	eval "$1=\"\$__rp\""
}

# Addresses and networks of the sets and rules fw4 generates. iptools'
# valid_subnet4/6 only look at the first characters: 1.2.3.4/33 or
# 2001:db8::/129 went into a set, nft refused the whole ruleset fw4 built
# ("Prefix length 33 is invalid"), and after a reboot there was no firewall
# at all. A range passed too, and fw4 writes it as "<addr>/null" in a set.
_valid_ip4() {
	local IFS=. __o
	case "$1" in ""|*[!0-9.]*|.*|*.|*..*) return 1 ;; esac
	set -- $1
	[ $# -eq 4 ] || return 1
	for __o in "$@"; do
		case "$__o" in ????*|0?*) return 1 ;; esac
		[ "$__o" -le 255 ] || return 1
	done
}

_valid_ip6() {
	local __a="$1" __g __n=0 IFS=:
	case "$__a" in *:*) ;; *) return 1 ;; esac
	case "$__a" in *[!0-9A-Fa-f:.]*|*:::*|*::*::*|:[!:]*|*[!:]:) return 1 ;; esac
	# an embedded IPv4 address counts as two groups
	case "$__a" in
		*.*)
			_valid_ip4 "${__a##*:}" || return 1
			__a="${__a%:*}:0:0"
			;;
	esac
	set -- $__a
	for __g in "$@"; do
		[ -n "$__g" ] || continue
		case "$__g" in *.*|?????*) return 1 ;; esac
		__n=$((__n + 1))
	done
	case "$__a" in
		*::*) [ "$__n" -le 7 ] ;;
		*) [ "$__n" -eq 8 ] ;;
	esac
}

# _valid_prefix_len <length> <max>
_valid_prefix_len() {
	case "$1" in ""|*[!0-9]*|????*) return 1 ;; esac
	[ "$1" -le "$2" ]
}

# _valid_ip4net / _valid_ip6net <value>: an address, or a network with a
# prefix length, as a set element
_valid_ip4net() {
	case "$1" in */*) _valid_prefix_len "${1#*/}" 32 || return 1 ;; esac
	_valid_ip4 "${1%%/*}"
}

_valid_ip6net() {
	case "$1" in */*) _valid_prefix_len "${1#*/}" 128 || return 1 ;; esac
	_valid_ip6 "${1%%/*}"
}

# _ip4_int <a.b.c.d>: the address as a number
_ip4_int() {
	local IFS=.
	set -- $1
	echo $(( ($1 << 24) + ($2 << 16) + ($3 << 8) + $4 ))
}

# _ip6_hex <address>: the address as 32 hex digits, which compare as strings
_ip6_hex() {
	local a="$1" h t g n=0 out="" IFS
	case "$a" in
		*.*)
			IFS=.
			set -- ${a##*:}
			a="${a%:*}:$(printf '%x' $((($1 << 8) + $2))):$(printf '%x' $((($3 << 8) + $4)))"
			;;
	esac
	case "$a" in
		*::*) h="${a%%::*}" t="${a#*::}" ;;
		*) h="$a" t="" ;;
	esac
	IFS=:
	for g in $h $t; do n=$((n + 1)); done
	for g in $h; do out="$out$(printf '%04x' "0x$g")"; done
	while [ "$n" -lt 8 ]; do out="${out}0000"; n=$((n + 1)); done
	for g in $t; do out="$out$(printf '%04x' "0x$g")"; done
	echo "$out"
}

# _ip4_mask_prefix <a.b.c.d>: the prefix length of a contiguous netmask
_ip4_mask_prefix() {
	local IFS=. __o __n=0 __end=""
	set -- $1
	for __o in "$@"; do
		case "$__o" in
			255) [ -z "$__end" ] || return 1; __n=$((__n + 8)) ;;
			0) __end=1 ;;
			254|252|248|240|224|192|128)
				[ -z "$__end" ] || return 1
				__end=1
				case "$__o" in
					254) __n=$((__n + 7)) ;; 252) __n=$((__n + 6)) ;;
					248) __n=$((__n + 5)) ;; 240) __n=$((__n + 4)) ;;
					224) __n=$((__n + 3)) ;; 192) __n=$((__n + 2)) ;;
					128) __n=$((__n + 1)) ;;
				esac
				;;
			*) return 1 ;;
		esac
	done
	echo "$__n"
}

# _valid_rule_ip <value>: what fw4 also takes in a rule's src_ip: a range, or
# an IPv4 network with a netmask. A range given high to low (only the CLI
# can write one) made nft refuse the whole ruleset.
_valid_rule_ip() {
	case "$1" in
		*-*)
			if _valid_ip4 "${1%%-*}" && _valid_ip4 "${1#*-}"; then
				[ "$(_ip4_int "${1%%-*}")" -le "$(_ip4_int "${1#*-}")" ]
			elif _valid_ip6 "${1%%-*}" && _valid_ip6 "${1#*-}"; then
				local __a __b
				__a="$(_ip6_hex "${1%%-*}")"
				__b="$(_ip6_hex "${1#*-}")"
				[ "$__a" = "$__b" ] || [ "$__b" \> "$__a" ]
			else
				return 1
			fi
			;;
		*/*.*) _valid_ip4 "${1%%/*}" && _valid_ip4 "${1#*/}" ;;
		*) _valid_ip4net "$1" || _valid_ip6net "$1" ;;
	esac
}

# _nft_ports <ports>: a port, a range or a list, as nft takes it. The DSCP
# port rules were written "ip tcp dport 443", which nft refuses ("ip" then a
# protocol): no port DSCP rule was ever installed.
_nft_ports() {
	case "$1" in
		*[,\ ]*) printf '{ %s }' "$(printf '%s' "$1" | tr ' ' ',' | sed -e 's/,,*/,/g' -e 's/^,//' -e 's/,$//' -e 's/,/, /g')" ;;
		*) printf '%s' "$1" ;;
	esac
}

# ASN prefixes, one file per set, which fw4 reads with the set's "loadfile":
# a large AS announces thousands of prefixes, and as uci list entries each
# config_load of the firewall config turned every one of them into a shell
# variable, which made a start take minutes.
_BYPASS_ASN_DIR="/tmp/omr-bypass-asn"
# The last prefix list RIPE gave for each AS, one file per AS. On flash:
# on tmpfs it was gone after a reboot, and with RIPE unreachable at boot
# the ASN rules matched nothing until the 02:00 refresh. Written only when
# the list changes (at most a start and the daily refresh).
_BYPASS_ASN_CACHE="/etc/omr-bypass/asn-cache"
# RIPE's answers while they are read
_BYPASS_ASN_TMP="/tmp/omr-bypass-asn-cache"

# _asn_set_add <set section> <prefix>...
_asn_set_add() {
	local sec="$1" file="$_BYPASS_ASN_DIR/$1"
	shift
	printf '%s\n' "$@" >> "$file"
	[ "$(uci -q get "firewall.${sec}.loadfile")" = "$file" ] || uci -q set "firewall.${sec}.loadfile=$file"
}

_asn_files_md5() {
	cat "$_BYPASS_ASN_DIR"/* 2>/dev/null | md5sum
}

# Rebuild the ASN files: the prefixes a set lost are deleted from it before
# fw4's light reload (which never removes elements), and a set no ASN rule
# uses any more stops reading its file.
_bypass_asns() {
	local f sec key new
	rm -rf "${_BYPASS_ASN_DIR}.before"
	[ -d "$_BYPASS_ASN_DIR" ] && mv "$_BYPASS_ASN_DIR" "${_BYPASS_ASN_DIR}.before"
	mkdir -p "$_BYPASS_ASN_DIR"
	config_load omr-bypass
	config_foreach _bypass_asn asns
	for f in "$_BYPASS_ASN_DIR"/*; do
		[ -f "$f" ] && sort -u -o "$f" "$f"
	done
	_asn_stale=""
	for f in "${_BYPASS_ASN_DIR}.before"/*; do
		[ -f "$f" ] || continue
		sec="${f##*/}"
		new="$_BYPASS_ASN_DIR/$sec"
		[ -f "$new" ] || new=/dev/null
		# BusyBox has no comm: the lines of the old file the new one lacks
		# (FILENAME, not NR == FNR, which an empty new file would defeat)
		_asn_stale="${_asn_stale}$(awk -v s="$sec" 'FILENAME == ARGV[1] { n[$0] = 1; next } !($0 in n) { print s, $0 }' \
			"$new" "$f")
"
	done
	rm -rf "${_BYPASS_ASN_DIR}.before"
	for key in $(uci -q show firewall 2>/dev/null | grep -E '^firewall\.omr6?_dst_bypass_[^.]+\.loadfile=' | cut -d= -f1); do
		sec="${key#firewall.}"; sec="${sec%.loadfile}"
		[ -s "$_BYPASS_ASN_DIR/$sec" ] || uci -q delete "$key"
	done
}

_valid_mac() {
	printf '%s' "$1" | grep -q -x -E '([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}'
}

# _get_rule_intf <var> <section>: what config_get <var> <section> interface
# used to give, with a list replaced by its WAN group key.
_get_rule_intf() {
	local _l
	_l=$(_rule_intf_list "$2")
	case "$_l" in
		*" "*) _l=$(_wan_group_key "$_l") ;;
	esac
	eval "$1=\"\$_l\""
}

_wan_groups=""
_wan_groups_collect() {
	local l key
	l=$(_rule_intf_list "$1")
	case "$l" in *" "*) ;; *) return ;; esac
	key=$(_wan_group_key "$l")
	case " $_wan_groups " in *" $key "*) return ;; esac
	_wan_groups="$_wan_groups $key"
	uci -q set "omr-bypass.${key}=wangroup"
	uci -q delete "omr-bypass.${key}.wan"
	for v in $l; do
		uci -q add_list "omr-bypass.${key}.wan=$v"
	done
}

# Collect every list in use, keep the ids of known groups, hand out free
# ids to new ones and drop the groups nothing uses any more.
_wan_groups_setup() {
	local t key id used="" metrics
	_wan_groups=""
	config_load omr-bypass
	for t in domains ips macs lan_ip dest_port src_port asns dpis categories; do
		config_foreach _wan_groups_collect "$t"
	done
	# A WAN's own bypass mark is 0x4539<metric>: a group id equal to a
	# metric would share it, and its ip rule
	metrics=" $(uci -q show network 2>/dev/null | sed -n "s/^network\.[^.]*\.metric='\{0,1\}\([0-9]*\)'\{0,1\}$/\1/p" | xargs) "
	used="$metrics"
	for key in $(uci -q -X show omr-bypass 2>/dev/null | sed -n 's/^omr-bypass\.\([^.=]*\)=wangroup$/\1/p'); do
		case " $_wan_groups " in
			*" $key "*)
				id=$(uci -q get "omr-bypass.${key}.id")
				case "$metrics" in
					*" $id "*) uci -q delete "omr-bypass.${key}.id" ;;
					*) used="$used $id" ;;
				esac
				;;
			*) uci -q delete "omr-bypass.${key}" ;;
		esac
	done
	for key in $_wan_groups; do
		[ -n "$(uci -q get "omr-bypass.${key}.id")" ] && continue
		id=9001
		while [ "$id" -le 9899 ]; do
			case " $used " in *" $id "*) id=$((id + 1)) ;; *) break ;; esac
		done
		[ "$id" -le 9899 ] || { logger -t "omr-bypass" "Too many output WAN lists, $key ignored"; uci -q delete "omr-bypass.${key}"; continue; }
		uci -q set "omr-bypass.${key}.id=$id"
		used="$used $id"
	done
}

_sanitize_intf_key() {
	printf '%s' "$1" | sed 's/[.-]/_/g; s/[^A-Za-z0-9_]/_/g'
}

_bypass_ip() {
	local ip=$1
	local type=$2
	local tcpudp=$3
	[ -z "$type" ] && type="all"
	[ "$type" = "default" ] && type="all"
	# Only tcp and udp have sets of their own: any other value would fill a
	# set that doesn't exist, and the rule would stop working altogether
	case "$tcpudp" in tcp|udp) ;; *) tcpudp="all" ;; esac
	# none and srv_vpn1 have no sets and rules per protocol
	case "$type" in none|srv_vpn1) tcpudp="all" ;; esac
	valid_ip4=""
	valid_ip6=""
	if _valid_ip4net "$ip"; then
		valid_ip4=$( valid_subnet4 "$ip")
	elif _valid_ip6net "$ip"; then
		valid_ip6=$( valid_subnet6 "$ip")
	fi
	if [ "$tcpudp" = "all" ]; then
		if [ "$valid_ip4" = "ok" ]; then
			_uci_add_list_unique firewall.omr_dst_bypass_${type}_4.entry "$ip"
			uci -q set firewall.omr_dst_bypass_${type}_4.enabled='1'
			for zone in $(_get_firewall_zones); do
				uci -q set firewall.omr_dst_bypass_${type}_dstip_4_${zone}.enabled='1'
				uci -q set firewall.omr_dst_bypass_${type}_dstip_4_${zone}_accept.enabled='1'
			done
		elif [ "$valid_ip6" = "ok" ]; then
			_uci_add_list_unique firewall.omr_dst_bypass_${type}_6.entry "$ip"
			uci -q set firewall.omr_dst_bypass_${type}_6.enabled='1'
			for zone in $(_get_firewall_zones); do
				uci -q set firewall.omr_dst_bypass_${type}_dstip_6_${zone}.enabled='1'
				uci -q set firewall.omr_dst_bypass_${type}_dstip_6_${zone}_accept.enabled='1'
			done
		fi
	else
		# the _tcp/_udp rules match the sets of that protocol
		if [ "$valid_ip4" = "ok" ]; then
			_uci_add_list_unique firewall.omr_dst_bypass_${type}_4_${tcpudp}.entry "$ip"
			uci -q set firewall.omr_dst_bypass_${type}_4_${tcpudp}.enabled='1'
			for zone in $(_get_firewall_zones); do
				uci -q set firewall.omr_dst_bypass_${type}_dstip_4_${zone}_${tcpudp}.enabled='1'
				uci -q set firewall.omr_dst_bypass_${type}_dstip_4_${zone}_accept.enabled='1'
			done
		elif [ "$valid_ip6" = "ok" ]; then
			_uci_add_list_unique firewall.omr_dst_bypass_${type}_6_${tcpudp}.entry "$ip"
			uci -q set firewall.omr_dst_bypass_${type}_6_${tcpudp}.enabled='1'
			for zone in $(_get_firewall_zones); do
				uci -q set firewall.omr_dst_bypass_${type}_dstip_6_${zone}_${tcpudp}.enabled='1'
				uci -q set firewall.omr_dst_bypass_${type}_dstip_6_${zone}_accept.enabled='1'
			done
		fi
	fi
}

# Reply-path routing for connections that came in from outside (issue #4369)
#
# A bypass rule is about where a connection *goes out*: fw4 renders its MARK
# rule into mangle_prerouting with the LAN devices as iifname, so it also
# matches the packets a port-forwarded LAN server sends back. Those packets
# are the reply direction of a connection that was opened from outside, and
# conntrack has already decided what source address they will leave with
# (the address the connection came in on), so steering them out of a bypass
# interface only makes the answer unroutable: a forward reached over omrvpn
# leaves a WAN carrying the tunnel address, and one reached on a WAN address
# leaves through whatever the default route happens to be. Either way the
# remote end never sees the reply, which is what "bypass and port forwarding
# do not play along" comes down to.
#
# Two halves fix it, both in one chain that runs right after fw4's own
# mangle_prerouting (so it has the last word on the mark) and still well
# before the routing decision:
#
#  * every connection coming in on a wan-zone device gets that device's
#    bypass mark stored in its ct mark, and the reply packets get it put
#    back -- a port forward reached directly on a WAN address now answers
#    through that same WAN, instead of following the default route into the
#    VPN. This is what makes a port forward work "in both directions" on a
#    bypassed/direct interface at all.
#  * a reply packet of a connection that came in anywhere else (typically
#    omrvpn, i.e. every port forward relayed by the VPS) gets any bypass
#    mark taken back off, so it keeps following the route its own ingress
#    interface implies instead of being hijacked by a rule that was meant
#    for LAN-originated traffic.
#
# Only the marks this run generated are cleared, never an unrelated mark:
# the proxies' tproxy chains sit at the same prerouting hook and use mark
# 0x1 to get packets delivered locally.
#
# This goes in an include file of its own rather than into
# /etc/firewall.omr-bypass, whose first line used to be an "is shadowsocks
# in the ruleset" guard that exited the whole script: reply-path routing has
# to hold for every proxy setting, including none at all.
#
# The chain is generated as an nft script and applied in ONE transaction,
# not as a dozen "nft add rule ... 2>/dev/null || true" calls. Those fail
# independently, and the two rules that carry the whole point of the chain
# are the last ones: a run that lost the race against a concurrent fw4
# pass left the six harmless per-WAN save rules in place and dropped both
# reply rules, so the chain looked installed while doing nothing at all
# (observed once on the bench, silently). One transaction is all-or-
# nothing, needs one nft invocation instead of ten, and a failure is
# logged instead of discarded.
_REPLY_INCLUDE="/etc/firewall.omr-bypass-reply"
_REPLY_NFT="/etc/firewall.omr-bypass-reply.nft"
# Where the include files are written. start_service writes them to /tmp
# and _omr_install_include puts each in place only when it changed: they
# were deleted and written again on flash at every start and reload.
_fw_include="/etc/firewall.omr-bypass"
_reply_nft_out="$_REPLY_NFT"
_reply_include_out="$_REPLY_INCLUDE"

# _omr_install_include <new> <file>: <file> becomes <new>, or goes away
# when this run wrote nothing
_omr_install_include() {
	if [ ! -f "$1" ]; then
		rm -f "$2"
	elif cmp -s "$1" "$2"; then
		rm -f "$1"
	else
		cp "$1" "$2.new" && mv -f "$2.new" "$2"
		rm -f "$1"
	fi
}
_reply_chain_written=""
_reply_marks=""
_reply_wan_marks=""

_reply_ensure_chain() {
	[ -n "$_reply_chain_written" ] && return
	_reply_chain_written=1
	# Declare-then-flush-then-fill, all inside the one transaction: the
	# declaration creates the chain when fw4 has just rebuilt the table
	# (and is a no-op when it is still there), and the flush is what keeps
	# a re-run from stacking duplicate rules.
	cat > "$_reply_nft_out" <<-'EOF'
		table inet fw4 {
			chain omr_bypass_reply {
				type filter hook prerouting priority mangle + 5; policy accept;
			}
		}
		flush chain inet fw4 omr_bypass_reply
		table inet fw4 {
			chain omr_bypass_reply {
	EOF
	cat > "$_reply_include_out" <<-EOF
		[ -f $_REPLY_NFT ] || exit 0
		nft -f $_REPLY_NFT || logger -t "omr-bypass" "omr_bypass_reply: reply-path rules not applied (see $_REPLY_NFT)"
		exit 0
	EOF
}

# Remember every mark the skeleton hands out, so the "don't hijack a reply"
# rule below can name them all and nothing else.
_reply_add_mark() {
	local mark
	for mark in "$@"; do
		[ -z "$mark" ] && continue
		case "$_reply_marks" in *" $mark "*) continue ;; esac
		_reply_marks="${_reply_marks} $mark "
	done
}

# Same for the subset actually stored in ct marks, so the restore rule can
# match on those values only and never copies a ct mark some other package
# owns into the packet mark.
_reply_add_wan_mark() {
	local mark
	for mark in "$@"; do
		[ -z "$mark" ] && continue
		case "$_reply_wan_marks" in *" $mark "*) continue ;; esac
		_reply_wan_marks="${_reply_wan_marks} $mark "
	done
}

# ct mark 0x0 as a match keeps this to one write per connection instead of
# one per packet, and leaves a connection alone once it is remembered.
_reply_wan_rule() {
	local dev="$1" count="$2"
	[ -z "$dev" ] && return
	_reply_ensure_chain
	_reply_add_wan_mark "0x4539${count}" "0x6539${count}"
	cat >> "$_reply_nft_out" <<-EOF
		iifname "$dev" meta nfproto ipv4 ct direction original ct mark 0x0 counter ct mark set 0x4539${count} comment "omr_bypass_reply_save_${dev}_4"
		iifname "$dev" meta nfproto ipv6 ct direction original ct mark 0x0 counter ct mark set 0x6539${count} comment "omr_bypass_reply_save_${dev}_6"
	EOF
}

_reply_finish() {
	# The chain earns its keep even on a router with no wan-zone device to
	# remember (no save rule ran): the "don't hijack a reply" rule below is
	# what keeps VPS-relayed port forwards working.
	[ -z "$_reply_marks" ] && return
	_reply_ensure_chain
	local mark markset="" wanmarkset=""
	for mark in $_reply_marks; do
		markset="${markset:+$markset, }$mark"
	done
	for mark in $_reply_wan_marks; do
		wanmarkset="${wanmarkset:+$wanmarkset, }$mark"
	done
	# Restore: only the values stored above, so a ct mark another package
	# owns is never copied into the packet mark.
	local notstored=""
	if [ -n "$wanmarkset" ]; then
		notstored="ct mark != { $wanmarkset } "
		cat >> "$_reply_nft_out" <<-EOF
			ct direction reply ct mark { $wanmarkset } counter meta mark set ct mark comment "omr_bypass_reply_restore"
		EOF
	fi
	# Neutralise: a reply of a connection that came in somewhere we did not
	# remember (omrvpn above all) keeps its own ingress route.
	[ -n "$markset" ] && cat >> "$_reply_nft_out" <<-EOF
		ct direction reply ${notstored}meta mark { $markset } counter meta mark set 0x0 comment "omr_bypass_reply_unhijack"
	EOF
	cat >> "$_reply_nft_out" <<-'EOF'
		}
		}
	EOF
}

_dscp_chain_written=""
_dscp_ipset_vals=""

_dscp_ensure_chain() {
	[ -n "$_dscp_chain_written" ] && return
	_dscp_chain_written=1
	cat >> "$_fw_include" <<-'EOF'
		nft add chain inet fw4 omr_dscp_pre "{ type filter hook prerouting priority filter - 2; policy accept; }" 2>/dev/null || true
	EOF
}

# The DSCP sets are omr-bypass's own: omr-dscp creates omr_dscp_<class>_4/_6
# sets and dnsmasq lists of its own, of another set type, and deletes them on
# every start. Sharing the names made each package delete the other's
# sections, and fw4 fail with "File exists" when the two set types met.
_dscp_ensure_ipset() {
	local dscp=$1
	[ -z "$dscp" ] && return
	case "$_dscp_ipset_vals" in *" $dscp "*) return ;; esac
	_dscp_ipset_vals="${_dscp_ipset_vals} $dscp "
	_dscp_ensure_chain
	uci -q batch <<-EOF
		set firewall.omr_dst_bypass_dscp_${dscp}_4=ipset
		set firewall.omr_dst_bypass_dscp_${dscp}_4.name="omr_dst_bypass_dscp_${dscp}_4"
		set firewall.omr_dst_bypass_dscp_${dscp}_4.match='dest_net'
		set firewall.omr_dst_bypass_dscp_${dscp}_4.family='ipv4'
		set firewall.omr_dst_bypass_dscp_${dscp}_4.enabled='1'
		set firewall.omr_dst_bypass_dscp_${dscp}_6=ipset
		set firewall.omr_dst_bypass_dscp_${dscp}_6.name="omr_dst_bypass_dscp_${dscp}_6"
		set firewall.omr_dst_bypass_dscp_${dscp}_6.match='dest_net'
		set firewall.omr_dst_bypass_dscp_${dscp}_6.family='ipv6'
		set firewall.omr_dst_bypass_dscp_${dscp}_6.enabled='1'
	EOF
	cat >> "$_fw_include" <<-EOF
		nft add rule inet fw4 omr_dscp_pre ip daddr @omr_dst_bypass_dscp_${dscp}_4 ip dscp set ${dscp} 2>/dev/null || true
		nft add rule inet fw4 omr_dscp_pre ip6 daddr @omr_dst_bypass_dscp_${dscp}_6 ip6 dscp set ${dscp} 2>/dev/null || true
	EOF
}

_dscp_ip() {
	local ip=$1
	local dscp=$2
	[ -z "$dscp" ] && return
	local valid_ip4="" valid_ip6=""
	if _valid_ip4net "$ip"; then
		valid_ip4=$( valid_subnet4 "$ip")
	elif _valid_ip6net "$ip"; then
		valid_ip6=$( valid_subnet6 "$ip")
	fi
	_dscp_ensure_ipset "$dscp"
	if [ "$valid_ip4" = "ok" ]; then
		_uci_add_list_unique "firewall.omr_dst_bypass_dscp_${dscp}_4.entry" "$ip"
	elif [ "$valid_ip6" = "ok" ]; then
		_uci_add_list_unique "firewall.omr_dst_bypass_dscp_${dscp}_6.entry" "$ip"
	fi
}

# The dnsmasq ipset sections as dnsmasq sees them: their domains, sets and
# order
_dnsipset_state() {
	uci -q -X show dhcp | grep -E '^dhcp\.omr_dst_bypass_[^.]*(=|\.(name|domain)=)'
}

# What start_service writes to dhcp: the generated sections, every option,
# and the noipv6 list
_omr_dhcp_state() {
	uci -q -X show dhcp | grep -E '^dhcp\.omr_dst_bypass_'
	uci -q get dhcp.@dnsmasq[0].noipv6
}

# The addresses dnsmasq (for a domain) and the nDPId consumer (for a
# protocol) add to the sets are in no .entry list: nothing took them out
# when the domain or the rule went away or moved to another interface, and
# fw4's light reload keeps them. A domain moved to another WAN was in both
# sets and the last MARK rule won. Those feeds are written as "<domain or
# protocol> <set>" pairs, before and after a run: a set that lost a pair is
# flushed before the reload adds back what it declares (_omr_fed_lost). A
# run that changes nothing flushes nothing: flushing also takes out what
# dnsmasq added for the other domains, until they are resolved again.
_NDPID_LIST="/var/run/omr-bypass-ndpid.list"

_dnsipset_pairs() {
	uci -q show dhcp 2>/dev/null | awk '
		/^dhcp\.omr_dst_bypass_[^.]*\.(name|domain)=/ {
			eq = index($0, "=")
			k = substr($0, 6, eq - 6)
			o = k; sub(/^[^.]*\./, "", o); sub(/\..*$/, "", k)
			v = substr($0, eq + 1); gsub(/\047/, "", v)
			if (!(k in sec)) { sec[k] = 1; order[++ns] = k }
			if (o == "name") names[k] = names[k] " " v
			else doms[k] = doms[k] " " v
		}
		END {
			for (i = 1; i <= ns; i++) {
				k = order[i]
				nd = split(doms[k], d, " "); nn = split(names[k], n, " ")
				for (a = 1; a <= nd; a++)
					for (b = 1; b <= nn; b++)
						print d[a], n[b]
			}
		}'
}

# _ndpid_list_pairs <consumer list>: the sets ndpid-consumer adds the
# addresses of each protocol to (it decides them the same way)
_ndpid_list_pairs() {
	printf '%s\n' $1 | awk -F: '
		NF {
			bp = $1; intf = $2; dscp = $4; fam = $5; p = $6
			v4 = (fam != "ipv6"); v6 = (fam != "ipv4")
			if (p != "tcp" && p != "udp") p = ""
			if (intf == "srv_vpn1") p = ""
			if (p != "") p = "_" p
			if (intf == "all") {
				if (v4) print bp, "bypass_" bp
				if (v6) print bp, "bypass6_" bp
			} else if (intf != "") {
				if (v4) print bp, "omr_dst_bypass_" intf "_4" p
				if (v6) print bp, "omr_dst_bypass_" intf "_6" p
			}
			if (dscp != "") {
				if (v4) print bp, "omr_dst_bypass_dscp_" dscp "_4"
				if (v6) print bp, "omr_dst_bypass_dscp_" dscp "_6"
			}
		}'
}

# _omr_fed_lost <before> <after>: the sets of the pairs <before> has and
# <after> has not, on one line
_omr_fed_lost() {
	awk 'FILENAME == ARGV[1] { n[$0] = 1; next } NF == 2 && !($0 in n) && !($2 in s) { s[$2] = 1; printf "%s%s", sep, $2; sep = " " }' "$2" "$1"
}

# Set when rules want nDPId and it is not up: the protocols of the last
# consumer list are not taken for removed. Flushing their sets took out
# every address dnsmasq had added for the domains too.
_omr_ndpid_unsure=""

# dnsmasq fills the sets of one ipset entry per domain, so omr-dscp gives a
# domain it marks and omr-bypass routes an entry of its own that also names
# the omr-bypass sets dnsmasq would have used (see its init). It has to look
# at them again when they changed, or a domain stays routed through a WAN
# after its rule is removed. Only when omr-dscp runs (its firewall sections
# exist) and has domains.
_omr_dscp_resync() {
	[ -x /etc/init.d/omr-dscp ] || return 0
	uci -q get firewall.omr_dscp_rule_cs0_4 >/dev/null || return 0
	uci -q show dscp 2>/dev/null | grep -q '=domains$' || return 0
	/etc/init.d/omr-dscp reload
}

# An fw4 restart creates table inet fw4 again, empty: the addresses dnsmasq
# added to the sets of an ipset section with domains (omr-bypass's,
# omr-dscp's, the user's) are gone, and dnsmasq adds them again only from
# an upstream answer, not from its cache.
_dnsmasq_feeds_sets() {
	uci -q show dhcp 2>/dev/null | awk -F= '
		/^dhcp\.[^.=]+=ipset$/ { s[substr($1, 6)] = 1; next }
		/^dhcp\.[^.=]+\.domain=/ {
			k = substr($1, 6); sub(/\..*$/, "", k)
			if (k in s) { f = 1; exit }
		}
		END { exit !f }'
}

# _dnsipset_open <family> <section> <set name>...: make <section> a dnsmasq
# ipset that names exactly these sets, and load the size of its nftset= line
# into the family's counters. A plain start keeps the domains an earlier run
# wrote (a reload has wiped them), so they are counted too; names that are
# not the expected ones (DSCP names an older version added to an interface's
# list) are dropped. The domains found are remembered in the family's list
# of domains seen this run.
_dnsipset_open() {
	local fam="$1" key="$2" cur n bytes=7 cnt=0 seen
	shift 2
	cur=$(uci -q get "dhcp.${key}.name")
	if [ "$cur" != "$*" ]; then
		{
			echo "set dhcp.${key}=ipset"
			echo "set dhcp.${key}.table='fw4'"
			echo "set dhcp.${key}.table_family='inet'"
			[ -n "$cur" ] && echo "delete dhcp.${key}.name"
			for n in "$@"; do
				echo "add_list dhcp.${key}.name=\"$n\""
			done
		} | uci -q batch
	fi
	for n in "$@"; do
		bytes=$((bytes + ${#n} + 12))
	done
	eval "seen=\${_dnsipset_seen_${fam}:-}"
	for n in $(uci -q get "dhcp.${key}.domain"); do
		bytes=$((bytes + ${#n} + 1))
		cnt=$((cnt + 1))
		seen="$seen $n"
	done
	eval "_dnsipset_cnt_${fam}=$cnt"
	eval "_dnsipset_bytes_${fam}=$bytes"
	eval "_dnsipset_seen_${fam}=\$seen"
}

# _dnsipset_add <family> <domain> <set name>...: add <domain> to the dnsmasq
# ipset of <family>. dnsmasq writes one nftset= line per ipset entry and its
# config parser has a ~1024-byte line buffer (issue #3242), so the domains of
# a family are spread over omr_dst_bypass_<family>, omr_dst_bypass_<family>_d1,
# _d2, ...: a slot is closed BEFORE a domain would push its line past
# _BYPASS_MAX_DNSIPSET_LINE_BYTES, set names included. Returns 0 when the
# domain was added, 1 when the family already had it (in any slot this run
# added to or opened).
_dnsipset_add() {
	local fam="$1" domain="$2" slot key cnt bytes add seen
	shift 2
	eval "slot=\${_dnsipset_slot_${fam}:-}"
	if [ -z "$slot" ]; then
		slot=0
		eval "_dnsipset_slot_${fam}=0"
		eval "_dnsipset_seen_${fam}="
		_dnsipset_open "$fam" "omr_dst_bypass_${fam}" "$@"
	fi
	if [ "$slot" = "0" ]; then
		key="omr_dst_bypass_${fam}"
	else
		key="omr_dst_bypass_${fam}_d${slot}"
	fi
	eval "seen=\${_dnsipset_seen_${fam}:-}"
	case "$seen " in *" $domain "*) return 1 ;; esac
	add=$((${#domain} + 1))
	while :; do
		eval "cnt=\${_dnsipset_cnt_${fam}:-0}"
		eval "bytes=\${_dnsipset_bytes_${fam}:-0}"
		# an empty slot always takes the domain: the loop has to end
		[ "$cnt" -eq 0 ] && break
		[ "$cnt" -lt "$_BYPASS_MAX_DOMAINS_PER_DNSIPSET" ] && \
			[ "$((bytes + add))" -le "$_BYPASS_MAX_DNSIPSET_LINE_BYTES" ] && break
		slot=$((slot + 1))
		key="omr_dst_bypass_${fam}_d${slot}"
		eval "_dnsipset_slot_${fam}=${slot}"
		_dnsipset_open "$fam" "$key" "$@"
		eval "seen=\${_dnsipset_seen_${fam}:-}"
		case "$seen " in *" $domain "*) return 1 ;; esac
	done
	uci -q add_list "dhcp.${key}.domain=$domain"
	eval "_dnsipset_cnt_${fam}=$((cnt + 1))"
	eval "_dnsipset_bytes_${fam}=$((bytes + add))"
	eval "_dnsipset_seen_${fam}=\"\$seen \$domain\""
	return 0
}

# The addresses dig gives the domains at a start, and what they went to.
# A reload does not run dig (one query per domain and per database host,
# minutes on small boards with the lock held), so the .entry lists it
# rebuilt lacked them all: the first reload after a start deleted them from
# the sets as stale. A start keeps "<domain> <address>" here, a reload
# writes what each domain goes to this time ("<domain> <interface> <v4>
# <v6> <tcpudp> <dscp>") and _bypass_resolved_replay adds the kept
# addresses back from these, in one uci batch. A domain whose rule is
# gone is not asked for and loses them; one moved to another interface
# takes them along.
_BYPASS_RESOLVED="/var/run/omr-bypass-resolved"
_bypass_resolved_new=""
_bypass_resolved_want=""

# _bypass_resolved_keep <domain> <address>: remember an address dig gave,
# when it is one _bypass_ip and _dscp_ip take
_bypass_resolved_keep() {
	[ -n "$_bypass_resolved_new" ] || return 0
	if _valid_ip4net "$2"; then
		[ "$(valid_subnet4 "$2")" = "ok" ] || return 0
	elif _valid_ip6net "$2"; then
		[ "$(valid_subnet6 "$2")" = "ok" ] || return 0
	else
		return 0
	fi
	echo "$1 $2" >> "$_bypass_resolved_new"
}

_bypass_resolved_replay() {
	[ -n "$_bypass_resolved_want" ] && [ -s "$_bypass_resolved_want" ] && [ -s "$_BYPASS_RESOLVED" ] || return 0
	# only sections this run has (a set or rule that is not there was a
	# silent no-op for "uci -q set" too), and only entries not there yet
	uci -q show firewall 2>/dev/null | grep -E '^firewall\.omr_dst_bypass_' | \
		awk -v zones="$(_get_firewall_zones)" -v res="$_BYPASS_RESOLVED" -v want="$_bypass_resolved_want" '
		function typed(s) {
			if (!(s in type)) return 0
			if (!(s in said)) { said[s] = 1; print "set firewall." s "=" type[s] }
			return 1
		}
		function entry(s, v) {
			if ((s, v) in have || !typed(s)) return
			have[s, v] = 1
			print "add_list firewall." s ".entry=\047" v "\047"
		}
		function enable(s) {
			if ((s in on) || !typed(s)) return
			on[s] = 1
			print "set firewall." s ".enabled=\0471\047"
		}
		{
			eq = index($0, "=")
			k = substr($0, 10, eq - 10)
			v = substr($0, eq + 1)
			if (k !~ /\./) { type[k] = v; next }
			if (k !~ /\.entry$/) next
			sub(/\.entry$/, "", k)
			n = split(v, vals, " ")
			for (i = 1; i <= n; i++) { gsub(/\047/, "", vals[i]); have[k, vals[i]] = 1 }
		}
		END {
			nz = split(zones, zone, " ")
			while ((getline l < res) > 0) {
				split(l, f, " ")
				if (f[2] != "" && !((f[1], f[2]) in seen)) { seen[f[1], f[2]] = 1; ips[f[1]] = ips[f[1]] " " f[2] }
			}
			while ((getline l < want) > 0) {
				split(l, w, " ")
				if (!(w[1] in ips)) continue
				t = w[2]; p = w[5]
				# as _bypass_ip: only tcp and udp have sets of their own,
				# and none and srv_vpn1 have none
				if (p != "tcp" && p != "udp") p = ""
				if (t == "none" || t == "srv_vpn1") p = ""
				if (p != "") p = "_" p
				n = split(ips[w[1]], a, " ")
				for (i = 1; i <= n; i++) {
					fam = index(a[i], ":") ? 6 : 4
					if ((fam == 4 && w[3] != "1") || (fam == 6 && w[4] != "1")) continue
					if (t != "-") {
						entry("omr_dst_bypass_" t "_" fam p, a[i])
						enable("omr_dst_bypass_" t "_" fam p)
						for (z = 1; z <= nz; z++) {
							enable("omr_dst_bypass_" t "_dstip_" fam "_" zone[z] p)
							enable("omr_dst_bypass_" t "_dstip_" fam "_" zone[z] "_accept")
						}
					}
					if (w[6] != "-") entry("omr_dst_bypass_dscp_" w[6] "_" fam, a[i])
				}
			}
		}' | _uci_batch
}

# _valid_domain <name>: a domain dnsmasq and dig can take, in the forms the
# Domains table accepts: a host name, optionally with a leading "." or "*."
# and a trailing "." or ".*" (TLD wildcard). Anything else went to dnsmasq
# as is: a pasted URL ("https://x.com", "x.com/") made an nftset= entry
# with an empty domain, which matches every name, and a non-ASCII or
# over-long label stopped dnsmasq from starting (no DNS, no DHCP).
_valid_domain() {
	local d="$1" l
	case "$d" in
		''|*[!A-Za-z0-9.*_-]*) return 1 ;;
	esac
	d="${d#\*.}"
	d="${d#.}"
	d="${d%.\*}"
	d="${d%.}"
	case "$d" in
		''|*\**|.*|*.|*..*) return 1 ;;
	esac
	[ "${#d}" -le 253 ] || return 1
	for l in $(echo "$d" | tr '.' ' '); do
		[ "${#l}" -le 63 ] || return 1
	done
	return 0
}

_bypass_domains() {
	local domain
	local intf
	local enabled
	local dscp
	config_get domain $1 name
	_get_rule_intf intf $1
	config_get enabled $1 enabled
	config_get noipv6 $1 noipv6 "1"
	config_get family $1 family "ipv4ipv6"
	# LuCI names this option proto for domains (tcpudp for protocols)
	config_get tcpudp $1 tcpudp
	[ -z "$tcpudp" ] && config_get tcpudp $1 proto "all"
	config_get vpn $1 vpn
	_rule_dscp dscp $1
	[ "$vpn" = "1" ] && intf="srv_vpn1"
	[ -z "$intf" ] && [ -z "$dscp" ] && return
	[ "$intf" = "default" ] && intf="all"
	[ "$enabled" = "0" ] && return
	[ -z "$domain" ] && return
	if ! _valid_domain "$domain"; then
		logger -t "omr-bypass" "Skipping invalid bypass domain '$domain'"
		return
	fi
	#[ -z "$family" ] && family="ipv4ipv6"
	#[ -z "$noipv6" ] && noipv6="1"
	case "$domain" in
		*.|*.\*) _domain_wildcard=1 ;;
		*)       _domain_wildcard=0 ;;
	esac
	if [ "$_domain_wildcard" = "1" ]; then
		_fetch_tlds
		# the single quotes made this the literal text "$domain" (quotes
		# included): a wildcard rule expanded to nothing at all
		domain="$(echo "$domain" | sed 's:*::')"
		domainlist="$(_wildcard_merge "$domain" $(_expand_wildcard_domain "$domain"))"
		for validdomain in $domainlist; do
			_bypass_domain "$validdomain" "$intf" "$family" "$noipv6" "$tcpudp" "$dscp"
		done
	else
		#echo "_bypass_domain $domain $intf $family $noipv6"
		_bypass_domain "$domain" "$intf" "$family" "$noipv6" "$tcpudp" "$dscp"
	fi
}

_bypass_domain() {
	local domain=$1
	local intf=$2
	local family=$3
	local noipv6=$4
	local tcpudp=$5
	local dscp=$6
	intf=$(_map_intf_device "$intf")
	intf=$(_sanitize_intf_key "$intf")
	[ -z "$intf" ] && [ -z "$dscp" ] && return
	[ "$intf" = "default" ] && intf="all"
	[ -z "$noipv6" ] && noipv6="1"
	[ -z "$tcpudp" ] && tcpudp="all"
	# "IPv4 only" / "IPv6 only": the sets, rules and DNS answers of the
	# other family are left alone. An IPv6 only rule ignores noipv6 (on by
	# default): blocking the AAAA answers would leave it nothing to match.
	local _v4=4 _v6=6 _fk=""
	case "$family" in
		ipv4) _v6="" _fk="_ipv4" ;;
		ipv6) _v4="" _fk="_ipv6" noipv6="0" ;;
	esac
	if [ -n "$domain" ]; then
		domain="${domain#.}"
		#logger -t "omr-bypass" "Get IPs of $domain..."
		if [ -z "$RELOAD" ]; then
			if [ -n "$_v4" ]; then
				resolve=$(dig a +timeout=1 +tries=1 +nocmd +noall +answer "$domain" | grep -v CNAME | awk '{print $5}')
				for ip in $resolve; do
					[ -n "$intf" ] && _bypass_ip $ip $intf $tcpudp
					[ -n "$dscp" ] && _dscp_ip $ip $dscp
					_bypass_resolved_keep "$domain" "$ip"
				done
			fi
			if [ -n "$_v6" ] && [ "$disableipv6" = "0" ]; then
				resolve=$(dig aaaa +timeout=1 +tries=1 +nocmd +noall +answer "$domain" | grep AAAA | awk '{print $5}')
				for ip in $resolve; do
					[ -n "$intf" ] && _bypass_ip $ip $intf $tcpudp
					[ -n "$dscp" ] && _dscp_ip $ip $dscp
					_bypass_resolved_keep "$domain" "$ip"
				done
			fi
		elif [ -n "$_bypass_resolved_want" ]; then
			# see _bypass_resolved_replay
			local _w4=0 _w6=0
			[ -n "$_v4" ] && _w4=1
			[ -n "$_v6" ] && [ "$disableipv6" = "0" ] && _w6=1
			echo "$domain ${intf:--} $_w4 $_w6 $tcpudp ${dscp:--}" >> "$_bypass_resolved_want"
		fi
		# One dnsmasq ipset family per destination: the interface alone,
		# the interface and a DSCP class, or a DSCP class alone (no
		# routing change). Every domain of an ipset entry populates every
		# set it names, so a domain without a DSCP class must not share an
		# entry with the DSCP set names of another rule, nor a domain of
		# one family with the sets of the other. _dnsipset_add spreads
		# each family over omr_dst_bypass_<family>, _d1, _d2, ...
		if [ -n "$intf" ]; then
			# TCP or UDP only: the sets, rules and dnsmasq list of that
			# protocol, which _intf_rule makes next to the generic ones
			# (none and srv_vpn1 have none)
			local _p="" _f _names=""
			case "$intf:$tcpudp" in
				none:*|srv_vpn1:*) ;;
				*:tcp|*:udp) _p="_${tcpudp}" ;;
			esac
			for _f in $_v4 $_v6; do
				_names="$_names omr_dst_bypass_${intf}_${_f}${_p}"
			done
			if [ -n "$dscp" ]; then
				_dscp_ensure_ipset "$dscp"
				for _f in $_v4 $_v6; do
					_names="$_names omr_dst_bypass_dscp_${dscp}_${_f}"
				done
				_dnsipset_add "${intf}${_p}_dscp_${dscp}${_fk}" "$domain" $_names
			else
				_dnsipset_add "${intf}${_p}${_fk}" "$domain" $_names
			fi && {
				uci -q set firewall.omr_dst_bypass_${intf}_${_v4:-6}${_p}.enabled='1'
				add_domains="true"
			}
			if [ -n "$_v4" ]; then
				for zone in $(_get_firewall_zones); do
					uci -q set firewall.omr_dst_bypass_${intf}_dstip_4_${zone}${_p}.enabled='1'
					uci -q set firewall.omr_dst_bypass_${intf}_dstip_4_${zone}_accept.enabled='1'
				done
			fi
			if [ -n "$_v6" ] && [ "$noipv6" != "1" ]; then
				uci -q set firewall.omr_dst_bypass_${intf}_6${_p}.enabled='1'
				for zone in $(_get_firewall_zones); do
					uci -q set firewall.omr_dst_bypass_${intf}_dstip_6_${zone}${_p}.enabled='1'
					uci -q set firewall.omr_dst_bypass_${intf}_dstip_6_${zone}_accept.enabled='1'
				done
			fi
			# noipv6 list entries are plain domains (no slashes), unlike the
			# ipset list above -- matching for "/$domain/" here never hits, so
			# this used to add the same domain again on every occurrence
			# within one run.
			# Only here: noipv6 defaults to "1", and a DSCP-only domain (no
			# routing change) must keep its AAAA answers -- dnsmasq answered
			# them with "::", which took IPv6 away from those domains.
			if [ "$noipv6" = "1" ] && ! _uci_list_has dhcp.@dnsmasq[0].noipv6 "$domain"; then
				uci -q add_list dhcp.@dnsmasq[0].noipv6="$domain"
			fi
		elif [ -n "$dscp" ]; then
			# DSCP-only domain: a dnsmasq ipset of its own for the DSCP sets
			local _f _names=""
			_dscp_ensure_ipset "$dscp"
			for _f in $_v4 $_v6; do
				_names="$_names omr_dst_bypass_dscp_${dscp}_${_f}"
			done
			_dnsipset_add "dscp_${dscp}${_fk}" "$domain" $_names \
				&& add_domains="true"
		fi
		#logger -t "omr-bypass" "Get IPs of $domain... Done"
	fi
}

# Only OMR's own dnsmasq init ever read dhcp.@dnsmasq[0].noipv6
# (append_noipv6). It was kept for 5.4 kernels only and later removed, and
# OpenWrt's init ignores the option, so "Disable AAAA IPv6 DNS" did nothing
# (issue #4342). Write the two lines that init generated per domain to a file
# in the conf-dir of that dnsmasq instance:
#   server=/<domain>/#    other queries still go to the usual servers
#   address=/<domain>/::  AAAA queries are answered with "::"
# One domain per line keeps every line far below dnsmasq's ~1024-byte limit.
# dnsmasq reads its conf-dir only when it starts, not on reload: return 0
# when the file changed, so that the caller restarts it.
_noipv6_dnsmasq_conf() {
	local cfg="" confdir conf tmp
	_noipv6_first_dnsmasq() {
		[ -z "$cfg" ] && cfg="$1"
	}
	_noipv6_dnsmasq_lines() {
		echo "server=/$1/#"
		echo "address=/$1/::"
	}
	config_load dhcp
	config_foreach _noipv6_first_dnsmasq dnsmasq
	[ -z "$cfg" ] && return 1
	# Take the conf-dir from the config the dnsmasq init generated: by default
	# it is /tmp/dnsmasq.<section>.d with OpenWrt's init, but /tmp/dnsmasq.d
	# with OMR's own init for 6.6 and 6.12 kernels.
	confdir="$(sed -n '/^conf-dir=/{s/^conf-dir=//;s/,.*//;p;q}' "/var/etc/dnsmasq.conf.$cfg" 2>/dev/null)"
	[ -z "$confdir" ] && return 1
	conf="$confdir/omr-bypass-noipv6.conf"
	# dnsmasq skips dot files, so it never reads a half-written list
	tmp="$confdir/.omr-bypass-noipv6.conf"
	mkdir -p "$confdir"
	config_list_foreach "$cfg" noipv6 _noipv6_dnsmasq_lines > "$tmp"
	if [ ! -s "$tmp" ]; then
		rm -f "$tmp"
		[ -f "$conf" ] || return 1
		rm -f "$conf"
		return 0
	fi
	cmp -s "$tmp" "$conf" && { rm -f "$tmp"; return 1; }
	mv -f "$tmp" "$conf"
}

_bypass_mac() {
	local mac
	local intf
	local enabled
	local dscp
	config_get mac $1 mac
	_get_rule_intf intf $1
	config_get enabled $1 enabled
	_rule_dscp dscp $1
	[ "$enabled" = "0" ] && return
	intf=$(_map_intf_device "$intf")
	intf=$(_sanitize_intf_key "$intf")
	[ -z "$intf" ] && [ -z "$dscp" ] && return
	local intfid="$(uci -q get omr-bypass.$intf.id)"

	[ "$intf" = "default" ] && intf="all"
	local mac_addr valid_macs=""
	for mac_addr in $mac; do
		if _valid_mac "$mac_addr"; then
			valid_macs="${valid_macs:+$valid_macs }$mac_addr"
		else
			logger -t "omr-bypass" "Rule $1: invalid MAC address '$mac_addr' ignored"
		fi
	done
	mac="$valid_macs"
	[ -z "$mac" ] && return
	if [ "$disableipv6" = "0" ]; then
		protocol="4 6"
	else
		protocol="4"
	fi
	if [ -n "$intf" ]; then
		for zone in $(_get_firewall_zones); do
			for ipv46 in $protocol; do
				uci -q set firewall.omr_dst_bypass_${intf}_mac_${ipv46}_${zone}.enabled='1'
				# one list element per MAC: fw4 reads "a b" in a single
				# element as one bad address and skips the whole rule,
				# every MAC bypass of this interface with it
				for mac_addr in $mac; do
					_uci_add_list_unique firewall.omr_dst_bypass_${intf}_mac_${ipv46}_${zone}.src_mac "$mac_addr"
				done
			done
		done
	fi
	if [ -n "$dscp" ]; then
		_dscp_ensure_chain
		for mac_addr in $mac; do
			cat >> "$_fw_include" <<-EOF
				nft add rule inet fw4 omr_dscp_pre ether saddr $mac_addr ip dscp set $dscp 2>/dev/null || true
				nft add rule inet fw4 omr_dscp_pre ether saddr $mac_addr ip6 dscp set $dscp 2>/dev/null || true
			EOF
		done
	fi
}

_bypass_lan_ip() {
	local ip
	local intf
	local enabled
	local dscp
	config_get ip $1 ip
	_get_rule_intf intf $1
	config_get enabled $1 enabled
	_rule_dscp dscp $1
	[ "$enabled" = "0" ] && return
	intf=$(_map_intf_device "$intf")
	intf=$(_sanitize_intf_key "$intf")
	#[ -n "$intf" ] && [ -z "$(ipset --list | grep omr_dst_bypass_$intf)" ] && return
	[ -z "$intf" ] && [ -z "$dscp" ] && return
	local intfid="$(uci -q get omr-bypass.$intf.id)"

	[ "$intf" = "default" ] && intf="all"
	[ -z "$ip" ] && return
	# valid_subnet6 knows lower case only: an upper-case address was dropped
	# without a word
	case "$ip" in *[A-F]*) ip="$(printf '%s' "$ip" | tr 'A-F' 'a-f')" ;; esac
	_valid_rule_ip "$ip" || {
		logger -t "omr-bypass" "Rule $1: invalid IP address, network or range '$ip' ignored"
		return
	}
	# fw4 takes a netmask, nft (the DSCP rule) only a prefix length
	case "$ip" in
		*/*.*)
			local __plen
			__plen="$(_ip4_mask_prefix "${ip#*/}")" || {
				logger -t "omr-bypass" "Rule $1: netmask of '$ip' is not contiguous, ignored"
				return
			}
			ip="${ip%%/*}/${__plen}"
			;;
	esac
	case "$ip" in
		*-*) ;;
		*)
			[ "$(valid_subnet4 "$ip")" = "ok" ] || [ "$(valid_subnet6 "$ip")" = "ok" ] || \
				logger -t "omr-bypass" "Rule $1: '$ip' is not a routable address (link-local, loopback...), ignored"
			;;
	esac
	if [ -n "$intf" ]; then
		for zone in $(_get_firewall_zones); do
			valid_ip4=$(valid_subnet4 $ip)
			valid_ip6=$(valid_subnet6 $ip)
			if [ "$valid_ip4" = "ok" ]; then
				uci -q set firewall.omr_dst_bypass_${intf}_srcip_4_${zone}.enabled='1'
				_uci_add_list_unique firewall.omr_dst_bypass_${intf}_srcip_4_${zone}.src_ip "$ip"
			elif [ "$valid_ip6" = "ok" ] && [ "$disableipv6" = "0" ]; then
				uci -q set firewall.omr_dst_bypass_${intf}_srcip_6_${zone}.enabled='1'
				_uci_add_list_unique firewall.omr_dst_bypass_${intf}_srcip_6_${zone}.src_ip "$ip"
			fi
		done
	fi
	if [ -n "$dscp" ]; then
		_dscp_ensure_chain
		valid_ip4=$(valid_subnet4 $ip)
		valid_ip6=$(valid_subnet6 $ip)
		if [ "$valid_ip4" = "ok" ]; then
			cat >> "$_fw_include" <<-EOF
				nft add rule inet fw4 omr_dscp_pre ip saddr $ip ip dscp set $dscp 2>/dev/null || true
			EOF
		elif [ "$valid_ip6" = "ok" ] && [ "$disableipv6" = "0" ]; then
			cat >> "$_fw_include" <<-EOF
				nft add rule inet fw4 omr_dscp_pre ip6 saddr $ip ip6 dscp set $dscp 2>/dev/null || true
			EOF
		fi
	fi
}

_bypass_dest_port() {
	local intf
	local enabled
	local dport
	local proto
	local dscp
	config_get dport $1 dport
	config_get proto $1 proto
	_get_rule_intf intf $1
	config_get enabled $1 enabled
	_rule_dscp dscp $1
	[ "$enabled" = "0" ] && return
	intf=$(_map_intf_device "$intf")
	intf=$(_sanitize_intf_key "$intf")
	#[ -n "$intf" ] && [ -z "$(ipset --list | grep omr_dst_bypass_$intf)" ] && return
	[ -z "$intf" ] && [ -z "$dscp" ] && return
	local intfid="$(uci -q get omr-bypass.$intf.id)"

	[ "$intf" = "default" ] && intf="all"
	[ -z "$dport" ] && return
	_valid_port_spec "$dport" || {
		logger -t "omr-bypass" "Rule $1: invalid port '$dport' ignored"
		return
	}
	_rule_ports dport "$1" "$dport"
	[ -z "$dport" ] && return
	[ -z "$proto" ] && return
	if [ "$disableipv6" = "0" ]; then
		protocol="4 6"
	else
		protocol="4"
	fi

	if [ -n "$intf" ]; then
		for zone in $(_get_firewall_zones); do
			if [ "$proto" = "tcp" ] || [ "$proto" = "tcp udp" ]; then
				for ipv46 in $protocol; do
					uci -q set firewall.omr_dst_bypass_${intf}_dstport_tcp_${ipv46}_${zone}.enabled='1'
					for _port in $dport; do
						_uci_add_list_unique firewall.omr_dst_bypass_${intf}_dstport_tcp_${ipv46}_${zone}.dest_port "$_port"
					done
				done
			fi
			if [ "$proto" = "udp" ] || [ "$proto" = "tcp udp" ]; then
				for ipv46 in $protocol; do
					uci -q set firewall.omr_dst_bypass_${intf}_dstport_udp_${ipv46}_${zone}.enabled='1'
					for _port in $dport; do
						_uci_add_list_unique firewall.omr_dst_bypass_${intf}_dstport_udp_${ipv46}_${zone}.dest_port "$_port"
					done
				done
			fi
		done
	fi
	if [ -n "$dscp" ]; then
		_dscp_ensure_chain
		local _nft_proto=""
		[ "$proto" = "tcp" ] || [ "$proto" = "tcp udp" ] && _nft_proto="${_nft_proto}tcp "
		[ "$proto" = "udp" ] || [ "$proto" = "tcp udp" ] && _nft_proto="${_nft_proto}udp "
		for _p in $_nft_proto; do
			cat >> "$_fw_include" <<-EOF
				nft add rule inet fw4 omr_dscp_pre meta nfproto ipv4 ${_p} dport $(_nft_ports "$dport") ip dscp set $dscp 2>/dev/null || true
			EOF
			[ "$disableipv6" = "0" ] && cat >> "$_fw_include" <<-EOF
				nft add rule inet fw4 omr_dscp_pre meta nfproto ipv6 ${_p} dport $(_nft_ports "$dport") ip6 dscp set $dscp 2>/dev/null || true
			EOF
		done
	fi
}

_bypass_src_port() {
	local intf
	local enabled
	local sport
	local proto
	local dscp
	config_get sport $1 sport
	config_get proto $1 proto
	_get_rule_intf intf $1
	config_get enabled $1 enabled
	_rule_dscp dscp $1
	[ "$enabled" = "0" ] && return
	intf=$(_map_intf_device "$intf")
	intf=$(_sanitize_intf_key "$intf")
	#[ -n "$intf" ] && [ -z "$(ipset --list | grep omr_dst_bypass_$intf)" ] && return
	[ -z "$intf" ] && [ -z "$dscp" ] && return
	local intfid="$(uci -q get omr-bypass.$intf.id)"

	[ "$intf" = "default" ] && intf="all"
	[ -z "$sport" ] && return
	_valid_port_spec "$sport" || {
		logger -t "omr-bypass" "Rule $1: invalid port '$sport' ignored"
		return
	}
	_rule_ports sport "$1" "$sport"
	[ -z "$sport" ] && return
	[ -z "$proto" ] && return
	if [ "$disableipv6" = "0" ]; then
		protocol="4 6"
	else
		protocol="4"
	fi

	if [ -n "$intf" ]; then
		for zone in $(_get_firewall_zones); do
			if [ "$proto" = "tcp" ] || [ "$proto" = "tcp udp" ]; then
				for ipv46 in $protocol; do
					uci -q set firewall.omr_dst_bypass_${intf}_srcport_tcp_${ipv46}_${zone}.enabled='1'
					for _port in $sport; do
						_uci_add_list_unique firewall.omr_dst_bypass_${intf}_srcport_tcp_${ipv46}_${zone}.src_port "$_port"
					done
				done
			fi
			if [ "$proto" = "udp" ] || [ "$proto" = "tcp udp" ]; then
				for ipv46 in $protocol; do
					uci -q set firewall.omr_dst_bypass_${intf}_srcport_udp_${ipv46}_${zone}.enabled='1'
					for _port in $sport; do
						_uci_add_list_unique firewall.omr_dst_bypass_${intf}_srcport_udp_${ipv46}_${zone}.src_port "$_port"
					done
				done
			fi
		done
	fi
	if [ -n "$dscp" ]; then
		_dscp_ensure_chain
		local _nft_proto=""
		[ "$proto" = "tcp" ] || [ "$proto" = "tcp udp" ] && _nft_proto="${_nft_proto}tcp "
		[ "$proto" = "udp" ] || [ "$proto" = "tcp udp" ] && _nft_proto="${_nft_proto}udp "
		for _p in $_nft_proto; do
			cat >> "$_fw_include" <<-EOF
				nft add rule inet fw4 omr_dscp_pre meta nfproto ipv4 ${_p} sport $(_nft_ports "$sport") ip dscp set $dscp 2>/dev/null || true
			EOF
			[ "$disableipv6" = "0" ] && cat >> "$_fw_include" <<-EOF
				nft add rule inet fw4 omr_dscp_pre meta nfproto ipv6 ${_p} sport $(_nft_ports "$sport") ip6 dscp set $dscp 2>/dev/null || true
			EOF
		done
	fi
}

# The dpis/category loops (and the nDPId collect) run before _omr_hash_gate:
# they add sections to the generated batches the gate hashes. But they also
# wrote straight into omr_dst_bypass_* sections (domain lists, resolved IPs,
# DSCP sets), and whenever the gate regenerated (first run, after a stop,
# any change to the generated sections) its delete batch removed those again,
# until the next reload_rules. While _omr_defer_file is set, _omr_defer
# queues such a call instead, and _omr_run_deferred replays the queue once
# the gate is done. Arguments are joined with "|": domains, IPs, interface
# keys and DSCP classes never contain one.
_omr_defer_file=""
_omr_defer() {
	if [ -z "$_omr_defer_file" ]; then
		"$@"
		return
	fi
	{ printf '%s|' "$@"; echo; } >> "$_omr_defer_file"
}

_omr_run_deferred() {
	local _defer_q="$_omr_defer_file" _defer_l _defer_ifs="$IFS"
	_omr_defer_file=""
	[ -n "$_defer_q" ] && [ -s "$_defer_q" ] || { rm -f "$_defer_q"; return 0; }
	# not "while read < file": a command run from the loop could eat its
	# input; the queue is read once and split here, without globbing
	set -f
	IFS='
'
	for _defer_l in $(cat "$_defer_q"); do
		IFS='|'
		set -- $_defer_l
		IFS="$_defer_ifs"
		set +f
		"$@"
		set -f
		IFS='
'
	done
	IFS="$_defer_ifs"
	set +f
	rm -f "$_defer_q"
}

_bypass_proto_core() {
	local proto=$1
	local intf=$2
	local intfid=$3
	local vpn=$4
	local tcpudp=$5
	local ndpi=$6
	local noipv6=$7
	local family=$8
	[ -z "$proto" ] && return
	# The static IP list, unless nDPId detects the protocol's addresses: it
	# is not running, or it never handles "none" rules. The kernel nDPI
	# match (/proc/net/xt_ndpi) marks nothing under fw4, so it does not
	# count: with it, ndpi=1 left a rule with its domains only.
	# Only a rule with "Enable ndpi" saved ticked leaves the list out: one
	# without the option (older LuCI, the CLI, the 41_omr-bypass migration)
	# keeps it, as before, with nDPId's detections added on top.
	local static=1
	[ "$_omr_ndpi" != "0" ] && [ "$ndpi" = "1" ] && [ "$vpn" != "1" ] && \
		[ "${_omr_ndpid:-0}" = "1" ] && [ "$intf" != "none" ] && static=""
	# A protocol of omr-bypass's own ("proto" sections: m6replay, mycanal...)
	# has its domains and addresses there, not in the database. Only the
	# kernel nDPI match ever read them, and only for interface rules.
	local cdomains="" cips=""
	case "$proto" in
		*[!A-Za-z0-9_]*) ;;
		*)
			if [ "$(uci -q get "omr-bypass.${proto}")" = "proto" ]; then
				cdomains="$(uci -q get "omr-bypass.${proto}.url")"
				cips="$(uci -q get "omr-bypass.${proto}.ip")"
			fi
			;;
	esac
	if [ "$intf" = "none" ]; then
		if [ -n "$static" ]; then
			local ALLIPS
			ALLIPS="$(sqlite3 /usr/share/omr-bypass/omr-bypass.db "select ip from ipproto where proto=\"$proto\";" ".exit") $cips"
			for ip in $ALLIPS; do
				valid_ip4=""
				valid_ip6=""
				if _valid_ip4net "$ip"; then
					valid_ip4=$(valid_subnet4 "$ip")
				elif _valid_ip6net "$ip"; then
					valid_ip6=$(valid_subnet6 "$ip")
				fi
				[ "$family" = "ipv6" ] && valid_ip4=""
				[ "$family" = "ipv4" ] && valid_ip6=""
				if [ "$valid_ip4" = "ok" ]; then
					_omr_defer _uci_add_list_unique firewall.omr_dst_bypass_none_4.entry "$ip"
					for zone in $(_get_firewall_zones); do
						echo "set firewall.omr_dst_bypass_none_dstip_4_${zone}.enabled='1'" >> "$_fw_uci_batch"
					done
				elif [ "$valid_ip6" = "ok" ] && [ "$disableipv6" = "0" ]; then
					_omr_defer _uci_add_list_unique firewall.omr_dst_bypass_none_6.entry "$ip"
					for zone in $(_get_firewall_zones); do
						echo "set firewall.omr_dst_bypass_none_dstip_6_${zone}.enabled='1'" >> "$_fw_uci_batch"
					done
				fi
			done
		fi
		local domains
		domains="$(sqlite3 /usr/share/omr-bypass/omr-bypass.db "select host from hostproto where proto='"$proto"';" ".exit") $cdomains"
		for domain in $domains; do
			[ -n "$domain" ] && _omr_defer _bypass_domain "${domain#.}" "none" "$family" "$noipv6" "${tcpudp}"
		done
		return
	fi
	if [ -n "$static" ]; then
		ALLIPS="$(sqlite3 /usr/share/omr-bypass/omr-bypass.db "select ip from ipproto where proto=\"$proto\";" ".exit") $cips"
		if [ -n "${ALLIPS# }" ]; then
			local _psec
			_proto_section_id _psec "$proto"
			cat >> "$_fw_uci_batch" <<-EOF
				set firewall.bypass_${_psec}=ipset
				set firewall.bypass_${_psec}.name="bypass_${proto}"
				set firewall.bypass_${_psec}.match='dest_net'
				set firewall.bypass_${_psec}.family='ipv4'
				set firewall.bypass_${_psec}.enabled='1'
				set firewall.bypass6_${_psec}=ipset
				set firewall.bypass6_${_psec}.name="bypass6_${proto}"
				set firewall.bypass6_${_psec}.match='dest_net'
				set firewall.bypass6_${_psec}.family='ipv6'
				set firewall.bypass6_${_psec}.enabled='1'
			EOF

			if [ "$vpn" != "1" ]; then
				for zone in $(_get_firewall_zones); do
					cat >> "$_fw_uci_batch" <<-EOF
					set firewall.bypass_${_psec}_${zone}_rule=rule
					set firewall.bypass_${_psec}_${zone}_rule.name="bypass_${proto}_${zone}_rule"
					set firewall.bypass_${_psec}_${zone}_rule.src="${zone}"
					set firewall.bypass_${_psec}_${zone}_rule.proto="${tcpudp}"
					set firewall.bypass_${_psec}_${zone}_rule.dest='*'
					set firewall.bypass_${_psec}_${zone}_rule.family='ipv4'
					set firewall.bypass_${_psec}_${zone}_rule.target='MARK'
					set firewall.bypass_${_psec}_${zone}_rule.ipset="bypass_${proto}"
					set firewall.bypass_${_psec}_${zone}_rule.enabled='1'
					set firewall.bypass_${_psec}_${zone}_rule.set_mark="0x4539${intfid}"
					set firewall.bypass6_${_psec}_${zone}_rule=rule
					set firewall.bypass6_${_psec}_${zone}_rule.name="bypass6_${proto}_${zone}_rule"
					set firewall.bypass6_${_psec}_${zone}_rule.src="${zone}"
					set firewall.bypass6_${_psec}_${zone}_rule.family='ipv6'
					set firewall.bypass6_${_psec}_${zone}_rule.dest='*'
					set firewall.bypass6_${_psec}_${zone}_rule.proto="${tcpudp}"
					set firewall.bypass6_${_psec}_${zone}_rule.target='MARK'
					set firewall.bypass6_${_psec}_${zone}_rule.set_mark="0x6539${intfid}"
					set firewall.bypass6_${_psec}_${zone}_rule.ipset="bypass6_${proto}"
					set firewall.bypass6_${_psec}_${zone}_rule.enabled='1'
					EOF
				done
				_intf_rules_all_rules ${proto} ${intfid} ${tcpudp}
			fi
			local _nft4="" _nft6="" _nft4n=0 _nft6n=0
			for ip in $ALLIPS; do
				valid_ip4=""
				valid_ip6=""
				if _valid_ip4net "$ip"; then
					valid_ip4=$( valid_subnet4 "$ip" )
				elif _valid_ip6net "$ip"; then
					valid_ip6=$( valid_subnet6 "$ip" )
				fi
				[ "$family" = "ipv6" ] && valid_ip4=""
				[ "$family" = "ipv4" ] && valid_ip6=""
				if [ "$valid_ip4" = "ok" ]; then
					if [ "$vpn" != "1" ]; then
						_nft4="${_nft4:+$_nft4, }$ip"
						_nft4n=$((_nft4n + 1))
						if [ "$_nft4n" -ge 64 ]; then
							printf 'nft add element inet fw4 bypass_%s { %s } 2>/dev/null || true\n' "$proto" "$_nft4" >> "$_fw_include"
							_nft4="" _nft4n=0
						fi
					else
						_omr_defer _uci_add_list_unique firewall.omr_dst_bypass_${intf}_4.entry "$ip"
					fi
				elif [ "$valid_ip6" = "ok" ]; then
					if [ "$vpn" != "1" ]; then
						_nft6="${_nft6:+$_nft6, }$ip"
						_nft6n=$((_nft6n + 1))
						if [ "$_nft6n" -ge 64 ]; then
							printf 'nft add element inet fw4 bypass6_%s { %s } 2>/dev/null || true\n' "$proto" "$_nft6" >> "$_fw_include"
							_nft6="" _nft6n=0
						fi
					else
						_omr_defer _uci_add_list_unique firewall.omr_dst_bypass_${intf}_6.entry "$ip"
					fi
				fi
			done
			[ -n "$_nft4" ] && printf 'nft add element inet fw4 bypass_%s { %s } 2>/dev/null || true\n' "$proto" "$_nft4" >> "$_fw_include"
			[ -n "$_nft6" ] && printf 'nft add element inet fw4 bypass6_%s { %s } 2>/dev/null || true\n' "$proto" "$_nft6" >> "$_fw_include"
		fi
	fi
	# Use dnsmasq ipset to bypass domains of the proto. The database holds
	# plain domains: nDPI's any-TLD patterns ("youtube.") are expanded when
	# it is built, so a reload adds back the same domains as a start.
	local domains
	domains="$(sqlite3 /usr/share/omr-bypass/omr-bypass.db "select host from hostproto where proto='"$proto"';" ".exit") $cdomains"
	for domain in $domains; do
		[ -n "$domain" ] && _omr_defer _bypass_domain "${domain#.}" "$intf" "$family" "$noipv6" "${tcpudp}"
	done
}

_bypass_proto_without_ndpi() {
	local proto
	local intf
	local enabled
	_rule_proto proto $1
	_get_rule_intf intf $1
	config_get enabled $1 enabled
	config_get ndpi $1 ndpi
	config_get noipv6 $1 noipv6 "1"
	config_get family $1 family "ipv4ipv6"
	config_get vpn $1 vpn
	config_get tcpudp $1 tcpudp "all"
	[ "$vpn" = "1" ] && intf="srv_vpn1"
	[ "$enabled" = "0" ] && return
	intf=$(_map_intf_device "$intf")
	intf=$(_sanitize_intf_key "$intf")
	[ -z "$intf" ] && return
	[ "$intf" = "default" ] && intf="all"
	local intfid="$(uci -q get omr-bypass.$intf.id)"
	[ "$intf" = "all" ] && intfid=""
	_bypass_proto_core "$proto" "$intf" "$intfid" "$vpn" "$tcpudp" "$ndpi" "$noipv6" "$family"
}

_bypass_category() {
	local category intf enabled vpn tcpudp ndpi noipv6 family intfid
	config_get category $1 category
	_get_rule_intf intf $1
	config_get enabled $1 enabled
	config_get vpn $1 vpn
	config_get tcpudp $1 tcpudp "all"
	config_get ndpi $1 ndpi
	config_get noipv6 $1 noipv6 "1"
	config_get family $1 family "ipv4ipv6"
	[ "$enabled" = "0" ] && return
	[ -z "$category" ] && return
	[ "$vpn" = "1" ] && intf="srv_vpn1"
	intf=$(_map_intf_device "$intf")
	intf=$(_sanitize_intf_key "$intf")
	[ -z "$intf" ] && return
	[ "$intf" = "default" ] && intf="all"
	intfid="$(uci -q get omr-bypass.$intf.id)"
	[ "$intf" = "all" ] && intfid=""

	local proto
	for proto in $(grep -F "\"category\":\"${category}\"" /usr/share/omr-bypass/omr-bypass-proto.json 2>/dev/null | awk -F'"' '{print $4}'); do
		[ -n "$proto" ] || continue
		_bypass_proto_core "$proto" "$intf" "$intfid" "$vpn" "$tcpudp" "$ndpi" "$noipv6" "$family"
	done
}
_intf_rules_all_rules() {
	local service=$1
	local count=$2
	local proto=$3
	[ -z "$proto" ] && proto="tcp udp"
	proxyproto=""
	([ "$(uci -q get shadowsocks-libev.sss0.disabled)" != "1" ] || [ "$(uci -q get shadowsocks-rust.sss0.disabled)" != "1" ]) && proxyproto="ss_rules"
	[ "$(uci -q get v2ray.main.enabled)" = "1" ] && proxyproto="v2r_rules"
	[ "$(uci -q get xray.main.enabled)" = "1" ] && proxyproto="xr_rules"
	[ -z "$proxyproto" ] && proxyproto="noproxy"
	local _pnft=nft
	[ "$proxyproto" = "ss_rules" ] && _pnft='$ssnft'
	cat >> "$_fw_include" <<-EOF
		${_pnft} insert rule inet fw4 ${proxyproto}_local_out ip daddr @bypass_${service} accept >/dev/null 2>&1 || true
		${_pnft} insert rule inet fw4 ${proxyproto}_local_out meta mark 0x4539${count} accept >/dev/null 2>&1 || true
		${_pnft} insert rule inet fw4 ${proxyproto}_local_out ip6 daddr @bypass6_${service} accept >/dev/null 2>&1 || true
		${_pnft} insert rule inet fw4 ${proxyproto}_local_out meta mark 0x6539${count} accept >/dev/null 2>&1 || true
	EOF
	# The proxies' own chains are declared by their rules files, with the
	# type their mode needs (filter/mangle for tproxy and the UDP redirect):
	# a NAT copy created here while those rules are not loaded survives fw4
	# reloads and makes the next reload that loads them fail. Inserting into
	# a missing chain just does nothing, like every line of this include.
	if [ "$proxyproto" = "noproxy" ]; then
		cat >> "$_fw_include" <<-EOF
			nft add chain inet fw4 noproxy_local_out "{ type nat hook output priority filter - 1; policy accept; }" >/dev/null 2>&1 || true
			nft add chain inet fw4 noproxy_pre_tcp "{ type nat hook prerouting priority filter - 1; policy accept; }" >/dev/null 2>&1 || true
		EOF
	fi
	if [ "$proto" = "tcp" ] || [ "$proto" = "tcp udp" ]; then
		cat >> "$_fw_include" <<-EOF
			${_pnft} insert rule inet fw4 ${proxyproto}_pre_tcp ip daddr @bypass_${service} accept >/dev/null 2>&1 || true
			${_pnft} insert rule inet fw4 ${proxyproto}_pre_tcp meta mark 0x4539${count} accept >/dev/null 2>&1 || true
		EOF
		if [ "$disableipv6" = "0" ]; then
			cat >> "$_fw_include" <<-EOF
				${_pnft} insert rule inet fw4 ${proxyproto}_pre_tcp ip6 daddr @bypass6_${service} accept >/dev/null 2>&1 || true
				${_pnft} insert rule inet fw4 ${proxyproto}_pre_tcp meta mark 0x6539${count} accept >/dev/null 2>&1 || true
			EOF
		fi
	fi
	if [ "$proxyproto" = "noproxy" ]; then
		cat >> "$_fw_include" <<-EOF
			nft add chain inet fw4 noproxy_pre_udp "{ type nat hook prerouting priority filter + 1; policy accept; }" >/dev/null 2>&1 || true
		EOF
	fi
	if [ "$proto" = "udp" ] || [ "$proto" = "tcp udp" ]; then
		cat >> "$_fw_include" <<-EOF
			${_pnft} insert rule inet fw4 ${proxyproto}_pre_udp ip daddr @bypass_${service} accept >/dev/null 2>&1 || true
			${_pnft} insert rule inet fw4 ${proxyproto}_pre_udp meta mark 0x4539${count} accept >/dev/null 2>&1 || true
		EOF
		if [ "$disableipv6" = "0" ]; then
			cat >> "$_fw_include" <<-EOF
				${_pnft} insert rule inet fw4 ${proxyproto}_pre_udp ip6 daddr @bypass6_${service} accept >/dev/null 2>&1 || true
				${_pnft} insert rule inet fw4 ${proxyproto}_pre_udp meta mark 0x6539${count} accept >/dev/null 2>&1 || true
			EOF
		fi
	fi

}

_intf_rule_ss_rules() {
	cat >> "$_fw_include" <<-EOF
		\$ssnft insert rule inet fw4 ss_rules_pre_tcp ip daddr @omr_dst_bypass_${intf}_4 accept >/dev/null 2>&1 || true
		\$ssnft insert rule inet fw4 ss_rules_pre_tcp meta mark 0x4539${count} accept >/dev/null 2>&1 || true
		\$ssnft insert rule inet fw4 ss_rules_local_out ip daddr @omr_dst_bypass_${intf}_4 accept >/dev/null 2>&1 || true
		\$ssnft insert rule inet fw4 ss_rules_local_out meta mark 0x4539${count} accept >/dev/null 2>&1 || true
	EOF
	if [ "$disableipv6" = "0" ]; then
		cat >> "$_fw_include" <<-EOF
			\$ssnft insert rule inet fw4 ss_rules_pre_tcp ip6 daddr @omr_dst_bypass_${intf}_6 accept >/dev/null 2>&1 || true
			\$ssnft insert rule inet fw4 ss_rules_pre_tcp meta mark 0x6539${count} accept >/dev/null 2>&1 || true
			\$ssnft insert rule inet fw4 ss_rules_local_out ip6 daddr @omr_dst_bypass_${intf}_6 accept >/dev/null 2>&1 || true
			\$ssnft insert rule inet fw4 ss_rules_local_out meta mark 0x6539${count} accept >/dev/null 2>&1 || true
		EOF
	fi
}

_intf_rule_v2ray_rules() {
	proto=$1
	[ -z "$proto" ] && proto="tcp udp"
	cat >> "$_fw_include" <<-EOF
		nft insert rule inet fw4 v2r_rules_local_out ip daddr @omr_dst_bypass_${intf}_4 accept >/dev/null 2>&1 || true
		nft insert rule inet fw4 v2r_rules_local_out meta mark 0x4539${count} accept >/dev/null 2>&1 || true
	EOF
	if [ "$disableipv6" = "0" ]; then
		cat >> "$_fw_include" <<-EOF
			nft insert rule inet fw4 v2r_rules_local_out ip6 daddr @omr_dst_bypass_${intf}_6 accept >/dev/null 2>&1 || true
			nft insert rule inet fw4 v2r_rules_local_out meta mark 0x6539${count} accept >/dev/null 2>&1 || true
		EOF
	fi
	if [ "$proto" = "tcp" ] || [ "$proto" = "tcp udp" ]; then
		cat >> "$_fw_include" <<-EOF
			nft insert rule inet fw4 v2r_rules_pre_tcp ip daddr @omr_dst_bypass_${intf}_4 accept >/dev/null 2>&1 || true
			nft insert rule inet fw4 v2r_rules_pre_tcp meta mark 0x4539${count} accept >/dev/null 2>&1 || true
		EOF
		if [ "$disableipv6" = "0" ]; then
			cat >> "$_fw_include" <<-EOF
				nft insert rule inet fw4 v2r_rules_pre_tcp ip6 daddr @omr_dst_bypass_${intf}_6 accept >/dev/null 2>&1 || true
				nft insert rule inet fw4 v2r_rules_pre_tcp meta mark 0x6539${count} accept >/dev/null 2>&1 || true
			EOF
		fi
	fi
	if [ "$proto" = "udp" ] || [ "$proto" = "tcp udp" ]; then
		cat >> "$_fw_include" <<-EOF
			nft insert rule inet fw4 v2r_rules_pre_udp ip daddr @omr_dst_bypass_${intf}_4 accept >/dev/null 2>&1 || true
			nft insert rule inet fw4 v2r_rules_pre_udp meta mark 0x4539${count} accept >/dev/null 2>&1 || true
		EOF
		if [ "$disableipv6" = "0" ]; then
			cat >> "$_fw_include" <<-EOF
				nft insert rule inet fw4 v2r_rules_pre_udp ip6 daddr @omr_dst_bypass_${intf}_6 accept >/dev/null 2>&1 || true
				nft insert rule inet fw4 v2r_rules_pre_udp meta mark 0x6539${count} accept >/dev/null 2>&1 || true
			EOF
		fi
	fi
}

_intf_rule_xray_rules() {
	proto=$1
	[ -z "$proto" ] && proto="tcp udp"
	cat >> "$_fw_include" <<-EOF
		nft insert rule inet fw4 xr_rules_local_out ip daddr @omr_dst_bypass_${intf}_4 accept >/dev/null 2>&1 || true
		nft insert rule inet fw4 xr_rules_local_out meta mark 0x4539${count} accept >/dev/null 2>&1 || true
	EOF
	if [ "$disableipv6" = "0" ]; then
		cat >> "$_fw_include" <<-EOF
			nft insert rule inet fw4 xr_rules_local_out ip6 daddr @omr_dst_bypass_${intf}_6 accept >/dev/null 2>&1 || true
			nft insert rule inet fw4 xr_rules_local_out meta mark 0x6539${count} accept >/dev/null 2>&1 || true
		EOF
	fi
	if [ "$proto" = "tcp" ] || [ "$proto" = "tcp udp" ]; then
		cat >> "$_fw_include" <<-EOF
			nft insert rule inet fw4 xr_rules_pre_tcp ip daddr @omr_dst_bypass_${intf}_4 accept >/dev/null 2>&1 || true
			nft insert rule inet fw4 xr_rules_pre_tcp meta mark 0x4539${count} accept >/dev/null 2>&1 || true
		EOF
		if [ "$disableipv6" = "0" ]; then
			cat >> "$_fw_include" <<-EOF
				nft insert rule inet fw4 xr_rules_pre_tcp ip6 daddr @omr_dst_bypass_${intf}_6 accept >/dev/null 2>&1 || true
				nft insert rule inet fw4 xr_rules_pre_tcp meta mark 0x6539${count} accept >/dev/null 2>&1 || true
			EOF
		fi
	fi
	if [ "$proto" = "udp" ] || [ "$proto" = "tcp udp" ]; then
		cat >> "$_fw_include" <<-EOF
			nft insert rule inet fw4 xr_rules_pre_udp ip daddr @omr_dst_bypass_${intf}_4 accept >/dev/null 2>&1 || true
			nft insert rule inet fw4 xr_rules_pre_udp meta mark 0x4539${count} accept >/dev/null 2>&1 || true
		EOF
		if [ "$disableipv6" = "0" ]; then
			cat >> "$_fw_include" <<-EOF
				nft insert rule inet fw4 xr_rules_pre_udp ip6 daddr @omr_dst_bypass_${intf}_6 accept >/dev/null 2>&1 || true
				nft insert rule inet fw4 xr_rules_pre_udp meta mark 0x6539${count} accept >/dev/null 2>&1 || true
			EOF
		fi
	fi
}

_intf_rule() {
	local intf _grp_id=""
	[ "$1" = "all" ] && intf="all"
	case "$1" in
		grp_*) _grp_id=$(uci -q get "omr-bypass.$1.id"); [ -n "$_grp_id" ] || return; intf="$1" ;;
	esac
	[ -z "$intf" ] && intf=$(ifstatus "$1" | jsonfilter -q -e '@["l3_device"]')
	case "$intf" in *@*) intf=$(ifstatus "$1" | jsonfilter -q -e '@["device"]') ;; esac
	[ -z "$intf" ] && config_get intf $1 device
	# A device path or vlan alias is unusable: fall back to the device
	# remembered from a previous run, so an interface that is not up yet
	# (modem still attaching at boot) still generates its sections and the
	# generated content stays identical across runs.
	case "$intf" in */*|*@*) intf="" ;; esac
	[ -z "$intf" ] && intf=$(uci -q get omr-bypass.ifmap_$1.device)
	[ -z "$intf" ] && return
	#count=$((count+1))
	if [ -n "$_grp_id" ]; then
		count="$_grp_id"
	elif [ "$intf" != "all" ]; then
		config_get count $1 metric
	fi
	[ "$intf" = "all" ] && count=""
	local mode
	#config_get mode $1 multipath "off"
	#[ "$mode" = "off" ] && return
	[ "$intf" != "all" ] && [ -z "$count" ] && return
	[ -z "$intf" ] && return
	# The sanitized form below is a uci section-name key: it turns "." and
	# "-" into "_", so it is not the device name any more. An nft iifname
	# needs the real one (a vlan or bridge WAN would otherwise get a rule
	# naming a device that does not exist -- and nft takes an unknown
	# iifname string happily, so it would just never match).
	local rawdev="$intf"
	intf=$(_sanitize_intf_key "$intf")
	case "$1" in *_dev*) return ;; esac
	[ "$intf" = "lo" ] && return
	[ -z "$intf" ] && return
	# Marks handed out below must never survive on the reply packets of a
	# connection that was opened from outside (issue #4369), and a
	# connection coming in on a wan-zone device has to be remembered so its
	# reply leaves the same way it arrived.
	_reply_add_mark "0x4539${count}" "0x6539${count}"
	case " $_omr_wan_networks " in
		*" $1 "*) _reply_wan_rule "$rawdev" "$count" ;;
	esac
#	[ -z "$RELOAD" ] || [ "$(uci show firewall.omr_dst_bypass_$intf_4)" = "" ] && {
		#unset RELOAD
		#echo "firewall omr_dst_bypass ipset"
		cat >> "$_fw_uci_batch" <<-EOF
			set firewall.omr_dst_bypass_${intf}_4=ipset
			set firewall.omr_dst_bypass_${intf}_4.name="omr_dst_bypass_${intf}_4"
			set firewall.omr_dst_bypass_${intf}_4.match='dest_net'
			set firewall.omr_dst_bypass_${intf}_4.family='ipv4'
			set firewall.omr_dst_bypass_${intf}_4.enabled='1'
			set firewall.omr_dst_bypass_${intf}_6=ipset
			set firewall.omr_dst_bypass_${intf}_6.name="omr_dst_bypass_${intf}_6"
			set firewall.omr_dst_bypass_${intf}_6.match='dest_net'
			set firewall.omr_dst_bypass_${intf}_6.family='ipv6'
			set firewall.omr_dst_bypass_${intf}_6.enabled='1'
		EOF
		for tcpudp in tcp udp; do
			cat >> "$_fw_uci_batch" <<-EOF
				set firewall.omr_dst_bypass_${intf}_4_${tcpudp}=ipset
				set firewall.omr_dst_bypass_${intf}_4_${tcpudp}.name="omr_dst_bypass_${intf}_4_${tcpudp}"
				set firewall.omr_dst_bypass_${intf}_4_${tcpudp}.match='dest_net'
				set firewall.omr_dst_bypass_${intf}_4_${tcpudp}.family='ipv4'
				set firewall.omr_dst_bypass_${intf}_4_${tcpudp}.enabled='1'
				set firewall.omr_dst_bypass_${intf}_6_${tcpudp}=ipset
				set firewall.omr_dst_bypass_${intf}_6_${tcpudp}.name="omr_dst_bypass_${intf}_6_${tcpudp}"
				set firewall.omr_dst_bypass_${intf}_6_${tcpudp}.match='dest_net'
				set firewall.omr_dst_bypass_${intf}_6_${tcpudp}.family='ipv6'
				set firewall.omr_dst_bypass_${intf}_6_${tcpudp}.enabled='1'
			EOF
		done
		#echo "firewall omr_dst_bypass rules"
		if [ "$disableipv6" = "0" ]; then
			protocol="4 6"
		else
			protocol="4"
		fi
		#zone="lan"
		for zone in $(_get_firewall_zones); do
			for ipv46 in $protocol; do
				cat >> "$_fw_uci_batch" <<-EOF
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}=rule
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}.name="omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_rule"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}.ipset="omr_dst_bypass_${intf}_${ipv46}"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}.target='MARK'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}.src="${zone}"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}.dest='*'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}.family="ipv${ipv46}"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}.enabled='0'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}.proto='all'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}.set_mark="0x${ipv46}539${count}"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_udp=rule
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_udp.name="omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_udp_rule"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_udp.ipset="omr_dst_bypass_${intf}_${ipv46}_udp"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_udp.target='MARK'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_udp.src="${zone}"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_udp.dest='*'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_udp.family="ipv${ipv46}"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_udp.enabled='0'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_udp.proto='udp'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_udp.set_mark="0x${ipv46}539${count}"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_tcp=rule
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_tcp.name="omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_tcp_rule"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_tcp.ipset="omr_dst_bypass_${intf}_${ipv46}_tcp"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_tcp.target='MARK'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_tcp.src="${zone}"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_tcp.dest='*'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_tcp.family="ipv${ipv46}"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_tcp.enabled='0'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_tcp.proto='tcp'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_tcp.set_mark="0x${ipv46}539${count}"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_accept=rule
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_accept.name="omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_rule_accept"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_accept.target='ACCEPT'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_accept.dest='*'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_accept.family="ipv${ipv46}"
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_accept.enabled='0'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_accept.proto='all'
				set firewall.omr_dst_bypass_${intf}_dstip_${ipv46}_${zone}_accept.mark="0x${ipv46}539${count}"
				set firewall.omr_dst_bypass_${intf}_srcip_${ipv46}_${zone}=rule
				set firewall.omr_dst_bypass_${intf}_srcip_${ipv46}_${zone}.name="omr_dst_bypass_${intf}_srcip_${ipv46}_${zone}"
				set firewall.omr_dst_bypass_${intf}_srcip_${ipv46}_${zone}.src="${zone}"
				set firewall.omr_dst_bypass_${intf}_srcip_${ipv46}_${zone}.dest='*'
				set firewall.omr_dst_bypass_${intf}_srcip_${ipv46}_${zone}.family="ipv${ipv46}"
				set firewall.omr_dst_bypass_${intf}_srcip_${ipv46}_${zone}.target='MARK'
				set firewall.omr_dst_bypass_${intf}_srcip_${ipv46}_${zone}.enabled='0'
				set firewall.omr_dst_bypass_${intf}_srcip_${ipv46}_${zone}.proto='all'
				set firewall.omr_dst_bypass_${intf}_srcip_${ipv46}_${zone}.set_mark="0x${ipv46}539${count}"
				set firewall.omr_dst_bypass_${intf}_mac_${ipv46}_${zone}=rule
				set firewall.omr_dst_bypass_${intf}_mac_${ipv46}_${zone}.name='omr_dst_bypass_${intf}_mac_${ipv46}_${zone}'
				set firewall.omr_dst_bypass_${intf}_mac_${ipv46}_${zone}.src="${zone}"
				set firewall.omr_dst_bypass_${intf}_mac_${ipv46}_${zone}.dest='*'
				set firewall.omr_dst_bypass_${intf}_mac_${ipv46}_${zone}.target='MARK'
				set firewall.omr_dst_bypass_${intf}_mac_${ipv46}_${zone}.enabled='0'
				set firewall.omr_dst_bypass_${intf}_mac_${ipv46}_${zone}.proto='all'
				set firewall.omr_dst_bypass_${intf}_mac_${ipv46}_${zone}.set_mark="0x${ipv46}539${count}"
				set firewall.omr_dst_bypass_${intf}_srcport_tcp_${ipv46}_${zone}=rule
				set firewall.omr_dst_bypass_${intf}_srcport_tcp_${ipv46}_${zone}.name="omr_dst_bypass_${intf}_srcport_tcp_${ipv46}_${zone}"
				set firewall.omr_dst_bypass_${intf}_srcport_tcp_${ipv46}_${zone}.proto='tcp'
				set firewall.omr_dst_bypass_${intf}_srcport_tcp_${ipv46}_${zone}.src="${zone}"
				set firewall.omr_dst_bypass_${intf}_srcport_tcp_${ipv46}_${zone}.dest='*'
				set firewall.omr_dst_bypass_${intf}_srcport_tcp_${ipv46}_${zone}.target='MARK'
				set firewall.omr_dst_bypass_${intf}_srcport_tcp_${ipv46}_${zone}.enabled='0'
				set firewall.omr_dst_bypass_${intf}_srcport_tcp_${ipv46}_${zone}.set_mark="0x${ipv46}539${count}"
				set firewall.omr_dst_bypass_${intf}_srcport_udp_${ipv46}_${zone}=rule
				set firewall.omr_dst_bypass_${intf}_srcport_udp_${ipv46}_${zone}.name="omr_dst_bypass_${intf}_srcport_udp_${ipv46}_${zone}"
				set firewall.omr_dst_bypass_${intf}_srcport_udp_${ipv46}_${zone}.proto='udp'
				set firewall.omr_dst_bypass_${intf}_srcport_udp_${ipv46}_${zone}.src="${zone}"
				set firewall.omr_dst_bypass_${intf}_srcport_udp_${ipv46}_${zone}.dest='*'
				set firewall.omr_dst_bypass_${intf}_srcport_udp_${ipv46}_${zone}.target='MARK'
				set firewall.omr_dst_bypass_${intf}_srcport_udp_${ipv46}_${zone}.enabled='0'
				set firewall.omr_dst_bypass_${intf}_srcport_udp_${ipv46}_${zone}.set_mark="0x${ipv46}539${count}"
				set firewall.omr_dst_bypass_${intf}_dstport_tcp_${ipv46}_${zone}=rule
				set firewall.omr_dst_bypass_${intf}_dstport_tcp_${ipv46}_${zone}.name="omr_dst_bypass_${intf}_dstport_tcp_${ipv46}_${zone}"
				set firewall.omr_dst_bypass_${intf}_dstport_tcp_${ipv46}_${zone}.src="${zone}"
				set firewall.omr_dst_bypass_${intf}_dstport_tcp_${ipv46}_${zone}.dest='*'
				set firewall.omr_dst_bypass_${intf}_dstport_tcp_${ipv46}_${zone}.target='MARK'
				set firewall.omr_dst_bypass_${intf}_dstport_tcp_${ipv46}_${zone}.proto='tcp'
				set firewall.omr_dst_bypass_${intf}_dstport_tcp_${ipv46}_${zone}.enabled='0'
				set firewall.omr_dst_bypass_${intf}_dstport_tcp_${ipv46}_${zone}.set_mark="0x${ipv46}539${count}"
				set firewall.omr_dst_bypass_${intf}_dstport_udp_${ipv46}_${zone}=rule
				set firewall.omr_dst_bypass_${intf}_dstport_udp_${ipv46}_${zone}.name="omr_dst_bypass_${intf}_dstport_udp_${ipv46}_${zone}"
				set firewall.omr_dst_bypass_${intf}_dstport_udp_${ipv46}_${zone}.src="${zone}"
				set firewall.omr_dst_bypass_${intf}_dstport_udp_${ipv46}_${zone}.dest='*'
				set firewall.omr_dst_bypass_${intf}_dstport_udp_${ipv46}_${zone}.proto='udp'
				set firewall.omr_dst_bypass_${intf}_dstport_udp_${ipv46}_${zone}.target='MARK'
				set firewall.omr_dst_bypass_${intf}_dstport_udp_${ipv46}_${zone}.enabled='0'
				set firewall.omr_dst_bypass_${intf}_dstport_udp_${ipv46}_${zone}.set_mark="0x${ipv46}539${count}"
				EOF
			done
		done
		if [ -n "$_grp_id" ]; then
			# its ip rules follow the WAN in use: wan-failover owns them
			:
		elif [ "$intf" = "all" ]; then
			cat >> "$_net_uci_batch" <<-EOF
				set network.${intf}_fw_rule=rule
				set network.${intf}_fw_rule.priority=1
				set network.${intf}_fw_rule.mark=0x4539
				set network.${intf}_fw_rule.lookup=991337
				set network.${intf}_fw_rule6=rule6
				set network.${intf}_fw_rule6.priority=1
				set network.${intf}_fw_rule6.mark=0x6539
				set network.${intf}_fw_rule6.lookup=6991337
			EOF
		else
			cat >> "$_net_uci_batch" <<-EOF
				set network.${intf}_fw_rule=rule
				set network.${intf}_fw_rule.priority=1
				set network.${intf}_fw_rule.mark=0x4539${count}
				set network.${intf}_fw_rule.lookup=${count}
				set network.${intf}_fw_rule6=rule6
				set network.${intf}_fw_rule6.priority=1
				set network.${intf}_fw_rule6.mark=0x6539${count}
				set network.${intf}_fw_rule6.lookup=6${count}
			EOF
		fi
		cat >> "$_dhcp_uci_batch" <<-EOF
			delete dhcp.omr_dst_bypass_$intf
			set dhcp.omr_dst_bypass_$intf=ipset
			set dhcp.omr_dst_bypass_$intf.table='fw4'
			set dhcp.omr_dst_bypass_$intf.table_family='inet'
			add_list dhcp.omr_dst_bypass_$intf.name="omr_dst_bypass_${intf}_4"
			add_list dhcp.omr_dst_bypass_$intf.name="omr_dst_bypass_${intf}_6"
		EOF
		for tcpudp in tcp udp; do
			cat >> "$_dhcp_uci_batch" <<-EOF
				delete dhcp.omr_dst_bypass_${intf}_${tcpudp}
				set dhcp.omr_dst_bypass_${intf}_${tcpudp}=ipset
				set dhcp.omr_dst_bypass_${intf}_${tcpudp}.table='fw4'
				set dhcp.omr_dst_bypass_${intf}_${tcpudp}.table_family='inet'
				add_list dhcp.omr_dst_bypass_${intf}_${tcpudp}.name="omr_dst_bypass_${intf}_4_${tcpudp}"
				add_list dhcp.omr_dst_bypass_${intf}_${tcpudp}.name="omr_dst_bypass_${intf}_6_${tcpudp}"
			EOF
		done

	if [ "$_omr_proxy" = "shadowsocks" ] && [ "$_ss_libev_disabled" != "1" ]; then
		#config_load shadowsocks-libev
		#config_foreach _intf_rule_ss_rules ss_rules
		_intf_rule_ss_rules
	elif [ "$_omr_proxy" = "shadowsocks-rust" ] && [ "$_ss_rust_disabled" != "1" ]; then
		#config_load shadowsocks-rust
		#config_foreach _intf_rule_ss_rules ss_rules
		_intf_rule_ss_rules
	elif [ "$_omr_proxy" != "${_omr_proxy#*v2ray}" ] && [ "$_v2ray_enabled" = "1" ]; then
		_intf_rule_v2ray_rules
	elif [ "$_omr_proxy" != "${_omr_proxy#*xray}" ] && [ "$_xray_enabled" = "1" ]; then
		_intf_rule_xray_rules
	fi

	if [ "$intf" != "all" ] && [ -z "$_grp_id" ]; then
		uci -q set omr-bypass.$intf=interface
		uci -q set omr-bypass.$intf.id=$count
		uci -q set omr-bypass.ifmap_$1=ifmap
		uci -q set omr-bypass.ifmap_$1.device=$intf
	fi
}

_intf_rule_none() {
	if [ "$disableipv6" = "0" ]; then
		protocol="4 6"
	else
		protocol="4"
	fi
	cat >> "$_fw_uci_batch" <<-EOF
		set firewall.omr_dst_bypass_none_4=ipset
		set firewall.omr_dst_bypass_none_4.name="omr_dst_bypass_none_4"
		set firewall.omr_dst_bypass_none_4.match='dest_net'
		set firewall.omr_dst_bypass_none_4.family='ipv4'
		set firewall.omr_dst_bypass_none_4.enabled='1'
		set firewall.omr_dst_bypass_none_6=ipset
		set firewall.omr_dst_bypass_none_6.name="omr_dst_bypass_none_6"
		set firewall.omr_dst_bypass_none_6.match='dest_net'
		set firewall.omr_dst_bypass_none_6.family='ipv6'
		set firewall.omr_dst_bypass_none_6.enabled='1'
	EOF
	for zone in $(_get_firewall_zones); do
		for ipv46 in $protocol; do
			cat >> "$_fw_uci_batch" <<-EOF
				set firewall.omr_dst_bypass_none_dstip_${ipv46}_${zone}=rule
				set firewall.omr_dst_bypass_none_dstip_${ipv46}_${zone}.name="omr_dst_bypass_none_dstip_${ipv46}_${zone}_rule"
				set firewall.omr_dst_bypass_none_dstip_${ipv46}_${zone}.ipset="omr_dst_bypass_none_${ipv46}"
				set firewall.omr_dst_bypass_none_dstip_${ipv46}_${zone}.target='DROP'
				set firewall.omr_dst_bypass_none_dstip_${ipv46}_${zone}.src="${zone}"
				set firewall.omr_dst_bypass_none_dstip_${ipv46}_${zone}.dest='*'
				set firewall.omr_dst_bypass_none_dstip_${ipv46}_${zone}.family="ipv${ipv46}"
				set firewall.omr_dst_bypass_none_dstip_${ipv46}_${zone}.enabled='0'
				set firewall.omr_dst_bypass_none_dstip_${ipv46}_${zone}.proto='all'
				set firewall.omr_dst_bypass_none_srcip_${ipv46}_${zone}=rule
				set firewall.omr_dst_bypass_none_srcip_${ipv46}_${zone}.name="omr_dst_bypass_none_srcip_${ipv46}_${zone}"
				set firewall.omr_dst_bypass_none_srcip_${ipv46}_${zone}.src="${zone}"
				set firewall.omr_dst_bypass_none_srcip_${ipv46}_${zone}.dest='*'
				set firewall.omr_dst_bypass_none_srcip_${ipv46}_${zone}.family="ipv${ipv46}"
				set firewall.omr_dst_bypass_none_srcip_${ipv46}_${zone}.target='DROP'
				set firewall.omr_dst_bypass_none_srcip_${ipv46}_${zone}.enabled='0'
				set firewall.omr_dst_bypass_none_srcip_${ipv46}_${zone}.proto='all'
				set firewall.omr_dst_bypass_none_mac_${ipv46}_${zone}=rule
				set firewall.omr_dst_bypass_none_mac_${ipv46}_${zone}.name='omr_dst_bypass_none_mac_${ipv46}_${zone}'
				set firewall.omr_dst_bypass_none_mac_${ipv46}_${zone}.src="${zone}"
				set firewall.omr_dst_bypass_none_mac_${ipv46}_${zone}.dest='*'
				set firewall.omr_dst_bypass_none_mac_${ipv46}_${zone}.target='DROP'
				set firewall.omr_dst_bypass_none_mac_${ipv46}_${zone}.enabled='0'
				set firewall.omr_dst_bypass_none_mac_${ipv46}_${zone}.proto='all'
				set firewall.omr_dst_bypass_none_srcport_tcp_${ipv46}_${zone}=rule
				set firewall.omr_dst_bypass_none_srcport_tcp_${ipv46}_${zone}.name="omr_dst_bypass_none_srcport_tcp_${ipv46}_${zone}"
				set firewall.omr_dst_bypass_none_srcport_tcp_${ipv46}_${zone}.proto='tcp'
				set firewall.omr_dst_bypass_none_srcport_tcp_${ipv46}_${zone}.src="${zone}"
				set firewall.omr_dst_bypass_none_srcport_tcp_${ipv46}_${zone}.dest='*'
				set firewall.omr_dst_bypass_none_srcport_tcp_${ipv46}_${zone}.target='DROP'
				set firewall.omr_dst_bypass_none_srcport_tcp_${ipv46}_${zone}.enabled='0'
				set firewall.omr_dst_bypass_none_srcport_udp_${ipv46}_${zone}=rule
				set firewall.omr_dst_bypass_none_srcport_udp_${ipv46}_${zone}.name="omr_dst_bypass_none_srcport_udp_${ipv46}_${zone}"
				set firewall.omr_dst_bypass_none_srcport_udp_${ipv46}_${zone}.proto='udp'
				set firewall.omr_dst_bypass_none_srcport_udp_${ipv46}_${zone}.src="${zone}"
				set firewall.omr_dst_bypass_none_srcport_udp_${ipv46}_${zone}.dest='*'
				set firewall.omr_dst_bypass_none_srcport_udp_${ipv46}_${zone}.target='DROP'
				set firewall.omr_dst_bypass_none_srcport_udp_${ipv46}_${zone}.enabled='0'
				set firewall.omr_dst_bypass_none_dstport_tcp_${ipv46}_${zone}=rule
				set firewall.omr_dst_bypass_none_dstport_tcp_${ipv46}_${zone}.name="omr_dst_bypass_none_dstport_tcp_${ipv46}_${zone}"
				set firewall.omr_dst_bypass_none_dstport_tcp_${ipv46}_${zone}.src="${zone}"
				set firewall.omr_dst_bypass_none_dstport_tcp_${ipv46}_${zone}.dest='*'
				set firewall.omr_dst_bypass_none_dstport_tcp_${ipv46}_${zone}.target='DROP'
				set firewall.omr_dst_bypass_none_dstport_tcp_${ipv46}_${zone}.proto='tcp'
				set firewall.omr_dst_bypass_none_dstport_tcp_${ipv46}_${zone}.enabled='0'
				set firewall.omr_dst_bypass_none_dstport_udp_${ipv46}_${zone}=rule
				set firewall.omr_dst_bypass_none_dstport_udp_${ipv46}_${zone}.name="omr_dst_bypass_none_dstport_udp_${ipv46}_${zone}"
				set firewall.omr_dst_bypass_none_dstport_udp_${ipv46}_${zone}.src="${zone}"
				set firewall.omr_dst_bypass_none_dstport_udp_${ipv46}_${zone}.dest='*'
				set firewall.omr_dst_bypass_none_dstport_udp_${ipv46}_${zone}.proto='udp'
				set firewall.omr_dst_bypass_none_dstport_udp_${ipv46}_${zone}.target='DROP'
				set firewall.omr_dst_bypass_none_dstport_udp_${ipv46}_${zone}.enabled='0'
			EOF
		done
	done
	# in the gated batch like the other interfaces: applied directly, its
	# delete and set left a dhcp change on every run, so every start
	# committed dhcp and restarted dnsmasq
	cat >> "$_dhcp_uci_batch" <<-EOF
		delete dhcp.omr_dst_bypass_none
		set dhcp.omr_dst_bypass_none=ipset
		set dhcp.omr_dst_bypass_none.table='fw4'
		set dhcp.omr_dst_bypass_none.table_family='inet'
		add_list dhcp.omr_dst_bypass_none.name="omr_dst_bypass_none_4"
		add_list dhcp.omr_dst_bypass_none.name="omr_dst_bypass_none_6"
	EOF
}

# "VPN on server" (vpn=1): the VPS sends these destinations out of its own VPN,
# both for the traffic it gets through the tunnel and for its proxy's, from the
# list openmptcprouter-vps set_bypass_ips reads in these two sets. The router
# only fills the sets: no mark, rule or route, the traffic still goes to the VPS.
_intf_rule_srv_vpn1() {
	cat >> "$_fw_uci_batch" <<-EOF
		set firewall.omr_dst_bypass_srv_vpn1_4=ipset
		set firewall.omr_dst_bypass_srv_vpn1_4.name="omr_dst_bypass_srv_vpn1_4"
		set firewall.omr_dst_bypass_srv_vpn1_4.match='dest_net'
		set firewall.omr_dst_bypass_srv_vpn1_4.family='ipv4'
		set firewall.omr_dst_bypass_srv_vpn1_4.enabled='1'
		set firewall.omr_dst_bypass_srv_vpn1_6=ipset
		set firewall.omr_dst_bypass_srv_vpn1_6.name="omr_dst_bypass_srv_vpn1_6"
		set firewall.omr_dst_bypass_srv_vpn1_6.match='dest_net'
		set firewall.omr_dst_bypass_srv_vpn1_6.family='ipv6'
		set firewall.omr_dst_bypass_srv_vpn1_6.enabled='1'
	EOF
	cat >> "$_dhcp_uci_batch" <<-EOF
		delete dhcp.omr_dst_bypass_srv_vpn1
		set dhcp.omr_dst_bypass_srv_vpn1=ipset
		set dhcp.omr_dst_bypass_srv_vpn1.table='fw4'
		set dhcp.omr_dst_bypass_srv_vpn1.table_family='inet'
		add_list dhcp.omr_dst_bypass_srv_vpn1.name="omr_dst_bypass_srv_vpn1_4"
		add_list dhcp.omr_dst_bypass_srv_vpn1.name="omr_dst_bypass_srv_vpn1_6"
	EOF
}

_bypass_ip_set() {
	local ip
	local interface
	local enabled
	local dscp
	local vpn
	config_get ip $1 ip
	_get_rule_intf interface $1
	config_get enabled $1 enabled
	_rule_dscp dscp $1
	config_get vpn $1 vpn
	[ "$vpn" = "1" ] && interface="srv_vpn1"
	[ "$enabled" = "0" ] && return
	interface=$(_map_intf_device "$interface")
	[ -z "$interface" ] && [ -z "$dscp" ] && return
	[ "$interface" = "default" ] && interface="all"
	local tcpudp
	config_get tcpudp $1 proto "all"
	for ip in $ip; do
		# valid_subnet6 knows lower case only: an upper-case address was
		# dropped without a word
		case "$ip" in *[A-F]*) ip="$(printf '%s' "$ip" | tr 'A-F' 'a-f')" ;; esac
		_valid_ip4net "$ip" || _valid_ip6net "$ip" || {
			logger -t "omr-bypass" "Rule $1: invalid IP address or network '$ip' ignored"
			continue
		}
		[ "$(valid_subnet4 "$ip")" = "ok" ] || [ "$(valid_subnet6 "$ip")" = "ok" ] || {
			logger -t "omr-bypass" "Rule $1: '$ip' is not a routable address (link-local, loopback...), ignored"
			continue
		}
		[ -n "$interface" ] && _bypass_ip $ip $interface $tcpudp
		[ -n "$dscp" ] && _dscp_ip $ip $dscp
	done
}

_bypass_asn() {
	local asn
	local interface
	local enabled
	local dscp
	local vpn
	config_get asn $1 asn
	_get_rule_intf interface $1
	config_get enabled $1 enabled
	_rule_dscp dscp $1
	config_get vpn $1 vpn
	[ "$vpn" = "1" ] && interface="srv_vpn1"
	[ "$enabled" = "0" ] && return
	interface=$(_map_intf_device "$interface")
	[ -z "$interface" ] && [ -z "$dscp" ] && return
	[ "$interface" = "default" ] && interface="all"
	# "AS13335" or "13335", and nothing else into the URL
	asn="${asn#[Aa][Ss]}"
	case "$asn" in
		""|*[!0-9]*)
			logger -t "omr-bypass" "Rule $1: invalid ASN '$asn' ignored"
			return
			;;
	esac
	local asnips cache="$_BYPASS_ASN_CACHE/AS${asn}" tmp="$_BYPASS_ASN_TMP/AS${asn}"
	mkdir -p "$_BYPASS_ASN_TMP"
	# the escaped quotes were part of the filter, which jsonfilter refused:
	# no ASN rule ever matched anything. Unverified TLS (-k) let anyone on
	# the path choose the prefixes.
	curl --max-time 4 -s -o "${tmp}.json" "https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS${asn}"
	# The set files are rebuilt from scratch on every run: when RIPE did not
	# answer (no WAN up yet at boot, a timeout) the AS lost all its prefixes
	# until the next reload. Its last answer is kept for that.
	if [ "$(jsonfilter -q -t '@.data.prefixes' < "${tmp}.json" 2>/dev/null)" = "array" ]; then
		asnips=$(jsonfilter -q -e '@.data.prefixes[*].prefix' < "${tmp}.json")
		# sorted, so that the same prefixes in another order are no change
		printf '%s\n' $asnips | sort > "$tmp"
		if ! cmp -s "$tmp" "$cache"; then
			mkdir -p "$_BYPASS_ASN_CACHE"
			cp "$tmp" "${cache}.new" && mv "${cache}.new" "$cache"
		fi
		rm -f "$tmp"
	else
		asnips=$(cat "$cache" 2>/dev/null)
		if [ -n "$asnips" ]; then
			logger -t "omr-bypass" "Rule $1: no answer from RIPE for AS${asn}, its last prefixes are kept"
		else
			logger -t "omr-bypass" "Rule $1: no answer from RIPE for AS${asn}, no prefix for it"
		fi
	fi
	rm -f "${tmp}.json"
	# A large AS announces thousands of prefixes, and adding them one by one
	# (two uci runs and a scan of the whole list each) took minutes: the
	# first prefix of each family sets the sets and rules up, all of them go
	# to the set's file, which fw4 reads into the set as it is.
	local ip v4="" v6=""
	for ip in $asnips; do
		if _valid_ip4net "$ip"; then
			v4="$v4 $ip"
		elif _valid_ip6net "$ip"; then
			v6="$v6 $ip"
		fi
	done
	local fam list
	for fam in 4 6; do
		[ "$fam" = "4" ] && list="$v4" || list="$v6"
		set -- $list
		[ $# -gt 0 ] || continue
		if [ -n "$interface" ]; then
			_bypass_ip "$1" "$interface"
			_asn_set_add "omr_dst_bypass_${interface}_${fam}" "$@"
		fi
		if [ -n "$dscp" ]; then
			_dscp_ip "$1" "$dscp"
			_asn_set_add "omr_dst_bypass_dscp_${dscp}_${fam}" "$@"
		fi
	done
}

# The nightly cron: the prefixes are fetched again by a rules reload. This
# only queued uci changes nothing ever committed.
bypass_asn() {
	reload_rules
}

_bypass_omr_server() {
	local ip _srv_ip
	config_get ip $1 ip
	# a list: the second address was taken as _bypass_ip's interface
	for _srv_ip in $ip; do
		_bypass_ip "$_srv_ip"
	done
}


_ss_rules_config() {
	# The ss_rules chains only exist while Shadowsocks runs: their rules
	# go through $ssnft, a no-op without them. This line used to
	# exit the whole script, so whenever Shadowsocks was down (or only
	# configured) the DSCP marking chain and every other rule after it
	# were never installed.
	cat >> "$_fw_include" <<-EOF
		if nft list chains inet 2>/dev/null | grep -q 'chain ss_rules'; then ssnft=nft; else ssnft=:; fi
		#nft insert rule inet fw4 ss_rules_dst_tcp ip daddr @omr_dst_bypass_all_4 meta mark set 0x00004539 accept
		#nft insert rule inet fw4 ss_rules_local_out ip daddr @omr_dst_bypass_all_4 meta mark set 0x00004539 accept
		#nft add chain inet fw4 bypass_prerouting '{ type nat hook prerouting priority filter - 5; policy accept; }'
		#nft add chain inet fw4 bypass_local '{ type nat hook output priority filter - 5; policy accept; }'
	EOF
	#if [ "$disableipv6" = "0" ]; then
	#	cat >> /etc/firewall.omr-bypass <<-EOF
	#		nft insert rule inet fw4 ss_rules_dst_tcp ip6 daddr @omr_dst_bypass_all_6 accept
	#		nft insert rule inet fw4 ss_rules_local_out ip6 daddr @omr_dst_bypass_all_6 accept
	#	EOF
	#fi
}

_v2ray_rules_config() {
	cat >> "$_fw_include" <<-EOF
		#nft insert rule inet fw4 v2r_rules_dst_tcp ip daddr @omr_dst_bypass_all_4 accept
		#nft insert rule inet fw4 v2r_rules_local_out ip daddr @omr_dst_bypass_all_4 accept
	EOF
	#if [ "$disableipv6" = "0" ]; then
	#	cat >> /etc/firewall.omr-bypass <<-EOF
	#		nft insert rule inet fw4 v2r_rules_dst_tcp ip6 daddr @omr_dst_bypass_all_6 accept
	#		nft insert rule inet fw4 v2r_rules_local_out ip6 daddr @omr_dst_bypass_all_6 accept
	#	EOF
	#fi
}

_xray_rules_config() {
	cat >> "$_fw_include" <<-EOF
		#nft insert rule inet fw4 xr_rules_dst_tcp ip daddr @omr_dst_bypass_all_4 accept
		#nft insert rule inet fw4 xr_rules_local_out ip daddr @omr_dst_bypass_all_4 accept
	EOF
	#if [ "$disableipv6" = "0" ]; then
	#	cat >> /etc/firewall.omr-bypass <<-EOF
	#		nft insert rule inet fw4 xr_rules_dst_tcp ip6 daddr @omr_dst_bypass_all_6 accept
	#		nft insert rule inet fw4 xr_rules_local_out ip6 daddr @omr_dst_bypass_all_6 accept
	#	EOF
	#fi
}

_delete_dhcp_ipset() {
	case "$1" in
	*omr_dst_bypass*)
		_dhcp_delete_batch="${_dhcp_delete_batch}delete dhcp.$1
"
		;;
	esac
}

_delete_firewall_rules() {
	case "$1" in
	*omr_dst_bypass*|*omr6_dst_bypass*|*bypass_*|*bypass6_*)
		_fw_delete_batch="${_fw_delete_batch}delete firewall.$1
"
		;;
	esac
}

_delete_network_rules() {
	case "$1" in
	*fw_rule*)
		_net_delete_batch="${_net_delete_batch}delete network.$1
"
		;;
	esac
}

_bypass_proto_ndpid_list=""

# _proto_section_id <var> <proto>: the uci section id for the sets and rules
# of an nDPI protocol. 13 protocol names have a dash (JSON-RPC, NAT-PMP,
# OPC-UA...), which uci refuses in a section id: their sets were never
# created, so ndpid-consumer could not add the addresses it detected, and
# the whole firewall batch fell back to the slow "uci batch". The set keeps
# the protocol name ("name" option, nft takes a dash), which is how the
# consumer and /etc/firewall.omr-bypass refer to it.
_proto_section_id() {
	case "$2" in
		*[-.]*) eval "$1=\"\$(printf '%s' \"\$2\" | tr '.-' '__')\"" ;;
		*) eval "$1=\"\$2\"" ;;
	esac
}

# Shared by _bypass_proto_collect_ndpid (dpis) and _bypass_category_collect_ndpid
# (categories): registers one proto for nDPId watching and, for interface=all,
# ensures the nftables sets/rules used to receive detected IPs exist.
_bypass_ndpid_collect_proto() {
	local proto="$1" intf="$2" intfid="$3" tcpudp="$4" dscp="$5" family="$6" _psec

	# Ensure nftables sets and rules exist for this protocol (even if DB is empty)
	if [ "$intf" = "all" ]; then
		_proto_section_id _psec "$proto"
		# Flag if the nft set is absent — used by start_service to force fw4
		nft list set inet fw4 "bypass_${proto}" >/dev/null 2>&1 || _ndpid_fw_needed=1
		cat >> "$_fw_uci_batch" <<-EOF
			set firewall.bypass_${_psec}=ipset
			set firewall.bypass_${_psec}.name="bypass_${proto}"
			set firewall.bypass_${_psec}.match='dest_net'
			set firewall.bypass_${_psec}.family='ipv4'
			set firewall.bypass_${_psec}.enabled='1'
			set firewall.bypass6_${_psec}=ipset
			set firewall.bypass6_${_psec}.name="bypass6_${proto}"
			set firewall.bypass6_${_psec}.match='dest_net'
			set firewall.bypass6_${_psec}.family='ipv6'
			set firewall.bypass6_${_psec}.enabled='1'
		EOF
		for zone in $(_get_firewall_zones); do
			cat >> "$_fw_uci_batch" <<-EOF
				set firewall.bypass_${_psec}_${zone}_rule=rule
				set firewall.bypass_${_psec}_${zone}_rule.name="bypass_${proto}_${zone}_rule"
				set firewall.bypass_${_psec}_${zone}_rule.src="${zone}"
				set firewall.bypass_${_psec}_${zone}_rule.proto="${tcpudp}"
				set firewall.bypass_${_psec}_${zone}_rule.dest='*'
				set firewall.bypass_${_psec}_${zone}_rule.family='ipv4'
				set firewall.bypass_${_psec}_${zone}_rule.target='MARK'
				set firewall.bypass_${_psec}_${zone}_rule.ipset="bypass_${proto}"
				set firewall.bypass_${_psec}_${zone}_rule.enabled='1'
				set firewall.bypass_${_psec}_${zone}_rule.set_mark="0x4539${intfid}"
				set firewall.bypass6_${_psec}_${zone}_rule=rule
				set firewall.bypass6_${_psec}_${zone}_rule.name="bypass6_${proto}_${zone}_rule"
				set firewall.bypass6_${_psec}_${zone}_rule.src="${zone}"
				set firewall.bypass6_${_psec}_${zone}_rule.family='ipv6'
				set firewall.bypass6_${_psec}_${zone}_rule.dest='*'
				set firewall.bypass6_${_psec}_${zone}_rule.proto="${tcpudp}"
				set firewall.bypass6_${_psec}_${zone}_rule.target='MARK'
				set firewall.bypass6_${_psec}_${zone}_rule.set_mark="0x6539${intfid}"
				set firewall.bypass6_${_psec}_${zone}_rule.ipset="bypass6_${proto}"
				set firewall.bypass6_${_psec}_${zone}_rule.enabled='1'
			EOF
		done
		_intf_rules_all_rules "${proto}" "${intfid}" "${tcpudp}"
	fi
	# ndpid-consumer reads proto:intf:intfid:dscp; the dscp field was never
	# written, so detected flows were never DSCP-marked. Its set has to
	# exist even when no domain or IP of the rule created it.
	[ -n "$dscp" ] && _omr_defer _dscp_ensure_ipset "$dscp"
	# a 5th field for "IPv4 only" / "IPv6 only": the consumer skips the
	# addresses of the other family. A 6th for "TCP only" / "UDP only": the
	# addresses went to the interface's set for every protocol, whose rule
	# such a rule never enables, so they bypassed nothing (or, with another
	# rule enabling it, bypassed the other protocol too).
	local _fam="" _p=""
	case "$family" in ipv4|ipv6) _fam="$family" ;; esac
	case "$tcpudp" in tcp|udp) _p="$tcpudp" ;; esac
	[ -n "$_fam$_p" ] && dscp="${dscp}:${_fam}"
	[ -n "$_p" ] && dscp="${dscp}:${_p}"
	_bypass_proto_ndpid_list="${_bypass_proto_ndpid_list} ${proto}:${intf}:${intfid}:${dscp}"
	# a protocol with no host in the database left the rules of the
	# interface's sets disabled (the domains enable them)
	case "$intf" in
		""|all|srv_vpn1) ;;
		*) _omr_defer _bypass_ndpid_enable "$intf" "$family" "$_p" ;;
	esac
}

# _bypass_ndpid_enable <interface key> <family> <tcp|udp|>: enable the MARK
# rules of the interface's set the consumer adds to, as _bypass_ip does
_bypass_ndpid_enable() {
	local f zone p="${3:+_$3}"
	for f in 4 6; do
		[ "$f" = "4" ] && [ "$2" = "ipv6" ] && continue
		[ "$f" = "6" ] && [ "$2" = "ipv4" ] && continue
		[ "$f" = "6" ] && [ "$disableipv6" != "0" ] && continue
		for zone in $(_get_firewall_zones); do
			uci -q set "firewall.omr_dst_bypass_${1}_dstip_${f}_${zone}${p}.enabled=1"
			uci -q set "firewall.omr_dst_bypass_${1}_dstip_${f}_${zone}_accept.enabled=1"
		done
	done
}

_bypass_proto_collect_ndpid() {
	local proto intf enabled intfid tcpudp ndpi dscp vpn family
	_rule_proto proto $1
	_get_rule_intf intf $1
	config_get enabled $1 enabled
	config_get tcpudp $1 tcpudp "all"
	config_get family $1 family "ipv4ipv6"
	config_get ndpi $1 ndpi
	_rule_dscp dscp $1
	config_get vpn $1 vpn
	[ "$enabled" = "0" ] && return
	[ "$ndpi" = "0" ] && return
	[ -z "$proto" ] && return
	# like a category: the detected addresses go to the server VPN's set,
	# not to the rule's interface
	[ "$vpn" = "1" ] && intf="srv_vpn1"
	intf=$(_map_intf_device "$intf")
	intf=$(_sanitize_intf_key "$intf")
	# no interface: DSCP marking only, if the rule has a class
	[ -z "$intf" ] && [ -z "$dscp" ] && return
	[ "$intf" = "none" ] && return
	[ "$intf" = "default" ] && intf="all"
	intfid=""
	[ -n "$intf" ] && intfid=$(uci -q get omr-bypass.$intf.id)
	[ "$intf" = "all" ] && intfid=""
	_bypass_ndpid_collect_proto "$proto" "$intf" "$intfid" "$tcpudp" "$dscp" "$family"
}

# Categories (a set of protos grouped in omr-bypass-proto.json) were never
# registered with the nDPId consumer: a category rule with ndpi=1 skipped the
# static DB IP list (via _bypass_proto_core's ndpi gate) but no mechanism ever
# watched for the live flows, so the rule silently bypassed nothing. Expand
# the category into its member protos here, same as _bypass_proto_collect_ndpid
# does for a single dpis proto.
_bypass_category_collect_ndpid() {
	local category intf enabled vpn intfid tcpudp ndpi proto dscp family
	config_get category $1 category
	_get_rule_intf intf $1
	config_get enabled $1 enabled
	config_get vpn $1 vpn
	config_get tcpudp $1 tcpudp "all"
	config_get family $1 family "ipv4ipv6"
	config_get ndpi $1 ndpi
	_rule_dscp dscp $1
	[ "$enabled" = "0" ] && return
	[ "$ndpi" = "0" ] && return
	[ -z "$category" ] && return
	[ "$vpn" = "1" ] && intf="srv_vpn1"
	intf=$(_map_intf_device "$intf")
	intf=$(_sanitize_intf_key "$intf")
	[ -z "$intf" ] && [ -z "$dscp" ] && return
	[ "$intf" = "none" ] && return
	[ "$intf" = "default" ] && intf="all"
	intfid=""
	[ -n "$intf" ] && intfid=$(uci -q get omr-bypass.$intf.id)
	[ "$intf" = "all" ] && intfid=""

	for proto in $(grep -F "\"category\":\"${category}\"" /usr/share/omr-bypass/omr-bypass-proto.json 2>/dev/null | awk -F'"' '{print $4}'); do
		[ -n "$proto" ] || continue
		_bypass_ndpid_collect_proto "$proto" "$intf" "$intfid" "$tcpudp" "$dscp" "$family"
	done
}

_start_ndpid_consumer() {
	[ "$_omr_ndpid" != "1" ] && return
	[ -z "$_bypass_proto_ndpid_list" ] && return

	# Kill any existing consumer (whole process group to avoid orphan tail/nc)
	if [ -f /var/run/omr-bypass-ndpid.pid ]; then
		kill -- -"$(cat /var/run/omr-bypass-ndpid.pid)" 2>/dev/null
		kill "$(cat /var/run/omr-bypass-ndpid.pid)" 2>/dev/null
		rm -f /var/run/omr-bypass-ndpid.pid
	fi

	# setsid makes the consumer a new session/process-group leader so that
	# kill -- -PID cleans up all its children (nc, tail, subshells).
	# It runs for good: the lock fds of whoever started it (rc.common's
	# 1000, omr-schedule's and wan-failover's 9) must not stay held with it.
	setsid /usr/lib/omr-bypass/ndpid-consumer "$_bypass_proto_ndpid_list" 1000>&- 9>&- &
	logger -t "omr-bypass" "nDPId consumer started (PID: $!)"
}

_stop_ndpid_consumer() {
	if [ -f /var/run/omr-bypass-ndpid.pid ]; then
		local pid
		pid=$(cat /var/run/omr-bypass-ndpid.pid)
		kill -- -"$pid" 2>/dev/null
		kill "$pid" 2>/dev/null
		rm -f /var/run/omr-bypass-ndpid.pid
		logger -t "omr-bypass" "nDPId consumer stopped"
	fi
}

# Set _ndpid_wanted=1 when at least one enabled proto bypass rule relies on
# nDPId detection (same conditions as _bypass_proto_collect_ndpid)
_ndpid_rule_wanted() {
	local proto intf enabled ndpi dscp vpn
	_rule_proto proto $1
	_get_rule_intf intf $1
	config_get enabled $1 enabled
	config_get ndpi $1 ndpi
	_rule_dscp dscp $1
	config_get vpn $1 vpn
	[ "$enabled" = "0" ] && return
	[ "$ndpi" = "0" ] && return
	[ -z "$proto" ] && return
	[ "$vpn" = "1" ] && intf="srv_vpn1"
	[ -z "$intf" ] && [ -z "$dscp" ] && return
	[ "$intf" = "none" ] && return
	_ndpid_wanted=1
}

# Same check as _ndpid_rule_wanted, for categories sections (a category rule
# has no single "proto" — any enabled, ndpi=1 category with a routing
# interface or a DSCP class is enough to require the consumer). Only with
# ndpi=1 saved: a category rule without the option never started nDPId.
_ndpid_category_rule_wanted() {
	local category intf enabled ndpi dscp vpn
	config_get category $1 category
	_get_rule_intf intf $1
	config_get enabled $1 enabled
	config_get ndpi $1 ndpi
	_rule_dscp dscp $1
	config_get vpn $1 vpn
	[ "$enabled" = "0" ] && return
	[ "$ndpi" = "1" ] || return
	[ -z "$category" ] && return
	[ "$vpn" = "1" ] && intf="srv_vpn1"
	[ -z "$intf" ] && [ -z "$dscp" ] && return
	[ "$intf" = "none" ] && return
	_ndpid_wanted=1
}

# nDPId ships disabled by default: when bypass rules need protocol detection,
# enable and start ndpisrvd/ndpid instead of silently doing nothing
_ensure_ndpid_running() {
	_ndpid_wanted=0
	config_load omr-bypass
	config_foreach _ndpid_rule_wanted dpis
	config_foreach _ndpid_category_rule_wanted categories
	[ "$_ndpid_wanted" != "1" ] && return
	pidof ndpisrvd >/dev/null 2>&1 && pidof ndpid >/dev/null 2>&1 && return
	[ "$(uci -q get ndpid.main.enabled)" != "1" ] && uci -q set ndpid.main.enabled='1'
	[ "$(uci -q get ndpid.distributor.enabled)" != "1" ] && uci -q set ndpid.distributor.enabled='1'
	[ -n "$(uci -q changes ndpid)" ] && uci -q commit ndpid
	if ! pidof ndpisrvd >/dev/null 2>&1; then
		logger -t "omr-bypass" "nDPId bypass rules present, starting ndpisrvd"
		/etc/init.d/ndpisrvd start >/dev/null 2>&1
	fi
	if ! pidof ndpid >/dev/null 2>&1; then
		logger -t "omr-bypass" "nDPId bypass rules present, starting ndpid"
		/etc/init.d/ndpid start >/dev/null 2>&1
	fi
	# procd spawns instances asynchronously — wait briefly so the consumer
	# check below sees ndpisrvd
	local _i=0
	while [ "$_i" -lt 5 ] && ! pidof ndpisrvd >/dev/null 2>&1; do
		sleep 1
		_i=$((_i+1))
	done
}

# The lock start_service and stop_service hold while they change the
# generated sections
_OMR_LOCK="/var/run/omr-bypass.lock"
_omr_locked=""

# _omr_lock <seconds>: take the lock, waiting at most <seconds> for the
# instance that holds it; 1 when it never freed
_omr_lock() {
	local _lock_wait=0 _lock_pid
	while ! mkdir "$_OMR_LOCK" 2>/dev/null; do
		# A holder killed before its trap could run left the lock, and
		# every later run gave up here until a reboot.
		_lock_pid="$(cat "$_OMR_LOCK/pid" 2>/dev/null)"
		if [ -n "$_lock_pid" ] && ! kill -0 "$_lock_pid" 2>/dev/null; then
			logger -t "omr-bypass" "Removing the lock of pid $_lock_pid, which is gone"
			rm -rf "$_OMR_LOCK"
			continue
		fi
		_lock_wait=$((_lock_wait + 1))
		[ "$_lock_wait" -ge "$1" ] && return 1
		sleep 1
	done
	echo "$$" > "$_OMR_LOCK/pid"
	_omr_locked=1
	trap "rm -rf '$_OMR_LOCK'" EXIT HUP INT TERM
}

_omr_unlock() {
	[ -n "$_omr_locked" ] || return 0
	rm -rf "$_OMR_LOCK"
	_omr_locked=""
	trap - EXIT HUP INT TERM
}

boot() {
	BOOT=1
	start "$@"
}

# rc.common's restart is stop then start, and procd's rc.common does not
# call a restart_service: stop deleted every generated section without the
# lock and restarted fw4 and dnsmasq, start wrote them all again and
# restarted both a second time, with no bypass in between, while a
# reload_rules could be running. A restart is a start that writes every
# generated section again, under the lock, with one fw4 restart.
restart() {
	FORCE=1
	start "$@"
}

start_service() {
	# Prevent concurrent/cascading invocations (procd triggers on firewall/network commits).
	# This is serialization, not a "do nothing else is happening" check: reload_rules/start is
	# also called directly (and often, not just via procd triggers) by other packages -- proxy
	# init scripts on restart, omr-schedule, the interface-change rpcd hook -- so some other
	# invocation holding the lock at any given moment is a routine, not exceptional, occurrence.
	# A losing invocation used to give up immediately, silently dropping whatever config change
	# (e.g. a newly added bypass entry) triggered it until some unrelated future trigger happened
	# to run start_service again -- observed as an intermittent miss of freshly added entries
	# (issue #4340). Retry for a bounded window (a normal run finishes in a few seconds, see the
	# hash-gate above) so a losing invocation waits for the winner instead of no-op'ing; still
	# degrades to the old skip-and-log behavior if the lock never frees.
	if ! _omr_lock 15; then
		logger -t "omr-bypass" "Another instance still running after 15s, skipping concurrent call"
		return
	fi
	_dnsipset_md5="$(_dnsipset_state | md5sum)"
	_omr_dhcp_md5="$(_omr_dhcp_state | md5sum)"
	# what dnsmasq and the nDPId consumer fed the sets for, see _omr_fed_lost
	_dnsipset_pairs > /tmp/omr-bypass-fed.before
	_ndpid_list_pairs "$(cat "$_NDPID_LIST" 2>/dev/null)" > /tmp/omr-bypass-fed.before-ndpid
	_omr_ndpid_unsure=""
	if [ -n "$RELOAD" ]; then
		_bypass_resolved_new=""
		_bypass_resolved_want="${_BYPASS_RESOLVED}.want"
		: > "$_bypass_resolved_want"
	else
		_bypass_resolved_want=""
		_bypass_resolved_new="${_BYPASS_RESOLVED}.new"
		: > "$_bypass_resolved_new"
	fi
	# A reload clears the per-entry state only now that it holds the lock:
	# cleared before, by a call that then gave up waiting, it was gone for
	# good once the running instance committed its own changes.
	[ -n "$RELOAD" ] && {
		# a stale consumer would keep collecting for protos that were just
		# removed; it is started again below when ndpid protos are still
		# configured
		_stop_ndpid_consumer
		_soft_wipe_dynamic
	}
	# a restart writes every section again, which drops the per-entry
	# state; the consumer is started again below with the current list
	[ -n "$FORCE" ] && _stop_ndpid_consumer

	#local count
	logger -t "omr-bypass" "Starting OMR-ByPass..."

	# Generated uci sections are accumulated in these files and applied in a
	# single "uci batch" per config at the end of the run: applying them one
	# batch at a time makes uci re-parse the (large) config plus a growing
	# delta on every invocation, which dominated boot time.
	_fw_uci_batch="/tmp/omr-bypass-uci-batch-fw"
	_dhcp_uci_batch="/tmp/omr-bypass-uci-batch-dhcp"
	_net_uci_batch="/tmp/omr-bypass-uci-batch-net"
	: > "$_fw_uci_batch"
	: > "$_dhcp_uci_batch"
	: > "$_net_uci_batch"

	_dhcp_delete_batch=""
	config_load dhcp
	config_foreach _delete_dhcp_ipset ipset
	uci -q delete dhcp.@dnsmasq[0].noipv6
	#uci -q commit dhcp
	_fw_delete_batch=""
	config_load firewall
	config_foreach _delete_firewall_rules rule
	config_foreach _delete_firewall_rules ipset
	#uci -q commit firewall
	_net_delete_batch=""
	config_load network
	config_foreach _delete_network_rules rule
	#uci -q commit network


	add_domains="false"
	_dscp_chain_written=""
	_dscp_ipset_vals=""
	_reply_chain_written=""
	_reply_marks=""
	_reply_wan_marks=""
	[ -d /proc/net/xt_ndpi ] && {
		config_load omr-bypass
		config_foreach _add_proto proto
	}
	disableipv6="$(uci -q get openmptcprouter.settings.disable_ipv6)"
	# Cache proxy/VPN/ndpi/ndpid state — read once, reused by _intf_rule and _bypass_proto_core (called per interface/proto)
	_omr_proxy=$(uci -q get openmptcprouter.settings.proxy)
	_omr_ndpi=$(uci -q get openmptcprouter.settings.ndpi)
	# A dpis/categories rule's ndpi option: 1 (saved ticked) relies on
	# nDPId instead of the static address list, 0 uses the list only, and
	# no option at all (rules saved before LuCI wrote it, the CLI, the
	# 41_omr-bypass migration) is what it always was: the list, with nDPId's
	# detections added when nDPId runs, and nDPId started for a dpis rule
	# but not for a category. Reading a missing option as 1 dropped the
	# list of those rules on upgrade and started nDPId for every category.
	_omr_ndpid=0
	_ndpid_wanted=0
	if [ -x /usr/sbin/ndpid ]; then
		_ensure_ndpid_running
		pidof ndpisrvd >/dev/null 2>&1 && _omr_ndpid=1
	fi
	# rules want nDPId but it is not up (yet): the consumer list of the
	# last run is kept, and what it fed is not taken for removed
	[ "$_ndpid_wanted" = "1" ] && [ "$_omr_ndpid" != "1" ] && _omr_ndpid_unsure=1
	_ss_libev_disabled=$(uci -q get shadowsocks-libev.sss0.disabled)
	_ss_rust_disabled=$(uci -q get shadowsocks-rust.sss0.disabled)
	_v2ray_enabled=$(uci -q get v2ray.main.enabled)
	_xray_enabled=$(uci -q get xray.main.enabled)
	_omr_fw_zones="$(_get_firewall_zones)"
	_omr_wan_networks="$(_get_wan_networks)"
	#noipv6="$(uci -q get omr-bypass.global.noipv6)"
	fwfilechangemd5=""
	[ -f /etc/firewall.omr-bypass ] && fwfilechangemd5="$(md5sum /etc/firewall.omr-bypass)"
	[ -f "$_REPLY_NFT" ] && fwfilechangemd5="${fwfilechangemd5} $(md5sum "$_REPLY_NFT")"
	# Not part of the restart decision below: an AS announcing one prefix
	# more or less (most nights) forced a full fw4 and dnsmasq restart,
	# which also dropped every address dnsmasq had added to the sets. The
	# light reload deletes the prefixes a set lost (_asn_stale) and adds the
	# new ones from the files.
	_asnmd5="$(_asn_files_md5)"
	_fw_include="/tmp/omr-bypass-include.new"
	_reply_nft_out="/tmp/omr-bypass-reply-nft.new"
	_reply_include_out="/tmp/omr-bypass-reply-include.new"
	rm -f "$_fw_include" "$_reply_nft_out" "$_reply_include_out"
	#cat > /etc/firewall.omr-bypass <<-EOF
	##!/bin/sh
	##nft insert rule inet fw4 ss_rules_dst_tcp ip daddr @omr_dst_bypass_all accept
	##nft insert rule inet fw4 ss_rules_local_out ip daddr @omr_dst_bypass_all accept
	#EOF
	#config_load shadowsocks-libev
	#config_foreach _ss_rules_config ss_rules
	{ [ "$_ss_libev_disabled" != "1" ] || [ "$_ss_rust_disabled" != "1" ]; } && _ss_rules_config
	#config_load shadowsocks-rust
	#config_foreach _ss_rules_config ss_rules
	[ "$_v2ray_enabled" = "1" ] && _v2ray_rules_config
	[ "$_xray_enabled" = "1" ] && _xray_rules_config


	cat >> "$_fw_uci_batch" <<-EOF
		set firewall.omr_bypass=include
		set firewall.omr_bypass.enabled='1'
		set firewall.omr_bypass.type='script'
		set firewall.omr_bypass.path='/etc/firewall.omr-bypass'
		set firewall.omr_bypass.fw4_compatible='1'
		set firewall.omr_bypass_reply=include
		set firewall.omr_bypass_reply.enabled='1'
		set firewall.omr_bypass_reply.type='script'
		set firewall.omr_bypass_reply.path='$_REPLY_INCLUDE'
		set firewall.omr_bypass_reply.fw4_compatible='1'
		set firewall.omr_bypass_srcif=include
		set firewall.omr_bypass_srcif.enabled='1'
		set firewall.omr_bypass_srcif.type='script'
		set firewall.omr_bypass_srcif.path='/usr/lib/omr-bypass/firewall.wan-failover'
		set firewall.omr_bypass_srcif.fw4_compatible='1'
	EOF
	#echo "intf_rule"
	_wan_groups_setup
	config_load network
	config_foreach _intf_rule interface
	for _grp in $_wan_groups; do
		_intf_rule "$_grp"
	done
	_intf_rule all
	_intf_rule_srv_vpn1
	_intf_rule_none
	# Needs every mark _intf_rule handed out, so it closes the chain here
	# rather than inside the loop.
	_reply_finish
	local ndpi_rules=""

#	ip rule add prio 1 fwmark 0x4539 lookup 991337 > /dev/null 2>&1
#	ip -6 rule add prio 1 fwmark 0x6539 lookup 6991337 > /dev/null 2>&1

	# NDPI Netfilter is not available for nftables
#	$IPTABLESSAVE --counters 2>/dev/null | grep -v omr-bypass-dpi | $IPTABLESRESTORE -w --counters 2>/dev/null
#	$IPTABLESRESTORE -w --wait=60  --noflush <<-EOF
#	*mangle
#	:omr-bypass-dpi -
#	-A INPUT -j omr-bypass-dpi
#	-A FORWARD -j omr-bypass-dpi
#	COMMIT
#	EOF
#	if [ "$disableipv6" = "0" ]; then
#		$IP6TABLESSAVE --counters | grep -v omr-bypass6-dpi | $IP6TABLESRESTORE -w --counters 2>/dev/null
#		$IP6TABLESRESTORE -w --wait=60  --noflush <<-EOF
#		*mangle
#		:omr-bypass6-dpi -
#		-A INPUT -j omr-bypass6-dpi
#		-A FORWARD -j omr-bypass6-dpi
#		COMMIT
#		EOF
#	fi
	config_load omr-bypass
	# their direct uci writes wait for the hash gate, see _omr_defer
	_omr_defer_file="/tmp/omr-bypass-deferred"
	: > "$_omr_defer_file"
	config_foreach _bypass_proto_without_ndpi dpis
	config_foreach _bypass_category categories
	if [ "$_omr_ndpid" = "1" ]; then
		_bypass_proto_ndpid_list=""
		_ndpid_fw_needed=0
		config_load omr-bypass
		config_foreach _bypass_proto_collect_ndpid dpis
		config_foreach _bypass_category_collect_ndpid categories
		_start_ndpid_consumer
	fi
	# the list the running consumer has, read back by the next run
	if [ "$_omr_ndpid" = "1" ] && [ -n "$_bypass_proto_ndpid_list" ]; then
		echo "$_bypass_proto_ndpid_list" > "$_NDPID_LIST"
	elif [ -z "$_omr_ndpid_unsure" ]; then
		rm -f "$_NDPID_LIST"
	fi
	# Apply the generated sections in one shot per config, skipping entirely
	# when the desired state is identical to what the previous run generated
	# and committed (hash match + sentinel section still present). On a normal
	# boot nothing changed, so all the uci/fw4/dnsmasq work is skipped.
	# <force> (a restart) applies it anyway.
	_omr_hash_gate() {
		local name="$1" file="$2" sentinel="$3" delete_batch="$4" force="$5"
		local hash
		hash="$(md5sum "$file" | awk '{print $1}')"
		if [ -z "$force" ] && [ "$hash" = "$(uci -q get omr-bypass.global.${name}_hash)" ] && [ -n "$sentinel" ]; then
			return 1
		fi
		{ printf '%s' "$delete_batch"; cat "$file"; } | _uci_batch
		uci -q set omr-bypass.global=global
		uci -q set omr-bypass.global.${name}_hash="$hash"
		return 0
	}
	_dhcp_regen=""
	_omr_hash_gate fw "$_fw_uci_batch" "$([ "$(uci -q get firewall.omr_bypass.enabled)" = "1" ] && uci -q get firewall.omr_dst_bypass_all_4)" "$_fw_delete_batch" "$FORCE" \
		&& logger -t "omr-bypass" "Firewall bypass rules regenerated"
	_omr_hash_gate dhcp "$_dhcp_uci_batch" "$(uci -q get dhcp.omr_dst_bypass_all)" "$_dhcp_delete_batch" "$FORCE" && _dhcp_regen=1
	_omr_hash_gate net "$_net_uci_batch" "$(uci -q get network.all_fw_rule)" "$_net_delete_batch"
	rm -f "$_fw_uci_batch" "$_dhcp_uci_batch" "$_net_uci_batch"
	# domain lists, IPs and DSCP sets of the dpis/category rules, on top of
	# whatever the gate just regenerated
	_omr_run_deferred

	#echo "bypass server"
	if [ "$(uci -q get openmptcprouter.settings.bypass_servers)" = "1" ]; then
		config_load openmptcprouter
		config_foreach _bypass_omr_server server
	fi
	config_load omr-bypass
	#echo "bypass ip"
	config_foreach _bypass_ip_set ips
	#echo "bypass mac"
	config_foreach _bypass_mac macs
	#echo "bypass lan ip"
	config_foreach _bypass_lan_ip lan_ip
	#echo "bypass dest port"
	config_foreach _bypass_dest_port dest_port
	#echo "bypass src port"
	config_foreach _bypass_src_port src_port
	#echo "bypass domains"
	config_foreach _bypass_domains domains
	#echo "bypass asn"
	_bypass_asns
	if [ -n "$_bypass_resolved_want" ]; then
		_bypass_resolved_replay
		rm -f "$_bypass_resolved_want"
		_bypass_resolved_want=""
	elif [ -n "$_bypass_resolved_new" ]; then
		sort -u "$_bypass_resolved_new" > "$_BYPASS_RESOLVED"
		rm -f "$_bypass_resolved_new"
		_bypass_resolved_new=""
	fi

	[ -n "$(uci -q changes network)" ] && {
		uci -q commit network
		/etc/init.d/network reload
	}
	uci -q commit omr-bypass
	# The domains and noipv6 list deleted above (and by a reload's wipe)
	# and added back the same are still a change for uci: every start, and
	# every reload with domains, committed dhcp (a flash write) and could
	# restart dnsmasq. What the generated sections hold decides; when it
	# did not change, their uci changes are dropped.
	_dhcp_changed=""
	if [ "$(_omr_dhcp_state | md5sum)" = "$_omr_dhcp_md5" ]; then
		for _ob_sec in $(uci -q changes dhcp | sed -n 's/^-\{0,1\}dhcp\.\(omr_dst_bypass_[^.=]*\).*/\1/p' | sort -u); do
			uci -q revert "dhcp.$_ob_sec"
		done
		uci -q revert dhcp.@dnsmasq[0].noipv6
	else
		_dhcp_changed=1
		uci -q commit dhcp
	fi
	_omr_install_include "$_fw_include" /etc/firewall.omr-bypass
	_omr_install_include "$_reply_nft_out" "$_REPLY_NFT"
	_omr_install_include "$_reply_include_out" "$_REPLY_INCLUDE"
	_fw_include="/etc/firewall.omr-bypass"
	_reply_nft_out="$_REPLY_NFT"
	_reply_include_out="$_REPLY_INCLUDE"
	fwfilechangemd5check=""
	[ -f /etc/firewall.omr-bypass ] && fwfilechangemd5check="$(md5sum /etc/firewall.omr-bypass)"
	[ -f "$_REPLY_NFT" ] && fwfilechangemd5check="${fwfilechangemd5check} $(md5sum "$_REPLY_NFT")"
	_asnmd5check="$(_asn_files_md5)"
	# Force the heavier fw4/dnsmasq restart path only when the generated
	# bypass content actually changed (new/removed nft elements). This
	# comparison used to be inverted ("=" instead of "!="), so it forced a
	# full fw4 restart + dnsmasq restart on every reload_rules() call even
	# when nothing changed -- which is the common case for the hourly
	# omr-schedule IP-refresh cron job, and interrupted established TCP
	# connections through the tunnel for no reason (issue #4353).
	if [ "$fwfilechangemd5check" != "$fwfilechangemd5" ]; then
		RELOAD=""
	fi


	# Elements that left a set since _soft_wipe_dynamic snapshotted it: the
	# light `fw4 reload` below cannot take elements out of a live set, so
	# they are deleted first (one uci get + one grep per set, not per entry).
	# Only them: flushing the set also took out the addresses dnsmasq had
	# added for its domains and their subdomains, which then went through
	# the VPN or the proxy until dnsmasq resolved them upstream again.
	_omr_stale="$_asn_stale"
	_omr_fed_flush=""
	if [ -f /tmp/omr-bypass-fed.before ]; then
		{
			_dnsipset_pairs
			[ "$_omr_ndpid" = "1" ] && _ndpid_list_pairs "$_bypass_proto_ndpid_list"
		} > /tmp/omr-bypass-fed.after
		[ -z "$_omr_ndpid_unsure" ] && cat /tmp/omr-bypass-fed.before-ndpid >> /tmp/omr-bypass-fed.before 2>/dev/null
		_omr_fed_flush="$(_omr_fed_lost /tmp/omr-bypass-fed.before /tmp/omr-bypass-fed.after)"
		rm -f /tmp/omr-bypass-fed.before /tmp/omr-bypass-fed.before-ndpid /tmp/omr-bypass-fed.after
	fi
	if [ -f /tmp/omr-bypass-entries.before ]; then
		for _ob_sec in $(cut -d' ' -f1 /tmp/omr-bypass-entries.before | sort -u); do
			_ob_after=" $(uci -q get "firewall.${_ob_sec}.entry") "
			for _ob_val in $(grep "^${_ob_sec} " /tmp/omr-bypass-entries.before | cut -d' ' -f2); do
				case "$_ob_after" in
					*" $_ob_val "*) ;;
					*) _omr_stale="${_omr_stale}${_ob_sec} ${_ob_val}
" ;;
				esac
			done
		done
		rm -f /tmp/omr-bypass-entries.before
	fi

	# Restore proxy nft rules parked as .down before any fw4 run (issue #4313),
	# even when a reload has no uci delta: a restored file still needs an fw4
	# pass to be loaded. Leave them parked while the proxy is not running (at
	# boot, omr-bypass starts before it) or its tracker reports it down: the
	# redirect would send traffic to a proxy that doesn't answer, and the
	# tracker restores the rules itself once the proxy answers again.
	_proxy_down_restored=""
	for _proxy_nft_down in /etc/nftables.d/90-proxy-*-rules.nft.down; do
		[ -f "$_proxy_nft_down" ] || continue
		case "$_proxy_nft_down" in
			*/90-proxy-v2ray-rules.nft.down) _proxy_name="v2ray" _proxy_state="v2ray" ;;
			*/90-proxy-x2ray-rules.nft.down) _proxy_name="xray" _proxy_state="xray" ;;
			*/90-proxy-ssr-rules.nft.down) _proxy_name="shadowsocks-rust" _proxy_state="proxy" ;;
			*/90-proxy-ss-rules.nft.down) _proxy_name="shadowsocks-libev" _proxy_state="proxy" ;;
			*) _proxy_name="" ;;
		esac
		if [ -n "$_proxy_name" ]; then
			[ "$(uci -q -P /var/state get openmptcprouter.omr.${_proxy_state})" = "down" ] && continue
			/etc/init.d/${_proxy_name} running 2>/dev/null || continue
		fi
		mv -f "$_proxy_nft_down" "${_proxy_nft_down%.down}"
		_proxy_down_restored=1
	done
	_fw4_restarted=""
	if [ -n "$(uci -q changes firewall)" ] || [ "$fwfilechangemd5" != "$fwfilechangemd5check" ] || [ "$_asnmd5" != "$_asnmd5check" ] || [ "${_ndpid_fw_needed:-0}" = "1" ] || [ -n "$_proxy_down_restored" ] || [ -n "$_omr_fed_flush" ] || [ -n "$FORCE" ]; then
		uci -q commit firewall
		[ -z "$RELOAD" ] && {
			fw4 restart || logger -t "omr-bypass" "ERROR: fw4 restart failed"
			_fw4_restarted=1
			sleep 1
		}
		[ -n "$RELOAD" ] && {
			# One nft call per set. A delete fails as a whole when one of
			# its elements is not in the set (deleting part of a merged
			# range works): that set is flushed as before.
			_ob_deleted="" _ob_flushed=""
			for _ob_set in $_omr_fed_flush; do
				nft flush set inet fw4 "$_ob_set" >/dev/null 2>&1
			done
			[ -n "$_omr_fed_flush" ] && logger -t "omr-bypass" "Flushed the set(s) of removed or moved domains and protocols: $_omr_fed_flush"
			for _ob_sec in $(printf '%s' "$_omr_stale" | cut -d' ' -f1 | sort -u); do
				_ob_set="$(uci -q get "firewall.${_ob_sec}.name")"
				# flushed just above, the reload adds back what it declares
				case " $_omr_fed_flush " in *" ${_ob_set:-$_ob_sec} "*) continue ;; esac
				_ob_elems="$(printf '%s' "$_omr_stale" | awk -v s="$_ob_sec" '$1 == s && NF == 2 && !seen[$2]++ { printf "%s%s", sep, $2; sep = ", " }')"
				[ -n "$_ob_elems" ] || continue
				if nft delete element inet fw4 "${_ob_set:-$_ob_sec}" "{ $_ob_elems }" >/dev/null 2>&1; then
					_ob_deleted="$_ob_deleted $_ob_sec"
				else
					nft flush set inet fw4 "${_ob_set:-$_ob_sec}" >/dev/null 2>&1
					_ob_flushed="$_ob_flushed $_ob_sec"
				fi
			done
			[ -n "$_ob_deleted" ] && logger -t "omr-bypass" "Deleted stale elements from set(s):$_ob_deleted"
			[ -n "$_ob_flushed" ] && logger -t "omr-bypass" "Flushed stale elements from set(s):$_ob_flushed"
			# fw4 returns the status of its includes, not of the nft
			# load: a ruleset nft refused (the running one stays) went
			# unnoticed. Its check mode tries the same ruleset against
			# the running one.
			{ fw4 reload && fw4 -q check; } || logger -t "omr-bypass" "ERROR: fw4 reload failed"
		}
	fi

	# a reload does not re-read the noipv6 file (see _noipv6_dnsmasq_conf)
	_dnsmasq_restart=""
	_noipv6_dnsmasq_conf && _dnsmasq_restart=1
	[ -z "$RELOAD" ] && { [ -n "$_dhcp_regen" ] || [ -n "$_dhcp_changed" ]; } && _dnsmasq_restart=1
	[ -n "$_dnsmasq_restart" ] && {
		logger -t "omr-bypass" "Restart dnsmasq..."
		/etc/init.d/dnsmasq restart
	}
	# also when the last domain went away: dnsmasq kept filling the sets for
	# it until its next restart
	_dnsmasq_reload=""
	[ -n "$RELOAD" ] && { [ "$add_domains" = "true" ] || [ -n "${_dhcp_had_domains:-}" ]; } && _dnsmasq_reload=1
	# The fw4 restart emptied the sets dnsmasq fills (see
	# _dnsmasq_feeds_sets): a reload clears its cache, so the next query of
	# each domain goes upstream and adds its addresses again. Its dhcp
	# sections unchanged, nothing else restarts or reloads dnsmasq.
	[ -n "$_fw4_restarted" ] && _dnsmasq_feeds_sets && _dnsmasq_reload=1
	[ -z "$_dnsmasq_restart" ] && [ -n "$_dnsmasq_reload" ] && {
		logger -t "omr-bypass" "Reload dnsmasq..."
		/etc/init.d/dnsmasq reload
	}
	# omr-bypass-proto.lst, the protocol list LuCI read before it took
	# omr-bypass-proto.json, is not written again at every start: nothing
	# reads it any more
	uci -q commit firewall
	# Output WAN lists and source interface policies: need the per-WAN
	# marks and ip rules generated above, and re-check which WAN each uses.
	/usr/lib/omr-bypass/wan-failover apply
	logger -t "omr-bypass" "OMR-ByPass is running"

	procd_open_instance
	procd_set_param command /bin/true
	procd_close_instance

	_omr_unlock
	[ "$(_dnsipset_state | md5sum)" != "$_dnsipset_md5" ] && _omr_dscp_resync
}

stop_service() {
	# A start or reload_rules writing the sections at the same time put
	# back what this deletes. Stop goes on without the lock when it never
	# frees: it is also what disable and the package removal run.
	_omr_lock 60 || logger -t "omr-bypass" "Another instance still running after 60s, stopping anyway"
	_stop_ndpid_consumer
	rm -f "$_NDPID_LIST"
	# Rules for ndpi
#	$IPTABLESSAVE --counters 2>/dev/null | grep -v omr-bypass | $IPTABLESRESTORE -w --counters 2>/dev/null
#	$IP6TABLESSAVE --counters 2>/dev/null | grep -v omr-bypass6 | $IP6TABLESRESTORE -w --counters 2>/dev/null
	# disable all rules
	uci -q set firewall.omr_bypass.enabled='0'
	uci -q set firewall.omr_bypass_reply.enabled='0'
	uci -q set firewall.omr_bypass_srcif.enabled='0'
	/usr/lib/omr-bypass/wan-failover stop
	# before its own sections go: dnsmasq keeps their nftset lines until
	# it reads its config again
	_stop_fed_sets=""
	_dnsmasq_feeds_sets && _stop_fed_sets=1
	_dhcp_delete_batch=""
	config_load dhcp
	config_foreach _delete_dhcp_ipset ipset
	[ -n "$_dhcp_delete_batch" ] && printf '%s' "$_dhcp_delete_batch" | _uci_batch
	uci -q delete dhcp.@dnsmasq[0].noipv6
	uci -q commit dhcp
	_noipv6_dnsmasq_conf && {
		logger -t "omr-bypass" "Restart dnsmasq..."
		/etc/init.d/dnsmasq restart
	}
	_fw_delete_batch=""
	config_load firewall
	config_foreach _delete_firewall_rules rule
	config_foreach _delete_firewall_rules ipset
	[ -n "$_fw_delete_batch" ] && printf '%s' "$_fw_delete_batch" | _uci_batch
	uci -q commit firewall
	[ -z "$RELOAD" ] && {
		fw4 restart || logger -t "omr-bypass" "ERROR: fw4 restart failed"
		# the restart emptied the sets of the user's and omr-dscp's
		# domains too (see _dnsmasq_feeds_sets)
		[ -n "$_stop_fed_sets" ] && {
			logger -t "omr-bypass" "Reload dnsmasq..."
			/etc/init.d/dnsmasq reload
		}
	}
	_omr_unlock
	_omr_dscp_resync
}

service_triggers() {
	procd_add_reload_trigger omr-bypass ndpid
}

reload_service() {
	# procd fires this on every omr-bypass config change, including a LuCI
	# Save & Apply that only reassigns a domain/mac/ip/port's interface. The
	# hash-gate skips the (unchanged) skeleton, so without clearing the old
	# per-entry state here, the stale entry stays live under its old
	# interface's ipset/dnsmasq section until "Reload OMR-ByPass rules" is
	# run manually or the hourly cron fires (issue #4340). start_service
	# clears it once it holds its lock.
	RELOAD=1
	#stop
	start
}

_soft_wipe_dynamic() {
	# reload_rules used to "stop; start": stop deleted every generated
	# section, including the hash-gate sentinels, so start always re-applied
	# the whole generated batch (minutes on small boards) even when nothing
	# changed. Instead, clear only the dynamic loop-managed state (ipset
	# entries, MAC/IP/port lists, rule enables) and keep the section
	# skeleton: the config_foreach loops in start_service rebuild that state
	# from the current config (so removed entries disappear), while the hash
	# gate can still skip the expensive batch apply.
	local key sec v
	# fw4's light `reload` (firewall4 2025.03+) only adds the set elements the
	# generated ruleset declares and never removes existing ones (verified
	# live: a deleted entry stayed in omr_dst_bypass_all_4 through `fw4
	# reload` and `fw4 reload-sets`; only `fw4 restart` dropped it). Before the
	# inverted-md5 fix for issue #4353 every reload_rules took the restart
	# path, which hid this. Remember what each set held before the wipe so
	# start_service can flush the sets that lost members right before the
	# light reload re-adds the survivors.
	_omr_entries_before="/tmp/omr-bypass-entries.before"
	: > "$_omr_entries_before"
	for key in $(uci -q show firewall 2>/dev/null | grep -E '^firewall\.omr6?_dst_bypass_[^.]+\.entry=' | cut -d= -f1); do
		sec="${key#firewall.}"; sec="${sec%.entry}"
		for v in $(uci -q get "$key"); do
			echo "$sec $v"
		done >> "$_omr_entries_before"
	done
	# one uci call per key took minutes with many interfaces and zones
	{
		uci -q show firewall 2>/dev/null | grep -E '^firewall\.omr6?_dst_bypass_[^.]+\.(entry|src_mac|src_ip|src_port|dest_port)=' | cut -d= -f1 | sed 's/^/delete /'
		# every generated omr_dst_bypass_* rule defaults to enabled='0' in
		# the batch; the loops re-enable the ones that still have config
		# entries. "set <rule>=rule" changes nothing, it gives the type.
		uci -q show firewall 2>/dev/null | grep -E '^firewall\.omr6?_dst_bypass_[^.]+=rule$' | cut -d= -f1 | \
			awk '{ print "set " $0 "=rule"; print "set " $0 ".enabled=\047" "0" "\047" }'
	} | _uci_batch
	_dhcp_had_domains=""
	key="$(uci -q show dhcp 2>/dev/null | grep -E '^dhcp\.omr_dst_bypass_[^.]+\.domain=' | cut -d= -f1)"
	if [ -n "$key" ]; then
		printf 'delete %s\n' $key | _uci_batch
		_dhcp_had_domains=1
	fi
}

reload_rules() {
	logger -t "omr-bypass" "Reload rules"
	#[ "$( ipset -n list | grep omr_ )" = "" ] && return 0
	# start_service stops the nDPId consumer and clears the per-entry state
	# once it holds its lock
	RELOAD=1
	start
}
