Files
op-packages/luci-app-fwlive/root/usr/libexec/rpcd/fwlive
T

1289 lines
40 KiB
Bash
Executable File

#!/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)
#
# Entry point (rpcd plugin). Sourced helpers inherit these options.
set -eu
# shellcheck disable=SC3040
(set -o pipefail) 2>/dev/null && set -o pipefail
FW4_TAG='!fw4: '
LIBEXEC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
FILTER_SH="$LIBEXEC_DIR/fwlive-log-filter.sh"
LOGGING_SH="$LIBEXEC_DIR/fwlive-logging.sh"
ADAPTIVE_SH="$LIBEXEC_DIR/fwlive-adaptive-cap.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
# Bound poll log.read (wedged logd must not hold an rpcd worker past the UI cadence).
# Integer seconds — BusyBox timeout truncates fractions to 0 (see RESOLVE_TIMEOUT).
POLL_TIMEOUT=5
# Host tests override this to a pinned libubox helper; devices keep the package path.
: "${FWLIVE_JSHN_SH:=/usr/share/libubox/jshn.sh}"
# json_escape lives in fwlive-logging.sh so prerm can source that file alone.
# shellcheck disable=SC1090,SC1091 # LOGGING_SH resolves to sibling ../fwlive-logging.sh
. "$LOGGING_SH"
# shellcheck disable=SC1090,SC1091
. "$ADAPTIVE_SH"
slug_key() {
echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' _' '--'
}
# Cap rules JSON size so a huge ruleset cannot unbound an rpcd reply.
RULES_MAP_MAX_KEYS=512
RULES_MAP_MAX_BYTES=65536
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).
# Callers apply UCI, then labeled (!fw4:) prefixes, then unlabeled
# cosmetics so first-wins prefers authoritative names.
case "$OUT" in
*"\"$esc_key\":"*) return 0 ;;
esac
esc_val=$(printf '%s' "$val" | json_escape)
_pair_len=$(( ${#esc_key} + ${#esc_val} + 5 ))
_next_len=$(( ${#OUT} + _pair_len ))
[ -n "$OUT" ] && _next_len=$((_next_len + 1))
if [ "${_map_keys:-0}" -ge "$RULES_MAP_MAX_KEYS" ] || \
[ "$_next_len" -gt "$RULES_MAP_MAX_BYTES" ]; then
_map_overflow=1
return 0
fi
_map_keys=$(( ${_map_keys:-0} + 1 ))
if [ -n "$OUT" ]; then
OUT="${OUT},"
fi
OUT="${OUT}\"${esc_key}\":\"${esc_val}\""
}
uci_rule_names() {
# uci missing / empty firewall config is "no names", not fatal.
# pipefail + set -e cannot apply to this pipeline.
{
uci -q show firewall 2>/dev/null |
sed -n "s/^firewall\.@rule\[[0-9]*\]\.name='\(.*\)'$/\1/p" || true
uci -q show firewall 2>/dev/null |
sed -n 's/^firewall\.\([A-Za-z0-9_-]*\)=rule$/\1/p' |
while IFS= read -r section; do
uci -q get "firewall.${section}.name" 2>/dev/null || true
done || true
}
}
is_uci_style_name() {
# Domain: non-empty UCI-style identifier, every character in
# [A-Za-z0-9_-]. That is the map-key set this helper admits
# (names parseRuleHint can look up). nft comments can print
# anything; skip names outside this set so they do not become
# dead map keys. parseRuleHint also allows '.' — not accepted
# here. The charset glob is *[!class]* (whole string);
# [!class]* only tested the first character.
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
# Fail closed: never run unbounded when timeout is absent.
return 127
fi
}
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.
# 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
}
# One awk pass: prefix<TAB>comment. Last quoted log prefix / comment on
# the line, including \" units (same capture as the former sed).
# Raw tabs in prefix/comment become the two-character sequence \t so the
# separator tab is unique. Backslashes are not re-encoded.
nft_dump_fields() {
LC_ALL=C awk '
function last_at(s, needle, p, i, found) {
found = 0
p = 1
while ((i = index(substr(s, p), needle)) > 0) {
found = p + i - 1
p = found + 1
}
return found
}
function quoted_at(s, needle, i, rest, out, c, n, esc, j) {
i = last_at(s, needle)
if (i == 0)
return ""
rest = substr(s, i + length(needle))
n = length(rest)
out = ""
esc = 0
for (j = 1; j <= n; j++) {
c = substr(rest, j, 1)
if (esc) {
out = out c
esc = 0
continue
}
if (c == "\\") {
out = out c
esc = 1
continue
}
if (c == "\"")
return out
out = out c
}
return ""
}
{
prefix = quoted_at($0, "log prefix \"")
comment = quoted_at($0, "comment \"")
if (prefix == "" && comment == "")
next
gsub(/\\/, "\\\\", prefix)
gsub(/\t/, "\\t", prefix)
gsub(/\\/, "\\\\", comment)
gsub(/\t/, "\\t", comment)
print prefix "\t" comment
}
' || return 1
}
# Restore nft_dump_fields TSV encoding. Walk left to right: \\ is a
# literal backslash, \t is a tab. Other backslash pairs stay as written.
# Do not put backslash sequences in case patterns (they are escapes).
# Fast-path strings with no backslash: dash ${var#?} walks are O(n^2) and
# the map byte-cap tests feed ~60k prefixes with none.
# Result is _tsv_out (no fork).
_nft_tsv_unescape() {
case "$1" in
*\\*) ;;
*)
_tsv_out="$1"
return 0
;;
esac
_tab=$(printf '\t')
_in="$1"
_out=
while [ -n "$_in" ]; do
_head=${_in%"${_in#?}"}
_in=${_in#?}
if [ "$_head" = "\\" ]; then
_n=${_in%"${_in#?}"}
_in=${_in#?}
if [ "$_n" = t ]; then
_out="${_out}${_tab}"
elif [ "$_n" = "\\" ]; then
_out="${_out}\\"
else
_out="${_out}\\${_n}"
fi
else
_out="${_out}${_head}"
fi
done
_tsv_out="$_out"
}
# pass=labeled|unlabeled|all — labeled before unlabeled so !fw4: beats
# an earlier cosmetic for the same prefix under first-wins.
# Reads TSV from nft_dump_fields; stops once the map cap trips.
map_from_nft_stream() {
pass="${1:-all}"
_tab=$(printf '\t')
_esc_t=$(printf '\\t')
_esc_b=$(printf '\\\\')
# IFS= keeps a leading tab (empty prefix + comment-only !fw4: line).
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$_map_overflow" ] && break
[ -n "$line" ] || continue
prefix="${line%%${_tab}*}"
case "$line" in
*"$_tab"*) comment="${line#*"$_tab"}" ;;
*) comment="" ;;
esac
_nft_tsv_unescape "$prefix"
prefix="$_tsv_out"
_nft_tsv_unescape "$comment"
comment="$_tsv_out"
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
case "$pass" in
labeled) [ -n "$label" ] || continue ;;
unlabeled) [ -z "$label" ] || continue ;;
esac
map_prefix_with_label "$prefix" "$label"
elif [ -n "$label" ]; then
case "$pass" in
unlabeled) continue ;;
esac
map_prefix_with_label "$label" "$label"
fi
done
}
nft_list_ruleset() {
run_with_timeout "$NFT_TIMEOUT" nft list ruleset
}
_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 the WAN logging lock). 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"
}
enrich_rules_dump() {
_dump_prefix="$1"
_rules_dump=$(_fwlive_mktemp "$_dump_prefix") || _rules_dump=''
if [ -n "$_rules_dump" ]; then
# One dump serves detect + parse. Empty/failed dump is not nft
# (unknown/no_backend, same as a failed detect probe).
if nft_list_ruleset >"$_rules_dump" 2>/dev/null; then
backend=nft
# Stream awk output to a second temp so a huge dump is
# not materialized in the rpcd worker's shell heap.
_nft_tsv=$(_fwlive_mktemp "fwlive-nft-tsv")
if [ -z "$_nft_tsv" ]; then
_enrich_error='mktemp_failed'
else
# awk under set -eu must not abort the plugin before JSON.
if nft_dump_fields <"$_rules_dump" >"$_nft_tsv"; then
map_from_nft_stream labeled <"$_nft_tsv"
if [ -z "$_map_overflow" ]; then
map_from_nft_stream unlabeled <"$_nft_tsv"
fi
else
_enrich_error='tsv_failed'
fi
rm -f "$_nft_tsv"
fi
fi
rm -f "$_rules_dump"
else
# nft is on PATH (caller checked); skip enrichment without a dump.
_enrich_error='mktemp_failed'
backend=nft
fi
}
build_rules_map() {
OUT=''
_map_keys=0
_map_overflow=''
_enrich_error=''
backend=unknown
# UCI first, then labeled stream prefixes, then unlabeled cosmetics
# First-wins keeps UCI over nft and !fw4: over cosmetic.
map_uci_rule_names
# Temp file first, then a single nft_list_ruleset; backend from success.
if command -v nft >/dev/null 2>&1; then
enrich_rules_dump fwlive-nft
fi
if [ "$backend" = unknown ] && [ -z "$_enrich_error" ]; then
_enrich_error='no_backend'
fi
_rules_json="{\"backend\":\"$backend\",\"rules\":{$OUT}"
if [ -n "$_enrich_error" ]; then
_rules_json="${_rules_json},\"error\":\"${_enrich_error}\""
elif [ -n "$_map_overflow" ]; then
_rules_json="${_rules_json},\"error\":\"rules_truncated\""
fi
printf '%s}' "$_rules_json"
}
poll_clamp_lines() {
# Contract: non-digit input returns default 50; digit strings are
# clamped to 1..POLL_LINES_MAX with leading zeros stripped. Validates
# itself so __selftest can call it without jshn.
_val="$1"
case "$_val" in
''|*[!0-9]*)
printf '%s' "50"
return 0
;;
esac
# Strip leading zeros in-shell so default-50 poll does not pay for sed.
_digits=${_val#${_val%%[!0]*}}
if [ -z "$_digits" ]; then
printf '%s' "50"
return 0
fi
if [ "${#_digits}" -gt "${#POLL_LINES_MAX}" ]; then
printf '%s' "$POLL_LINES_MAX"
return 0
fi
if [ "$_digits" -gt "$POLL_LINES_MAX" ]; then
printf '%s' "$POLL_LINES_MAX"
else
printf '%s' "$_digits"
fi
}
# Failed jshn reads still default to 50 (silent numeric contract; syslog named cause only).
poll_lines_from_input() {
input="$1"
lines=50
_cause=
if ! command -v jshn >/dev/null 2>&1; then
_cause=jshn_missing
else
_rc=0
# jshn uses intentionally unset state variables. Keep nounset disabled in
# this subshell so strict mode is restored automatically on every exit.
# exit 2: library missing or source failed; exit 1: JSON parse failed.
# Missing addresses[0] is an intentional silent default, not a parse error.
_first=$( (
set +u
# shellcheck disable=SC1090,SC1091
. "${FWLIVE_JSHN_SH:-/usr/share/libubox/jshn.sh}" 2>/dev/null || exit 2
json_init >/dev/null 2>&1 || exit 2
# json_load masks a failing jshn exit through eval. Check the real
# parser once, then evaluate only its generated shell assignments.
_json_no_warning=1
_json_code=$(jshn -r "$input" 2>/dev/null) || exit 1
eval "$_json_code" || exit 1
first=
if json_select addresses 2>/dev/null; then
json_get_var first 1 || first=
fi
printf '%s' "${first:-}"
) ) || _rc=$?
if [ "$_rc" -eq 2 ]; then
_cause=jshn_lib_missing
elif [ "$_rc" -ne 0 ]; then
_cause=invalid_input
else
case "${_first:-}" in
''|*[!0-9]*) ;;
*) lines="$_first" ;;
esac
fi
fi
# Named cause goes to syslog only: poll JSON "error" is connection-lost in the view.
if [ -n "$_cause" ]; then
logger -t fwlive "$_cause" 2>/dev/null || true
fi
lines=$(poll_clamp_lines "$lines")
printf '%s' "$lines"
}
fetch_firewall_logs() {
input="$1"
requested=$(poll_lines_from_input "$input")
# shellcheck disable=SC2046
set -- $(fwlive_adaptive_plan "$requested")
lines=$1
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.
if ! raw=$(run_with_timeout "$POLL_TIMEOUT" ubus call log read "{\"lines\":$lines,\"stream\":false}"); then
# Do not record ~0 ms as cold: failure is not healthy fast processing
# and must not clear an existing hot/shed cap.
out='{"log":[],"error":"log_read_failed"}'
fwlive_adaptive_merge_reply "$out" "$shed_flag" "$lines" 0 0 0
return 0
fi
# 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.
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.
# 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" | run_with_timeout "$POLL_TIMEOUT" "$FILTER_SH" /tmp); then
_filter_ok=0
if [ -z "$out" ]; then
out='{"log":[],"error":"filter_failed"}'
fi
fi
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
# Zero-exit JSON without a numeric messages_received is not a healthy
# sample. The merge fallback of 0 must not clear hot/shed.
case "$out" in
*'"messages_received":'[0-9]*) ;;
*) _filter_ok=0 ;;
esac
end_cs=$(fwlive_adaptive_clock_cs)
# Uptime wrap / skew: end < start — do not record as cold.
if [ "$_filter_ok" = 1 ] && [ "$end_cs" -ge "$start_cs" ]; then
_dur=$(((end_cs - start_cs) * 10))
fwlive_adaptive_record "$_dur" "$lines"
fi
# 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
fwlive_adaptive_merge_reply "$out" "$shed_flag" "$lines" "$_trunc" 0 "$_filter_ok"
}
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>". "name =" must be a whole token
# (not a suffix of hostname, not the phrase "domain name =").
# 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 '
{
low = tolower($0)
host = ""
if (match(low, /^name[ \t]*=[ \t]*/)) {
host = substr($0, RLENGTH + 1)
} else if (match(low, /[^a-z0-9_]name[ \t]*=[ \t]*/)) {
pre = substr(low, 1, RSTART - 1)
sub(/[ \t]+$/, "", pre)
if (pre != "domain" && pre !~ /[^a-z0-9_]domain$/)
host = substr($0, RSTART + RLENGTH)
}
if (host != "") {
gsub(/[ \t].*$/, "", host)
gsub(/\.$/, "", host)
if (host != "") { print host; 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
# timeout/nslookup miss is "no PTR", not a plugin abort.
out=$(run_with_timeout "$RESOLVE_TIMEOUT" nslookup "$ip") || return 1
[ -n "$out" ] || return 1
name=$(parse_nslookup_name "$out") || return 1
[ -n "$name" ] || return 1
printf '%s' "$name"
}
# Strict IPv4/IPv6 validation before nslookup: 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: 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". 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
# Load shed: previous poll was hot.
if fwlive_adaptive_is_hot; then
printf '{"names":{},"disabled":"load"}'
return 0
fi
# Optional JSON on $1 (rpcd may also pass argv $3 at the call site); else stdin.
input=$(read_rpc_input "$1")
# Wall-clock budget starts after the input read and bounds the lookup
# loop only. Stalled stdin is not interrupted — production rpcd closes
# a finite payload. `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)
lookups=0
truncated=0
OUT=''
if ! command -v jshn >/dev/null 2>&1; then
printf '{"names":{},"error":"jshn_missing"}'
return 0
fi
_addresses=$( (
set +u
# shellcheck disable=SC1090,SC1091
. "${FWLIVE_JSHN_SH:-/usr/share/libubox/jshn.sh}"
# json_load masks a failing jshn exit through eval. Check the real
# parser once, then evaluate only its generated shell assignments.
_json_no_warning=1
_json_code=$(jshn -r "$input" 2>/dev/null) || exit 1
eval "$_json_code" || exit 1
if ! json_select addresses 2>/dev/null; then exit 0; fi
idx=1
# json_get_type false ends the array. A non-string element is skipped
# so later strings still resolve; skipped types are not truncated.
# shellcheck disable=SC2154
while json_get_type atype "$idx"; do
if [ "$atype" != string ]; then
idx=$((idx + 1))
continue
fi
json_get_var ip "$idx" || exit 1
# Preserve JSON element boundaries in the newline transport.
# Invalid addresses (including embedded newlines) never leave it.
if is_resolvable_address "${ip:-}"; then
printf '%s\n' "$ip"
fi
idx=$((idx + 1))
done
) ) || {
printf '{"names":{},"error":"invalid_input"}'
return 0
}
while IFS= read -r ip; do
if [ "$lookups" -ge "$RESOLVE_MAX" ]; then
truncated=1
break
fi
# Abort cleanly once the lookup-loop wall-clock budget is spent;
# never START a lookup past the budget.
now=$(date +%s)
if [ $((now - start)) -ge "$RESOLVE_BUDGET" ]; then
truncated=1
break
fi
# Missing/empty element: set -u cannot read bare $ip.
if ! is_resolvable_address "${ip:-}"; then
continue
fi
lookups=$((lookups + 1))
# NXDOMAIN / timeout: resolve_hostname returns 1. set -e
# cannot apply to this assignment.
name=$(resolve_hostname "$ip") || name=
if [ -n "$name" ]; then
map_add "$ip" "$name"
else
# Record a completed miss so the view can fail-mark NXDOMAIN
# without treating the unprocessed truncated tail as tried.
# map_add skips empty values (rules-map labels); resolve needs them.
esc_key=$(printf '%s' "$ip" | json_escape)
case "$OUT" in
*"\"$esc_key\":"*) ;;
*)
if [ -n "$OUT" ]; then
OUT="${OUT},"
fi
OUT="${OUT}\"${esc_key}\":\"\""
;;
esac
fi
done <<EOF
$_addresses
EOF
if [ "$truncated" -eq 1 ]; then
printf '{"names":{%s},"truncated":true}' "$OUT"
else
printf '{"names":{%s}}' "$OUT"
fi
}
check_eq() {
_check_name="$1"
_check_got="$2"
_check_expected="$3"
_check_failure="$4"
if [ "$_check_got" != "$_check_expected" ]; then
# Keep the failure suffix at each call site so stderr stays byte-for-byte
# identical to the pre-extraction selftest messages.
echo "$_check_name: $_check_failure" >&2
return 1
fi
}
run_selftest() {
input=$(printf 'a\\b"c\nd\te')
escaped=$(printf '%s' "$input" | json_escape)
expected=$(printf 'a\\\\b\\"c\\nd\\te')
check_eq json_escape "$escaped" "$expected" mismatch || return 1
# 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'
check_eq 'json_escape C0' "$escaped" "$expected" "expected $expected got $escaped" || return 1
# 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')
check_eq 'json_escape blank line' "$escaped" "$expected" "expected $expected got $escaped" || return 1
# Charset applies to every character, not only the first.
if ! is_uci_style_name 'Rule'; then
echo 'is_uci_style_name: expected accept Rule' >&2
return 1
fi
if ! is_uci_style_name 'Allow-SSH'; then
echo 'is_uci_style_name: expected accept Allow-SSH' >&2
return 1
fi
if ! is_uci_style_name '-lead'; then
echo 'is_uci_style_name: expected accept -lead' >&2
return 1
fi
if is_uci_style_name 'My Rule'; then
echo 'is_uci_style_name: expected reject for space' >&2
return 1
fi
# First character is legal; later $ / ; / . must still fail.
# shellcheck disable=SC2016
if is_uci_style_name 'rule$x'; then
echo 'is_uci_style_name: expected reject rule$x (later-char illegal)' >&2
return 1
fi
if is_uci_style_name 'rule;id'; then
echo 'is_uci_style_name: expected reject rule;id (later-char illegal)' >&2
return 1
fi
if is_uci_style_name 'rule.x'; then
echo 'is_uci_style_name: expected reject rule.x (later-char illegal)' >&2
return 1
fi
if is_uci_style_name ''; then
echo 'is_uci_style_name: expected reject empty' >&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
# 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: 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).
_tab=$(printf '\t')
_esc_t=$(printf '\\t')
_esc_b=$(printf '\\\\')
got=$(printf '%s\n' 'log prefix "My \" Rule"' | nft_dump_fields)
got="${got%%${_tab}*}"
if [ "$got" != 'My \\" Rule' ]; then
echo "escaped-quote prefix capture: expected encoded 'My \\\\\" Rule' got '$got'" >&2
return 1
fi
_nft_tsv_unescape "$got"
if [ "$_tsv_out" != 'My \" Rule' ]; then
echo "escaped-quote prefix unescape: expected 'My \\\" Rule' got '$_tsv_out'" >&2
return 1
fi
# Plain prefix still captures unchanged.
got=$(printf '%s\n' 'log prefix "Plain Rule"' | nft_dump_fields)
got="${got%%${_tab}*}"
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 ""' | nft_dump_fields)
got="${got%%${_tab}*}"
if [ "$got" != '' ]; then
echo "empty prefix capture: expected '' got '$got'" >&2
return 1
fi
# Guest nft list emits a raw tab inside the quoted prefix
# (log prefix "fwlive<TAB>tabbed"). Encode it in the TSV field.
got=$(printf '%s\n' "log prefix \"fwlive${_tab}tabbed\"" | nft_dump_fields)
case "$got" in
*"${_tab}"*"${_tab}"*)
echo "tabbed prefix TSV: expected one separator tab, got extra" >&2
return 1
;;
*"${_tab}"*) ;;
*)
echo "tabbed prefix TSV: missing separator tab, got '$got'" >&2
return 1
;;
esac
got_prefix="${got%%${_tab}*}"
if [ "$got_prefix" != "fwlive${_esc_t}tabbed" ]; then
echo "tabbed prefix TSV: expected fwlive\\ttabbed got '$got_prefix'" >&2
return 1
fi
_nft_tsv_unescape "$got_prefix"
if [ "$_tsv_out" != "fwlive${_tab}tabbed" ]; then
echo "tabbed prefix unescape: expected fwlive<TAB>tabbed" >&2
return 1
fi
# Literal backslash-t must round-trip and not become a tab.
got=$(printf '%s\n' "log prefix \"fwlive${_esc_t}lit\"" | nft_dump_fields)
got_prefix="${got%%${_tab}*}"
if [ "$got_prefix" != "fwlive${_esc_b}tlit" ]; then
echo "literal \\\\t TSV: expected fwlive\\\\\\\\tlit got '$got_prefix'" >&2
return 1
fi
_nft_tsv_unescape "$got_prefix"
if [ "$_tsv_out" != "fwlive${_esc_t}lit" ]; then
echo "literal \\\\t unescape: expected fwlive\\\\tlit got '$_tsv_out'" >&2
return 1
fi
# Tabbed prefix plus !fw4: label: map key is the full prefix.
got=$(printf '%s\n' "log prefix \"fwlive${_tab}tabbed\" comment \"!fw4: TabRule\"" | nft_dump_fields)
OUT=''
_map_keys=0
_map_overflow=''
map_from_nft_stream all <<EOF
$got
EOF
if [ "$OUT" != '"fwlive'"${_esc_t}"'tabbed":"TabRule"' ]; then
echo "tabbed prefix map: expected full prefix -> TabRule, got $OUT" >&2
return 1
fi
# Comment-only / !fw4: label path is unchanged when there is no tab.
got=$(printf '%s\n' 'comment "!fw4: GoodName"' | nft_dump_fields)
if [ "$got" != "${_tab}!fw4: GoodName" ]; then
echo "comment-only TSV: expected leading tab + !fw4: GoodName got '$got'" >&2
return 1
fi
OUT=''
_map_keys=0
_map_overflow=''
map_from_nft_stream all <<EOF
$got
EOF
if [ "$OUT" != '"GoodName":"GoodName","goodname":"GoodName"' ]; then
echo "comment-only map: expected GoodName mapping, got $OUT" >&2
return 1
fi
got=$(poll_clamp_lines "99999999999999999999")
check_eq poll_clamp_lines "$got" "$POLL_LINES_MAX" "over-long expected $POLL_LINES_MAX got $got" || return 1
got=$(poll_clamp_lines "0")
check_eq poll_clamp_lines "$got" "50" "zero expected 50 got $got" || return 1
got=$(poll_clamp_lines "000")
check_eq poll_clamp_lines "$got" "50" "all-zeros expected 50 got $got" || return 1
got=$(poll_clamp_lines "050")
check_eq poll_clamp_lines "$got" "50" "leading-zero expected 50 got $got" || return 1
got=$(poll_clamp_lines "2001")
check_eq poll_clamp_lines "$got" "$POLL_LINES_MAX" "2001 expected $POLL_LINES_MAX got $got" || return 1
got=$(poll_clamp_lines "500")
check_eq poll_clamp_lines "$got" "500" "expected 500 got $got" || return 1
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.")
check_eq parse_nslookup_name "$got" "dns.google" "bind-style expected dns.google got '$got'" || return 1
got=$(parse_nslookup_name "Server: 127.0.0.1
Address: 127.0.0.1:53
Address 1: 8.8.8.8 dns.google.")
check_eq parse_nslookup_name "$got" "dns.google" "busybox Address N expected dns.google got '$got'" || return 1
got=$(parse_nslookup_name "Server: 2001:db8::53
Address: 2001:db8::53#53
2001:db8::1.ip6.arpa name = v6.example.")
check_eq parse_nslookup_name "$got" "v6.example" "IPv6 bind-style expected v6.example got '$got'" || return 1
got=$(parse_nslookup_name "Server: 2001:db8::53
Address: 2001:db8::53#53
Address 1: 2001:db8::1 v6.example.")
check_eq parse_nslookup_name "$got" "v6.example" "IPv6 busybox Address N expected v6.example got '$got'" || return 1
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")
check_eq parse_nslookup_name "$got" '' "server-only expected empty got '$got'" || return 1
got=$(parse_nslookup_name "hostname = evil.example.")
check_eq parse_nslookup_name "$got" '' "hostname = must not parse as name got '$got'" || return 1
got=$(parse_nslookup_name "domain name = other.example.")
check_eq parse_nslookup_name "$got" '' "domain name = must not parse as name got '$got'" || return 1
got=$(parse_nslookup_name "domain name = two.example.")
check_eq parse_nslookup_name "$got" '' "domain name = must not parse as name got '$got'" || return 1
got=$(parse_nslookup_name "domain name = tab.example.")
check_eq parse_nslookup_name "$got" '' "domain tab name = must not parse as name got '$got'" || return 1
got=$(parse_nslookup_name "name = dns.google. hostname = evil.example.")
check_eq parse_nslookup_name "$got" "dns.google" "standalone name = must beat hostname = got '$got'" || return 1
run_logging_selftest || return 1
# Strict-mode smoke: 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
return 1
}
case "$_status" in
*'"blockers":'*) ;;
*) echo "build_logging_status_json: missing blockers: $_status" >&2; return 1 ;;
esac
if ! command -v jshn >/dev/null 2>&1; then
echo "skip: jshn not available (poll cap via jshn not tested)" >&2
return 0
fi
# Dash rejects libubox `export -n` in json_init. Skip numeric poll-cap
# checks unless this interpreter can actually use the helper; poll still
# defaults to 50 and logs a named cause in that case.
if ! (
set +u
# shellcheck disable=SC1090,SC1091
. "${FWLIVE_JSHN_SH:-/usr/share/libubox/jshn.sh}" 2>/dev/null || exit 1
json_init >/dev/null 2>&1
); then
echo "skip: jshn library not usable (poll cap via jshn not tested)" >&2
return 0
fi
got=$(poll_lines_from_input '{"addresses":["999999"]}')
check_eq poll_lines_from_input "$got" "$POLL_LINES_MAX" "expected $POLL_LINES_MAX got $got" || return 1
got=$(poll_lines_from_input '{"addresses":["99999999999999999999"]}')
check_eq poll_lines_from_input "$got" "$POLL_LINES_MAX" "over-long expected $POLL_LINES_MAX got $got" || return 1
got=$(poll_lines_from_input '{"addresses":["18446744073709551616"]}')
check_eq poll_lines_from_input "$got" "$POLL_LINES_MAX" "18446744073709551616 expected $POLL_LINES_MAX got $got" || return 1
got=$(poll_lines_from_input '{"addresses":["0"]}')
check_eq poll_lines_from_input "$got" "50" "zero expected 50 got $got" || return 1
got=$(poll_lines_from_input '{"addresses":["2001"]}')
check_eq poll_lines_from_input "$got" "$POLL_LINES_MAX" "2001 expected $POLL_LINES_MAX got $got" || return 1
got=$(poll_lines_from_input '{"addresses":["500"]}')
check_eq poll_lines_from_input "$got" "500" "expected 500 got $got" || return 1
got=$(poll_lines_from_input '{"addresses":["100"]}')
check_eq poll_lines_from_input "$got" "100" "expected 100 got $got" || return 1
# jshn failures must log a named cause; stdout stays the numeric default.
# logger() override is inherited by the $() subshell; command -v still finds
# the binary, so the unqualified logger call is what this intercepts.
_poll_log="/tmp/fwlive-poll-cause.$$"
: > "$_poll_log"
logger() { printf '%s\n' "$*" >> "$_poll_log"; }
got=$(poll_lines_from_input '{"addresses":["500"]}')
check_eq poll_lines_from_input "$got" "500" "override path expected 500 got $got" || return 1
case "$(cat "$_poll_log")" in
*jshn_missing*|*jshn_lib_missing*|*invalid_input*)
echo "poll_lines_from_input: successful cap must stay silent, got $(cat "$_poll_log")" >&2
rm -f "$_poll_log"
unset -f logger
return 1
;;
esac
: > "$_poll_log"
got=$(poll_lines_from_input '{}')
check_eq poll_lines_from_input "$got" "50" "missing addresses expected 50 got $got" || return 1
case "$(cat "$_poll_log")" in
*jshn_missing*|*jshn_lib_missing*|*invalid_input*)
echo "poll_lines_from_input: missing addresses[0] must stay silent, got $(cat "$_poll_log")" >&2
rm -f "$_poll_log"
unset -f logger
return 1
;;
esac
: > "$_poll_log"
got=$(poll_lines_from_input 'not-json{{{')
check_eq poll_lines_from_input "$got" "50" "invalid JSON expected 50 got $got" || return 1
case "$(cat "$_poll_log")" in
*invalid_input*) ;;
*) echo "poll_lines_from_input: expected invalid_input log, got $(cat "$_poll_log")" >&2
rm -f "$_poll_log"
unset -f logger
return 1 ;;
esac
: > "$_poll_log"
_saved_jshn_sh="$FWLIVE_JSHN_SH"
FWLIVE_JSHN_SH="/no/such/fwlive-jshn.sh"
got=$(poll_lines_from_input '{"addresses":["500"]}')
FWLIVE_JSHN_SH="$_saved_jshn_sh"
check_eq poll_lines_from_input "$got" "50" "missing library expected 50 got $got" || return 1
case "$(cat "$_poll_log")" in
*jshn_lib_missing*) ;;
*) echo "poll_lines_from_input: expected jshn_lib_missing log, got $(cat "$_poll_log")" >&2
rm -f "$_poll_log"
unset -f logger
return 1 ;;
esac
rm -f "$_poll_log"
unset -f logger
return 0
}
case "${1:-}" in
__selftest)
run_selftest
exit $?
;;
__poll_clamp)
poll_clamp_lines "${2:-}"
exit $?
;;
__poll_lines)
poll_lines_from_input "${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)
# rpcd may omit argv $3 and pass JSON on stdin.
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
;;
*)
# Removed CLI hooks (including __rulesmap_iptables) must not succeed.
exit 1
;;
esac