#!/bin/sh /etc/rc.common
# vim: set noexpandtab tabstop=4 shiftwidth=4 softtabstop=4 :

# shellcheck disable=SC2034
START=90
# shellcheck disable=SC2034
STOP=10
# shellcheck disable=SC2034
USE_PROCD=1

# shellcheck disable=SC1091
. /lib/functions.sh

# Get the lan interface name
lan_device=
config_load network
config_get lan_device lan ifname

#config_load dscp

# Resolves the actually-active proxy's real VPS-facing port, for classify
# rules using 'option auto_dest_port' instead of a static 'dest_port' --
# see the "OMR proxy" rule in files/etc/config/dscp for why a hardcoded
# port silently stops matching the moment a different proxy/port is
# configured. No shared helper for this existed anywhere else in the
# codebase (checked omr-bypass, omr-tracker, openmptcprouter-vps) -- each
# proxy type's UCI section/option naming is confirmed from the actual
# config generators, not just uci-defaults:
#   shadowsocks (libev):  shadowsocks-libev.sss0.server_port
#   shadowsocks-rust:     shadowsocks-rust.sss0.server_port
#   v2ray:                v2ray.omrout.s_<protocol>_port, protocol from v2ray.omrout.protocol
#   xray:                 xray.omrout.s_<protocol>_port, protocol from xray.omrout.protocol
_resolve_auto_dest_port() {
	case "$1" in
		proxy)
			proxy="$(uci -q get openmptcprouter.settings.proxy)"
			case "$proxy" in
				shadowsocks)
					uci -q get shadowsocks-libev.sss0.server_port
					;;
				shadowsocks-rust)
					uci -q get shadowsocks-rust.sss0.server_port
					;;
				*v2ray*)
					v2ray_protocol="$(uci -q get v2ray.omrout.protocol)"
					[ -n "$v2ray_protocol" ] && uci -q get v2ray.omrout.s_${v2ray_protocol}_port
					;;
				*xray*)
					xray_protocol="$(uci -q get xray.omrout.protocol)"
					[ -n "$xray_protocol" ] && uci -q get xray.omrout.s_${xray_protocol}_port
					;;
			esac
			;;
	esac
}

# Addresses of the classify rules, checked the way nft takes them: a source
# or destination like 1.2.3.4/33 or 2001:db8::/129 went into the rule as is,
# fw4 wrote it out unchanged and nft refused the whole ruleset ("Prefix
# length 33 is invalid"): no firewall at all. The same checks as omr-bypass,
# which this package does not depend on.
_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() {
	case "$1" in ""|*[!0-9]*|????*) return 1 ;; esac
	[ "$1" -le "$2" ]
}

# _valid_rule_ip <value>: an address, a network with a prefix length or a
# netmask, or a range, as fw4 takes them in a rule
_valid_rule_ip() {
	case "$1" in
		*-*)
			{ _valid_ip4 "${1%%-*}" && _valid_ip4 "${1#*-}"; } || \
				{ _valid_ip6 "${1%%-*}" && _valid_ip6 "${1#*-}"; }
			;;
		*/*.*) _valid_ip4 "${1%%/*}" && _valid_ip4 "${1#*/}" ;;
		*/*:*) _valid_ip6 "${1%%/*}" && _valid_ip6 "${1#*/}" ;;
		*/*)
			{ _valid_prefix_len "${1#*/}" 32 && _valid_ip4 "${1%%/*}"; } || \
				{ _valid_prefix_len "${1#*/}" 128 && _valid_ip6 "${1%%/*}"; }
			;;
		*) _valid_ip4 "$1" || _valid_ip6 "$1" ;;
	esac
}

# _valid_rule_hosts <value>: every word an address as above or the name of
# a network interface (fw4 takes its addresses), "!" for a negation
_valid_rule_hosts() {
	local __h
	case "$1" in *[!A-Za-z0-9_.:/!\ -]*) return 1 ;; esac
	for __h in $1; do
		__h="${__h#!}"
		case "$__h" in
			"") return 1 ;;
			*[!A-Za-z0-9_]*) _valid_rule_ip "$__h" || return 1 ;;
		esac
	done
}

# Collects the domains for _add_dscp_dnsmasq_ipsets, one "D <class> <domain>"
# line each. The name went to dnsmasq as is: a "/" or a space in it broke
# the nftset= line, and dnsmasq refused to start.
_add_dscp_domain() {
	local domain class
	config_get domain "$1" name ""
	config_get class "$1" class ""
	domain="${domain#.}"
	domain="${domain%.}"
	[ -n "$domain" ] && [ -n "$class" ] || return 0
	case "$class" in
		cs[0-7]|ef) ;;
		*)
			logger -t "omr-dscp" "Domain $domain: unknown class '$class', ignored"
			return 0
			;;
	esac
	case "$domain" in
		*[!A-Za-z0-9._-]*|.*|*..*)
			logger -t "omr-dscp" "Invalid domain '$domain', ignored"
			return 0
			;;
	esac
	_dscp_domains="${_dscp_domains}D $class $domain
"
}

# Budget for one dnsmasq nftset= line, counted as omr-bypass does (see its
# _BYPASS_MAX_DNSIPSET_LINE_BYTES): "nftset=", "/<domain>" per domain and
# ",4#inet#fw4#<set>" per set name. dnsmasq 2.90 read its config lines
# into a 1025-byte buffer and refused the rest of a longer one as a bad
# option, then exited: no DNS and no DHCP on the LAN. All the domains of a
# class were on one line, about 30 more in one class were enough.
_DSCP_MAX_DNSIPSET_LINE_BYTES=900

# dnsmasq fills the sets of one ipset entry per name: the longest domain
# that matches, and of two entries with the same domain the one of the
# first line. A domain omr-bypass routes through a WAN (the YouTube or
# Netflix hosts of its protocol lists are in the default DSCP list) was
# then either bypassed or marked, never both, and which one depended on
# which of the two ran last. So a DSCP domain gets an entry that also
# names the sets of the omr-bypass entry dnsmasq would pick for it, and so
# does a domain omr-bypass lists under a DSCP domain (more specific, its
# entry would win). These entries are put before every other ipset
# section, so that they win over the omr-bypass ones. omr-bypass runs
# omr-dscp again when its entries changed.
# Sections: omr_dscp_<class> for the domains omr-bypass does not list,
# omr_dscp_<class>_b<n> for those it does (one per omr-bypass set list),
# and each split in _d1, _d2... under the line budget.
_add_dscp_dnsmasq_ipsets() {
	local filter_aaaa c n
	# dnsmasq answers no AAAA query then, nothing would fill the IPv6 sets.
	# Only the IPv6 set: this skipped the whole domain, and omr-6in4 sets
	# filter_aaaa when IPv6 is disabled, so no domain was marked at all.
	filter_aaaa="$(uci -q get dhcp.dnsmasq1.filter_aaaa)"
	[ -n "$_dscp_domains" ] || return 0
	{
		for c in cs0 cs1 cs2 cs3 cs4 cs5 cs6 cs7 ef; do
			n="omr_dscp_${c}_4"
			[ "$disableipv6" = "0" ] && [ "$filter_aaaa" != "1" ] && n="$n omr_dscp_${c}_6"
			echo "N $c $n"
		done
		printf '%s' "$_dscp_domains"
		uci -q -X show dhcp | sed 's/^/U /'
	} | awk -v max="$_DSCP_MAX_DNSIPSET_LINE_BYTES" -v q="'" '
		# the longest DSCP domain, and omr-bypass domain, that k is or is under
		function dlong(k,  i) {
			while (!(k in dcls)) {
				i = index(k, ".")
				if (!i) return ""
				k = substr(k, i + 1)
			}
			return k
		}
		function blong(k,  i) {
			while (!(k in bsec)) {
				i = index(k, ".")
				if (!i) return ""
				k = substr(k, i + 1)
			}
			return k
		}
		function slot(g, n,  a, i, m) {
			sec = gbase[g]
			if (n > 0) sec = sec "_d" n
			print "set dhcp." sec "=ipset"
			m = split(gnames[g], a, " ")
			for (i = 1; i <= m; i++)
				print "add_list dhcp." sec ".name=" q a[i] q
			secs[++ns] = sec
			gsec[g] = sec; gslot[g] = n; gbytes[g] = ghead[g]; gcnt[g] = 0
		}
		function addkey(k, orig,  cls, bw, bn, g, a, i, m, add) {
			cls = dcls[dlong(k)]
			bw = blong(k)
			bn = (bw == "") ? "" : bnames[bsec[bw]]
			g = cls SUBSEP bn
			if (!(g in gbase)) {
				gbase[g] = "omr_dscp_" cls
				if (bn != "") gbase[g] = gbase[g] "_b" (++nb[cls])
				gnames[g] = cnames[cls] ((bn == "") ? "" : " " bn)
				ghead[g] = 7
				m = split(gnames[g], a, " ")
				for (i = 1; i <= m; i++) ghead[g] += length(a[i]) + 12
				slot(g, 0)
			}
			add = length(orig) + 1
			if (gcnt[g] > 0 && gbytes[g] + add > max) slot(g, gslot[g] + 1)
			print "add_list dhcp." gsec[g] ".domain=" q orig q
			gbytes[g] += add; gcnt[g]++
		}
		$1 == "N" { s = $3; for (i = 4; i <= NF; i++) s = s " " $i; cnames[$2] = s; next }
		$1 == "D" {
			d = tolower($3)
			if (!(d in dcls)) { dcls[d] = $2; dorig[d] = $3; dorder[++nd] = d }
			next
		}
		$1 == "U" {
			l = substr($0, 3); eq = index(l, "=")
			v = substr(l, eq + 1)
			if (split(substr(l, 1, eq - 1), p, ".") == 2) {
				if (v == "ipset" && first == "") first = nsec
				nsec++
				type[p[2]] = v
				next
			}
			if (type[p[2]] != "ipset" || p[2] !~ /^omr_dst_bypass_/) next
			gsub(q, "", v)
			m = split(v, a, " ")
			if (p[3] == "name") {
				s = ""
				for (i = 1; i <= m; i++)
					if (a[i] ~ /^[A-Za-z0-9_.-]+$/) s = s ((s == "") ? "" : " ") a[i]
				bnames[p[2]] = s
			} else if (p[3] == "domain") {
				for (i = 1; i <= m; i++) {
					d = tolower(a[i]); sub(/^\.+/, "", d)
					if (d !~ /^[a-z0-9_.-]+$/ || (d in bsec)) continue
					bsec[d] = p[2]; borig[d] = a[i]; border[++nbd] = d
				}
			}
		}
		END {
			for (i = 1; i <= nd; i++) addkey(dorder[i], dorig[dorder[i]])
			for (i = 1; i <= nbd; i++) {
				d = border[i]
				if (!(d in dcls) && dlong(d) != "") addkey(d, borig[d])
			}
			if (first != "")
				for (i = 1; i <= ns; i++) print "reorder dhcp." secs[i] "=" (first + i - 1)
		}
	' | uci -q batch
}

# No commit here: start_service commits each config once, at the end
_add_dscp_domains_rules() {
	for class in cs0 cs1 cs2 cs3 cs4 cs5 cs6 cs7 ef; do
		uci batch <<-EOF
			set firewall.omr_dscp_${class}_4=ipset
			set firewall.omr_dscp_${class}_4.name="omr_dscp_${class}_4"
			set firewall.omr_dscp_${class}_4.match='dest_ip'
		EOF
		if [ "$disableipv6" = "0" ]; then
			uci -q batch <<-EOF
				set firewall.omr_dscp_${class}_6=ipset
				set firewall.omr_dscp_${class}_6.name="omr_dscp_${class}_6"
				set firewall.omr_dscp_${class}_6.match='dest_ip'
				set firewall.omr_dscp_${class}_6.family='ipv6'
			EOF
		fi
		uci -q batch <<-EOF
			set firewall.omr_dscp_rule_${class}_4=rule
			set firewall.omr_dscp_rule_${class}_4.name="omr_dscp_${class}_4"
			set firewall.omr_dscp_rule_${class}_4.ipset="omr_dscp_${class}_4"
			set firewall.omr_dscp_rule_${class}_4.set_dscp="$(echo ${class} | tr '[a-z'] '[A-Z]')"
			set firewall.omr_dscp_rule_${class}_4.target='DSCP'
			set firewall.omr_dscp_rule_${class}_4.enabled='1'
			set firewall.omr_dscp_rule_${class}_4.src='*'
			set firewall.omr_dscp_rule_${class}_4.dest='*'
			set firewall.omr_dscp_rule_${class}_4.counter='${dscp_counter:-0}'
		EOF
		if [ "$disableipv6" = "0" ]; then
			uci -q batch <<-EOF
				set firewall.omr_dscp_rule_${class}_6=rule
				set firewall.omr_dscp_rule_${class}_6.name="omr6_dscp_${class}_6"
				set firewall.omr_dscp_rule_${class}_6.ipset="omr_dscp_${class}_6"
				set firewall.omr_dscp_rule_${class}_6.family='ipv6'
				set firewall.omr_dscp_rule_${class}_6.target='DSCP'
				set firewall.omr_dscp_rule_${class}_6.set_dscp="$(echo ${class} | tr '[a-z'] '[A-Z]')"
				set firewall.omr_dscp_rule_${class}_6.enabled='1'
				set firewall.omr_dscp_rule_${class}_6.src='*'
				set firewall.omr_dscp_rule_${class}_6.dest='*'
				set firewall.omr_dscp_rule_${class}_6.counter='${dscp_counter:-0}'
			EOF
		fi
	done
}

# Writes one firewall 'DSCP' rule section from the caller's current
# $class/$proto/$src_ip/$dest_ip/$src_port/$dest_port. $2 is the src zone
# to use, or EMPTY to omit the src option entirely (not set it to "",
# which is a different thing to fw4 -- see the "both" case below for why
# that distinction matters). $3 is the dest zone.
_apply_dscp_rule_fields() {
	rulename="$1"
	rsrc="$2"
	rdst="$3"
	uci -q batch <<-EOF
		set firewall.${rulename}=rule
		set firewall.${rulename}.name="${rulename}"
		set firewall.${rulename}.target="DSCP"
		set firewall.${rulename}.set_dscp="$(echo ${class} | tr '[a-z'] '[A-Z]')"
		set firewall.${rulename}.dest="$rdst"
		set firewall.${rulename}.src_ip="$src_ip"
		set firewall.${rulename}.dest_ip="$dest_ip"
		set firewall.${rulename}.proto="$proto"
		set firewall.${rulename}.enabled='1'
		set firewall.${rulename}.counter='${dscp_counter:-0}'
	EOF
	if [ -n "$rsrc" ]; then
		uci -q set firewall.${rulename}.src="$rsrc"
	else
		# Must be genuinely absent, not set to "" -- fw4 only emits an
		# OUTPUT-hook (mangle_output) nftables rule, matching the
		# router's OWN locally-generated traffic, when the section has
		# no src option at all. Confirmed live on the bench.
		uci -q delete firewall.${rulename}.src 2>/dev/null
	fi
	rule_src_ports="$(echo $src_port | sed 's/,/ /g')"
	rule_dest_ports="$(echo $dest_port | sed 's/,/ /g')"
	for port in $rule_src_ports; do
		uci -q add_list firewall.${rulename}.src_port="$port"
	done
	for port in $rule_dest_ports; do
		uci -q add_list firewall.${rulename}.dest_port="$port"
	done
}

_add_dscp_rules() {
	proto=""     ; config_get proto "$1" proto all
	src_ip=""    ; config_get src_ip "$1" src_ip 0.0.0.0/0
	src_port=""  ; config_get src_port "$1" src_port 0:65535
	dest_ip=""   ; config_get dest_ip "$1" dest_ip 0.0.0.0/0
	dest_port="" ; config_get dest_port "$1" dest_port 0:65535
	auto_dest_port=""; config_get auto_dest_port "$1" auto_dest_port ""
	class=""     ; config_get class "$1" class
	direction="" ; config_get direction "$1" direction "upload"
	comment=""   ; config_get comment "$1" comment "-"

	if [ -n "$auto_dest_port" ]; then
		dest_port="$(_resolve_auto_dest_port "$auto_dest_port")"
		if [ -z "$dest_port" ]; then
			# Nothing currently matches (e.g. 'proxy' but no proxy is
			# active right now) -- skip this rule entirely rather than
			# falling back to config_get's 0:65535 default, which would
			# silently mark ALL tcp traffic instead of none.
			return
		fi
	fi

	if ! _valid_rule_hosts "$src_ip" || ! _valid_rule_hosts "$dest_ip"; then
		logger -t "omr-dscp" "Rule $1: invalid source or destination host '$src_ip' / '$dest_ip', rule ignored"
		return
	fi
	# Both go into the uci batch below: a quote or a newline in them wrote
	# any firewall option, with only a write access to the dscp config.
	# The protocol is free text in LuCI: names, numbers, a list, "!".
	case "$proto" in
		""|*[!A-Za-z0-9_.!\ -]*)
			logger -t "omr-dscp" "Rule $1: invalid protocol '$proto', rule ignored"
			return
			;;
	esac
	case "$class" in
		*[!A-Za-z0-9]*)
			logger -t "omr-dscp" "Rule $1: invalid class '$class', rule ignored"
			return
			;;
	esac
	src_port="$(echo $src_port | sed 's/:/-/g')"
	dest_port="$(echo $dest_port | sed 's/:/-/g')"
	count=$((count + 1))
	[ "$proto" = "all" ] && proto="tcp udp"
	src=""
	dst=""
	add_output_rule=0
	# Both zones are given: fw4 puts a DSCP rule with only a src zone in
	# mangle_input and one with only a dest zone in mangle_output, i.e. on
	# traffic to and from the router itself, never on what LAN clients send
	# or receive through it. src=lan dest=* is mangle_prerouting (iif lan),
	# src=* dest=lan mangle_postrouting (oif lan).
	case "$direction" in
		upload)
				src="lan"
				dst="*"
			;;
		download)
				src="*"
				dst="lan"
			;;
		both)
				src="*"
				dst="*"
				# "both" is also the direction every shipped
				# router-tunnel rule uses ("OMR proxy"/"OMR vpn":
				# marking the router's OWN outbound proxy/VPN
				# connection, not LAN-forwarded traffic) -- but a
				# plain src=*/dest=* rule only ever lands in
				# fw4's mangle_forward chain (routed/forwarded
				# traffic), which never sees packets the router
				# itself originates. Confirmed live: a proxy's
				# own outbound tunnel connection showed tos 0x0
				# on the wire despite this exact rule existing
				# and being correctly compiled into
				# mangle_forward, and only started carrying the
				# configured DSCP class once the companion
				# mangle_output rule below was added alongside
				# it.
				add_output_rule=1
			;;
	esac
	_apply_dscp_rule_fields "omr_dscp_rule$count" "$src" "$dst"
	[ "$add_output_rule" = "1" ] && _apply_dscp_rule_fields "omr_dscp_rule${count}_output" "" "wan"

}


_add_fwmark_chain() {
	_ipt4 -N dscp_mark
	_ipt4 -A PREROUTING -i "$lan_device" -j dscp_mark
	_ipt4 -A POSTROUTING -j dscp_mark
	if [ "$disableipv6" = "0" ]; then
		_ipt6 -N dscp_mark
		_ipt6 -A PREROUTING -i "$lan_device" -j dscp_mark
		_ipt6 -A POSTROUTING -j dscp_mark
	fi
	for class in cs4 cs5 cs6 cs7; do
		# xtun (hex) -> 0x7874756e
		_ipt4 -A dscp_mark \
			-m comment --comment "$class" \
			-m dscp --dscp-class "$class" \
			-j MARK --set-mark 0x7874756e
		if [ "$disableipv6" = "0" ]; then
			_ipt6 -A dscp_mark \
				-m comment --comment "$class" \
				-m dscp --dscp-class "$class" \
				-j MARK --set-mark 0x7874756e
		fi
	done
}

# Old dnsmasq "ipset" strings (omr_dscp-<class>, omr_dst_bypass_<intf>) become
# anonymous ipset sections when OpenWrt migrates them: only those are
# leftovers. Named ipset sections are omr-bypass's, and the other sections
# with a name option are the user's (static leases, hostnames...).
_remove_old_ipset_dnsmasq() {
	case "$1" in
		cfg[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;;
		*) return ;;
	esac
	[ -n "$(uci -q get dhcp.${1}.name | grep omr)" ] && uci -q delete dhcp.$1
}
# A case, not "echo | grep": these run for every ipset or firewall section,
# two processes each, under omr-bypass' lock
_remove_ipset_dnsmasq() {
	case "$1" in *omr_dscp*) uci -q delete dhcp.$1 ;; esac
}

_remove_rules() {
	case "$1" in *omr_dscp*|*omr6_dscp*) uci -q delete firewall.$1 ;; esac
}

_setup_tunnel() {
	# Mark the packets to route through xtun0
	_add_fwmark_chain
	# tun0: cs0 (default)
	# xtun0: cs6
	_ipt4 -A dscp_output -o "tun0" -j DSCP --set-dscp-class cs6
	if [ "$disableipv6" = "0" ]; then
		_ipt6 -A dscp_output -o "tun0" -j DSCP --set-dscp-class cs6
	fi
}

_cleanup() {
	uci -q del dhcp.@dnsmasq[0].ipset
	config_load dhcp
	config_foreach _remove_ipset_dnsmasq ipset
	config_foreach _remove_old_ipset_dnsmasq ipset
	config_load firewall
	config_foreach _remove_rules
}

# omr-bypass builds its firewall and dhcp sections under this lock, as
# uncommitted uci changes, and every "uci commit firewall" writes out all
# the pending changes, whoever made them: a run of each committed the
# other's half-built sections. (A private savedir, uci -t, does not help:
# uci still reads and commits the shared /tmp/.uci changes.) Held while
# this script has changes pending; omr-bypass runs can take minutes on a
# small board, a run that still holds it after that goes on without it.
_UCI_LOCK="/var/run/omr-bypass.lock"
_uci_lock() {
	local _i=0 _pid
	while ! mkdir "$_UCI_LOCK" 2>/dev/null; do
		_pid="$(cat "$_UCI_LOCK/pid" 2>/dev/null)"
		if [ -n "$_pid" ] && ! kill -0 "$_pid" 2>/dev/null; then
			rm -rf "$_UCI_LOCK"
			continue
		fi
		_i=$((_i + 1))
		if [ "$_i" -ge 300 ]; then
			logger -t "omr-dscp" "omr-bypass still holds its lock after ${_i}s, going on"
			return 1
		fi
		sleep 1
	done
	echo "$$" > "$_UCI_LOCK/pid"
	_uci_locked=1
}
_uci_unlock() {
	[ "$_uci_locked" = "1" ] && rm -rf "$_UCI_LOCK"
	_uci_locked=""
}

# What omr-dscp generates, to tell whether a run changed anything
_dscp_fw_state() {
	uci -q -X show firewall | grep -E '^firewall\.omr6?_dscp'
}
# the order of the ipset sections too: of two entries for a domain, dnsmasq
# fills the sets of the first one
_dscp_dhcp_state() {
	uci -q -X show dhcp | grep -E '^dhcp\.([^.]+=ipset$|omr_dscp|cfg[0-9a-f]+\.(ipset|name)=.*omr)'
}

# An fw4 restart creates table inet fw4 again, empty: the addresses dnsmasq
# added to the sets of an ipset section with domains (omr-dscp's,
# omr-bypass's, the user's) are gone, and dnsmasq adds them again only from
# an upstream answer, not from its cache. As omr-bypass's.
_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 }'
}

# _uci_commit_changed <config> <md5 of its "uci show" before the run>
# <its pending changes before the run>: a run rebuilds every section it
# owns, and committing that wrote the same config to flash again on each
# commit of the openmptcprouter config. What it staged is dropped when the
# result is the same; someone else's pending changes cannot be told apart
# from this run's, they are committed along as before.
_uci_commit_changed() {
	if [ -n "$3" ] || [ "$(uci -q -X show "$1" | md5sum)" != "$2" ]; then
		uci -q commit "$1"
	else
		uci -q revert "$1"
	fi
}

start_service() {
	local fw_before dhcp_before fw_all dhcp_all fw_pending dhcp_pending fw_restarted=""
	fw_before="$(_dscp_fw_state | md5sum)"
	_uci_lock
	# under the lock: it covers the omr-bypass sections, which a run of
	# omr-bypass changes
	dhcp_before="$(_dscp_dhcp_state | md5sum)"
	# Everything is staged and each config committed once, at the end: a
	# commit per section rewrote dhcp and firewall on flash 70 to 90 times
	# under omr-bypass' lock, and an omr-bypass run waiting for it gave up
	# after 15 s and dropped its reload.
	fw_all="$(uci -q -X show firewall | md5sum)"
	dhcp_all="$(uci -q -X show dhcp | md5sum)"
	fw_pending="$(uci -q changes firewall)"
	dhcp_pending="$(uci -q changes dhcp)"
	# Cleanup
	_cleanup
	disableipv6="$(uci -q get openmptcprouter.settings.disable_ipv6)"
	dscp_counter=0
	[ "$(uci -q get openmptcprouter.settings.debug)" = "true" ] && dscp_counter=1
	config_load dscp
	# Add chains
	_add_dscp_domains_rules

	# Setup the tunnels dscp / marks
	#_setup_tunnel

	# Add rules base on the user configuration
	count=0
	config_foreach _add_dscp_rules classify
	_dscp_domains=""
	config_foreach _add_dscp_domain domains
	_add_dscp_dnsmasq_ipsets
	_uci_commit_changed dhcp "$dhcp_all" "$dhcp_pending"
	_uci_commit_changed firewall "$fw_all" "$fw_pending"
	_uci_unlock
	# Every commit of the openmptcprouter config gets here (reload trigger),
	# and this ran an fw4 reload then an fw4 restart: the restart deletes the
	# fw4 table, and with it the addresses dnsmasq had added to every set
	# (omr-bypass's included), which then went through the default route
	# until dnsmasq resolved them again. A reload keeps the set elements.
	# Nothing changed: no fw4 run at all, unless the sets are not loaded.
	if [ "$(_dscp_fw_state | md5sum)" != "$fw_before" ] || \
	   ! nft -t list set inet fw4 omr_dscp_cs0_4 >/dev/null 2>&1; then
		# fw4 reload returns the status of its includes (or 1 when fw4 is
		# not loaded), not of the nft load: a ruleset nft refused (a set
		# whose type changed cannot be redeclared, the running one stays)
		# is told by its check mode, which tries the same ruleset against
		# the running one. No restart then: fw4 restart runs that same
		# check first and refuses to go on.
		if ! fw4 -q reload; then
			logger -t "omr-dscp" "fw4 reload failed, restarting it"
			fw4 -q restart || logger -t "omr-dscp" "fw4 restart refused: the firewall ruleset has errors"
			fw_restarted=1
		elif ! fw4 -q check; then
			logger -t "omr-dscp" "fw4 refused the firewall ruleset: it has errors"
		fi
	fi
	# dnsmasq builds its nftset lines from the dhcp config when it starts.
	# A reload also clears its cache: after the restart, the next query of
	# each domain goes upstream and adds its addresses to the sets again.
	if [ "$(_dscp_dhcp_state | md5sum)" != "$dhcp_before" ] || \
	   { [ -n "$fw_restarted" ] && _dnsmasq_feeds_sets; }; then
		/etc/init.d/dnsmasq reload
	fi
}

stop_service() {
	_uci_lock
	_cleanup
	uci -q commit dhcp
	uci -q commit firewall
	_uci_unlock
	fw4 -q reload
}

reload_service() {
	start
}

service_triggers() {
	procd_add_reload_trigger dscp
	# Reapply rules (regenerating any 'auto_dest_port' resolution, e.g.
	# the "OMR proxy" rule) whenever the active proxy or its port
	# actually changes -- these configs only get committed on an actual
	# proxy switch/reconfigure (see openmptcprouter-vps's proxy-switch
	# and port-push paths), so this fires proportionally rarely rather
	# than polling every tracker cycle.
	procd_add_reload_trigger openmptcprouter shadowsocks-rust shadowsocks-libev v2ray xray
}
