⛅ Sync 2026-09-16 03:46:54
Merge-upstream / merge (push) Canceled after 0s

This commit is contained in:
github-actions[bot] committed 2026-09-16 03:46:54 +08:00
1 parent c13653dc17
commit 77ae924426
29 files changed
+1215 -429

No files matched your search

@@ -2,7 +2,7 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com>
#
# Layer 1 adaptive poll cap. Sourced by rpcd/fwlive.
# Layer 1 adaptive poll cap (#306). Sourced by rpcd/fwlive.
# Always on unless test/triage override (no UCI / no product config):
# FWLIVE_ADAPTIVE=0|false|off|no
# or sentinel ${FWLIVE_ADAPTIVE_OFF_FILE:-<state-dir>/fwlive-adaptive-off}
@@ -18,10 +18,10 @@
# flock, lock busy, or corrupt state. Lock-busy ⇒ unlocked last-writer-wins is
# acceptable (state stays one valid JSON line; ordering is not guaranteed).
# Failed ubus log.read must NOT call record() — a ~0 ms failure is not "cold"
# health and must not clear an existing hot/shed cap.
# health and must not clear an existing hot/shed cap (#329 Hermes Q1).
# Outside the measured duration interval: plan (pre), record/merge (post).
# messages_received is 0 in Layer 1 — do not ash-scan the filter JSON after
# end_cs; accurate count belongs in the filter or a later layer.
# messages_received is supplied by the filter-side jsonfilter enumeration;
# failed reads still use 0 without scanning the response in ash.
FWLIVE_ADAPTIVE_STATE_FILE="${FWLIVE_ADAPTIVE_STATE_FILE:-/var/run/fwlive-state.json}"
# Optional overrides; when unset, lock/off paths are siblings of the current state.
@@ -37,6 +37,7 @@ FWLIVE_ADAPTIVE_HOT_FLOOR=250
FWLIVE_ADAPTIVE_WARM_MIN=50
FWLIVE_ADAPTIVE_COOLDOWN_WARM_CS=300
FWLIVE_ADAPTIVE_COOLDOWN_HOT_CS=600
FWLIVE_ADAPTIVE_COOLDOWN_PROBE_CS=300
fwlive_adaptive_lock_path() {
if [ -n "${FWLIVE_ADAPTIVE_LOCK_FILE:-}" ]; then
@@ -273,7 +274,8 @@ fwlive_adaptive_bucket_for_ms() {
fi
}
# Args: requested_limit duration_ms prev_limit prev_bucket prev_warm prev_completed_cs
# Args: requested_limit duration_ms prev_limit prev_bucket prev_warm
# prev_completed_cs planning
# Prints next limit.
fwlive_adaptive_compute_limit() {
_req=$1
@@ -282,6 +284,7 @@ fwlive_adaptive_compute_limit() {
_prev_b=$4
_prev_w=$5
_prev_c=$6
_planning=${7:-0}
_max="${POLL_LINES_MAX:-2000}"
_now=$(fwlive_adaptive_clock_cs)
_bucket=$(fwlive_adaptive_bucket_for_ms "$_ms")
@@ -292,9 +295,10 @@ fwlive_adaptive_compute_limit() {
hot) _cd=$FWLIVE_ADAPTIVE_COOLDOWN_HOT_CS ;;
warm) _cd=$FWLIVE_ADAPTIVE_COOLDOWN_WARM_CS ;;
esac
_elapsed=$((_now - _prev_c))
if [ "$_cd" -gt 0 ] && [ "$_prev_c" -gt 0 ] && \
[ "$((_now - _prev_c))" -lt "$_cd" ]; then
[ "$_elapsed" -lt "$_cd" ]; then
if [ "$_ms" -le "$FWLIVE_ADAPTIVE_HOT_EXIT_MS" ] && \
[ "$_bucket" != hot ]; then
:
@@ -308,9 +312,50 @@ fwlive_adaptive_compute_limit() {
fi
fi
# Once a hot/warm cooldown expires, deliberately test a larger request.
# Planning and recording use the same pure helper: planning raises the next
# request, while recording raises the retained limit only when that probe
# completed below the hot-exit threshold. A still-hot probe therefore falls
# through to the hot floor below.
if [ "$_cd" -gt 0 ] && [ "$_prev_c" -gt 0 ] && \
[ "$_elapsed" -ge "$_cd" ]; then
if [ "$_planning" = 1 ]; then
_limit=$_prev_l
[ "$_limit" -lt 1 ] && _limit=$_max
_limit=$((_limit * 2))
[ "$_limit" -gt "$_max" ] && _limit=$_max
[ "$_limit" -gt "$_req" ] && _limit=$_req
[ "$_limit" -lt 1 ] && _limit=1
printf '%s\n' "$_limit"
return 0
fi
if [ "$_ms" -le "$FWLIVE_ADAPTIVE_HOT_EXIT_MS" ] && \
[ "$_bucket" != hot ]; then
_limit=$_prev_l
[ "$_limit" -lt 1 ] && _limit=$_max
_limit=$((_limit * 2))
[ "$_limit" -gt "$_max" ] && _limit=$_max
[ "$_limit" -gt "$_req" ] && _limit=$_req
[ "$_limit" -lt 1 ] && _limit=1
printf '%s\n' "$_limit"
return 0
fi
fi
case "$_bucket" in
cold) _limit=$_max ;;
cool) _limit=$FWLIVE_ADAPTIVE_COOL_CAP ;;
cool)
# A healthy cooldown probe may retain its raised limit for the
# short probe window. Ordinary cool samples still use the fixed
# 250-line cap.
if [ "$_prev_l" -gt "$FWLIVE_ADAPTIVE_COOL_CAP" ] && \
[ "$_prev_c" -gt 0 ] && \
[ "$((_now - _prev_c))" -lt "$FWLIVE_ADAPTIVE_COOLDOWN_PROBE_CS" ]; then
_limit=$_prev_l
else
_limit=$FWLIVE_ADAPTIVE_COOL_CAP
fi
;;
warm)
if [ "$_prev_w" = 1 ] && [ "$_prev_b" = warm ]; then
_limit=$_prev_l
@@ -350,7 +395,7 @@ fwlive_adaptive_plan() {
_prev_w=$4
_s=$5
_prev_c=$6
_l=$(fwlive_adaptive_compute_limit "$_req" "$_d" "$_prev_l" "$_b" "$_prev_w" "$_prev_c")
_l=$(fwlive_adaptive_compute_limit "$_req" "$_d" "$_prev_l" "$_b" "$_prev_w" "$_prev_c" 1)
_nb=$(fwlive_adaptive_bucket_for_ms "$_d")
_shed=0
[ "$_nb" = hot ] && _shed=1
@@ -395,7 +440,9 @@ fwlive_adaptive_is_hot() {
return 1
}
# Merge adaptive siblings into a JSON object ending with }.
# Merge adaptive siblings into a JSON object ending with }. A successful
# filter already carries the exact messages_received count; do not append a
# duplicate key in that case.
# Args: json_body shed_flag limit truncated messages_received
fwlive_adaptive_merge_reply() {
_body=$1
@@ -410,34 +457,32 @@ fwlive_adaptive_merge_reply() {
*) printf '%s' "$_body"; return 0 ;;
esac
_base=${_body%\}}
_has_msgs=0
case "$_body" in
*',"messages_received":'*) _has_msgs=1 ;;
esac
if [ "$_adapt" = 0 ]; then
printf '%s,"adaptive":0,"messages_received":%s}' "$_base" "$_msgs"
if [ "$_has_msgs" = 1 ]; then
printf '%s,"adaptive":0}' "$_base"
else
printf '%s,"adaptive":0,"messages_received":%s}' "$_base" "$_msgs"
fi
return 0
fi
if [ "$_shed" = 1 ]; then
printf '%s,"adaptive":1,"messages_received":%s,"truncated":%s,"shed":{"level":"hot","limit":%s}}' \
"$_base" "$_msgs" "$_trunc" "$_limit"
if [ "$_has_msgs" = 1 ]; then
printf '%s,"adaptive":1,"truncated":%s,"shed":{"level":"hot","limit":%s}}' \
"$_base" "$_trunc" "$_limit"
else
printf '%s,"adaptive":1,"messages_received":%s,"truncated":%s,"shed":{"level":"hot","limit":%s}}' \
"$_base" "$_msgs" "$_trunc" "$_limit"
fi
else
printf '%s,"adaptive":1,"messages_received":%s,"truncated":%s}' \
"$_base" "$_msgs" "$_trunc"
if [ "$_has_msgs" = 1 ]; then
printf '%s,"adaptive":1,"truncated":%s}' "$_base" "$_trunc"
else
printf '%s,"adaptive":1,"messages_received":%s,"truncated":%s}' \
"$_base" "$_msgs" "$_trunc"
fi
fi
}
# Count log[*] via "msg" keys — helper for tests / future filter-side count.
# NOT called on the poll hot path: ash-scanning a 2000-entry
# reply after end_cs is unmeasured overhead that cannot shed itself.
fwlive_adaptive_count_log() {
_json=$1
_n=0
_rest=$_json
while :; do
case "$_rest" in
*'"msg"'*)
_n=$((_n + 1))
_rest=${_rest#*\"msg\"}
;;
*) break ;;
esac
done
printf '%s\n' "$_n"
}
@@ -106,11 +106,101 @@ function is_fw(s, action) {
if (has_hint(s) && (has_kv(s, "IN") || has_kv(s, "OUT") || has_kv(s, "SRC") || has_kv(s, "DST") || has_kv(s, "PROTO"))) return 1
return 0
}
BEGIN { if (MODE != "json") ORS = "" }
function utf8_prefix(s, max_bytes, out, i, c, width) {
out = ""
i = 1
while (i <= length(s)) {
c = substr(s, i, 1)
width = 1
if (c ~ ("^[\302-\337]$")) width = 2
else if (c ~ ("^[\340-\357]$")) width = 3
else if (c ~ ("^[\360-\367]$")) width = 4
if (i + width - 1 > max_bytes) break
out = out substr(s, i, width)
i += width
}
return out
}
function summary_kv(s, key, part) {
s = normalize(s)
if (!match(s, "(^|[[:space:]])" key "=[^[:space:]]+")) return ""
part = substr(s, RSTART, RLENGTH)
sub(/^[^=]*=/, "", part)
return part
}
function summary_rule(s, first) {
s = trim(normalize(s))
sub(/^\[[[:space:]]*[0-9.]+\][[:space:]]*/, "", s)
if (tolower(s) ~ /^fw4:[[:space:]]*/) return "fw4"
first = s
sub(/[[:space:]:].*/, "", first)
if (first == "" || first ~ /^(IN|OUT|SRC|DST|PROTO|SPT|DPT|LEN|MAC|TYPE|CODE|TTL|TOS|PREC|DF)=/) return ""
if (tolower(first) == "kernel" || tolower(first) == "iptables") return ""
return utf8_prefix(first, 64)
}
function summary_add_count(counts, key) {
if (key != "") counts[key]++
}
function json_escape(s, out, i, c) {
out = ""
for (i = 1; i <= length(s); i++) {
c = substr(s, i, 1)
if (c == "\\") out = out "\\\\"
else if (c == "\"") out = out "\\\""
else if (c == "\n") out = out "\\n"
else if (c == "\r") out = out "\\r"
else if (c == "\t") out = out "\\t"
else if (c < " ") out = out " "
else out = out c
}
return out
}
function summary_top(counts, limit, out, i, k, best, best_n) {
for (k in summary_picked) delete summary_picked[k]
out = "["
for (i = 0; i < limit; i++) {
best = ""; best_n = 0
for (k in counts) {
if (!(k in summary_picked) && (counts[k] > best_n || (counts[k] == best_n && (best == "" || k < best)))) {
best = k; best_n = counts[k]
}
}
if (best == "") break
summary_picked[best] = 1
if (i) out = out ","
out = out "{\"value\":\"" json_escape(best) "\",\"count\":" best_n "}"
}
return out "]"
}
function summary_add(s, action, src, dst, rule) {
summary_count++
action = tolower(detect_action(normalize(s)))
if (action == "drop" || action == "reject" || action == "deny" || action == "block") summary_add_count(summary_drop_counts, action)
src = summary_kv(s, "SRC")
if (src == "") src = summary_kv(s, "DST")
if (src != "") summary_add_count(summary_talker_counts, utf8_prefix(src, 64))
rule = summary_rule(s)
if (rule != "") summary_add_count(summary_rule_counts, rule)
}
function summary_json(out) {
out = "{\"scope\":\"top of shown sample\",\"top_talkers\":" summary_top(summary_talker_counts, 3)
out = out ",\"top_drops\":" summary_top(summary_drop_counts, 3)
out = out ",\"top_rules\":" summary_top(summary_rule_counts, 3) "}"
# LC_ALL=C makes this a byte budget; utf8_prefix keeps field cuts valid.
if (length(out) > 256) return "{\"scope\":\"top of shown sample\",\"truncated\":true}"
return out
}
BEGIN {
if (MODE == "json_reply") {
ORS = ""
printf "{\"log\":["
} else if (MODE != "json") ORS = ""
}
{
if (MODE == "json") {
if (MODE == "json" || MODE == "json_reply") {
msg = json_get_msg($0)
if (is_fw(msg)) {
if (MODE == "json_reply" && SUMMARY != "0") summary_add(msg)
if (out_n++) printf ","
printf "%s", $0
}
@@ -119,6 +209,11 @@ BEGIN { if (MODE != "json") ORS = "" }
buf = (NR == 1) ? $0 : buf "\n" $0
}
END {
if (MODE != "json")
if (MODE == "json_reply") {
printf "],\"messages_received\":%d", NR
if (SUMMARY != "0" && summary_count > 0) printf ",\"summary\":%s", summary_json()
printf "}"
}
else if (MODE != "json")
print is_fw(buf) ? 1 : 0
}
@@ -5,14 +5,18 @@
# source: core/fwlive-log.js CLASSIFY_SPEC
# Shared isFirewallEvent parity logic (shell). Sourced by fwlive-log-filter.sh and tests.
# Sourced library: do not add set -euo here (callers own strict mode, #291 C3).
# One awk process classifies a batch (MODE=json) or one message (default).
# One awk process classifies a batch (MODE=json/json_reply) or one message (default).
# The caller sets FILTER_DIR when this file is sourced. Resolve the asset
# once, not once per classification call.
CLASSIFY_AWK="${FILTER_DIR:-${0%/*}}/fwlive-is-firewall-event.awk"
_fwlive_run_classify() {
awk -v MODE="${1:-msg}" -f "$CLASSIFY_AWK"
if [ "${1:-msg}" = json_reply ]; then
LC_ALL=C awk -v MODE="${1:-msg}" -v SUMMARY="${FWLIVE_SUMMARY:-1}" -f "$CLASSIFY_AWK"
else
awk -v MODE="${1:-msg}" -v SUMMARY="${FWLIVE_SUMMARY:-1}" -f "$CLASSIFY_AWK"
fi
}
is_firewall_event_msg() {
@@ -23,3 +27,7 @@ is_firewall_event_msg() {
_fwlive_filter_json_entries() {
_fwlive_run_classify json
}
_fwlive_filter_json_reply() {
_fwlive_run_classify json_reply
}
@@ -6,14 +6,13 @@
# Log messages are treated as data (jsonfilter + awk stdin); never interpolated
# into shell command strings. Usage: ubus call log read '...' | fwlive-log-filter.sh
#
# Perf: one jsonfilter for @.log[*] plus one awk classify. Process
# count is constant per poll, not O(entries).
# Perf (#219): one jsonfilter for @.log[*] plus one awk classify. The same
# awk pass counts enumerated entries for messages_received; process count is
# constant per poll, not O(entries).
#
# Entry point (pipeline). The classifier sibling is sourced and must not set
# strict mode itself.
# strict mode itself (#291 C3).
set -eu
# shellcheck disable=SC3040
(set -o pipefail) 2>/dev/null && set -o pipefail
if ! command -v jsonfilter >/dev/null 2>&1; then
command -v logger >/dev/null 2>&1 && logger -t fwlive "jsonfilter not found; cannot filter firewall logs"
@@ -26,17 +25,35 @@ FILTER_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$FILTER_DIR/fwlive-is-firewall-event.sh"
# The classifier is a package asset, not generated at request time. Fail
# closed if an incomplete install or a damaged package removed it.
# closed if an incomplete install or a damaged package removed it (#321).
if [ ! -r "$CLASSIFY_AWK" ]; then
printf '%s' '{"log":[],"error":"classifier_missing"}'
exit 1
fi
printf '%s' '{"log":['
# 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.
# jsonfilter miss / empty @.log is not fatal; must still close JSON.
# set -e + pipefail cannot apply to this pipeline.
jsonfilter -e '@.log[*]' 2>/dev/null | _fwlive_filter_json_entries || true
printf '%s' ']}'
# while this script still returns a valid empty result (#234).
# A valid input with no log entries is not fatal; the classifier closes JSON
# and reports messages_received:0 (#220). A jsonfilter failure is different:
# it must remain an error so rpcd does not record a fast healthy sample. Keep
# jsonfilter output in a secure temporary file before classification so a
# partial pipeline cannot produce malformed JSON on failure.
# This script runs from rpcd as root and reopens the path for classification.
# Match _fwlive_mktemp: only use the verified, sticky system /tmp; honoring
# TMPDIR would permit a non-sticky attacker-controlled directory here.
# shellcheck disable=SC3065 # OpenWrt BusyBox test supports -k; match rpcd helper.
if [ ! -d /tmp ] || [ -L /tmp ] || ! [ -k /tmp ]; then
printf '%s' '{"log":[],"error":"filter_tempfile_failed"}'
exit 1
fi
_filter_tmp=$(mktemp /tmp/fwlive-filter.XXXXXX) || {
printf '%s' '{"log":[],"error":"filter_tempfile_failed"}'
exit 1
}
trap 'rm -f "$_filter_tmp"' 0 1 2 3 15
if ! jsonfilter -e '@.log[*]' >"$_filter_tmp" 2>/dev/null; then
printf '%s' '{"log":[],"error":"filter_failed"}'
exit 1
fi
_fwlive_filter_json_reply <"$_filter_tmp"
@@ -27,6 +27,54 @@ NF_LOG_IPV6='/proc/sys/net/netfilter/nf_log/10'
WAN_LOG_LOCK_FILE="${FWLIVE_WAN_LOG_LOCK_FILE:-/etc/fwlive/logging.lock}"
WAN_LOG_BASELINE_FILE="${FWLIVE_WAN_LOG_BASELINE_FILE:-/etc/fwlive/wan-log-baseline}"
weak_device_detected() {
# Path overrides are test hooks; production defaults stay in procfs.
_meminfo=${FWLIVE_MEMINFO_PATH:-/proc/meminfo}
_cpuinfo=${FWLIVE_CPUINFO_PATH:-/proc/cpuinfo}
_mem_total=
if [ -r "$_meminfo" ]; then
while IFS= read -r _line; do
case "$_line" in
MemTotal:*)
_mem_total=${_line#MemTotal:}
while :; do
case "$_mem_total" in
[[:space:]]*) _mem_total=${_mem_total#?} ;;
*) break ;;
esac
done
_mem_total=${_mem_total%%[[:space:]]*}
break
;;
esac
done <"$_meminfo" || :
fi
_cores=0
_cpu_valid=0
if [ -r "$_cpuinfo" ]; then
while IFS= read -r _line; do
case "$_line" in
processor[[:space:]]*:*)
_cores=$((_cores + 1))
_cpu_valid=1
;;
esac
done <"$_cpuinfo" || :
fi
case "$_mem_total" in
''|*[!0-9]*) printf 'false\n'; return 0 ;;
esac
[ "$_cpu_valid" = 1 ] || { printf 'false\n'; return 0; }
# MemTotal is reported in KiB; 256 MiB is 262144 KiB.
if [ "$_mem_total" -lt 262144 ] || [ "$_cores" -le 1 ]; then
printf 'true\n'
else
printf 'false\n'
fi
}
# RFC 8259 string escape. Lives here so prerm can source this file
# standalone. rpcd sources us and must not redefine this.
json_escape() {
@@ -470,12 +518,14 @@ build_logging_status_json() {
if [ -n "$zone" ] && [ "$wan_log" = true ] && [ "$nf4" = true ] && [ "$nf6" = true ]; then
ready=true
fi
weak_device=false
[ "$(weak_device_detected)" = true ] && weak_device=true
zone_json=$(json_null_or_string "$zone")
limit_json=$(json_null_or_string "$limit_val")
printf '{"wan_zone":%s,"wan_log":%s,"wan_log_limit":%s,"nf_log_ipv4":%s,"nf_log_ipv6":%s,"ready":%s,"blockers":%s,"warnings":%s}' \
"$zone_json" "$wan_log" "$limit_json" "$nf4" "$nf6" "$ready" "$blockers" "$warnings"
printf '{"wan_zone":%s,"wan_log":%s,"wan_log_limit":%s,"nf_log_ipv4":%s,"nf_log_ipv6":%s,"ready":%s,"weak_device":%s,"blockers":%s,"warnings":%s}' \
"$zone_json" "$wan_log" "$limit_json" "$nf4" "$nf6" "$ready" "$weak_device" "$blockers" "$warnings"
}
reload_firewall() {
+52 -30
View File
@@ -23,7 +23,7 @@ ADAPTIVE_SH="$LIBEXEC_DIR/fwlive-adaptive-cap.sh"
RESOLVE_MAX=32
# Per-lookup nslookup timeout (integer seconds — BusyBox `timeout` truncates
# fractional values to 0 on OpenWrt 23.05/1.36, so 0.5 would silently
# degrade to no timeout.. The wall-clock budget
# degrade to no timeout; luna fold 2026-08-10). The wall-clock budget
# below is the real whole-call bound; this per-lookup cap only prevents a
# single hung resolver from dominating the budget. LAN/local-resolver
# lookups are single-digit ms, so 1s is ample. Passed as argv to
@@ -42,7 +42,7 @@ NFT_TIMEOUT=5
# CLI-only iptables fixture path for __rulesmap_iptables (no arbitrary paths).
RULESMAP_IPTABLES_FILE='/tmp/rulesmap'
# json_escape lives in fwlive-logging.sh so prerm can source that file alone.
# json_escape lives in fwlive-logging.sh so prerm can source that file alone (#222).
# shellcheck disable=SC1090,SC1091 # LOGGING_SH resolves to sibling ../fwlive-logging.sh
. "$LOGGING_SH"
# shellcheck disable=SC1090,SC1091
@@ -52,10 +52,10 @@ slug_key() {
echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' _' '--'
}
# Cap rules JSON size so a huge ruleset cannot unbound an rpcd reply.
# 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.
# 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}"
@@ -70,7 +70,7 @@ map_add() {
# ("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.
# cosmetics so first-wins prefers authoritative names (#230).
case "$OUT" in
*"\"$esc_key\":"*) return 0 ;;
esac
@@ -92,7 +92,7 @@ map_add() {
uci_rule_names() {
# uci missing / empty firewall config is "no names", not fatal.
# pipefail + set -e cannot apply to this pipeline.
# pipefail + set -e cannot apply to this pipeline (#291 C3).
uci -q show firewall 2>/dev/null | sed -n "s/^firewall\.@rule\[[0-9]*\]\.name='\(.*\)'$/\1/p" || true
}
@@ -142,7 +142,7 @@ run_with_timeout() {
if command -v timeout >/dev/null 2>&1; then
timeout "$secs" "$@" 2>/dev/null
else
# Fail closed: never run unbounded when timeout is absent.
# Fail closed (#229): never run unbounded when timeout is absent.
return 127
fi
}
@@ -170,7 +170,7 @@ map_uci_rule_names() {
# Line-wise (here-doc, not an unquoted for-loop) so a name with
# spaces stays one token. Names that fail is_uci_style_name are
# skipped: parseRuleHint() cannot match them, and fragments of
# "My Rule" must not shadow a real rule named Rule.
# "My Rule" must not shadow a real rule named Rule (#226).
# The while-read stays in the current shell (here-doc, not a
# pipeline) so map_add mutations reach OUT.
while IFS= read -r name || [ -n "$name" ]; do
@@ -187,7 +187,7 @@ EOF
}
# pass=labeled|unlabeled|all — labeled before unlabeled so !fw4: beats
# an earlier cosmetic for the same prefix under first-wins.
# an earlier cosmetic for the same prefix under first-wins (#230).
map_from_nft_stream() {
pass="${1:-all}"
while IFS= read -r line; do
@@ -288,7 +288,7 @@ build_rules_map() {
backend=$(detect_firewall_backend)
# UCI first, then labeled stream prefixes, then unlabeled cosmetics
# First-wins keeps UCI over nft and !fw4: over cosmetic.
# (#230). First-wins keeps UCI over nft and !fw4: over cosmetic.
map_uci_rule_names
case "$backend" in
@@ -435,32 +435,54 @@ fetch_firewall_logs() {
shed_flag=${2:-0}
# Adaptive plan prints: lines shed bucket
start_cs=$(fwlive_adaptive_clock_cs)
# Drop dead oneshot flag — only consumed on logd's stream branch.
# Drop dead oneshot flag — only consumed on logd's stream branch (#306).
if ! raw=$(ubus call log read "{\"lines\":$lines,\"stream\":false}" 2>/dev/null); then
# Do not record ~0 ms as cold: failure is not healthy fast processing
# and must not clear an existing hot/shed cap.
# and must not clear an existing hot/shed cap (#329 Hermes Q1).
out='{"log":[],"error":"log_read_failed"}'
fwlive_adaptive_merge_reply "$out" "$shed_flag" "$lines" 0 0
return 0
fi
# Do not swallow a filter failure into a silent empty table.
# Summary aggregation is part of the adaptive contract. Keep it out of
# adaptive=0 replies, while the classifier still remains the single pass
# over decoded log entries (#306).
if fwlive_adaptive_enabled; then
FWLIVE_SUMMARY=1
else
FWLIVE_SUMMARY=0
fi
export FWLIVE_SUMMARY
# Do not swallow a filter failure into a silent empty table (#220).
# If the filter exits non-zero but still printed JSON, keep it.
# Empty success is not valid poll JSON — the shipped filter always
# prints {"log":[...]}, but a stub/regression must not emit a blank body.
_filter_ok=1
if ! out=$(printf '%s' "$raw" | "$FILTER_SH"); then
_filter_ok=0
if [ -z "$out" ]; then
out='{"log":[],"error":"filter_failed"}'
fi
fi
[ -n "$out" ] || out='{"log":[],"error":"filter_empty"}'
if [ -z "$out" ]; then
_filter_ok=0
out='{"log":[],"error":"filter_empty"}'
fi
# A classifier can return a structured error body while exiting zero (for
# example classifier_missing). Preserve the error for the caller, but do
# not let it overwrite a prior hot state as a healthy fast sample.
case "$out" in
*'"error":'*) _filter_ok=0 ;;
esac
end_cs=$(fwlive_adaptive_clock_cs)
# Uptime wrap / skew: end < start — do not record as cold.
if [ "$end_cs" -ge "$start_cs" ]; then
# Uptime wrap / skew: end < start — do not record as cold (Grok #329 P2).
if [ "$_filter_ok" = 1 ] && [ "$end_cs" -ge "$start_cs" ]; then
_dur=$(((end_cs - start_cs) * 10))
fwlive_adaptive_record "$_dur" "$lines"
fi
# Layer 1: messages_received stays 0 — no post-filter ash count.
# The filter carries messages_received from its jsonfilter enumeration;
# failed/read-invalid paths keep the merge fallback at 0.
_trunc=0
[ "$shed_flag" = 1 ] && _trunc=1
[ "$lines" -lt "$requested" ] && _trunc=1
@@ -502,7 +524,7 @@ resolve_hostname() {
[ -n "$ip" ] || return 1
is_resolvable_address "$ip" || return 1
command -v nslookup >/dev/null 2>&1 || return 1
# timeout/nslookup miss is "no PTR", not a plugin abort.
# timeout/nslookup miss is "no PTR", not a plugin abort (#291 C3).
out=$(run_with_timeout "$RESOLVE_TIMEOUT" nslookup "$ip") || return 1
[ -n "$out" ] || return 1
name=$(parse_nslookup_name "$out") || return 1
@@ -510,7 +532,7 @@ resolve_hostname() {
printf '%s' "$name"
}
# Strict IPv4/IPv6 validation before nslookup: the old char-class
# Strict IPv4/IPv6 validation before nslookup (issue #190): the old char-class
# check admitted hostname-shaped tokens of hex chars + dots/colons
# (e.g. "dead.beef.cafe.baad"), letting the resolver fall back to upstream DNS
# queries chosen by an authenticated session. Validate the real address
@@ -523,7 +545,7 @@ is_resolvable_address() {
# Reject anything outside the address alphabet BEFORE awk, so embedded
# newlines/whitespace (which awk would treat as record separators) can
# never let a valid first record mask trailing garbage that still reaches
# nslookup: nslookup only ever sees a single
# nslookup (#190, CodeRabbit/luna fold): nslookup only ever sees a single
# token. Note the check intentionally allows only hex/colon/dot.
case "$addr" in
*[!0-9a-fA-F:.]*) return 1 ;;
@@ -596,7 +618,7 @@ is_resolvable_address() {
}
resolve_addresses() {
# Missing resolver must not look like "no PTR". Check before
# Missing resolver must not look like "no PTR" (#218). Check before
# starting the budget clock or reading stdin.
if ! command -v nslookup >/dev/null 2>&1; then
command -v logger >/dev/null 2>&1 && logger -t fwlive "nslookup not found; resolve disabled"
@@ -604,14 +626,14 @@ resolve_addresses() {
return 0
fi
# Load shed: previous poll was hot.
# Load shed: previous poll was hot (#306 Layer 1).
if fwlive_adaptive_is_hot; then
printf '{"names":{},"disabled":"load"}'
return 0
fi
# Wall-clock budget clock starts at the TRUE function entry: the
# budget must bound the whole call — including
# Wall-clock budget clock starts at the TRUE function entry (luna
# folds 2026-08-10): the budget must bound the whole call — including
# read_rpc_input (stalled stdin must not escape the budget) and json
# parsing. `date +%s` can jump under NTP sync — accepted (worst case
# the budget over- or under-runs by the jump; worker starvation is
@@ -657,12 +679,12 @@ resolve_addresses() {
# never START a lookup past the budget.
now=$(date +%s)
[ $((now - start)) -ge "$RESOLVE_BUDGET" ] && break
# Missing/empty element: set -u cannot read bare $ip.
# Missing/empty element: set -u cannot read bare $ip (#291 C3).
if ! is_resolvable_address "${ip:-}"; then
continue
fi
# NXDOMAIN / timeout: resolve_hostname returns 1. set -e
# cannot apply to this assignment.
# cannot apply to this assignment (#291 C3).
name=$(resolve_hostname "$ip") || name=
if [ -n "$name" ]; then
map_add "$ip" "$name"
@@ -729,7 +751,7 @@ run_selftest() {
return 1
fi
# hostname-shaped tokens of hex chars + dots/colons passed the
# Issue #190: hostname-shaped tokens of hex chars + dots/colons passed the
# old char-class check and made the resolver fall back to upstream DNS. Each case
# below must REJECT (and would be ACCEPTED by the reverted char-class code).
if is_resolvable_address 'ab.cd.ef.01'; then
@@ -757,7 +779,7 @@ run_selftest() {
echo 'is_resolvable_address: expected accept IPv6-mapped ::ffff:192.0.2.1' >&2
return 1
fi
# Multi-line input must be rejected wholesale: awk
# Multi-line input must be rejected wholesale (CodeRabbit/luna fold): awk
# reads newline-separated records, so a valid first record must not mask
# trailing garbage that would still reach nslookup.
if is_resolvable_address "$(printf '192.0.2.1\nnot-an-address')"; then
@@ -849,7 +871,7 @@ Address: 127.0.0.1:53")
run_logging_selftest || return 1
# Strict-mode smoke: status JSON uses soft-fail helpers
# Strict-mode smoke (#291 C3): status JSON uses soft-fail helpers
# (uci miss, empty WAN zone). Must not abort under dash/ash set -e.
_status=$(build_logging_status_json) || {
echo "build_logging_status_json: aborted under set -e" >&2
@@ -950,7 +972,7 @@ case "${1:-}" in
build_rules_map
;;
poll)
# rpcd may omit argv $3 and pass JSON on stdin.
# rpcd may omit argv $3 and pass JSON on stdin (#291 C3).
poll_logs "${3:-}"
;;
resolve)