magisk-zapret2/zapret2/scripts/firewall-reconciler.sh
loop-uh c0a973b7d5 Name the extension the kernel refused
A missing match or target produced several lines of backend warnings and a
parser complaint about whatever could no longer be read, handed to the user
unchanged. That reads as a broken configuration rather than as a kernel
without the module, and for mark, NFQUEUE and queue-bypass — which have no
alternative and must refuse — naming the extension is the only help the
message can offer.

Every match and target the module authors is recognised, so the refusal now
leads with the cause and keeps the backend's own words after it.
2026-07-25 14:55:31 +03:00

947 lines
36 KiB
Shell

#!/system/bin/sh
# Idempotent boot-local firewall reconciler.
#
# The stable ZAPRET2_OUT/ZAPRET2_IN namespace is exclusively owned by this
# module. A complete ruleset is derived from the compiled preset on every
# start. There is deliberately no firewall WAL: iptables-restore validates the
# complete candidate and publishes it at COMMIT. Any interruption is recovered
# by repeating z2_fw_cleanup_family under the lifecycle lock.
Z2_FW_OUT_CHAIN="${Z2_FW_OUT_CHAIN:-ZAPRET2_OUT}"
Z2_FW_IN_CHAIN="${Z2_FW_IN_CHAIN:-ZAPRET2_IN}"
Z2_FW_BACKEND=""
Z2_FW_CONNBYTES=0
Z2_FW_MULTIPORT=1
Z2_FW_RULES=0
Z2_FW_CHAINS=0
Z2_FW_ANCHORS=0
Z2_FW_FAILURE_CLASS=""
Z2_FW_ERROR_DETAIL=""
Z2_FW_FALLBACK_DETAIL=""
Z2_FW_LAST_RESTORE_EXIT=0
Z2_FW_LAST_RESTORE_DETAIL=""
Z2_FW_LAST_FAILURE_CLASS=""
Z2_FW_RESTORE_WAIT_IPTABLES=unknown
Z2_FW_RESTORE_WAIT_IP6TABLES=unknown
Z2_FW_BASELINE_READY=0
Z2_FW_BASELINE_OUT_CHAIN=0
Z2_FW_BASELINE_IN_CHAIN=0
Z2_FW_BASELINE_OUT_ANCHORS=0
Z2_FW_BASELINE_IN_ANCHORS=0
Z2_FW_AUDIT_IPTABLES=""
Z2_FW_AUDIT_IP6TABLES=""
Z2_FW_VERIFY_DETAIL=""
# iptables-restore gained native xtables-lock waiting later than the oldest
# Android release supported by the module. Prefer the backend's own lock wait
# when advertised. Older/vendor backends receive the same bounded wait only
# after they explicitly report the xtables lock as busy.
Z2_FW_LOCK_WAIT_SECONDS=5
Z2_FW_DIAGNOSTIC_MAX_BYTES=384
z2_fw_restore_command() {
case "$1" in
iptables) printf '%s\n' iptables-restore ;;
ip6tables) printf '%s\n' ip6tables-restore ;;
*) return 1 ;;
esac
}
z2_fw_restore_available() {
local restore
restore="$(z2_fw_restore_command "$1")" || return 1
command -v "$restore" >/dev/null 2>&1
}
z2_fw_restore_supports_wait() {
local restore="$1" cached
case "$restore" in
iptables-restore) cached="$Z2_FW_RESTORE_WAIT_IPTABLES" ;;
ip6tables-restore) cached="$Z2_FW_RESTORE_WAIT_IP6TABLES" ;;
*) return 1 ;;
esac
if [ "$cached" = unknown ]; then
if "$restore" --help 2>&1 | grep -Fq -- '--wait'; then
cached=1
else
cached=0
fi
case "$restore" in
iptables-restore) Z2_FW_RESTORE_WAIT_IPTABLES="$cached" ;;
ip6tables-restore) Z2_FW_RESTORE_WAIT_IP6TABLES="$cached" ;;
esac
fi
[ "$cached" = 1 ]
}
z2_fw_reset_restore_wait_capabilities() {
Z2_FW_RESTORE_WAIT_IPTABLES=unknown
Z2_FW_RESTORE_WAIT_IP6TABLES=unknown
}
z2_fw_normalize_diagnostic() {
local LC_ALL=C
printf '%s' "$1" | tr '[:cntrl:]' ' ' | cut -b "1-$Z2_FW_DIAGNOSTIC_MAX_BYTES"
}
z2_fw_read_restore_diagnostic() {
local path="$1" detail
detail="$(tail -c "$Z2_FW_DIAGNOSTIC_MAX_BYTES" "$path" 2>/dev/null)" || detail=""
z2_fw_normalize_diagnostic "$detail"
}
z2_fw_diagnostic_is_lock_busy() {
case "$1" in
*xtables*lock*|*XTABLES*lock*|*Another\ app*holding*lock*|\
*another\ app*holding*lock*|*lock*temporarily\ unavailable*|\
*lock*busy*) return 0 ;;
*) return 1 ;;
esac
}
z2_fw_lock_retry_pause() {
sleep 1
}
z2_fw_diagnostic_is_connbytes_unsupported() {
case "$1" in
*[Cc]onnbytes*) return 0 ;;
*) return 1 ;;
esac
}
# Two independent signals are required here, unlike connbytes. A kernel without
# xt_multiport makes iptables report the missing extension and then reject the
# port argument it can no longer parse, so the port complaint arrives together
# with the extension name. That complaint on its own is also exactly what a
# genuinely malformed port list produces, and silently rebuilding the intended
# topology because of it would hide a broken configuration instead of a missing
# kernel module.
z2_fw_diagnostic_is_multiport_unsupported() {
case "$1" in
*[Mm]ultiport*) ;;
*) return 1 ;;
esac
case "$1" in
*not\ supported*|*missing\ kernel\ module*|*no\ kernel\ module*|\
*load\ match*|*[Uu]nknown\ option*|*invalid\ port/service*) return 0 ;;
*) return 1 ;;
esac
}
# The backend answers a missing extension with several lines of warnings and a
# parser complaint about whatever it could no longer read. Handed to the user
# unchanged that reads as a broken configuration. Every match and target the
# module authors is named here, so the one the kernel refused can be said
# plainly ahead of the backend's own words — including the three that have no
# fallback, where naming the extension is the only help there is.
z2_fw_missing_extension() {
local detail="$1" name
case "$detail" in
*not\ supported*|*missing\ kernel\ module*|*no\ kernel\ module*|\
*[Nn]o\ chain/target/match*|*[Uu]nknown\ option*|*[Cc]ouldn\'t\ load*) ;;
*) return 1 ;;
esac
for name in multiport connbytes mark NFQUEUE; do
case "$detail" in
*"$name"*) printf '%s\n' "$name"; return 0 ;;
esac
done
return 1
}
z2_fw_ensure_scratch_dir() {
# common.sh owns the authenticated implementation; the standalone fallback
# exists only for tests that source this reconciler on its own.
if command -v ensure_state_tmp_dir >/dev/null 2>&1; then
ensure_state_tmp_dir
return
fi
umask 077
if [ ! -e "$STATE_DIR/tmp" ] && [ ! -L "$STATE_DIR/tmp" ]; then
mkdir "$STATE_DIR/tmp" 2>/dev/null
fi
[ -d "$STATE_DIR/tmp" ] && [ ! -L "$STATE_DIR/tmp" ] || return 1
chmod 0700 "$STATE_DIR/tmp" 2>/dev/null || return 1
[ -d "$STATE_DIR/tmp" ] && [ ! -L "$STATE_DIR/tmp" ] || return 1
}
# Scratch names end in the creating PID, so residue from a previous boot whose
# PID the kernel handed us again would otherwise fence every transaction
# forever — and a liveness sweep cannot help, because the PID in the name is
# ours and therefore alive. Nothing else can hold this name: we own the
# lifecycle lock and have not written it yet in this process, so whatever is
# there was abandoned by a process that no longer exists.
z2_fw_claim_scratch_path() {
local path="$1"
{ [ -e "$path" ] || [ -L "$path" ]; } || return 0
rm -rf "$path" 2>/dev/null || return 1
[ ! -e "$path" ] && [ ! -L "$path" ]
}
z2_fw_run_restore() {
local restore="$1" tool="$2" phase="$3" batch="$4"
local capture wait_supported=0 attempts=0 rc=1 cleanup_rc=0 detail
capture="$STATE_DIR/tmp/firewall-restore.${tool}.$$.error"
# Reset the result fields before the first failure exit, or a failure here
# would report the exit code of the previous phase.
Z2_FW_LAST_RESTORE_EXIT=0
Z2_FW_LAST_RESTORE_DETAIL=""
Z2_FW_LAST_FAILURE_CLASS=""
z2_fw_ensure_scratch_dir || {
Z2_FW_LAST_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_LAST_RESTORE_DETAIL="unavailable firewall scratch directory"
return 1
}
state_path_is_managed_file "$capture" || {
Z2_FW_LAST_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_LAST_RESTORE_DETAIL="unsafe firewall diagnostic path"
return 1
}
z2_fw_claim_scratch_path "$capture" || {
Z2_FW_LAST_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_LAST_RESTORE_DETAIL="firewall diagnostic path already exists"
return 1
}
umask 077
if ! : > "$capture" || ! chmod 0600 "$capture" 2>/dev/null; then
rm -f "$capture" 2>/dev/null
Z2_FW_LAST_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_LAST_RESTORE_DETAIL="cannot create private firewall diagnostic capture"
return 1
fi
z2_fw_restore_supports_wait "$restore" && wait_supported=1
while :; do
: > "$capture" || {
rc=1
Z2_FW_LAST_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_LAST_RESTORE_DETAIL="cannot reset firewall diagnostic capture"
break
}
if [ "$wait_supported" = 1 ]; then
if [ "$phase" = test ]; then
"$restore" --wait "$Z2_FW_LOCK_WAIT_SECONDS" --test --noflush \
< "$batch" >/dev/null 2>"$capture"
else
"$restore" --wait "$Z2_FW_LOCK_WAIT_SECONDS" --noflush \
< "$batch" >/dev/null 2>"$capture"
fi
elif [ "$phase" = test ]; then
"$restore" --test --noflush < "$batch" >/dev/null 2>"$capture"
else
"$restore" --noflush < "$batch" >/dev/null 2>"$capture"
fi
rc=$?
detail="$(z2_fw_read_restore_diagnostic "$capture")"
[ "$rc" -ne 0 ] || break
if [ "$wait_supported" = 0 ] && [ "$rc" -eq 4 ] 2>/dev/null &&
z2_fw_diagnostic_is_lock_busy "$detail" &&
[ "$attempts" -lt "$Z2_FW_LOCK_WAIT_SECONDS" ] 2>/dev/null; then
attempts=$((attempts + 1))
if ! z2_fw_lock_retry_pause; then
Z2_FW_LAST_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_LAST_RESTORE_DETAIL="xtables lock wait could not be scheduled"
rc=1
break
fi
continue
fi
break
done
Z2_FW_LAST_RESTORE_EXIT="$rc"
if [ -z "$Z2_FW_LAST_RESTORE_DETAIL" ]; then
Z2_FW_LAST_RESTORE_DETAIL="$(z2_fw_read_restore_diagnostic "$capture")"
fi
rm -f "$capture" 2>/dev/null || cleanup_rc=1
if [ "$cleanup_rc" -ne 0 ]; then
Z2_FW_LAST_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_LAST_RESTORE_DETAIL="cannot remove private firewall diagnostic capture"
return 1
fi
[ "$rc" -eq 0 ] 2>/dev/null && return 0
if [ -z "$Z2_FW_LAST_FAILURE_CLASS" ]; then
if z2_fw_diagnostic_is_lock_busy "$Z2_FW_LAST_RESTORE_DETAIL"; then
Z2_FW_LAST_FAILURE_CLASS=LOCK_TIMEOUT
elif [ "$phase" = test ]; then
Z2_FW_LAST_FAILURE_CLASS=RULESET_REJECTED
else
Z2_FW_LAST_FAILURE_CLASS=PUBLICATION_FAILED
fi
fi
return "$rc"
}
z2_fw_set_restore_failure() {
local restore="$1" phase="$2" connbytes="$3" detail missing cause=""
detail="${Z2_FW_LAST_RESTORE_DETAIL:-no backend diagnostic}"
Z2_FW_FAILURE_CLASS="${Z2_FW_LAST_FAILURE_CLASS:-PUBLICATION_FAILED}"
if missing="$(z2_fw_missing_extension "$detail")"; then
cause="this kernel does not provide the $missing extension; "
fi
Z2_FW_ERROR_DETAIL="$restore $phase failed (connbytes=$connbytes, exit=$Z2_FW_LAST_RESTORE_EXIT): $cause$detail"
Z2_FW_ERROR_DETAIL="$(z2_fw_normalize_diagnostic "$Z2_FW_ERROR_DETAIL")"
}
z2_fw_tool_available() {
command -v "$1" >/dev/null 2>&1 &&
"$1" -t mangle -L OUTPUT -n >/dev/null 2>&1
}
z2_fw_capture_baseline() {
local tool="$1" listing plan
Z2_FW_BASELINE_READY=0
Z2_FW_BASELINE_OUT_CHAIN=0
Z2_FW_BASELINE_IN_CHAIN=0
Z2_FW_BASELINE_OUT_ANCHORS=0
Z2_FW_BASELINE_IN_ANCHORS=0
listing="$("$tool" -t mangle -S 2>/dev/null)" || return 1
plan="$(printf '%s\n' "$listing" |
awk -v out="$Z2_FW_OUT_CHAIN" -v inchain="$Z2_FW_IN_CHAIN" '
$1 == "-N" && $2 == out { out_chain++ }
$1 == "-N" && $2 == inchain { in_chain++ }
$1 == "-A" {
for (i = 3; i <= NF; i++) {
if ($i != "-j" && $i != "--jump" &&
$i != "-g" && $i != "--goto") continue
target = $(i + 1)
if (target == out) {
if ($0 == "-A OUTPUT -j " out) out_anchor++
else bad = 1
}
if (target == inchain) {
if ($0 == "-A INPUT -j " inchain) in_anchor++
else bad = 1
}
}
}
END {
if (bad || out_chain > 1 || in_chain > 1 ||
out_anchor > 8 || in_anchor > 8 ||
(out_anchor && !out_chain) || (in_anchor && !in_chain))
exit 1
printf "%d %d %d %d\n",
out_chain, in_chain, out_anchor, in_anchor
}
')" || return 1
# The awk producer emits exactly four decimal fields.
# shellcheck disable=SC2086
set -- $plan
[ "$#" = 4 ] || return 1
Z2_FW_BASELINE_OUT_CHAIN="$1"
Z2_FW_BASELINE_IN_CHAIN="$2"
Z2_FW_BASELINE_OUT_ANCHORS="$3"
Z2_FW_BASELINE_IN_ANCHORS="$4"
Z2_FW_BASELINE_READY=1
return 0
}
z2_fw_cleanup_is_unambiguous() {
z2_fw_capture_baseline "$1"
}
z2_fw_save_audit() {
local tool="$1" plan
[ "$Z2_FW_BASELINE_READY" = 1 ] || return 1
plan="$Z2_FW_BASELINE_OUT_CHAIN $Z2_FW_BASELINE_IN_CHAIN $Z2_FW_BASELINE_OUT_ANCHORS $Z2_FW_BASELINE_IN_ANCHORS"
case "$tool" in
iptables) Z2_FW_AUDIT_IPTABLES="$plan" ;;
ip6tables) Z2_FW_AUDIT_IP6TABLES="$plan" ;;
*) return 1 ;;
esac
}
z2_fw_load_audit() {
local tool="$1" plan
case "$tool" in
iptables) plan="$Z2_FW_AUDIT_IPTABLES" ;;
ip6tables) plan="$Z2_FW_AUDIT_IP6TABLES" ;;
*) return 1 ;;
esac
# Saved audit plans contain exactly four decimal fields.
# shellcheck disable=SC2086
set -- $plan
[ "$#" = 4 ] || return 1
Z2_FW_BASELINE_OUT_CHAIN="$1"
Z2_FW_BASELINE_IN_CHAIN="$2"
Z2_FW_BASELINE_OUT_ANCHORS="$3"
Z2_FW_BASELINE_IN_ANCHORS="$4"
Z2_FW_BASELINE_READY=1
}
z2_fw_family_absent() {
local tool="$1"
command -v "$tool" >/dev/null 2>&1 || return 2
z2_fw_capture_baseline "$tool" || return 2
[ "$Z2_FW_BASELINE_OUT_CHAIN:$Z2_FW_BASELINE_IN_CHAIN:$Z2_FW_BASELINE_OUT_ANCHORS:$Z2_FW_BASELINE_IN_ANCHORS" = 0:0:0:0 ]
}
z2_fw_emit_baseline_cleanup() {
local n
[ "$Z2_FW_BASELINE_READY" = 1 ] || return 1
n=0
while [ "$n" -lt "$Z2_FW_BASELINE_OUT_ANCHORS" ]; do
printf '%s\n' "-D OUTPUT -j $Z2_FW_OUT_CHAIN"
n=$((n + 1))
done
n=0
while [ "$n" -lt "$Z2_FW_BASELINE_IN_ANCHORS" ]; do
printf '%s\n' "-D INPUT -j $Z2_FW_IN_CHAIN"
n=$((n + 1))
done
if [ "$Z2_FW_BASELINE_IN_CHAIN" = 1 ]; then
printf '%s\n' "-F $Z2_FW_IN_CHAIN" "-X $Z2_FW_IN_CHAIN"
fi
if [ "$Z2_FW_BASELINE_OUT_CHAIN" = 1 ]; then
printf '%s\n' "-F $Z2_FW_OUT_CHAIN" "-X $Z2_FW_OUT_CHAIN"
fi
}
# multiport accepts at most 15 values and a range spends two of them. That is a
# userspace parser limit rather than a kernel capability, so it is computed
# before the batch is written instead of being discovered from a rejection.
Z2_FW_MULTIPORT_MAX_VALUES=15
z2_fw_port_intervals() {
local rest="$1" token
while [ -n "$rest" ]; do
case "$rest" in
*,*) token="${rest%%,*}"; rest="${rest#*,}" ;;
*) token="$rest"; rest="" ;;
esac
[ -z "$token" ] || printf '%s\n' "$token"
done
}
z2_fw_port_list_weight() {
local list="$1" weight=0 token
for token in $(z2_fw_port_intervals "$list"); do
case "$token" in
*:*) weight=$((weight + 2)) ;;
*) weight=$((weight + 1)) ;;
esac
done
printf '%s\n' "$weight"
}
z2_fw_port_interval_count() {
local count=0 token
for token in $(z2_fw_port_intervals "$1"); do
count=$((count + 1))
done
printf '%s\n' "$count"
}
# Both families are authored from the same port lists, so one list over the
# limit disqualifies multiport for the whole ruleset rather than for one rule.
z2_fw_multiport_fits() {
local tcp udp
tcp="$(z2_fw_port_list_weight "$PORTS_TCP")" || return 1
udp="$(z2_fw_port_list_weight "$PORTS_UDP")" || return 1
[ "$tcp" -le "$Z2_FW_MULTIPORT_MAX_VALUES" ] &&
[ "$udp" -le "$Z2_FW_MULTIPORT_MAX_VALUES" ]
}
z2_fw_emit_rule_tail() {
local packet_count="$1" cb_dir="$2" connbytes="$3"
if [ "$connbytes" = 1 ]; then
printf '%s' " -m connbytes --connbytes 1:$packet_count --connbytes-dir $cb_dir --connbytes-mode packets"
fi
printf '%s\n' " -m mark ! --mark $DESYNC_MARK/$DESYNC_MARK -j NFQUEUE --queue-num $QNUM --queue-bypass"
}
z2_fw_emit_batch_rule() {
local chain="$1" proto="$2" direction="$3" ports="$4"
local packet_count="$5" cb_dir="$6" connbytes="$7" multiport="${8:-1}"
local token
[ -n "$ports" ] || return 0
if [ "$multiport" = 1 ]; then
printf '%s' "-A $chain -p $proto -m multiport"
if [ "$direction" = out ]; then
printf '%s' " --dports $ports"
else
printf '%s' " --sports $ports"
fi
z2_fw_emit_rule_tail "$packet_count" "$cb_dir" "$connbytes"
return 0
fi
# A port list has no single-rule form without xt_multiport, so each
# interval becomes its own rule. The protocol match provides --dport and
# --sport natively, and both accept one port or one range, so this form
# needs no extension beyond the one -p already loaded.
for token in $(z2_fw_port_intervals "$ports"); do
printf '%s' "-A $chain -p $proto"
if [ "$direction" = out ]; then
printf '%s' " --dport $token"
else
printf '%s' " --sport $token"
fi
z2_fw_emit_rule_tail "$packet_count" "$cb_dir" "$connbytes"
done
}
z2_fw_write_batch() {
local path="$1" connbytes="$2" multiport="${3:-1}"
{
printf '%s\n' '*mangle'
z2_fw_emit_baseline_cleanup
printf ':%s - [0:0]\n' "$Z2_FW_OUT_CHAIN"
[ "$connbytes" != 1 ] || printf ':%s - [0:0]\n' "$Z2_FW_IN_CHAIN"
z2_fw_emit_batch_rule "$Z2_FW_OUT_CHAIN" tcp out "$PORTS_TCP" "$TCP_PKT_OUT" original "$connbytes" "$multiport"
z2_fw_emit_batch_rule "$Z2_FW_OUT_CHAIN" udp out "$PORTS_UDP" "$UDP_PKT_OUT" original "$connbytes" "$multiport"
if [ "$connbytes" = 1 ]; then
z2_fw_emit_batch_rule "$Z2_FW_IN_CHAIN" tcp in "$PORTS_TCP" "$TCP_PKT_IN" reply 1 "$multiport"
z2_fw_emit_batch_rule "$Z2_FW_IN_CHAIN" udp in "$PORTS_UDP" "$UDP_PKT_IN" reply 1 "$multiport"
fi
printf '%s\n' "-A OUTPUT -j $Z2_FW_OUT_CHAIN"
[ "$connbytes" != 1 ] || printf '%s\n' "-A INPUT -j $Z2_FW_IN_CHAIN"
printf '%s\n' COMMIT
} > "$path"
}
z2_fw_write_cleanup_batch() {
local path="$1"
{
printf '%s\n' '*mangle'
z2_fw_emit_baseline_cleanup
printf '%s\n' COMMIT
} > "$path"
}
z2_fw_apply_restore() {
local tool="$1" connbytes="$2" multiport="${3:-1}" restore batch
Z2_FW_FAILURE_CLASS=""
Z2_FW_ERROR_DETAIL=""
restore="$(z2_fw_restore_command "$tool")" || return 2
command -v "$restore" >/dev/null 2>&1 || return 3
batch="$STATE_DIR/tmp/firewall-batch.${tool}.$$"
z2_fw_ensure_scratch_dir || {
Z2_FW_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_ERROR_DETAIL="unavailable firewall scratch directory"
return 1
}
state_path_is_managed_file "$batch" || {
Z2_FW_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_ERROR_DETAIL="unsafe firewall batch path"
return 1
}
z2_fw_claim_scratch_path "$batch" || {
Z2_FW_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_ERROR_DETAIL="firewall batch path already exists"
return 1
}
umask 077
z2_fw_write_batch "$batch" "$connbytes" "$multiport" || {
rm -f "$batch" 2>/dev/null
Z2_FW_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_ERROR_DETAIL="cannot create firewall batch"
return 1
}
chmod 0600 "$batch" 2>/dev/null || {
rm -f "$batch" 2>/dev/null
Z2_FW_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_ERROR_DETAIL="cannot secure firewall batch"
return 1
}
if z2_fw_run_restore "$restore" "$tool" test "$batch"; then
:
else
z2_fw_set_restore_failure "$restore" test "$connbytes"
rm -f "$batch" 2>/dev/null || {
Z2_FW_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_ERROR_DETAIL="cannot remove rejected firewall batch"
return 1
}
[ "$Z2_FW_FAILURE_CLASS" = RULESET_REJECTED ] && return 4
return 1
fi
if z2_fw_run_restore "$restore" "$tool" commit "$batch"; then
:
else
z2_fw_set_restore_failure "$restore" commit "$connbytes"
rm -f "$batch" 2>/dev/null || {
Z2_FW_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_ERROR_DETAIL="cannot remove failed firewall batch"
return 1
}
return 1
fi
rm -f "$batch" 2>/dev/null || {
Z2_FW_FAILURE_CLASS=STATE_UNAVAILABLE
Z2_FW_ERROR_DETAIL="cannot remove committed firewall batch"
return 1
}
Z2_FW_BACKEND=restore
return 0
}
z2_fw_expected_rule_count() {
local connbytes="$1" multiport="${2:-1}" per_direction=0 tcp udp
if [ "$multiport" = 1 ]; then
[ -z "$PORTS_TCP" ] || per_direction=$((per_direction + 1))
[ -z "$PORTS_UDP" ] || per_direction=$((per_direction + 1))
else
# One rule per interval, so the published count is the interval count.
tcp="$(z2_fw_port_interval_count "$PORTS_TCP")" || return 1
udp="$(z2_fw_port_interval_count "$PORTS_UDP")" || return 1
per_direction=$((tcp + udp))
fi
printf '%s\n' $((per_direction * (1 + connbytes)))
}
z2_fw_verify_family() {
local tool="$1" connbytes="$2" multiport="${3:-1}" listing verification
local out_tcp out_udp in_tcp in_udp
Z2_FW_VERIFY_DETAIL=""
listing="$("$tool" -t mangle -S 2>/dev/null)" || {
Z2_FW_VERIFY_DETAIL="$tool mangle snapshot command failed"
return 1
}
out_tcp="$(z2_fw_emit_batch_rule "$Z2_FW_OUT_CHAIN" tcp out "$PORTS_TCP" "$TCP_PKT_OUT" original "$connbytes" "$multiport")" || return 1
out_udp="$(z2_fw_emit_batch_rule "$Z2_FW_OUT_CHAIN" udp out "$PORTS_UDP" "$UDP_PKT_OUT" original "$connbytes" "$multiport")" || return 1
in_tcp=""
in_udp=""
if [ "$connbytes" = 1 ]; then
in_tcp="$(z2_fw_emit_batch_rule "$Z2_FW_IN_CHAIN" tcp in "$PORTS_TCP" "$TCP_PKT_IN" reply 1 "$multiport")" || return 1
in_udp="$(z2_fw_emit_batch_rule "$Z2_FW_IN_CHAIN" udp in "$PORTS_UDP" "$UDP_PKT_IN" reply 1 "$multiport")" || return 1
fi
# The backend re-renders published rules in its own save format: match
# option order, --dports vs --dport for a single port, and mark mask
# elision all differ between iptables builds. Verification therefore
# compares canonical rule signatures built from the closed module
# vocabulary, never the authored batch text. Anything outside that
# vocabulary inside the owned namespace is a foreign rule.
verification="$(printf '%s\n' "$listing" | awk \
-v out="$Z2_FW_OUT_CHAIN" -v inchain="$Z2_FW_IN_CHAIN" \
-v connbytes="$connbytes" \
-v out_tcp="$out_tcp" -v out_udp="$out_udp" \
-v in_tcp="$in_tcp" -v in_udp="$in_udp" '
# Without multiport a port list becomes one rule per interval, so a
# single expected signature per chain and protocol is no longer the
# shape to compare against. Expectations are loaded as a multiset of
# canonical signatures: every published rule must match one, and every
# expected one must appear exactly as often as it was authored.
function load_expected(rules, chainkey, n, lines, i, sig) {
if (rules == "") return
n = split(rules, lines, "\n")
for (i = 1; i <= n; i++) {
if (lines[i] == "") continue
sig = canon(lines[i])
if (sig == "") { expected_bad = 1; return }
exp_count[sig]++
exp_total[chainkey]++
}
}
function expectations_met(chainkey, sig) {
for (sig in exp_count)
if (index(sig, "-A " chainkey " ") == 1 &&
seen_count[sig] != exp_count[sig]) return 0
return 1
}
function canon(line, n, t, i, tok, val, chain, proto, portskey,
ports, cbrange, cbdir, cbmode, markval, markinv,
target, qnum, bypass, invert, sig) {
n = split(line, t, " ")
if (n < 4 || t[1] != "-A") return ""
chain = t[2]
invert = 0
for (i = 3; i <= n; i++) {
tok = t[i]
if (tok == "!") {
if (invert) return ""
invert = 1
continue
}
if (tok == "-m" || tok == "--match") {
i++
if (invert || i > n) return ""
if (t[i] != "multiport" && t[i] != "connbytes" &&
t[i] != "mark" && t[i] != "tcp" && t[i] != "udp")
return ""
continue
}
if (tok == "--queue-bypass") {
if (invert) return ""
bypass = 1
continue
}
i++
if (i > n) return ""
val = t[i]
if (tok == "-p" || tok == "--protocol") {
if (invert || proto != "") return ""
proto = val
} else if (tok == "--dports" || tok == "--dport") {
if (invert || ports != "") return ""
portskey = "d"; ports = val
} else if (tok == "--sports" || tok == "--sport") {
if (invert || ports != "") return ""
portskey = "s"; ports = val
} else if (tok == "--connbytes") {
if (invert || cbrange != "") return ""
cbrange = val
} else if (tok == "--connbytes-dir") {
if (invert || cbdir != "") return ""
cbdir = val
} else if (tok == "--connbytes-mode") {
if (invert || cbmode != "") return ""
cbmode = val
} else if (tok == "--mark") {
if (markval != "") return ""
markinv = invert
invert = 0
if (!index(val, "/")) val = val "/0xffffffff"
markval = val
} else if (tok == "-j" || tok == "--jump") {
if (invert || target != "") return ""
target = val
} else if (tok == "--queue-num") {
if (invert || qnum != "") return ""
qnum = val
} else {
return ""
}
}
if (invert) return ""
if ((cbrange != "" || cbdir != "" || cbmode != "") &&
(cbrange == "" || cbdir == "" || cbmode == "")) return ""
sig = "-A " chain " p=" proto " " portskey "ports=" ports
if (cbrange != "") sig = sig " cb=" cbrange "/" cbmode "/" cbdir
if (markval != "") sig = sig " mark=" (markinv ? "!" : "") markval
sig = sig " j=" target
if (qnum != "") sig = sig " qnum=" qnum
if (bypass) sig = sig " bypass"
return sig
}
BEGIN {
load_expected(out_tcp, out)
load_expected(out_udp, out)
load_expected(in_tcp, inchain)
load_expected(in_udp, inchain)
if (expected_bad) {
print "EXPECTED_RULE_UNPARSEABLE"
bail = 1
exit 1
}
}
$1 == "-N" && $2 == out { out_chain++ }
$1 == "-N" && $2 == inchain { in_chain++ }
$1 == "-A" && $2 == out {
out_rules++
sig = canon($0)
if (sig != "" && (sig in exp_count)) seen_count[sig]++
else bad=1
}
$1 == "-A" && $2 == inchain {
in_rules++
sig = canon($0)
if (sig != "" && (sig in exp_count)) seen_count[sig]++
else bad=1
}
$1 == "-A" {
for (i=3; i<=NF; i++) {
if ($i != "-j" && $i != "--jump" &&
$i != "-g" && $i != "--goto") continue
target=$(i+1)
if (target == out) {
if ($0 == "-A OUTPUT -j " out) out_anchor++
else bad=1
} else if (target == inchain) {
if ($0 == "-A INPUT -j " inchain) in_anchor++
else bad=1
}
}
}
END {
if (bail) exit 1
expected_out=exp_total[out]
expected_in=exp_total[inchain]
if (bad) reason="FOREIGN_OR_UNEXPECTED_RULE"
else if (out_chain != 1) reason="OUT_CHAIN_COUNT:" out_chain
else if (out_anchor != 1) reason="OUT_ANCHOR_COUNT:" out_anchor
else if (out_rules != expected_out) reason="OUT_RULE_COUNT:" out_rules
else if (!expectations_met(out)) reason="OUT_RULE_MISMATCH"
if (connbytes == 1) {
if (reason == "" && in_chain != 1) reason="INPUT_CHAIN_COUNT:" in_chain
else if (reason == "" && in_anchor != 1) reason="INPUT_ANCHOR_COUNT:" in_anchor
else if (reason == "" && in_rules != expected_in) reason="INPUT_RULE_COUNT:" in_rules
else if (reason == "" && !expectations_met(inchain))
reason="INPUT_RULE_MISMATCH"
} else {
if (reason == "" &&
(in_chain != 0 || in_anchor != 0 || in_rules != 0))
reason="UNEXPECTED_INPUT_TOPOLOGY"
}
if (reason != "") {
print reason
exit 1
}
}')" || {
[ -n "$verification" ] || verification=UNKNOWN_TOPOLOGY_MISMATCH
Z2_FW_VERIFY_DETAIL="$tool post-publication topology mismatch (connbytes=$connbytes, reason=$verification)"
return 1
}
Z2_FW_CONNBYTES="$connbytes"
Z2_FW_RULES="$(z2_fw_expected_rule_count "$connbytes" "$multiport")" || return 1
Z2_FW_CHAINS=$((1 + connbytes))
Z2_FW_ANCHORS=$((1 + connbytes))
return 0
}
z2_fw_apply_cleanup() {
local tool="$1" restore batch phase rc detail
[ "$Z2_FW_BASELINE_READY" = 1 ] || return 1
if [ "$Z2_FW_BASELINE_OUT_CHAIN:$Z2_FW_BASELINE_IN_CHAIN:$Z2_FW_BASELINE_OUT_ANCHORS:$Z2_FW_BASELINE_IN_ANCHORS" = 0:0:0:0 ]; then
return 0
fi
restore="$(z2_fw_restore_command "$tool")" || return 2
command -v "$restore" >/dev/null 2>&1 || return 3
batch="$STATE_DIR/tmp/firewall-cleanup.${tool}.$$"
z2_fw_ensure_scratch_dir || return 1
state_path_is_managed_file "$batch" || return 1
z2_fw_claim_scratch_path "$batch" || return 1
umask 077
if ! z2_fw_write_cleanup_batch "$batch" ||
! chmod 0600 "$batch" 2>/dev/null; then
rm -f "$batch" 2>/dev/null
return 1
fi
for phase in test commit; do
if z2_fw_run_restore "$restore" "$tool" "$phase" "$batch"; then
:
else
rc=$?
detail="${Z2_FW_LAST_RESTORE_DETAIL:-no backend diagnostic}"
Z2_FW_FAILURE_CLASS="${Z2_FW_LAST_FAILURE_CLASS:-CLEANUP_FAILED}"
Z2_FW_ERROR_DETAIL="$restore atomic cleanup $phase failed (exit=$Z2_FW_LAST_RESTORE_EXIT): $detail"
Z2_FW_ERROR_DETAIL="$(z2_fw_normalize_diagnostic "$Z2_FW_ERROR_DETAIL")"
rm -f "$batch" 2>/dev/null || true
return "$rc"
fi
done
rm -f "$batch" 2>/dev/null || return 1
if z2_fw_family_absent "$tool"; then
return 0
fi
Z2_FW_FAILURE_CLASS=POSTCONDITION_FAILED
Z2_FW_ERROR_DETAIL="$tool atomic cleanup postcondition failed"
return 1
}
z2_fw_cleanup_family() {
local tool="$1" baseline_mode="${2:-owned}"
case "$baseline_mode" in
owned)
command -v "$tool" >/dev/null 2>&1 || return 2
z2_fw_capture_baseline "$tool" || return 1
;;
audited)
command -v "$tool" >/dev/null 2>&1 || return 2
z2_fw_load_audit "$tool" || return 1
;;
*) return 2 ;;
esac
z2_fw_restore_available "$tool" || return 3
z2_fw_apply_cleanup "$tool"
}
z2_fw_reconcile_family() {
local tool="$1" baseline_mode="${2:-owned}" apply_rc candidate_detail verify_detail
local connbytes multiport
case "$baseline_mode" in owned|audited) ;; *) return 2 ;; esac
Z2_FW_BACKEND=""; Z2_FW_CONNBYTES=0; Z2_FW_MULTIPORT=1
Z2_FW_RULES=0; Z2_FW_CHAINS=0; Z2_FW_ANCHORS=0
Z2_FW_FAILURE_CLASS=""; Z2_FW_ERROR_DETAIL=""; Z2_FW_FALLBACK_DETAIL=""
command -v "$tool" >/dev/null 2>&1 || {
Z2_FW_FAILURE_CLASS=BACKEND_UNAVAILABLE
Z2_FW_ERROR_DETAIL="$tool command is unavailable"
return 2
}
z2_fw_restore_available "$tool" || {
Z2_FW_FAILURE_CLASS=BACKEND_UNAVAILABLE
Z2_FW_ERROR_DETAIL="$tool restore backend is unavailable"
return 3
}
if [ "$baseline_mode" = audited ]; then
z2_fw_load_audit "$tool" || {
Z2_FW_FAILURE_CLASS=CLEANUP_FAILED
Z2_FW_ERROR_DETAIL="$tool authenticated transition baseline is unavailable"
return 1
}
else
z2_fw_capture_baseline "$tool" || {
Z2_FW_FAILURE_CLASS=CLEANUP_FAILED
Z2_FW_ERROR_DETAIL="$tool stable namespace transition preflight failed"
return 1
}
fi
# Two optional capabilities, each with its own latch. A rejection may only
# retire the capability its own diagnostic names, and each is retired at
# most once, so at most two downgrades happen and neither can be undone by
# a later failure. Anything the backend rejects for a reason it does not
# name is a publication error: silently rebuilding a different topology
# would hide a broken configuration instead of a missing kernel module.
#
# The two fail at different phases. iptables-restore --test parses in
# userspace but asks the kernel for match revisions while doing so, which
# is where a missing xt_multiport surfaces; connbytes passes the test phase
# and is rejected only at COMMIT. Legacy restore submits the whole table in
# one atomic replace, so a rejected COMMIT leaves the pre-transaction
# state, and post-publication verification still gates every result.
connbytes=1
multiport=1
# The 15-value limit is a parser rule, not a capability, so it is settled
# before the first attempt rather than learned from a rejection.
z2_fw_multiport_fits || {
multiport=0
Z2_FW_FALLBACK_DETAIL="port list exceeds the $Z2_FW_MULTIPORT_MAX_VALUES values multiport accepts"
}
while :; do
if z2_fw_apply_restore "$tool" "$connbytes" "$multiport"; then
apply_rc=0
else
apply_rc=$?
fi
if [ "$apply_rc" = 0 ]; then
if ! z2_fw_verify_family "$tool" "$connbytes" "$multiport"; then
verify_detail="$Z2_FW_VERIFY_DETAIL"
z2_fw_cleanup_family "$tool" >/dev/null 2>&1 || true
Z2_FW_FAILURE_CLASS=POSTCONDITION_FAILED
Z2_FW_ERROR_DETAIL="$verify_detail"
return 1
fi
Z2_FW_CONNBYTES="$connbytes"
Z2_FW_MULTIPORT="$multiport"
Z2_FW_FAILURE_CLASS=""; Z2_FW_ERROR_DETAIL=""
return 0
fi
candidate_detail="$Z2_FW_ERROR_DETAIL"
# A candidate rejection at test is a capability signal by itself; any
# other failure has to be a publication failure to be one at all.
if [ "$apply_rc" != 4 ] && [ "$Z2_FW_FAILURE_CLASS" != PUBLICATION_FAILED ]; then
return 1
fi
# Match the raw backend stderr: the wrapped detail carries markers of
# its own. The named capability wins, so a multiport rejection never
# spends the connbytes latch on its way down.
if [ "$connbytes" = 1 ] &&
z2_fw_diagnostic_is_connbytes_unsupported "$Z2_FW_LAST_RESTORE_DETAIL"; then
connbytes=0
elif [ "$multiport" = 1 ] &&
z2_fw_diagnostic_is_multiport_unsupported "$Z2_FW_LAST_RESTORE_DETAIL"; then
multiport=0
elif [ "$apply_rc" = 4 ] && [ "$connbytes" = 1 ]; then
# An unnamed test rejection retires the richer topology first,
# which is the only one whose absence a kernel can survive.
connbytes=0
else
if [ -z "$Z2_FW_ERROR_DETAIL" ]; then
Z2_FW_FAILURE_CLASS=POSTCONDITION_FAILED
Z2_FW_ERROR_DETAIL="$tool post-publication verification failed (connbytes=$connbytes, multiport=$multiport)"
fi
return 1
fi
Z2_FW_FALLBACK_DETAIL="${Z2_FW_FALLBACK_DETAIL:+$Z2_FW_FALLBACK_DETAIL; }$candidate_detail"
done
}