mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 16:44:23 +08:00
⛅ Sync 2026-08-31 22:31:57
This commit is contained in:
1 parent
6680eb5cb5
commit
057993bb50
41 files changed
+8458
-31
No files matched your search
@@ -0,0 +1,139 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com>
|
||||
#
|
||||
# GENERATED FILE — do not edit. Run: ./scripts/gen-all.sh
|
||||
# source: core/fwlive-log.js CLASSIFY_SPEC
|
||||
# Shared isFirewallEvent parity logic (shell). Sourced by fwlive-log-filter.sh and tests.
|
||||
# One awk process classifies a batch (MODE=json) or one message (default).
|
||||
|
||||
_fwlive_run_classify() {
|
||||
awk -v MODE="${1:-msg}" "$(cat <<'AWK'
|
||||
function normalize(s, keys, n, i, k) {
|
||||
n = split("IN OUT SRC DST PROTO SPT DPT LEN MAC TYPE CODE TTL TOS PREC DF", keys, " ")
|
||||
for (i = 1; i <= n; i++) {
|
||||
k = keys[i]
|
||||
while (match(s, "[^[:space:]]" k "="))
|
||||
s = substr(s, 1, RSTART) " " substr(s, RSTART + 1)
|
||||
}
|
||||
return s
|
||||
}
|
||||
function trim(s) {
|
||||
sub(/^[[:space:]]+/, "", s)
|
||||
sub(/[[:space:]]+$/, "", s)
|
||||
return s
|
||||
}
|
||||
function has_kv(s, key) {
|
||||
return s ~ "(^|[^A-Za-z0-9_])" key "="
|
||||
}
|
||||
function has_hint(s, lc) {
|
||||
lc = tolower(s)
|
||||
return lc ~ "(^|[^a-z0-9_])(fw4|nft|iptables|kernel|firewall)([^a-z0-9_]|$)"
|
||||
}
|
||||
function non_fw_prefix(s, lc) {
|
||||
lc = tolower(s)
|
||||
return lc ~ "^(dnsmasq|procd|ubusd|netifd|odhcpd|logd|dropbear|uhttpd|hostapd|wpad)([^a-z0-9_]|$)"
|
||||
}
|
||||
function detect_action(s, words, n, i, w, wl, lc, start, pos, before, afterc, best, bestpos) {
|
||||
n = split("ACCEPT ALLOW PASS DROP REJECT DENY BLOCK", words, " ")
|
||||
lc = tolower(s)
|
||||
best = ""
|
||||
bestpos = length(s) + 1
|
||||
for (i = 1; i <= n; i++) {
|
||||
w = words[i]
|
||||
wl = tolower(w)
|
||||
start = 1
|
||||
while (start <= length(lc) && match(substr(lc, start), wl)) {
|
||||
pos = start + RSTART - 1
|
||||
before = (pos == 1) ? " " : substr(lc, pos - 1, 1)
|
||||
afterc = substr(lc, pos + length(wl), 1)
|
||||
if (before !~ /[a-z0-9_]/ && (afterc == "" || afterc !~ /[a-z0-9_]/)) {
|
||||
if (pos < bestpos) { bestpos = pos; best = w }
|
||||
break
|
||||
}
|
||||
start = pos + 1
|
||||
}
|
||||
}
|
||||
return best == "" ? "UNKNOWN" : best
|
||||
}
|
||||
function json_unhex4(h, n, i, c, v) {
|
||||
n = 0
|
||||
h = tolower(h)
|
||||
for (i = 1; i <= 4; i++) {
|
||||
c = substr(h, i, 1)
|
||||
v = index("0123456789abcdef", c)
|
||||
if (v == 0) return -1
|
||||
n = n * 16 + v - 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
function json_get_msg(obj, s, i, c, esc, out, hex, n) {
|
||||
if (!match(obj, /"msg"[[:space:]]*:[[:space:]]*"/)) return ""
|
||||
s = substr(obj, RSTART + RLENGTH)
|
||||
out = ""
|
||||
esc = 0
|
||||
for (i = 1; i <= length(s); i++) {
|
||||
c = substr(s, i, 1)
|
||||
if (esc) {
|
||||
if (c == "n") out = out "\n"
|
||||
else if (c == "t") out = out "\t"
|
||||
else if (c == "r") out = out "\r"
|
||||
else if (c == "b") out = out "\b"
|
||||
else if (c == "f") out = out "\f"
|
||||
else if (c == "u") {
|
||||
hex = substr(s, i + 1, 4)
|
||||
n = (length(hex) == 4) ? json_unhex4(hex) : -1
|
||||
if (n >= 1 && n <= 255) out = out sprintf("%c", n)
|
||||
else if (n < 0) out = out "u"
|
||||
if (n >= 0) i += 4
|
||||
} else out = out c
|
||||
esc = 0
|
||||
} else if (c == "\\") {
|
||||
esc = 1
|
||||
} else if (c == "\"") {
|
||||
return out
|
||||
} else {
|
||||
out = out c
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
function is_fw(s, action) {
|
||||
s = trim(normalize(s))
|
||||
if (s == "") return 0
|
||||
if (non_fw_prefix(s)) return 0
|
||||
action = detect_action(s)
|
||||
if (has_kv(s, "SRC") && has_kv(s, "DST")) return 1
|
||||
if ((has_kv(s, "IN") || has_kv(s, "OUT")) && (has_kv(s, "SRC") || has_kv(s, "DST") || has_kv(s, "PROTO") || has_kv(s, "SPT") || has_kv(s, "DPT"))) return 1
|
||||
if (action != "UNKNOWN" && (has_kv(s, "IN") || has_kv(s, "OUT") || has_kv(s, "PROTO") || has_kv(s, "SRC") || has_kv(s, "DST"))) return 1
|
||||
if (has_hint(s) && action != "UNKNOWN") return 1
|
||||
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 = "" }
|
||||
{
|
||||
if (MODE == "json") {
|
||||
msg = json_get_msg($0)
|
||||
if (is_fw(msg)) {
|
||||
if (out_n++) printf ","
|
||||
printf "%s", $0
|
||||
}
|
||||
next
|
||||
}
|
||||
buf = (NR == 1) ? $0 : buf "\n" $0
|
||||
}
|
||||
END {
|
||||
if (MODE != "json")
|
||||
print is_fw(buf) ? 1 : 0
|
||||
}
|
||||
AWK
|
||||
)"
|
||||
}
|
||||
|
||||
is_firewall_event_msg() {
|
||||
_r=$(printf '%s' "$1" | _fwlive_run_classify msg)
|
||||
[ "$_r" = 1 ]
|
||||
}
|
||||
|
||||
_fwlive_filter_json_entries() {
|
||||
_fwlive_run_classify json
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com>
|
||||
#
|
||||
# Filter log.read JSON to firewall-only entries (isFirewallEvent parity).
|
||||
# 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 (#219): one jsonfilter for @.log[*] plus one awk classify. Process
|
||||
# count is constant per poll, not O(entries).
|
||||
|
||||
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"
|
||||
printf '%s' '{"log":[],"error":"jsonfilter_missing"}'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
FILTER_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck disable=SC1091 # classifier is a sibling file next to this script
|
||||
. "$FILTER_DIR/fwlive-is-firewall-event.sh"
|
||||
|
||||
input="$(cat)"
|
||||
[ -n "$input" ] || input='{"log":[]}'
|
||||
|
||||
printf '%s' '{"log":['
|
||||
jsonfilter -s "$input" -e '@.log[*]' 2>/dev/null | _fwlive_filter_json_entries
|
||||
printf '%s' ']}'
|
||||
+712
@@ -0,0 +1,712 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com>
|
||||
#
|
||||
# WAN zone logging helpers for ubus fwlive (logging_status / enable / disable).
|
||||
|
||||
NF_LOG_IPV4='/proc/sys/net/netfilter/nf_log/2'
|
||||
NF_LOG_IPV6='/proc/sys/net/netfilter/nf_log/10'
|
||||
|
||||
# Serialize the WAN logging read->compute->set->commit window across
|
||||
# concurrent ubus write-ACL callers (#151): each toggle re-reads the current
|
||||
# firewall.<zone>.log bit, computes a target, then uci set + uci commit. Two
|
||||
# concurrent callers could otherwise interleave and last-commit-wins.
|
||||
#
|
||||
# BusyBox flock constraint: it has NO -w timeout. A stuck lock holder blocks
|
||||
# any waiter until the holder exits or the device reboots. The critical
|
||||
# section MUST stay SHORT (a few uci commands). Do NOT hold the lock across
|
||||
# the /etc/init.d/firewall reload (can take seconds); the lock is released
|
||||
# before reload, and reload-failure rollback is a best-effort UCI write
|
||||
# outside the lock.
|
||||
# Overridable for tests/containers (default is root-only /etc/fwlive).
|
||||
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}"
|
||||
|
||||
# RFC 8259 string escape. Lives here so prerm can source this file
|
||||
# standalone (#222). rpcd sources us and must not redefine this.
|
||||
json_escape() {
|
||||
# Escape for JSON string content per RFC 8259 (including remaining C0 controls).
|
||||
# Slurp stdin as one string. Default awk RS is newline — RS="" is
|
||||
# paragraph mode and drops blank-line separators (a\n\nb → ab). A
|
||||
# sentinel is appended so a leading, trailing, or lone newline is kept.
|
||||
{ cat; printf '%s' '_'; } | awk '
|
||||
BEGIN {
|
||||
ORS = ""
|
||||
for (n = 1; n < 128; n++)
|
||||
ord[sprintf("%c", n)] = n
|
||||
}
|
||||
{
|
||||
if (NR > 1) buf = buf "\n"
|
||||
buf = buf $0
|
||||
}
|
||||
END {
|
||||
if (length(buf) > 0)
|
||||
buf = substr(buf, 1, length(buf) - 1)
|
||||
for (i = 1; i <= length(buf); i++) {
|
||||
c = substr(buf, i, 1)
|
||||
if (c == "\\") printf "\\\\"
|
||||
else if (c == "\"") printf "\\\""
|
||||
else if (c == "\t") printf "\\t"
|
||||
else if (c == "\r") printf "\\r"
|
||||
else if (c == "\n") printf "\\n"
|
||||
else {
|
||||
o = ord[c] + 0
|
||||
if (o > 0 && o < 32)
|
||||
printf "\\u%04x", o
|
||||
else if (o == 127)
|
||||
printf "\\u007f"
|
||||
else
|
||||
printf "%s", c
|
||||
}
|
||||
}
|
||||
}'
|
||||
}
|
||||
|
||||
# Production lock dir must be root-owned with no group/other write (#204).
|
||||
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 ]
|
||||
}
|
||||
|
||||
# Acquire the exclusive logging lock on fd 9. Blocks until free; fails closed
|
||||
# (return 1) only if the lock file cannot be opened or flock is unavailable.
|
||||
# Create/tighten the lock to 0600 so unprivileged UIDs cannot take LOCK_EX on
|
||||
# a world-readable fd (issue #167 / flock(2) allows exclusive locks on O_RDONLY).
|
||||
acquire_wan_log_lock() {
|
||||
# Fail closed on symlinks: chmod/chown/exec O_TRUNC follow the target as root
|
||||
# (#204). Default lock lives under /etc/fwlive (root-only), not world-writable
|
||||
# /var/lock. Re-check after create/tighten (TOCTOU).
|
||||
lock_dir="$(dirname "$WAN_LOG_LOCK_FILE")"
|
||||
[ -L "$lock_dir" ] && return 1
|
||||
[ -L "$WAN_LOG_LOCK_FILE" ] && return 1
|
||||
( umask 077; mkdir -p "$lock_dir" ) 2>/dev/null || return 1
|
||||
[ -L "$lock_dir" ] && return 1
|
||||
if [ -z "${FWLIVE_WAN_LOG_LOCK_FILE:-}" ]; then
|
||||
wan_log_lock_dir_safe "$lock_dir" || return 1
|
||||
fi
|
||||
( umask 077; : >> "$WAN_LOG_LOCK_FILE" ) 2>/dev/null || return 1
|
||||
[ -L "$WAN_LOG_LOCK_FILE" ] && return 1
|
||||
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
|
||||
flock 9 2>/dev/null || {
|
||||
exec 9>&-
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
# Release the logging lock (explicit unlock, then close fd 9).
|
||||
release_wan_log_lock() {
|
||||
flock -u 9 2>/dev/null || true
|
||||
exec 9>&-
|
||||
}
|
||||
|
||||
find_wan_zone_section() {
|
||||
# Match anonymous (@zone[N]) and named (e.g. wan) sections whose name option
|
||||
# is 'wan'. Prefer the first section whose type is zone (issue #168); skip
|
||||
# non-zone sections that happen to share name='wan'.
|
||||
_zones=$(uci -q show firewall 2>/dev/null \
|
||||
| sed -n "s/^firewall\.\([^.]*\)\.name='wan'$/\1/p")
|
||||
for zone in $_zones; do
|
||||
[ -n "$zone" ] || continue
|
||||
[ "$(uci -q get "firewall.${zone}" 2>/dev/null)" = "zone" ] || continue
|
||||
printf '%s' "$zone"
|
||||
return 0
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
firewall_changes_pending() {
|
||||
pending="$(uci -q changes firewall 2>/dev/null)"
|
||||
[ -n "$pending" ]
|
||||
}
|
||||
|
||||
wan_zone_log_value() {
|
||||
zone="$1"
|
||||
[ -n "$zone" ] || return 1
|
||||
uci -q get "firewall.${zone}.log" 2>/dev/null
|
||||
}
|
||||
|
||||
# Match only firewall.<zone>.log deltas in `uci changes` — not log_limit.
|
||||
wan_log_foreign_staged_lines() {
|
||||
_zone="$1"
|
||||
_staged="$2"
|
||||
_ours_prefix="firewall.${_zone}.log="
|
||||
_del="-firewall.${_zone}.log"
|
||||
_del_sp="- firewall.${_zone}.log"
|
||||
printf '%s\n' "$_staged" | awk -v p="$_ours_prefix" -v d="$_del" -v ds="$_del_sp" '
|
||||
NF == 0 { next }
|
||||
index($0, p) == 1 { next }
|
||||
$0 == d || $0 == ds { next }
|
||||
{ print }
|
||||
'
|
||||
}
|
||||
|
||||
wan_log_count_our_staged_lines() {
|
||||
_zone="$1"
|
||||
_staged="$2"
|
||||
_ours_prefix="firewall.${_zone}.log="
|
||||
_del="-firewall.${_zone}.log"
|
||||
_del_sp="- firewall.${_zone}.log"
|
||||
printf '%s\n' "$_staged" | awk -v p="$_ours_prefix" -v d="$_del" -v ds="$_del_sp" '
|
||||
NF == 0 { next }
|
||||
index($0, p) == 1 { c++; next }
|
||||
$0 == d || $0 == ds { c++; next }
|
||||
END { print c+0 }
|
||||
'
|
||||
}
|
||||
|
||||
wan_log_baseline_path() {
|
||||
printf '%s' "$WAN_LOG_BASELINE_FILE"
|
||||
}
|
||||
|
||||
# Snapshot firewall.<wan>.log once before the first enable changes UCI.
|
||||
# Empty file means the option was unset. Skipped when baseline already exists.
|
||||
maybe_snapshot_wan_log_baseline() {
|
||||
zone="$1"
|
||||
path="$(wan_log_baseline_path)"
|
||||
[ -n "$zone" ] || return 1
|
||||
[ -f "$path" ] && return 0
|
||||
mkdir -p "$(dirname "$path")" 2>/dev/null || return 1
|
||||
current=$(wan_zone_log_value "$zone")
|
||||
printf '%s' "$current" >"$path" 2>/dev/null || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
# Restore WAN zone log from the install-time baseline (package prerm).
|
||||
# No-op when baseline is missing. Returns 1 on failure; baseline file is
|
||||
# kept until restore commits successfully.
|
||||
#
|
||||
# Hold the logging lock across the current-value read, equality cleanup,
|
||||
# commit, and baseline unlink — otherwise a concurrent enable can snapshot
|
||||
# the old baseline (or race the unlink) and lose the only restore value.
|
||||
restore_wan_log_baseline() {
|
||||
path="$(wan_log_baseline_path)"
|
||||
[ -f "$path" ] || return 0
|
||||
baseline=$(cat "$path" 2>/dev/null)
|
||||
zone=$(find_wan_zone_section)
|
||||
if [ -z "$zone" ]; then
|
||||
logger -t fwlive "WAN log baseline restore skipped: no WAN zone" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
if ! acquire_wan_log_lock; then
|
||||
logger -t fwlive "WAN log baseline restore skipped: lock unavailable" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
current=$(wan_zone_log_value "$zone")
|
||||
if [ "${current:-}" = "${baseline:-}" ]; then
|
||||
rm -f "$path"
|
||||
release_wan_log_lock
|
||||
return 0
|
||||
fi
|
||||
zone_json=$(json_null_or_string "$zone")
|
||||
if firewall_changes_pending; then
|
||||
release_wan_log_lock
|
||||
logger -t fwlive "WAN log baseline restore skipped: firewall changes pending" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
if ! commit_wan_log_change "$zone" "$zone_json" "$baseline"; then
|
||||
release_wan_log_lock
|
||||
logger -t fwlive "WAN log baseline restore: commit gate failed" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
rm -f "$path"
|
||||
release_wan_log_lock
|
||||
reload_firewall || logger -t fwlive "WAN log baseline restored; firewall reload failed" 2>/dev/null || true
|
||||
return 0
|
||||
}
|
||||
|
||||
wan_filter_log_enabled() {
|
||||
log_val="$1"
|
||||
[ -n "$log_val" ] || return 1
|
||||
case "$log_val" in
|
||||
*[!0-9]*) return 1 ;;
|
||||
esac
|
||||
[ $((log_val & 1)) -ne 0 ]
|
||||
}
|
||||
|
||||
wan_filter_log_target_value() {
|
||||
current="$1"
|
||||
if wan_filter_log_enabled "$current"; then
|
||||
printf '%s' "$current"
|
||||
return 0
|
||||
fi
|
||||
case "$current" in
|
||||
''|*[!0-9]*)
|
||||
printf '1'
|
||||
;;
|
||||
*)
|
||||
printf '%d' $((current | 1))
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Clear filter-log bit 0 only. Prints remaining value, or empty when the option
|
||||
# should be deleted (no bits left / non-numeric / already empty).
|
||||
wan_filter_log_clear_value() {
|
||||
current="$1"
|
||||
case "$current" in
|
||||
''|*[!0-9]*)
|
||||
printf ''
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
cleared=$((current & ~1))
|
||||
if [ "$cleared" -eq 0 ]; then
|
||||
printf ''
|
||||
else
|
||||
printf '%d' "$cleared"
|
||||
fi
|
||||
}
|
||||
|
||||
read_nf_log_backend() {
|
||||
path="$1"
|
||||
[ -f "$path" ] || return 1
|
||||
val=$(cat "$path" 2>/dev/null)
|
||||
[ -n "$val" ] && [ "$val" != 'none' ]
|
||||
}
|
||||
|
||||
check_nf_log_ipv4() {
|
||||
read_nf_log_backend "$NF_LOG_IPV4"
|
||||
}
|
||||
|
||||
check_nf_log_ipv6() {
|
||||
read_nf_log_backend "$NF_LOG_IPV6"
|
||||
}
|
||||
|
||||
logging_blockers_append() {
|
||||
blocker="$1"
|
||||
[ -n "$blocker" ] || return 0
|
||||
esc=$(printf '%s' "$blocker" | json_escape)
|
||||
if [ -n "$LOGGING_BLOCKERS" ]; then
|
||||
LOGGING_BLOCKERS="${LOGGING_BLOCKERS},"
|
||||
fi
|
||||
LOGGING_BLOCKERS="${LOGGING_BLOCKERS}\"${esc}\""
|
||||
}
|
||||
|
||||
collect_logging_blockers() {
|
||||
zone="$1"
|
||||
LOGGING_BLOCKERS=''
|
||||
|
||||
[ -n "$zone" ] || logging_blockers_append 'no_wan_zone'
|
||||
check_nf_log_ipv4 || logging_blockers_append 'nf_log_ipv4_missing'
|
||||
check_nf_log_ipv6 || logging_blockers_append 'nf_log_ipv6_missing'
|
||||
|
||||
[ -n "$LOGGING_BLOCKERS" ] || return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
json_null_or_string() {
|
||||
val="$1"
|
||||
if [ -z "$val" ]; then
|
||||
printf 'null'
|
||||
else
|
||||
esc=$(printf '%s' "$val" | json_escape)
|
||||
printf '"%s"' "$esc"
|
||||
fi
|
||||
}
|
||||
|
||||
build_logging_status_json() {
|
||||
zone=$(find_wan_zone_section)
|
||||
log_val=$(wan_zone_log_value "$zone")
|
||||
limit_val=$( [ -n "$zone" ] && uci -q get "firewall.${zone}.log_limit" 2>/dev/null )
|
||||
wan_log=false
|
||||
if wan_filter_log_enabled "$log_val"; then
|
||||
wan_log=true
|
||||
fi
|
||||
|
||||
nf4=false
|
||||
check_nf_log_ipv4 && nf4=true
|
||||
nf6=false
|
||||
check_nf_log_ipv6 && nf6=true
|
||||
|
||||
collect_logging_blockers "$zone"
|
||||
blockers="[${LOGGING_BLOCKERS:-}]"
|
||||
|
||||
ready=false
|
||||
if [ -n "$zone" ] && [ "$wan_log" = true ] && [ "$nf4" = true ] && [ "$nf6" = true ]; then
|
||||
ready=true
|
||||
fi
|
||||
|
||||
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}' \
|
||||
"$zone_json" "$wan_log" "$limit_json" "$nf4" "$nf6" "$ready" "$blockers"
|
||||
}
|
||||
|
||||
reload_firewall() {
|
||||
if [ -x /etc/init.d/firewall ]; then
|
||||
/etc/init.d/firewall reload >/dev/null 2>&1
|
||||
return $?
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Best-effort UCI rollback when firewall reload fails after commit.
|
||||
restore_wan_zone_log() {
|
||||
zone="$1"
|
||||
previous="$2"
|
||||
[ -n "$zone" ] || return 1
|
||||
# Refuse to publish unrelated staged firewall deltas (issue #168).
|
||||
if firewall_changes_pending; then
|
||||
logger -t fwlive "WAN log rollback skipped: firewall changes pending" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
if [ -z "$previous" ]; then
|
||||
uci -q delete "firewall.${zone}.log" 2>/dev/null || true
|
||||
else
|
||||
uci -q set "firewall.${zone}.log=${previous}" 2>/dev/null || true
|
||||
fi
|
||||
uci commit firewall 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Stage + commit the WAN log bit. Caller MUST hold the logging lock; this
|
||||
# closes the read->compute->set->commit window so a concurrent toggle cannot
|
||||
# commit between our read and our write (no lost update / no stale overwrite).
|
||||
#
|
||||
# TOCTOU hardening (#191): UCI staging is global per config file, so a
|
||||
# non-cooperating writer (another admin's `uci set`, the LuCI firewall page)
|
||||
# can stage a delta AFTER the toggle's early firewall_changes_pending check.
|
||||
# Staging and committing therefore live INSIDE this function — the only path
|
||||
# to `uci commit firewall` in the toggle flow, so callers cannot bypass it —
|
||||
# gated by:
|
||||
# 1. a pending re-check BEFORE our own delta exists in staging (foreign-only);
|
||||
# 2. a post-stage re-check AFTER our set/delete: if anything besides our
|
||||
# log option is staged, undo our staging (restore the pre-stage committed
|
||||
# value) and abort without commit — foreign staging stays intact.
|
||||
# Residual window: a writer that stages between the post-stage check and
|
||||
# `uci commit` can still ride along; post-commit verification detects a
|
||||
# mismatched log bit. Same-option races (another writer also staging
|
||||
# firewall.<wan>.log) are not distinguishable in the changes list.
|
||||
# The caller-reported error is firewall_changes_pending: the LuCI view already
|
||||
# maps it to the accurate "another change is staged" notice.
|
||||
#
|
||||
# target: value to stage; EMPTY means delete the option (bit fully cleared).
|
||||
# Prints the failure JSON and returns 1 on abort or failure.
|
||||
verify_wan_log_commit() {
|
||||
zone="$1"
|
||||
expected="$2"
|
||||
readback="$(uci -q get "firewall.${zone}.log" 2>/dev/null || true)"
|
||||
if [ -n "$expected" ]; then
|
||||
[ "$readback" = "$expected" ] && return 0
|
||||
return 1
|
||||
fi
|
||||
[ -z "$readback" ] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
commit_wan_log_change() {
|
||||
zone="$1"
|
||||
zone_json="$2"
|
||||
target="$3"
|
||||
|
||||
# Last-moment guard (#191): runs before OUR delta is staged, so a
|
||||
# non-empty changes list here can only be a foreign writer's race.
|
||||
if firewall_changes_pending; then
|
||||
logger -t fwlive "WAN log toggle aborted at commit gate: firewall changes staged by another writer" 2>/dev/null || true
|
||||
printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"firewall_changes_pending"}' "$zone_json"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Staging is empty here — capture the committed value so a post-stage
|
||||
# foreign race can undo our delta without `uci revert firewall`.
|
||||
previous=$(wan_zone_log_value "$zone")
|
||||
|
||||
if [ -n "$target" ]; then
|
||||
if ! uci set "firewall.${zone}.log=${target}"; then
|
||||
printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"uci_set_failed"}' "$zone_json"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
if ! uci delete "firewall.${zone}.log"; then
|
||||
printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"uci_delete_failed"}' "$zone_json"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Post-stage guard (#191): anything besides our log option is foreign.
|
||||
# Undo our staging only (set previous / delete to match committed); leave
|
||||
# foreign deltas untouched and abort without commit.
|
||||
_staged=$(uci -q changes firewall 2>/dev/null || true)
|
||||
_foreign=$(wan_log_foreign_staged_lines "$zone" "$_staged")
|
||||
if [ -n "$_foreign" ]; then
|
||||
if [ -z "$previous" ]; then
|
||||
uci delete "firewall.${zone}.log" 2>/dev/null || true
|
||||
else
|
||||
uci set "firewall.${zone}.log=${previous}" 2>/dev/null || true
|
||||
fi
|
||||
logger -t fwlive "WAN log toggle aborted after stage: firewall changes staged by another writer" 2>/dev/null || true
|
||||
printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"firewall_changes_pending"}' "$zone_json"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! uci commit firewall; then
|
||||
# Drop our staged log delta so a later toggle is not stuck on
|
||||
# firewall_changes_pending from this package's own orphaned write —
|
||||
# but ONLY when nothing foreign is staged: `uci revert firewall` is
|
||||
# config-wide (uci has no option-level revert), and reverting would
|
||||
# clobber a concurrent writer's uncommitted delta (CodeRabbit/luna
|
||||
# fold, #191). With foreign staging present, leave it and warn.
|
||||
_staged=$(uci -q changes firewall 2>/dev/null || true)
|
||||
_total=$(printf '%s\n' "$_staged" | grep -c . 2>/dev/null || true)
|
||||
_ours=$(wan_log_count_our_staged_lines "$zone" "$_staged")
|
||||
if [ "${_total:-0}" -gt 0 ] && [ "${_total:-0}" -eq "${_ours:-0}" ]; then
|
||||
uci -q revert firewall 2>/dev/null || true
|
||||
else
|
||||
logger -t fwlive "WAN log commit failed with foreign changes staged — not reverting (uci_commit_failed)" 2>/dev/null || true
|
||||
fi
|
||||
printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"uci_commit_failed"}' "$zone_json"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Post-commit verification (#191): confirm the committed config really
|
||||
# carries what we wrote (empty target = option must now be gone/empty).
|
||||
# A mismatch means another writer overtook the commit: warn loudly but do
|
||||
# NOT blind-revert (that would destroy unrelated committed data); the
|
||||
# reload-failure rollback path still guards the ordinary failure case.
|
||||
if ! verify_wan_log_commit "$zone" "$target"; then
|
||||
logger -t fwlive "WAN log post-commit verify FAILED: wrote '${target:-<deleted>}', read back differs" 2>/dev/null || true
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Firewall reload + best-effort UCI rollback on reload failure. The reload
|
||||
# itself runs WITHOUT the logging lock (it can take seconds and a held lock
|
||||
# would block a concurrent toggle until the holder exits — BusyBox flock has
|
||||
# no -w timeout).
|
||||
#
|
||||
# The ROLLBACK re-acquires the lock (luna fold 2026-08-10): read->compare->
|
||||
# restore is only atomic when no other writer can commit between the read and
|
||||
# the restore. All writers hold the same flock, so re-acquiring it makes the
|
||||
# decision-and-restore a serialized unit. The lock is held only for the few
|
||||
# uci commands of the restore (short critical section), never across the
|
||||
# reload. If the lock cannot be re-acquired, skip the rollback (report the
|
||||
# reload failure; the next toggle self-corrects).
|
||||
reload_and_report_wan_log() {
|
||||
zone="$1"
|
||||
previous="$2"
|
||||
committed="$3"
|
||||
fail_msg="$4"
|
||||
success_msg="$5"
|
||||
zone_json="$6"
|
||||
|
||||
if ! reload_firewall; then
|
||||
# Re-acquire the logging lock so the rollback decision is atomic
|
||||
# against concurrent toggles (no check-then-restore race).
|
||||
if acquire_wan_log_lock; then
|
||||
now="$(wan_zone_log_value "$zone")"
|
||||
if [ "$now" = "$committed" ]; then
|
||||
# Current value is still what THIS caller committed — restore
|
||||
# the pre-commit value. (If a concurrent toggle committed the
|
||||
# same value, the toggle is idempotent: the state intent is
|
||||
# identical, so restoring previous is the correct rollback.)
|
||||
if restore_wan_zone_log "$zone" "$previous"; then
|
||||
logger -t fwlive "$fail_msg" 2>/dev/null || true
|
||||
else
|
||||
logger -t fwlive "Firewall reload failed; WAN log rollback skipped (pending changes or restore failed)" 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
# A concurrent toggle changed the value after our commit; do
|
||||
# not clobber it. Log the divergence and leave the newer value.
|
||||
logger -t fwlive "Firewall reload failed; WAN log changed concurrently — rollback skipped" 2>/dev/null || true
|
||||
fi
|
||||
release_wan_log_lock
|
||||
else
|
||||
# Cannot re-acquire the lock: skip the rollback, report the
|
||||
# reload failure. The next toggle self-corrects the state.
|
||||
logger -t fwlive "Firewall reload failed; rollback lock unavailable — skipped" 2>/dev/null || true
|
||||
fi
|
||||
printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"firewall_reload_failed"}' "$zone_json"
|
||||
return 0
|
||||
fi
|
||||
logger -t fwlive "$success_msg" 2>/dev/null || true
|
||||
printf '{"ok":true,"changed":true,"wan_zone":%s}' "$zone_json"
|
||||
return 0
|
||||
}
|
||||
|
||||
enable_wan_logging() {
|
||||
zone=$(find_wan_zone_section)
|
||||
if [ -z "$zone" ]; then
|
||||
printf '{"ok":false,"changed":false,"wan_zone":null,"error":"no_wan_zone"}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
zone_json=$(json_null_or_string "$zone")
|
||||
|
||||
if ! check_nf_log_ipv4 || ! check_nf_log_ipv6; then
|
||||
printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"nf_log_missing"}' "$zone_json"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Locked critical section: read->compute->stage->commit for firewall.<zone>.log.
|
||||
# The log bit is re-read AFTER acquiring the lock so the target is computed
|
||||
# from the latest committed value; a concurrent toggle cannot interleave.
|
||||
# Staging + commit live inside commit_wan_log_change behind its last-moment
|
||||
# firewall_changes_pending re-check (#191): a foreign writer racing between
|
||||
# the early check above and the commit aborts the toggle instead of having
|
||||
# its half-finished delta published with our log bit.
|
||||
if ! acquire_wan_log_lock; then
|
||||
printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"lock_failed"}' "$zone_json"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if firewall_changes_pending; then
|
||||
release_wan_log_lock
|
||||
printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"firewall_changes_pending"}' "$zone_json"
|
||||
return 0
|
||||
fi
|
||||
|
||||
current=$(wan_zone_log_value "$zone")
|
||||
if wan_filter_log_enabled "$current"; then
|
||||
release_wan_log_lock
|
||||
printf '{"ok":true,"changed":false,"wan_zone":%s}' "$zone_json"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! maybe_snapshot_wan_log_baseline "$zone"; then
|
||||
release_wan_log_lock
|
||||
printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"baseline_snapshot_failed"}' "$zone_json"
|
||||
return 0
|
||||
fi
|
||||
|
||||
target=$(wan_filter_log_target_value "$current")
|
||||
if ! commit_wan_log_change "$zone" "$zone_json" "$target"; then
|
||||
release_wan_log_lock
|
||||
return 0
|
||||
fi
|
||||
release_wan_log_lock
|
||||
|
||||
reload_and_report_wan_log "$zone" "$current" "$target" \
|
||||
'Firewall reload failed after enable; reverted UCI WAN log' \
|
||||
'WAN zone logging enabled' \
|
||||
"$zone_json"
|
||||
return 0
|
||||
}
|
||||
|
||||
disable_wan_logging() {
|
||||
zone=$(find_wan_zone_section)
|
||||
if [ -z "$zone" ]; then
|
||||
printf '{"ok":false,"changed":false,"wan_zone":null,"error":"no_wan_zone"}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
zone_json=$(json_null_or_string "$zone")
|
||||
|
||||
# Locked critical section: read->compute->stage->commit for firewall.<zone>.log.
|
||||
# The log bit is re-read AFTER acquiring the lock so the target is computed
|
||||
# from the latest committed value; a concurrent toggle cannot interleave.
|
||||
# Staging + commit live inside commit_wan_log_change behind its last-moment
|
||||
# firewall_changes_pending re-check (#191): a foreign writer racing between
|
||||
# the early check above and the commit aborts the toggle instead of having
|
||||
# its half-finished delta published with our log bit.
|
||||
if ! acquire_wan_log_lock; then
|
||||
printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"lock_failed"}' "$zone_json"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if firewall_changes_pending; then
|
||||
release_wan_log_lock
|
||||
printf '{"ok":false,"changed":false,"wan_zone":%s,"error":"firewall_changes_pending"}' "$zone_json"
|
||||
return 0
|
||||
fi
|
||||
|
||||
current=$(wan_zone_log_value "$zone")
|
||||
if [ -z "$current" ] || ! wan_filter_log_enabled "$current"; then
|
||||
release_wan_log_lock
|
||||
printf '{"ok":true,"changed":false,"wan_zone":%s}' "$zone_json"
|
||||
return 0
|
||||
fi
|
||||
|
||||
target=$(wan_filter_log_clear_value "$current")
|
||||
if ! commit_wan_log_change "$zone" "$zone_json" "$target"; then
|
||||
release_wan_log_lock
|
||||
return 0
|
||||
fi
|
||||
release_wan_log_lock
|
||||
|
||||
reload_and_report_wan_log "$zone" "$current" "$target" \
|
||||
'Firewall reload failed after disable; reverted UCI WAN log' \
|
||||
'WAN zone logging disabled' \
|
||||
"$zone_json"
|
||||
return 0
|
||||
}
|
||||
|
||||
run_logging_selftest() {
|
||||
if wan_filter_log_enabled ''; then
|
||||
echo 'wan_filter_log_enabled empty: expected false' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! wan_filter_log_enabled '1'; then
|
||||
echo 'wan_filter_log_enabled 1: expected true' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! wan_filter_log_enabled '3'; then
|
||||
echo 'wan_filter_log_enabled 3: expected true' >&2
|
||||
return 1
|
||||
fi
|
||||
if wan_filter_log_enabled '2'; then
|
||||
echo 'wan_filter_log_enabled 2: expected false' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
got=$(wan_filter_log_target_value '')
|
||||
if [ "$got" != '1' ]; then
|
||||
echo "wan_filter_log_target_value empty: expected 1 got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
got=$(wan_filter_log_target_value '2')
|
||||
if [ "$got" != '3' ]; then
|
||||
echo "wan_filter_log_target_value 2: expected 3 got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
got=$(wan_filter_log_target_value '1')
|
||||
if [ "$got" != '1' ]; then
|
||||
echo "wan_filter_log_target_value 1: expected 1 got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Disable clears bit 0 only: log=3 -> 2; log=1 -> delete (empty).
|
||||
got=$(wan_filter_log_clear_value '3')
|
||||
if [ "$got" != '2' ]; then
|
||||
echo "wan_filter_log_clear_value 3: expected 2 got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
got=$(wan_filter_log_clear_value '1')
|
||||
if [ -n "$got" ]; then
|
||||
echo "wan_filter_log_clear_value 1: expected empty got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
got=$(wan_filter_log_clear_value '2')
|
||||
if [ "$got" != '2' ]; then
|
||||
echo "wan_filter_log_clear_value 2: expected 2 got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Enable/disable parity around multi-bit values.
|
||||
got=$(wan_filter_log_target_value '2')
|
||||
if [ "$got" != '3' ]; then
|
||||
echo "enable from 2: expected 3 got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
got=$(wan_filter_log_clear_value '3')
|
||||
if [ "$got" != '2' ]; then
|
||||
echo "disable from 3: expected 2 got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
Executable
+849
@@ -0,0 +1,849 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Copyright 2025-2026 Lucas Albers <lucas.b.albers@gmail.com>
|
||||
#
|
||||
# rpcd plugin: ubus object "fwlive"
|
||||
# rules — rule hint map (no args)
|
||||
# poll — firewall-only log tail; pass line count as addresses[0] (rpcd array schema)
|
||||
# resolve — reverse DNS for IP addresses (BusyBox nslookup)
|
||||
# logging_status — WAN zone logging readiness (no args)
|
||||
# enable_wan_logging / disable_wan_logging — opt-in WAN zone log=1 (no args)
|
||||
|
||||
FW4_TAG='!fw4: '
|
||||
LIBEXEC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
FILTER_SH="$LIBEXEC_DIR/fwlive-log-filter.sh"
|
||||
LOGGING_SH="$LIBEXEC_DIR/fwlive-logging.sh"
|
||||
# Max reverse-DNS lookups per resolve call (~32 names keeps nslookup load bounded on small routers).
|
||||
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; 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
|
||||
# `timeout`, never interpolated into a shell string.
|
||||
RESOLVE_TIMEOUT=1
|
||||
# Wall-clock budget (seconds) for a single ubus fwlive.resolve call. The
|
||||
# lookup loop checks elapsed time and aborts cleanly once this budget is
|
||||
# spent, so a flood of unresolvable IPs cannot hold an rpcd worker for the
|
||||
# full RESOLVE_MAX * RESOLVE_TIMEOUT worst case. Worst case total time is
|
||||
# budget + one in-progress lookup (<= RESOLVE_TIMEOUT), i.e. ~6s.
|
||||
RESOLVE_BUDGET=5
|
||||
# Cap raw logd lines per poll (~2000 ≈ typical logd ring / ~200 KB JSON before filter).
|
||||
POLL_LINES_MAX=2000
|
||||
# Bound nft ruleset dumps (corrupted/hung nft must not block LuCI rules load).
|
||||
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 (#222).
|
||||
# shellcheck disable=SC1090,SC1091 # LOGGING_SH resolves to sibling ../fwlive-logging.sh
|
||||
. "$LOGGING_SH"
|
||||
|
||||
slug_key() {
|
||||
echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' _' '--'
|
||||
}
|
||||
|
||||
map_add() {
|
||||
key="$1"
|
||||
val="$2"
|
||||
[ -n "$key" ] || return 0
|
||||
[ -n "$val" ] || return 0
|
||||
esc_key=$(printf '%s' "$key" | json_escape)
|
||||
# Global first-wins deduplication: skip if escaped key already present.
|
||||
# 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).
|
||||
case "$OUT" in
|
||||
*"\"$esc_key\":"*) return 0 ;;
|
||||
esac
|
||||
esc_val=$(printf '%s' "$val" | json_escape)
|
||||
if [ -n "$OUT" ]; then
|
||||
OUT="${OUT},"
|
||||
fi
|
||||
OUT="${OUT}\"${esc_key}\":\"${esc_val}\""
|
||||
}
|
||||
|
||||
uci_rule_names() {
|
||||
uci -q show firewall 2>/dev/null | sed -n "s/^firewall\.@rule\[[0-9]*\]\.name='\(.*\)'$/\1/p"
|
||||
}
|
||||
|
||||
is_uci_style_name() {
|
||||
case "$1" in
|
||||
*' '*|*'"'*|*"'"*) return 1 ;;
|
||||
[!A-Za-z0-9_-]*) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
normalize_log_prefix() {
|
||||
printf '%s' "$1" | sed 's/[[:space:]:]*$//'
|
||||
}
|
||||
|
||||
read_rpc_input() {
|
||||
if [ -n "$1" ]; then
|
||||
printf '%s' "$1"
|
||||
else
|
||||
cat
|
||||
fi
|
||||
}
|
||||
|
||||
map_prefix_with_label() {
|
||||
prefix="$1"
|
||||
label="$2"
|
||||
prefix=$(normalize_log_prefix "$prefix")
|
||||
[ -n "$prefix" ] || return 0
|
||||
slug=$(slug_key "$prefix")
|
||||
if [ -n "$label" ]; then
|
||||
map_add "$prefix" "$label"
|
||||
if [ "$slug" != "$prefix" ]; then
|
||||
map_add "$slug" "$label"
|
||||
fi
|
||||
else
|
||||
cosmetic=$(echo "$prefix" | tr '-' ' ')
|
||||
map_add "$prefix" "$cosmetic"
|
||||
if [ "$slug" != "$prefix" ]; then
|
||||
map_add "$slug" "$cosmetic"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
run_with_timeout() {
|
||||
secs="$1"
|
||||
shift
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout "$secs" "$@" 2>/dev/null
|
||||
else
|
||||
"$@" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
map_log_prefix_entry() {
|
||||
prefix="$1"
|
||||
comment="$2"
|
||||
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
|
||||
|
||||
map_prefix_with_label "$prefix" "$label"
|
||||
}
|
||||
|
||||
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 (#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
|
||||
[ -n "$name" ] || continue
|
||||
is_uci_style_name "$name" || continue
|
||||
map_add "$name" "$name"
|
||||
slug=$(slug_key "$name")
|
||||
if [ "$slug" != "$name" ]; then
|
||||
map_add "$slug" "$name"
|
||||
fi
|
||||
done <<EOF
|
||||
$(uci_rule_names)
|
||||
EOF
|
||||
}
|
||||
|
||||
map_from_nft_stream() {
|
||||
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.
|
||||
prefix=$(echo "$line" | sed -n 's/.*log prefix "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p')
|
||||
comment=$(echo "$line" | sed -n 's/.*comment "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p')
|
||||
label=''
|
||||
|
||||
case "$comment" in
|
||||
"${FW4_TAG}"*)
|
||||
suffix="${comment#"${FW4_TAG}"}"
|
||||
if is_uci_style_name "$suffix"; then
|
||||
label="$suffix"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -n "$prefix" ]; then
|
||||
map_prefix_with_label "$prefix" "$label"
|
||||
elif [ -n "$label" ]; then
|
||||
map_prefix_with_label "$label" "$label"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
map_from_iptables_save_stream() {
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
-*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
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"
|
||||
done
|
||||
}
|
||||
|
||||
nft_list_ruleset() {
|
||||
run_with_timeout "$NFT_TIMEOUT" nft list ruleset
|
||||
}
|
||||
|
||||
detect_firewall_backend() {
|
||||
if command -v nft >/dev/null 2>&1 && nft_list_ruleset >/dev/null; then
|
||||
printf '%s' nft
|
||||
return 0
|
||||
fi
|
||||
if command -v iptables-save >/dev/null 2>&1; then
|
||||
printf '%s' iptables
|
||||
return 0
|
||||
fi
|
||||
printf '%s' unknown
|
||||
}
|
||||
|
||||
_fwlive_tmp_dir_ok() {
|
||||
# Fail closed unless the dump dir is a real directory with the sticky
|
||||
# bit. Without sticky, another uid can unlink a just-created mktemp
|
||||
# file and replace it with a symlink before we reopen with `>` as
|
||||
# root (same class as #204). Optional $1 is for CLI tests; production
|
||||
# always uses /tmp.
|
||||
dir="${1:-/tmp}"
|
||||
[ -n "$dir" ] || return 1
|
||||
[ -L "$dir" ] && return 1
|
||||
[ -d "$dir" ] || return 1
|
||||
# POSIX XCU `test -k`; BusyBox/dash implement it. shellcheck SC3065
|
||||
# incorrectly treats -k as non-POSIX (and `stat -c` is not on OpenWrt).
|
||||
# shellcheck disable=SC3065
|
||||
[ -k "$dir" ]
|
||||
}
|
||||
|
||||
_fwlive_mktemp() {
|
||||
# Secure temp file for ruleset dumps. Uses mktemp only -- no
|
||||
# predictable fallback (symlink arbitrary-write primitive when
|
||||
# running as root under rpcd). Uses /tmp explicitly and does not
|
||||
# honour TMPDIR. /tmp must be sticky (checked). Never rm+reuse,
|
||||
# never touch/chmod into existence, template stays inside /tmp.
|
||||
_fwlive_tmp_dir_ok || return 1
|
||||
_mk_prefix="$1"
|
||||
_mk_file=$(mktemp "/tmp/${_mk_prefix}.XXXXXX" 2>/dev/null) || return 1
|
||||
[ -n "$_mk_file" ] || return 1
|
||||
printf '%s' "$_mk_file"
|
||||
}
|
||||
|
||||
build_rules_map() {
|
||||
OUT=''
|
||||
backend=$(detect_firewall_backend)
|
||||
|
||||
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"
|
||||
fi
|
||||
rm -f "$_tmp"
|
||||
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"
|
||||
fi
|
||||
rm -f "$_tmp"
|
||||
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"
|
||||
fi
|
||||
rm -f "$_tmp"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
printf '{"backend":"%s","rules":{%s}}' "$backend" "$OUT"
|
||||
}
|
||||
|
||||
rulesmap_from_iptables_file() {
|
||||
file="$1"
|
||||
OUT=''
|
||||
[ "$file" = "$RULESMAP_IPTABLES_FILE" ] || return 1
|
||||
[ -f "$file" ] || return 1
|
||||
map_from_iptables_save_stream < "$file"
|
||||
printf '{"backend":"iptables","rules":{%s}}' "$OUT"
|
||||
}
|
||||
|
||||
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.
|
||||
_val="$1"
|
||||
case "$_val" in
|
||||
''|*[!0-9]*)
|
||||
printf '%s' "50"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
_tmp=$(printf '%s' "$_val" | sed 's/^0*//')
|
||||
if [ -z "$_tmp" ]; then
|
||||
printf '%s' "50"
|
||||
return 0
|
||||
fi
|
||||
if [ "${#_tmp}" -gt "${#POLL_LINES_MAX}" ]; then
|
||||
printf '%s' "$POLL_LINES_MAX"
|
||||
return 0
|
||||
fi
|
||||
if [ "$_tmp" -gt "$POLL_LINES_MAX" ]; then
|
||||
printf '%s' "$POLL_LINES_MAX"
|
||||
else
|
||||
printf '%s' "$_tmp"
|
||||
fi
|
||||
}
|
||||
|
||||
poll_lines_from_input() {
|
||||
input="$1"
|
||||
lines=50
|
||||
|
||||
if ! command -v jshn >/dev/null 2>&1; then
|
||||
printf '%s' "$lines"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Device-only helper; not present on host CI runners.
|
||||
# shellcheck disable=SC1091
|
||||
. /usr/share/libubox/jshn.sh
|
||||
json_load "$input" 2>/dev/null || {
|
||||
printf '%s' "$lines"
|
||||
return 0
|
||||
}
|
||||
|
||||
if json_select addresses 2>/dev/null; then
|
||||
# json_get_var assigns into the named shell variable (jshn).
|
||||
json_get_var first 1
|
||||
json_select ..
|
||||
# shellcheck disable=SC2154 # set by json_get_var above
|
||||
case "$first" in
|
||||
''|*[!0-9]*) ;;
|
||||
*) lines="$first" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
lines=$(poll_clamp_lines "$lines")
|
||||
|
||||
printf '%s' "$lines"
|
||||
}
|
||||
|
||||
fetch_firewall_logs() {
|
||||
input="$1"
|
||||
lines=$(poll_lines_from_input "$input")
|
||||
# lines is numeric-clamped; stream/oneshot are fixed (not caller-controlled).
|
||||
raw=$(ubus call log read "{\"lines\":$lines,\"stream\":false,\"oneshot\":true}" 2>/dev/null) \
|
||||
|| raw='{"log":[]}'
|
||||
|
||||
# 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.
|
||||
if ! out=$(printf '%s' "$raw" | "$FILTER_SH"); then
|
||||
if [ -z "$out" ]; then
|
||||
out='{"log":[],"error":"filter_failed"}'
|
||||
fi
|
||||
fi
|
||||
[ -n "$out" ] || out='{"log":[]}'
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
poll_logs() {
|
||||
# Optional JSON on $1 (rpcd may also pass argv $3 at the call site); else stdin.
|
||||
input=$(read_rpc_input "$1")
|
||||
fetch_firewall_logs "$input"
|
||||
}
|
||||
|
||||
# Parse BusyBox and bind-utils nslookup reverse output. Prefer
|
||||
# "<ptr> name = <host>"; fall back to "Address N: <ip> <host>".
|
||||
# Trailing dots are stripped. Empty / server-only output is not a name.
|
||||
parse_nslookup_name() {
|
||||
# Prefer "<ptr> name = <host>". The "Address N: ip host" fallback
|
||||
# must ignore the Server block (BusyBox mini nslookup prints the
|
||||
# resolver via the same Address N: format first). Only accept
|
||||
# Address N: after a blank line (the answer section).
|
||||
printf '%s\n' "$1" | awk '
|
||||
tolower($0) ~ /name[ \t]*=/ {
|
||||
sub(/.*[Nn][Aa][Mm][Ee][ \t]*=[ \t]*/, "")
|
||||
gsub(/[ \t].*$/, "")
|
||||
gsub(/\.$/, "")
|
||||
if ($0 != "") { print; exit }
|
||||
}
|
||||
NF == 0 { in_answer = 1; next }
|
||||
in_answer && $1 == "Address" && $2 ~ /^[0-9]+:/ && NF >= 4 {
|
||||
host = $4
|
||||
gsub(/\.$/, "", host)
|
||||
if (host != "") { print host; exit }
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
resolve_hostname() {
|
||||
ip="$1"
|
||||
[ -n "$ip" ] || return 1
|
||||
is_resolvable_address "$ip" || return 1
|
||||
command -v nslookup >/dev/null 2>&1 || return 1
|
||||
out=$(run_with_timeout "$RESOLVE_TIMEOUT" nslookup "$ip")
|
||||
[ -n "$out" ] || return 1
|
||||
name=$(parse_nslookup_name "$out")
|
||||
[ -n "$name" ] || return 1
|
||||
printf '%s' "$name"
|
||||
}
|
||||
|
||||
# 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
|
||||
# families here so nslookup only ever sees genuine numeric addresses.
|
||||
# Embedded-IPv4 tails ("::ffff:192.0.2.1") are fully validated, not failed
|
||||
# closed. awk is already a dependency of this script (json_escape).
|
||||
is_resolvable_address() {
|
||||
addr="$1"
|
||||
[ -n "$addr" ] || return 1
|
||||
# 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 (#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 ;;
|
||||
esac
|
||||
printf '%s\n' "$addr" | awk '
|
||||
function is_ipv4(s, n, o, i) {
|
||||
if (s !~ /^[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$/)
|
||||
return 0
|
||||
n = split(s, o, ".")
|
||||
if (n != 4)
|
||||
return 0
|
||||
for (i = 1; i <= 4; i++)
|
||||
if (o[i] + 0 > 255)
|
||||
return 0
|
||||
return 1
|
||||
}
|
||||
function is_hexgroup(g) {
|
||||
return (g ~ /^[0-9a-fA-F][0-9a-fA-F]?[0-9a-fA-F]?[0-9a-fA-F]?$/)
|
||||
}
|
||||
function is_ipv6(s, k, head, tail, lc, v4, extra, nh, nt, h, t, i) {
|
||||
if (s !~ /^[0-9a-fA-F:.]+$/)
|
||||
return 0
|
||||
if (s ~ /:::/)
|
||||
return 0
|
||||
k = index(s, "::")
|
||||
if (k > 0) {
|
||||
if (index(substr(s, k + 2), "::") > 0)
|
||||
return 0
|
||||
head = substr(s, 1, k - 1)
|
||||
tail = substr(s, k + 2)
|
||||
} else {
|
||||
head = ""
|
||||
tail = s
|
||||
}
|
||||
extra = 0
|
||||
if (tail ~ /\./) {
|
||||
lc = length(tail)
|
||||
while (lc > 0 && substr(tail, lc, 1) != ":")
|
||||
lc--
|
||||
if (lc == 0) {
|
||||
if (!is_ipv4(tail))
|
||||
return 0
|
||||
tail = ""
|
||||
} else {
|
||||
v4 = substr(tail, lc + 1)
|
||||
if (!is_ipv4(v4))
|
||||
return 0
|
||||
tail = substr(tail, 1, lc - 1)
|
||||
}
|
||||
extra = 2
|
||||
}
|
||||
nh = (head == "" ? 0 : split(head, h, ":"))
|
||||
nt = (tail == "" ? 0 : split(tail, t, ":"))
|
||||
for (i = 1; i <= nh; i++)
|
||||
if (!is_hexgroup(h[i]))
|
||||
return 0
|
||||
for (i = 1; i <= nt; i++)
|
||||
if (!is_hexgroup(t[i]))
|
||||
return 0
|
||||
if (k > 0)
|
||||
return (nh + nt + extra <= 7)
|
||||
return (nh + nt + extra == 8)
|
||||
}
|
||||
NR == 1 {
|
||||
valid = (($0 ~ /:/) ? is_ipv6($0) : is_ipv4($0))
|
||||
next
|
||||
}
|
||||
END { exit (NR == 1 && valid) ? 0 : 1 }
|
||||
'
|
||||
}
|
||||
|
||||
resolve_addresses() {
|
||||
# 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"
|
||||
printf '{"names":{},"error":"no_resolver"}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 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
|
||||
# still prevented).
|
||||
start=$(date +%s)
|
||||
# Optional JSON on $1 (rpcd may also pass argv $3 at the call site); else stdin.
|
||||
input=$(read_rpc_input "$1")
|
||||
count=0
|
||||
OUT=''
|
||||
|
||||
if ! command -v jshn >/dev/null 2>&1; then
|
||||
printf '{"names":{}}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
. /usr/share/libubox/jshn.sh
|
||||
json_load "$input" 2>/dev/null || {
|
||||
printf '{"names":{}}'
|
||||
return 0
|
||||
}
|
||||
|
||||
if json_select addresses 2>/dev/null; then
|
||||
idx=1
|
||||
# json_get_type / json_get_var assign into named shell variables (jshn).
|
||||
# shellcheck disable=SC2154
|
||||
while json_get_type atype "$idx" && [ "$atype" = string ]; do
|
||||
[ "$count" -ge "$RESOLVE_MAX" ] && break
|
||||
# Abort cleanly once the whole-call wall-clock budget is spent;
|
||||
# never START a lookup past the budget.
|
||||
now=$(date +%s)
|
||||
[ $((now - start)) -ge "$RESOLVE_BUDGET" ] && break
|
||||
json_get_var ip "$idx"
|
||||
if ! is_resolvable_address "$ip"; then
|
||||
idx=$((idx + 1))
|
||||
continue
|
||||
fi
|
||||
name=$(resolve_hostname "$ip")
|
||||
if [ -n "$name" ]; then
|
||||
map_add "$ip" "$name"
|
||||
count=$((count + 1))
|
||||
fi
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
json_select ..
|
||||
fi
|
||||
|
||||
printf '{"names":{%s}}' "$OUT"
|
||||
}
|
||||
|
||||
run_selftest() {
|
||||
input=$(printf 'a\\b"c\nd\te')
|
||||
escaped=$(printf '%s' "$input" | json_escape)
|
||||
expected=$(printf 'a\\\\b\\"c\\nd\\te')
|
||||
if [ "$escaped" != "$expected" ]; then
|
||||
echo "json_escape: mismatch" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Remaining C0 controls must become \u00XX (RFC 8259), not raw bytes.
|
||||
input=$(printf 'x\001y\037z')
|
||||
escaped=$(printf '%s' "$input" | json_escape)
|
||||
expected='x\u0001y\u001fz'
|
||||
if [ "$escaped" != "$expected" ]; then
|
||||
echo "json_escape C0: expected $expected got $escaped" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Blank lines are data (paragraph-mode RS="" used to drop them).
|
||||
input=$(printf 'a\n\nb')
|
||||
escaped=$(printf '%s' "$input" | json_escape)
|
||||
expected=$(printf 'a\\n\\nb')
|
||||
if [ "$escaped" != "$expected" ]; then
|
||||
echo "json_escape blank line: expected $expected got $escaped" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if is_resolvable_address 'not an ip'; then
|
||||
echo 'is_resolvable_address: expected reject for spaces' >&2
|
||||
return 1
|
||||
fi
|
||||
# Literal metachar token (must not expand) — reject before nslookup.
|
||||
# shellcheck disable=SC2016
|
||||
if is_resolvable_address '$(reboot)'; then
|
||||
echo 'is_resolvable_address: expected reject for metachar' >&2
|
||||
return 1
|
||||
fi
|
||||
if is_resolvable_address 'example.com'; then
|
||||
echo 'is_resolvable_address: expected reject for hostname' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! is_resolvable_address '192.0.2.1'; then
|
||||
echo 'is_resolvable_address: expected accept IPv4' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! is_resolvable_address '2001:db8::1'; then
|
||||
echo 'is_resolvable_address: expected accept IPv6' >&2
|
||||
return 1
|
||||
fi
|
||||
if ! is_resolvable_address '::1'; then
|
||||
echo 'is_resolvable_address: expected accept IPv6 loopback ::1' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 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
|
||||
echo 'is_resolvable_address: expected reject for dotted-hex ab.cd.ef.01' >&2
|
||||
return 1
|
||||
fi
|
||||
if is_resolvable_address 'dead.beef.cafe.baad'; then
|
||||
echo 'is_resolvable_address: expected reject for dotted-hex dead.beef.cafe.baad' >&2
|
||||
return 1
|
||||
fi
|
||||
if is_resolvable_address 'a.b.c.d.e.f'; then
|
||||
echo 'is_resolvable_address: expected reject for 6-group dotted token a.b.c.d.e.f' >&2
|
||||
return 1
|
||||
fi
|
||||
if is_resolvable_address 'aa:bb:cc:dd'; then
|
||||
echo 'is_resolvable_address: expected reject for under-populated IPv6 aa:bb:cc:dd (no ::)' >&2
|
||||
return 1
|
||||
fi
|
||||
if is_resolvable_address '123.456.1.1'; then
|
||||
echo 'is_resolvable_address: expected reject for octet > 255 in 123.456.1.1' >&2
|
||||
return 1
|
||||
fi
|
||||
# Embedded-IPv4 tail must validate, not fail closed.
|
||||
if ! is_resolvable_address '::ffff:192.0.2.1'; then
|
||||
echo 'is_resolvable_address: expected accept IPv6-mapped ::ffff:192.0.2.1' >&2
|
||||
return 1
|
||||
fi
|
||||
# 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
|
||||
echo 'is_resolvable_address: expected reject for multi-line IPv4 token' >&2
|
||||
return 1
|
||||
fi
|
||||
if is_resolvable_address "$(printf '2001:db8::1\nextra')"; then
|
||||
echo 'is_resolvable_address: expected reject for multi-line IPv6 token' >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Escaped-quote capture: a log prefix containing \" must not be truncated
|
||||
# at the escaped quote (nft dumps strings with backslash escapes).
|
||||
got=$(printf '%s\n' 'log prefix "My \" Rule"' | sed -n 's/.*log prefix "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p')
|
||||
if [ "$got" != 'My \" Rule' ]; then
|
||||
echo "escaped-quote prefix capture: expected 'My \\\" Rule' got '$got'" >&2
|
||||
return 1
|
||||
fi
|
||||
# Plain prefix still captures unchanged.
|
||||
got=$(printf '%s\n' 'log prefix "Plain Rule"' | sed -n 's/.*log prefix "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p')
|
||||
if [ "$got" != 'Plain Rule' ]; then
|
||||
echo "plain prefix capture: expected 'Plain Rule' got '$got'" >&2
|
||||
return 1
|
||||
fi
|
||||
# Empty prefix edge.
|
||||
got=$(printf '%s\n' 'log prefix ""' | sed -n 's/.*log prefix "\([^"\\]*\(\\.[^"\\]*\)*\)".*/\1/p')
|
||||
if [ "$got" != '' ]; then
|
||||
echo "empty prefix capture: expected '' got '$got'" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
got=$(poll_clamp_lines "99999999999999999999")
|
||||
if [ "$got" != "$POLL_LINES_MAX" ]; then
|
||||
echo "poll_clamp_lines: over-long expected $POLL_LINES_MAX got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
got=$(poll_clamp_lines "0")
|
||||
if [ "$got" != "50" ]; then
|
||||
echo "poll_clamp_lines: zero expected 50 got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
got=$(poll_clamp_lines "2001")
|
||||
if [ "$got" != "$POLL_LINES_MAX" ]; then
|
||||
echo "poll_clamp_lines: 2001 expected $POLL_LINES_MAX got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
got=$(poll_clamp_lines "500")
|
||||
if [ "$got" != "500" ]; then
|
||||
echo "poll_clamp_lines: expected 500 got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! _fwlive_tmp_dir_ok; then
|
||||
echo "_fwlive_tmp_dir_ok: /tmp must be a sticky directory" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
got=$(parse_nslookup_name "Server: 127.0.0.1
|
||||
Address: 127.0.0.1:53
|
||||
|
||||
8.8.8.8.in-addr.arpa name = dns.google.")
|
||||
if [ "$got" != "dns.google" ]; then
|
||||
echo "parse_nslookup_name: bind-style expected dns.google got '$got'" >&2
|
||||
return 1
|
||||
fi
|
||||
got=$(parse_nslookup_name "Server: 127.0.0.1
|
||||
Address: 127.0.0.1:53
|
||||
|
||||
Address 1: 8.8.8.8 dns.google.")
|
||||
if [ "$got" != "dns.google" ]; then
|
||||
echo "parse_nslookup_name: busybox Address N expected dns.google got '$got'" >&2
|
||||
return 1
|
||||
fi
|
||||
got=$(parse_nslookup_name "Server: 192.168.1.1
|
||||
Address 1: 192.168.1.1 router.lan
|
||||
|
||||
Name: 8.8.8.8
|
||||
Address 1: 8.8.8.8 dns.google")
|
||||
if [ "$got" != "dns.google" ]; then
|
||||
echo "parse_nslookup_name: mini nslookup must skip resolver Address N, got '$got'" >&2
|
||||
return 1
|
||||
fi
|
||||
got=$(parse_nslookup_name "Server: 127.0.0.1
|
||||
Address: 127.0.0.1:53")
|
||||
if [ -n "$got" ]; then
|
||||
echo "parse_nslookup_name: server-only expected empty got '$got'" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
run_logging_selftest || return 1
|
||||
|
||||
if ! command -v jshn >/dev/null 2>&1; then
|
||||
echo "skip: jshn not available (poll cap via jshn not tested)" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
got=$(poll_lines_from_input '{"addresses":["999999"]}')
|
||||
if [ "$got" != "$POLL_LINES_MAX" ]; then
|
||||
echo "poll_lines_from_input: expected $POLL_LINES_MAX got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
got=$(poll_lines_from_input '{"addresses":["99999999999999999999"]}')
|
||||
if [ "$got" != "$POLL_LINES_MAX" ]; then
|
||||
echo "poll_lines_from_input: over-long expected $POLL_LINES_MAX got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
got=$(poll_lines_from_input '{"addresses":["18446744073709551616"]}')
|
||||
if [ "$got" != "$POLL_LINES_MAX" ]; then
|
||||
echo "poll_lines_from_input: 18446744073709551616 expected $POLL_LINES_MAX got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
got=$(poll_lines_from_input '{"addresses":["0"]}')
|
||||
if [ "$got" != "50" ]; then
|
||||
echo "poll_lines_from_input: zero expected 50 got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
got=$(poll_lines_from_input '{"addresses":["2001"]}')
|
||||
if [ "$got" != "$POLL_LINES_MAX" ]; then
|
||||
echo "poll_lines_from_input: 2001 expected $POLL_LINES_MAX got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
got=$(poll_lines_from_input '{"addresses":["500"]}')
|
||||
if [ "$got" != "500" ]; then
|
||||
echo "poll_lines_from_input: expected 500 got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
got=$(poll_lines_from_input '{"addresses":["100"]}')
|
||||
if [ "$got" != "100" ]; then
|
||||
echo "poll_lines_from_input: expected 100 got $got" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
__selftest)
|
||||
run_selftest
|
||||
exit $?
|
||||
;;
|
||||
__rulesmap_iptables)
|
||||
# CLI selftest only — never a ubus method. Fixed path only (no argv file read).
|
||||
rulesmap_from_iptables_file "$RULESMAP_IPTABLES_FILE"
|
||||
exit $?
|
||||
;;
|
||||
__poll_clamp)
|
||||
poll_clamp_lines "$2"
|
||||
exit $?
|
||||
;;
|
||||
__tmp_dir_ok)
|
||||
_fwlive_tmp_dir_ok "$2"
|
||||
exit $?
|
||||
;;
|
||||
__resolve_one)
|
||||
if name=$(resolve_hostname "$2"); then
|
||||
printf '%s\n' "$name"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
;;
|
||||
__parse_nslookup)
|
||||
name=$(parse_nslookup_name "$2")
|
||||
if [ -n "$name" ]; then
|
||||
printf '%s\n' "$name"
|
||||
exit 0
|
||||
fi
|
||||
printf '\n'
|
||||
exit 1
|
||||
;;
|
||||
list)
|
||||
# Method names only — args objects describe call parameters.
|
||||
# "backend" is a rules reply field, not a method.
|
||||
echo '{"rules":{},"poll":{"addresses":[]},"resolve":{"addresses":[]},"logging_status":{},"enable_wan_logging":{},"disable_wan_logging":{}}'
|
||||
;;
|
||||
call)
|
||||
case "$2" in
|
||||
rules)
|
||||
build_rules_map
|
||||
;;
|
||||
poll)
|
||||
poll_logs "$3"
|
||||
;;
|
||||
resolve)
|
||||
resolve_addresses "$3"
|
||||
;;
|
||||
logging_status)
|
||||
build_logging_status_json
|
||||
;;
|
||||
enable_wan_logging)
|
||||
enable_wan_logging
|
||||
;;
|
||||
disable_wan_logging)
|
||||
disable_wan_logging
|
||||
;;
|
||||
*)
|
||||
echo '{"error":"Method not found"}'
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"admin/status/fwlive": {
|
||||
"title": "Firewall Live View",
|
||||
"order": 5,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "status/fwlive"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [ "luci-app-fwlive" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"luci-app-fwlive": {
|
||||
"description": "Grant access to firewall live log view",
|
||||
"read": {
|
||||
"ubus": {
|
||||
"fwlive": [ "rules", "poll", "resolve", "logging_status" ]
|
||||
}
|
||||
},
|
||||
"write": {
|
||||
"ubus": {
|
||||
"fwlive": [ "enable_wan_logging", "disable_wan_logging" ]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user