magisk-zapret2/zapret2/scripts/firewall-reconciler.sh
loop-uh 852d42c02e Answer in builtins what the replace path kept re-buying in forks
Android prices every fork at ~25ms and every exec at ~90ms, and the
preset apply was paying that price for questions it had already
answered: 95 separate stat calls re-proving file metadata one field at
a time, command substitutions around proc readers whose parsing was
already builtin-only, expected firewall rules rebuilt through captures
on every verify, and printf — an external binary on Android's mksh —
invoked once per line of every publication.

One stat capture now serves every metadata question a proof asks
(path_meta_capture, consumed by the uid/mode/nlink/size predicates and
retired on proof exit); the state-dir proof and a fully-pinned process
identity become lock-scoped facts on the same single-writer argument as
the owner read cache; proc_starttime/proc_argv0 gain fork-free
global-return forms with the printf wrappers kept as seams; the qnum
argument check reuses the cmdline snapshot argv0 already paid for;
expected firewall rules are built as data instead of captured; and
multi-line writers emit once through z2_emit_line, which resolves to
mksh's raw print builtin on the device and printf elsewhere. Load-time
probes replace the per-call command -v PATH walks, and log stamps reuse
one date exec per second via EPOCHREALTIME.

Measured on the Pixel 9 Pro XL: preset apply 56s before this series,
40s after the owner read cache, 14.6s now. tests/shell/run.sh passes;
the three test seams that named replaced internals (proc_starttime,
z2_fw_restore_command, the owner printf format) follow the new forms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 16:55:22 +03:00

965 lines
38 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.
# common.sh defines the fork-free line emitter and sources this file before
# that definition runs; the fallback only keeps the reconciler sourceable on
# its own, which the reconciler tests do.
command -v z2_emit_line >/dev/null 2>&1 || z2_emit_line() { printf '%s\n' "$1"; }
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_read() {
Z2_FW_RESTORE_COMMAND=""
case "$1" in
iptables) Z2_FW_RESTORE_COMMAND=iptables-restore ;;
ip6tables) Z2_FW_RESTORE_COMMAND=ip6tables-restore ;;
*) return 1 ;;
esac
}
z2_fw_restore_available() {
z2_fw_restore_command_read "$1" || return 1
command -v "$Z2_FW_RESTORE_COMMAND" >/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 ]
}
# Builder counterpart of the old per-line emitter: cleanup lines land in
# Z2_FW_BUILT_CLEANUP (empty when the baseline is already clean) so batch
# authors can fold them into one write.
z2_fw_build_baseline_cleanup() {
local n nl='
'
Z2_FW_BUILT_CLEANUP=""
[ "$Z2_FW_BASELINE_READY" = 1 ] || return 1
n=0
while [ "$n" -lt "$Z2_FW_BASELINE_OUT_ANCHORS" ]; do
Z2_FW_BUILT_CLEANUP="${Z2_FW_BUILT_CLEANUP}${Z2_FW_BUILT_CLEANUP:+$nl}-D OUTPUT -j $Z2_FW_OUT_CHAIN"
n=$((n + 1))
done
n=0
while [ "$n" -lt "$Z2_FW_BASELINE_IN_ANCHORS" ]; do
Z2_FW_BUILT_CLEANUP="${Z2_FW_BUILT_CLEANUP}${Z2_FW_BUILT_CLEANUP:+$nl}-D INPUT -j $Z2_FW_IN_CHAIN"
n=$((n + 1))
done
if [ "$Z2_FW_BASELINE_IN_CHAIN" = 1 ]; then
Z2_FW_BUILT_CLEANUP="${Z2_FW_BUILT_CLEANUP}${Z2_FW_BUILT_CLEANUP:+$nl}-F $Z2_FW_IN_CHAIN$nl-X $Z2_FW_IN_CHAIN"
fi
if [ "$Z2_FW_BASELINE_OUT_CHAIN" = 1 ]; then
Z2_FW_BUILT_CLEANUP="${Z2_FW_BUILT_CLEANUP}${Z2_FW_BUILT_CLEANUP:+$nl}-F $Z2_FW_OUT_CHAIN$nl-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
# One fork-free walk answers both interval questions a port list gets asked:
# the multiport value weight (a range spends two values) and the interval
# count that sizes the per-interval fallback topology.
z2_fw_measure_port_list() {
local rest="$1" token
Z2_FW_PORT_WEIGHT=0
Z2_FW_PORT_INTERVALS=0
while [ -n "$rest" ]; do
case "$rest" in
*,*) token="${rest%%,*}"; rest="${rest#*,}" ;;
*) token="$rest"; rest="" ;;
esac
[ -n "$token" ] || continue
case "$token" in
*:*) Z2_FW_PORT_WEIGHT=$((Z2_FW_PORT_WEIGHT + 2)) ;;
*) Z2_FW_PORT_WEIGHT=$((Z2_FW_PORT_WEIGHT + 1)) ;;
esac
Z2_FW_PORT_INTERVALS=$((Z2_FW_PORT_INTERVALS + 1))
done
}
# 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
z2_fw_measure_port_list "$PORTS_TCP"
tcp="$Z2_FW_PORT_WEIGHT"
z2_fw_measure_port_list "$PORTS_UDP"
udp="$Z2_FW_PORT_WEIGHT"
[ "$tcp" -le "$Z2_FW_MULTIPORT_MAX_VALUES" ] &&
[ "$udp" -le "$Z2_FW_MULTIPORT_MAX_VALUES" ]
}
# The rules a chain/protocol pair publishes, built as data in a global. The
# batch writer prints them and post-publication verification compares them,
# so building them here instead of inside a command substitution spares the
# verifier its per-call subshell forks.
z2_fw_build_batch_rules() {
local chain="$1" proto="$2" direction="$3" ports="$4"
local packet_count="$5" cb_dir="$6" connbytes="$7" multiport="${8:-1}"
local tail rest token portopt nl='
'
Z2_FW_BUILT_RULES=""
[ -n "$ports" ] || return 0
tail=""
if [ "$connbytes" = 1 ]; then
tail=" -m connbytes --connbytes 1:$packet_count --connbytes-dir $cb_dir --connbytes-mode packets"
fi
tail="$tail -m mark ! --mark $DESYNC_MARK/$DESYNC_MARK -j NFQUEUE --queue-num $QNUM --queue-bypass"
if [ "$multiport" = 1 ]; then
if [ "$direction" = out ]; then portopt="--dports"; else portopt="--sports"; fi
Z2_FW_BUILT_RULES="-A $chain -p $proto -m multiport $portopt $ports$tail"
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.
if [ "$direction" = out ]; then portopt="--dport"; else portopt="--sport"; fi
rest="$ports"
while [ -n "$rest" ]; do
case "$rest" in
*,*) token="${rest%%,*}"; rest="${rest#*,}" ;;
*) token="$rest"; rest="" ;;
esac
[ -n "$token" ] || continue
Z2_FW_BUILT_RULES="${Z2_FW_BUILT_RULES}${Z2_FW_BUILT_RULES:+$nl}-A $chain -p $proto $portopt $token$tail"
done
}
z2_fw_write_batch() {
local path="$1" connbytes="$2" multiport="${3:-1}" batch nl='
'
z2_fw_build_baseline_cleanup || return 1
batch="*mangle"
[ -z "$Z2_FW_BUILT_CLEANUP" ] || batch="$batch$nl$Z2_FW_BUILT_CLEANUP"
batch="$batch$nl:$Z2_FW_OUT_CHAIN - [0:0]"
[ "$connbytes" != 1 ] || batch="$batch$nl:$Z2_FW_IN_CHAIN - [0:0]"
z2_fw_build_batch_rules "$Z2_FW_OUT_CHAIN" tcp out "$PORTS_TCP" "$TCP_PKT_OUT" original "$connbytes" "$multiport" || return 1
[ -z "$Z2_FW_BUILT_RULES" ] || batch="$batch$nl$Z2_FW_BUILT_RULES"
z2_fw_build_batch_rules "$Z2_FW_OUT_CHAIN" udp out "$PORTS_UDP" "$UDP_PKT_OUT" original "$connbytes" "$multiport" || return 1
[ -z "$Z2_FW_BUILT_RULES" ] || batch="$batch$nl$Z2_FW_BUILT_RULES"
if [ "$connbytes" = 1 ]; then
z2_fw_build_batch_rules "$Z2_FW_IN_CHAIN" tcp in "$PORTS_TCP" "$TCP_PKT_IN" reply 1 "$multiport" || return 1
[ -z "$Z2_FW_BUILT_RULES" ] || batch="$batch$nl$Z2_FW_BUILT_RULES"
z2_fw_build_batch_rules "$Z2_FW_IN_CHAIN" udp in "$PORTS_UDP" "$UDP_PKT_IN" reply 1 "$multiport" || return 1
[ -z "$Z2_FW_BUILT_RULES" ] || batch="$batch$nl$Z2_FW_BUILT_RULES"
fi
batch="$batch$nl-A OUTPUT -j $Z2_FW_OUT_CHAIN"
[ "$connbytes" != 1 ] || batch="$batch$nl-A INPUT -j $Z2_FW_IN_CHAIN"
batch="$batch${nl}COMMIT"
z2_emit_line "$batch" > "$path"
}
z2_fw_write_cleanup_batch() {
local path="$1" batch nl='
'
z2_fw_build_baseline_cleanup || return 1
batch="*mangle"
[ -z "$Z2_FW_BUILT_CLEANUP" ] || batch="$batch$nl$Z2_FW_BUILT_CLEANUP"
batch="$batch${nl}COMMIT"
z2_emit_line "$batch" > "$path"
}
z2_fw_apply_restore() {
local tool="$1" connbytes="$2" multiport="${3:-1}" restore batch
Z2_FW_FAILURE_CLASS=""
Z2_FW_ERROR_DETAIL=""
z2_fw_restore_command_read "$tool" || return 2
restore="$Z2_FW_RESTORE_COMMAND"
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
Z2_FW_EXPECTED_RULES=0
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.
z2_fw_measure_port_list "$PORTS_TCP"
tcp="$Z2_FW_PORT_INTERVALS"
z2_fw_measure_port_list "$PORTS_UDP"
udp="$Z2_FW_PORT_INTERVALS"
per_direction=$((tcp + udp))
fi
Z2_FW_EXPECTED_RULES=$((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
}
z2_fw_build_batch_rules "$Z2_FW_OUT_CHAIN" tcp out "$PORTS_TCP" "$TCP_PKT_OUT" original "$connbytes" "$multiport" || return 1
out_tcp="$Z2_FW_BUILT_RULES"
z2_fw_build_batch_rules "$Z2_FW_OUT_CHAIN" udp out "$PORTS_UDP" "$UDP_PKT_OUT" original "$connbytes" "$multiport" || return 1
out_udp="$Z2_FW_BUILT_RULES"
in_tcp=""
in_udp=""
if [ "$connbytes" = 1 ]; then
z2_fw_build_batch_rules "$Z2_FW_IN_CHAIN" tcp in "$PORTS_TCP" "$TCP_PKT_IN" reply 1 "$multiport" || return 1
in_tcp="$Z2_FW_BUILT_RULES"
z2_fw_build_batch_rules "$Z2_FW_IN_CHAIN" udp in "$PORTS_UDP" "$UDP_PKT_IN" reply 1 "$multiport" || return 1
in_udp="$Z2_FW_BUILT_RULES"
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_expected_rule_count "$connbytes" "$multiport" || return 1
Z2_FW_RULES="$Z2_FW_EXPECTED_RULES"
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
z2_fw_restore_command_read "$tool" || return 2
restore="$Z2_FW_RESTORE_COMMAND"
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
}