⛅ Sync 2026-09-01 05:29:46

This commit is contained in:
github-actions[bot] committed 2026-09-01 05:29:46 +08:00
1 parent 16957188b7
commit ded477010d
16 files changed
+1624 -1211

No files matched your search

@@ -23,5 +23,8 @@ input="$(cat)"
[ -n "$input" ] || input='{"log":[]}'
printf '%s' '{"log":['
jsonfilter -s "$input" -e '@.log[*]' 2>/dev/null | _fwlive_filter_json_entries
# Prefer stdin over -s: Linux MAX_ARG_STRLEN is 128KiB; a raised logd ring
# (or paused FETCH_LINES_MAX poll) can exceed that and make jsonfilter fail
# while this script still printed {"log":[]} and exited 0 (#234).
printf '%s' "$input" | jsonfilter -e '@.log[*]' 2>/dev/null | _fwlive_filter_json_entries
printf '%s' ']}'
@@ -62,18 +62,20 @@ json_escape() {
}'
}
# Production lock dir must be root-owned with no group/other write (#204).
# Production lock dir must be owned by euid with no group/other write (#204).
# Avoid GNU/BusyBox `stat -c` — stock OpenWrt omits FEATURE_STAT_FORMAT (#232).
wan_log_lock_dir_safe() {
dir="$1"
[ -n "$dir" ] || return 1
[ -L "$dir" ] && return 1
[ -d "$dir" ] || return 1
u=$(stat -c '%u' "$dir" 2>/dev/null) || return 1
[ "$u" = "0" ] || return 1
m=$(stat -c '%a' "$dir" 2>/dev/null) || return 1
o=$((m % 10))
g=$(( (m / 10) % 10 ))
[ $((o & 2)) -eq 0 ] && [ $((g & 2)) -eq 0 ]
# POSIX -O: true when the effective uid owns the directory (rpcd → root).
# shellcheck disable=SC3067 # BusyBox/dash implement -O; SC3067 is overly strict
[ -O "$dir" ] || return 1
# Fail closed if group or other write is set. find -perm is on BusyBox;
# -prune limits the walk to this directory only.
_writable=$(find "$dir" -prune \( -perm -020 -o -perm -002 \) -print 2>/dev/null) || return 1
[ -z "$_writable" ]
}
# Acquire the exclusive logging lock on fd 9. Blocks until free; fails closed
@@ -97,7 +99,12 @@ acquire_wan_log_lock() {
chmod 0600 "$WAN_LOG_LOCK_FILE" 2>/dev/null || true
chown 0:0 "$WAN_LOG_LOCK_FILE" 2>/dev/null || true
[ -L "$WAN_LOG_LOCK_FILE" ] && return 1
exec 9>"$WAN_LOG_LOCK_FILE" 2>/dev/null || return 1
# Probe in a subshell first: a failed `exec` redirection aborts a POSIX
# non-interactive shell outright, so `|| return 1` on the real exec would
# never run — and `2>/dev/null` on the same exec would permanently
# silence this process's stderr on the success path (#244).
( exec 9>>"$WAN_LOG_LOCK_FILE" ) 2>/dev/null || return 1
exec 9>>"$WAN_LOG_LOCK_FILE"
flock 9 2>/dev/null || {
exec 9>&-
return 1
+91 -38
View File
@@ -44,6 +44,13 @@ slug_key() {
echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' _' '--'
}
# Cap rules JSON size so a huge ruleset cannot unbound an rpcd reply (#229).
RULES_MAP_MAX_KEYS=512
RULES_MAP_MAX_BYTES=65536
# Same wall-clock bound as nft for iptables-save / ip6tables-save (#229).
# Overridable for host tests (e.g. FWLIVE_IPTABLES_TIMEOUT=1).
IPTABLES_TIMEOUT="${FWLIVE_IPTABLES_TIMEOUT:-5}"
map_add() {
key="$1"
val="$2"
@@ -54,10 +61,21 @@ map_add() {
# Uses anchored pattern "\"<key>\":" to avoid substring false positives
# ("foo" vs "foobar") and value false positives ("see foo").
# Quoting "$esc_key" makes the match literal (no glob escaping needed).
# Callers apply UCI, then labeled (!fw4:) prefixes, then unlabeled
# cosmetics so first-wins prefers authoritative names (#230).
case "$OUT" in
*"\"$esc_key\":"*) return 0 ;;
esac
esc_val=$(printf '%s' "$val" | json_escape)
_pair_len=$(( ${#esc_key} + ${#esc_val} + 5 ))
_next_len=$(( ${#OUT} + _pair_len ))
[ -n "$OUT" ] && _next_len=$((_next_len + 1))
if [ "${_map_keys:-0}" -ge "$RULES_MAP_MAX_KEYS" ] || \
[ "$_next_len" -gt "$RULES_MAP_MAX_BYTES" ]; then
_map_overflow=1
return 0
fi
_map_keys=$(( ${_map_keys:-0} + 1 ))
if [ -n "$OUT" ]; then
OUT="${OUT},"
fi
@@ -114,22 +132,26 @@ run_with_timeout() {
if command -v timeout >/dev/null 2>&1; then
timeout "$secs" "$@" 2>/dev/null
else
"$@" 2>/dev/null
# Fail closed (#229): never run unbounded when timeout is absent.
return 127
fi
}
map_log_prefix_entry() {
prefix="$1"
comment="$2"
pass="${3:-all}"
label=''
if [ -n "$comment" ] && is_uci_style_name "$comment"; then
label="$comment"
fi
# Single normalization site is map_prefix_with_label; idempotent so
# double-normalization is safe but this redundant call is removed.
[ -n "$prefix" ] || return 0
case "$pass" in
labeled) [ -n "$label" ] || return 0 ;;
unlabeled) [ -z "$label" ] || return 0 ;;
esac
map_prefix_with_label "$prefix" "$label"
}
@@ -154,7 +176,10 @@ $(uci_rule_names)
EOF
}
# pass=labeled|unlabeled|all — labeled before unlabeled so !fw4: beats
# an earlier cosmetic for the same prefix under first-wins (#230).
map_from_nft_stream() {
pass="${1:-all}"
while IFS= read -r line; do
# Capture [^"]* plus escaped-quote units (\"...) so a log prefix or
# comment containing an escaped quote is not truncated at the quote.
@@ -172,14 +197,22 @@ map_from_nft_stream() {
esac
if [ -n "$prefix" ]; then
case "$pass" in
labeled) [ -n "$label" ] || continue ;;
unlabeled) [ -z "$label" ] || continue ;;
esac
map_prefix_with_label "$prefix" "$label"
elif [ -n "$label" ]; then
case "$pass" in
unlabeled) continue ;;
esac
map_prefix_with_label "$label" "$label"
fi
done
}
map_from_iptables_save_stream() {
pass="${1:-all}"
while IFS= read -r line; do
case "$line" in
-*) ;;
@@ -188,7 +221,7 @@ map_from_iptables_save_stream() {
prefix=$(echo "$line" | sed -n 's/.*--log-prefix "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p')
comment=$(echo "$line" | sed -n 's/.*--comment "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p')
[ -n "$prefix" ] || continue
map_log_prefix_entry "$prefix" "$comment"
map_log_prefix_entry "$prefix" "$comment" "$pass"
done
}
@@ -239,65 +272,85 @@ _fwlive_mktemp() {
build_rules_map() {
OUT=''
_map_keys=0
_map_overflow=''
_enrich_error=''
backend=$(detect_firewall_backend)
# UCI first, then labeled stream prefixes, then unlabeled cosmetics
# (#230). First-wins keeps UCI over nft and !fw4: over cosmetic.
map_uci_rule_names
case "$backend" in
nft)
_tmp=$(_fwlive_mktemp fwlive-nft) || _tmp=''
if [ -n "$_tmp" ]; then
# nft ruleset write to /tmp (tmpfs on OpenWrt, RAM) is
# duration-bounded by NFT_TIMEOUT (5s) for nft only;
# iptables-save/ip6tables-save below run with no timeout.
# Size is not bounded by code; typical firewall ruleset
# dumps are <100KB and the file is removed immediately
# after parsing so RAM impact is negligible in practice.
# No pipeline subshell: accumulation stays in main shell via
# redirect for global first-wins dedup.
if nft_list_ruleset >"$_tmp" 2>/dev/null; then
map_from_nft_stream <"$_tmp"
_rules_dump=$(_fwlive_mktemp fwlive-nft) || _rules_dump=''
if [ -n "$_rules_dump" ]; then
# Dumps land on /tmp (tmpfs). Duration bounded by
# NFT_TIMEOUT / IPTABLES_TIMEOUT; key/byte caps in map_add.
if nft_list_ruleset >"$_rules_dump" 2>/dev/null; then
map_from_nft_stream labeled <"$_rules_dump"
map_from_nft_stream unlabeled <"$_rules_dump"
fi
rm -f "$_tmp"
rm -f "$_rules_dump"
else
_enrich_error='mktemp_failed'
fi
;;
iptables)
_tmp=$(_fwlive_mktemp fwlive-ipt) || _tmp=''
if [ -n "$_tmp" ]; then
if iptables-save >"$_tmp" 2>/dev/null; then
map_from_iptables_save_stream <"$_tmp"
_rules_dump=$(_fwlive_mktemp fwlive-ipt) || _rules_dump=''
if [ -n "$_rules_dump" ]; then
if run_with_timeout "$IPTABLES_TIMEOUT" iptables-save >"$_rules_dump" 2>/dev/null; then
map_from_iptables_save_stream labeled <"$_rules_dump"
map_from_iptables_save_stream unlabeled <"$_rules_dump"
fi
rm -f "$_tmp"
rm -f "$_rules_dump"
else
_enrich_error='mktemp_failed'
fi
if command -v ip6tables-save >/dev/null 2>&1; then
_tmp=$(_fwlive_mktemp fwlive-ip6t) || _tmp=''
if [ -n "$_tmp" ]; then
if ip6tables-save >"$_tmp" 2>/dev/null; then
map_from_iptables_save_stream <"$_tmp"
_rules_dump=$(_fwlive_mktemp fwlive-ip6t) || _rules_dump=''
if [ -n "$_rules_dump" ]; then
if run_with_timeout "$IPTABLES_TIMEOUT" ip6tables-save >"$_rules_dump" 2>/dev/null; then
map_from_iptables_save_stream labeled <"$_rules_dump"
map_from_iptables_save_stream unlabeled <"$_rules_dump"
fi
rm -f "$_tmp"
rm -f "$_rules_dump"
elif [ -z "$_enrich_error" ]; then
_enrich_error='mktemp_failed'
fi
fi
;;
esac
printf '{"backend":"%s","rules":{%s}}' "$backend" "$OUT"
_rules_json="{\"backend\":\"$backend\",\"rules\":{$OUT}"
if [ -n "$_enrich_error" ]; then
_rules_json="${_rules_json},\"error\":\"${_enrich_error}\""
elif [ -n "$_map_overflow" ]; then
_rules_json="${_rules_json},\"error\":\"rules_truncated\""
fi
printf '%s}' "$_rules_json"
}
rulesmap_from_iptables_file() {
file="$1"
OUT=''
_map_keys=0
_map_overflow=''
[ "$file" = "$RULESMAP_IPTABLES_FILE" ] || return 1
[ -f "$file" ] || return 1
map_from_iptables_save_stream < "$file"
printf '{"backend":"iptables","rules":{%s}}' "$OUT"
map_from_iptables_save_stream labeled < "$file"
map_from_iptables_save_stream unlabeled < "$file"
_rules_json="{\"backend\":\"iptables\",\"rules\":{$OUT}"
if [ -n "$_map_overflow" ]; then
_rules_json="${_rules_json},\"error\":\"rules_truncated\""
fi
printf '%s}' "$_rules_json"
}
poll_clamp_lines() {
# Contract: non-digit input returns default 50; digit strings are
# clamped to 1..POLL_LINES_MAX with leading zeros stripped. The
# caller (poll_lines_from_input) already filters non-digits, but this
# helper is now directly testable so it validates itself.
# clamped to 1..POLL_LINES_MAX with leading zeros stripped. Validates
# itself so __selftest can call it without jshn.
_val="$1"
case "$_val" in
''|*[!0-9]*)
@@ -305,19 +358,19 @@ poll_clamp_lines() {
return 0
;;
esac
_tmp=$(printf '%s' "$_val" | sed 's/^0*//')
if [ -z "$_tmp" ]; then
_digits=$(printf '%s' "$_val" | sed 's/^0*//')
if [ -z "$_digits" ]; then
printf '%s' "50"
return 0
fi
if [ "${#_tmp}" -gt "${#POLL_LINES_MAX}" ]; then
if [ "${#_digits}" -gt "${#POLL_LINES_MAX}" ]; then
printf '%s' "$POLL_LINES_MAX"
return 0
fi
if [ "$_tmp" -gt "$POLL_LINES_MAX" ]; then
if [ "$_digits" -gt "$POLL_LINES_MAX" ]; then
printf '%s' "$POLL_LINES_MAX"
else
printf '%s' "$_tmp"
printf '%s' "$_digits"
fi
}