Files
NeoTachyon/tests/event_controller.sh
T

205 lines
8.0 KiB
Bash

#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
CONTROLLER_UC="$TACHYON_LIB/service/event_controller.uc"
WATCHDOG_UC="$TACHYON_LIB/service/watchdog.uc"
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
uc() {
command ucode -L "$TACHYON_LIB" "$@"
}
# Prints the fact types a line classifies into, one per line.
classify() {
uc "$CONTROLLER_UC" classify "$1" 2>/dev/null || true
}
# Prints the classified types joined by '+' so a multi-fact line is one token.
classify_joined() {
classify "$1" | tr '\n' '+' | sed 's/+$//'
}
assert_eq() {
local actual="$1" expected="$2" label="$3"
[ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'"
}
# --- OOM detection: the real thing must be recognised ---
for line in \
'kernel: sing-box invoked oom-killer: gfp_mask=0x100cca' \
'kernel: Out of memory: Kill process 1234 (sing-box) score 900' \
'daemon.err sing-box[123]: fatal error: out of memory'
do
assert_eq "$(classify_joined "$line")" "oom.detected" "OOM line: $line"
done
# --- OOM discrimination: netlink/nlbwmon noise must NOT read as OOM ---
# This is the guard at watchdog.uc:1509. Misreading it would shrink
# GOMEMLIMIT for nothing, degrading throughput on every router that logs it.
assert_eq "$(classify_joined 'nlbwmon: netlink: out of memory allocating buffer')" \
"" "netlink out-of-memory warning is not an OOM event"
assert_eq "$(classify_joined 'kernel: netlink: 12 bytes leftover after parsing, out of memory')" \
"" "kernel netlink warning is not an OOM event"
# --- OOM is terminal: no further facts from the same line ---
# The original returned immediately after handling OOM.
assert_eq "$(classify_joined 'kernel: sing-box invoked oom-killer, URLTest switch proxy')" \
"oom.detected" "OOM short-circuits URLTest classification"
# --- keyword pre-filter: unrelated lines cost nothing and yield nothing ---
assert_eq "$(classify_joined 'daemon.info dnsmasq[1]: reading /tmp/resolv.conf')" \
"" "unrelated line yields no fact"
assert_eq "$(classify_joined '')" "" "empty line yields no fact"
# --- smart detect: a direct-connection failure carries the domain ---
assert_eq "$(classify_joined 'outbound/direct: failed to connect to "blocked.example.com:443"')" \
"smartdetect.candidate" "direct failure classifies as smart-detect candidate"
assert_eq "$(uc "$CONTROLLER_UC" classify-domain \
'outbound/direct: failed to connect to "blocked.example.com:443"' 2>/dev/null)" \
"blocked.example.com" "smart-detect fact carries the extracted domain"
# A direct line with no usable host is not a candidate.
assert_eq "$(classify_joined 'outbound/direct: failed to connect to 192.168.1.1:443')" \
"" "direct failure without a domain yields no candidate"
# A direct line that did not fail is not a candidate.
assert_eq "$(classify_joined 'outbound/direct: connected to "example.com:443"')" \
"" "successful direct connection yields no candidate"
# --- URLTest switches ---
assert_eq "$(classify_joined 'URLTest: selected proxy outbound-2')" \
"urltest.switched" "URLTest line classifies as a switch"
assert_eq "$(classify_joined 'group: switch proxy to node-tokyo')" \
"urltest.switched" "switch proxy line classifies as a switch"
# --- smart detect and URLTest are not exclusive ---
# handle_log_line() fell through from one branch to the other, so a line that
# satisfies both must still produce both facts.
assert_eq "$(classify_joined 'URLTest: direct failed "blocked.example.com:443", switch proxy')" \
"smartdetect.candidate+urltest.switched" "one line can yield both facts, in order"
# --- extractor parity: the controller must match watchdog byte for byte ---
# tests/smart_detect_domain_extraction.sh pins the watchdog CLI; this pins the
# controller against it, so the two cannot drift apart silently.
for line in \
'outbound/direct: failed to connect to "example.com:443"' \
'direct connection failed: "sub.domain.example.org"' \
'DIRECT timeout "xn--80ak6aa92e.com:443"' \
'direct reset "a-b-c.example.co.uk:8443"' \
'direct failed target=blocked.example.net' \
'direct failed target blocked2.example.net' \
'direct failed to connect to 192.168.1.1:443' \
'direct connection failed, no host in line' \
'direct failed "localhost"' \
'direct failed "a.co"' \
'direct failed "*.example.com"' \
'direct failed "example..com"' \
'direct failed "-example.com"'
do
from_watchdog="$(uc "$WATCHDOG_UC" smart-detect-extract-domain "$line" 2>/dev/null || true)"
from_controller="$(uc "$CONTROLLER_UC" extract-domain "$line" 2>/dev/null || true)"
[ "$from_watchdog" = "$from_controller" ] || \
fail "extractor drift on '$line': watchdog='$from_watchdog' controller='$from_controller'"
done
# --- event vocabulary is exported and non-empty ---
types="$(uc "$CONTROLLER_UC" event-types)"
for expected in \
"SINGBOX_STOPPED=singbox.stopped" \
"DNS_DOWN=dns.down" \
"PROXY_DOWN=proxy.down" \
"OOM_DETECTED=oom.detected" \
"SMARTDETECT_CANDIDATE=smartdetect.candidate"
do
grep -qxF "$expected" <<< "$types" || fail "event vocabulary missing $expected"
done
# --- publication layer: the OOM replay guard ---
# logread -f replays the historical buffer on start. Facts derived from that
# replay describe the past, not the present, and must not be published.
run_case() {
local body="$1"
local tmp
tmp="$(mktemp "${TMPDIR:-/tmp}/tachyon_ec_case_XXXXXX")"
{
printf '%s\n' 'let events = require("core.events");'
printf '%s\n' 'let ec = require("service.event_controller");'
printf '%s\n' "$body"
} >"$tmp"
uc "$tmp"
rm -f "$tmp"
}
actual="$(run_case '
let b = events.bus();
let c = ec.controller(b, {});
let seen = 0;
b.on("oom.detected", function(ev) { seen++; });
// syslog_start_time still 0: we are inside the historical replay window.
print(length(c.handle_log_line("kernel: sing-box invoked oom-killer")) + "," + seen);
')"
assert_eq "$actual" "0,0" "OOM from the log replay window is not published"
actual="$(run_case '
let b = events.bus();
let c = ec.controller(b, {});
let seen = 0;
b.on("oom.detected", function(ev) { seen++; });
// Started well in the past: live lines, not replay.
c.set_syslog_start(time() - 3600);
c.handle_log_line("kernel: sing-box invoked oom-killer");
c.handle_log_line("kernel: sing-box invoked oom-killer");
print(seen);
')"
assert_eq "$actual" "1" "an OOM storm collapses into one published fact"
# --- publication layer: URLTest throttle ---
actual="$(run_case '
let b = events.bus();
let c = ec.controller(b, {});
let seen = 0;
b.on("urltest.switched", function(ev) { seen++; });
c.handle_log_line("URLTest: selected proxy a");
c.handle_log_line("URLTest: selected proxy b");
c.handle_log_line("URLTest: selected proxy c");
print(seen);
')"
assert_eq "$actual" "1" "URLTest facts are throttled to one per window"
# --- publication layer: ubus service stop is scoped to sing-box ---
actual="$(run_case '
let b = events.bus();
let c = ec.controller(b, {});
let seen = [];
b.on("singbox.stopped", function(ev) { push(seen, ev.payload.reason); });
let ours = c.handle_ubus_service_stop("sing-box", "procd");
let theirs = c.handle_ubus_service_stop("dnsmasq", "procd");
print(ours + "," + theirs + "," + join(",", seen));
')"
assert_eq "$actual" "true,false,procd" "only sing-box stops publish singbox.stopped"
# --- publication layer: honeypot input is validated before publishing ---
# The FIFO is world-writable by design; a malformed line must not become a fact
# that a subscriber later interpolates into an nft or iptables command.
actual="$(run_case '
let b = events.bus();
let c = ec.controller(b, {});
let seen = [];
b.on("honeypot.hit", function(ev) { push(seen, ev.payload.ip); });
let ok4 = c.handle_honeypot_line("203.0.113.7\n");
let ok6 = c.handle_honeypot_line("2001:db8::1");
let bad = c.handle_honeypot_line("203.0.113.7; nft flush ruleset");
let empty = c.handle_honeypot_line("");
print(ok4 + "," + ok6 + "," + bad + "," + empty + "|" + join(",", seen));
')"
assert_eq "$actual" "true,true,false,false|203.0.113.7,2001:db8::1" \
"honeypot publishes only well-formed addresses"
printf 'event_controller checks passed\n'