mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-31 23:25:13 +08:00
This commit is contained in:
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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user