secubox-openwrt/package/secubox/luci-app-threat-analyst/root/usr/libexec/rpcd/luci.threat-analyst
CyberMind-FR 93b9636258 fix(threat-analyst): Async AI chat to handle slow LocalAI inference
- LocalAI inference takes 30-60s on ARM64 hardware
- Changed RPCD chat handler to async pattern:
  - Returns poll_id immediately
  - Background process runs AI query (120s timeout)
  - Saves result to /var/lib/threat-analyst/chat_*.json
  - Client polls with poll_id to get result
- Updated api.js with chatAsync() that polls automatically
- Changed default LocalAI port from 8081 to 8091
- Frontend shows "Thinking..." message with spinner during inference
- Uses curl instead of wget (BusyBox wget doesn't support --post-data=-)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 16:41:27 +01:00

289 lines
7.8 KiB
Bash

#!/bin/sh
# SecuBox Threat Analyst RPCD Handler
. /usr/share/libubox/jshn.sh
CONFIG="threat-analyst"
STATE_DIR="/var/lib/threat-analyst"
LIB_DIR="/usr/lib/threat-analyst"
log_info() { logger -t threat-analyst-rpcd "$*"; }
uci_get() { uci -q get "${CONFIG}.$1"; }
# Source libraries for analysis functions
[ -f "$LIB_DIR/analyzer.sh" ] && {
localai_url=$(uci_get main.localai_url)
localai_model=$(uci_get main.localai_model)
[ -z "$localai_url" ] && localai_url="http://127.0.0.1:8091"
[ -z "$localai_model" ] && localai_model="tinyllama-1.1b-chat-v1.0.Q4_K_M"
. "$LIB_DIR/analyzer.sh"
. "$LIB_DIR/appliers.sh"
}
case "$1" in
list)
cat <<'EOF'
{
"status": {},
"get_threats": {"limit": 50},
"get_alerts": {"limit": 20},
"get_pending": {},
"chat": {"message": "string"},
"analyze": {},
"generate_rules": {"target": "string"},
"approve_rule": {"id": "string"},
"reject_rule": {"id": "string"},
"run_cycle": {}
}
EOF
;;
call)
case "$2" in
status)
# Get agent status
enabled=$(uci_get main.enabled)
interval=$(uci_get main.interval)
last_run=""
[ -f "$STATE_DIR/last_run" ] && last_run=$(cat "$STATE_DIR/last_run")
# Check LocalAI
localai_status="offline"
wget -q -O /dev/null --timeout=2 "${localai_url}/v1/models" 2>/dev/null && localai_status="online"
# Check daemon
daemon_running="false"
pgrep -f "threat-analyst daemon" >/dev/null 2>&1 && daemon_running="true"
# Count pending
pending_count=0
[ -f "$STATE_DIR/pending_rules.json" ] && \
pending_count=$(jsonfilter -i "$STATE_DIR/pending_rules.json" -e '@[*]' 2>/dev/null | wc -l)
# Count recent threats and CVE alerts
threat_count=0
cve_count=0
if command -v cscli >/dev/null 2>&1; then
alerts_json=$(cscli alerts list -o json --since 1h 2>/dev/null)
threat_count=$(echo "$alerts_json" | jsonfilter -e '@[*]' 2>/dev/null | wc -l)
# Count CVE-related alerts
cve_count=$(echo "$alerts_json" | grep -ic 'cve-' 2>/dev/null)
[ -z "$cve_count" ] && cve_count=0
fi
cat <<EOF
{
"enabled": $([ "$enabled" = "1" ] && echo "true" || echo "false"),
"daemon_running": $daemon_running,
"interval": ${interval:-300},
"last_run": "$last_run",
"localai_status": "$localai_status",
"localai_url": "$localai_url",
"pending_rules": $pending_count,
"recent_threats": $threat_count,
"cve_alerts": $cve_count
}
EOF
;;
get_threats)
read -r input
limit=$(echo "$input" | jsonfilter -e '@.limit' 2>/dev/null)
[ -z "$limit" ] && limit=50
# Get CrowdSec alerts
alerts='[]'
if command -v cscli >/dev/null 2>&1; then
alerts=$(cscli alerts list -o json --limit "$limit" 2>/dev/null || echo '[]')
fi
printf '{"threats":%s}' "$alerts"
;;
get_alerts)
read -r input
limit=$(echo "$input" | jsonfilter -e '@.limit' 2>/dev/null)
[ -z "$limit" ] && limit=20
# Get mitmproxy threats
threats='[]'
log_file="/srv/mitmproxy/threats.log"
if [ -f "$log_file" ]; then
# Build JSON array without jq
threats=$({
printf '['
first=1
tail -n "$limit" "$log_file" 2>/dev/null | while IFS= read -r line; do
# Skip empty or invalid JSON
echo "$line" | jsonfilter -e '@' >/dev/null 2>&1 || continue
[ $first -eq 0 ] && printf ','
first=0
printf '%s' "$line"
done
printf ']'
})
fi
printf '{"alerts":%s}' "$threats"
;;
get_pending)
pending='[]'
[ -f "$STATE_DIR/pending_rules.json" ] && pending=$(cat "$STATE_DIR/pending_rules.json")
printf '{"pending":%s}' "$pending"
;;
chat)
read -r input
message=$(echo "$input" | jsonfilter -e '@.message' 2>/dev/null)
if [ -z "$message" ]; then
echo '{"error":"No message provided"}'
exit 0
fi
# Generate request ID
req_id=$(head -c 8 /dev/urandom | md5sum | head -c 8)
req_file="$STATE_DIR/chat_${req_id}.json"
mkdir -p "$STATE_DIR"
# Check for pending response from previous request
poll_id=$(echo "$input" | jsonfilter -e '@.poll_id' 2>/dev/null)
if [ -n "$poll_id" ]; then
poll_file="$STATE_DIR/chat_${poll_id}.json"
if [ -f "$poll_file" ]; then
cat "$poll_file"
rm -f "$poll_file"
else
echo '{"pending":true,"poll_id":"'"$poll_id"'"}'
fi
exit 0
fi
# Check LocalAI quickly
if ! curl -s --max-time 2 "${localai_url}/v1/models" >/dev/null 2>&1; then
echo '{"error":"LocalAI not available","suggestion":"Start LocalAI: localaictl start"}'
exit 0
fi
# Start background AI query
(
escaped_message=$(printf '%s' "$message" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n' ' ')
prompt="You are SecuBox AI security assistant. $escaped_message"
response=$(curl -s --max-time 120 -X POST "${localai_url}/v1/chat/completions" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$localai_model\",\"messages\":[{\"role\":\"user\",\"content\":\"$prompt\"}],\"max_tokens\":256,\"temperature\":0.7}" 2>/dev/null)
if [ -n "$response" ]; then
content=$(echo "$response" | jsonfilter -e '@.choices[0].message.content' 2>/dev/null)
if [ -n "$content" ]; then
content=$(printf '%s' "$content" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n' ' ')
printf '{"response":"%s"}' "$content" > "$req_file"
else
echo '{"error":"Empty AI response"}' > "$req_file"
fi
else
echo '{"error":"AI query failed"}' > "$req_file"
fi
) &
# Return poll ID immediately
echo '{"pending":true,"poll_id":"'"$req_id"'"}'
;;
analyze)
# Run analysis without rule generation
threats=$(collect_threats 2>/dev/null)
analysis=$(analyze_threats "$threats" 2>/dev/null)
if [ -n "$analysis" ]; then
escaped=$(echo "$analysis" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr '\n' ' ')
printf '{"analysis":"%s"}' "$escaped"
else
echo '{"error":"Analysis failed"}'
fi
;;
generate_rules)
read -r input
target=$(echo "$input" | jsonfilter -e '@.target' 2>/dev/null)
[ -z "$target" ] && target="all"
# Source generators
[ -f "$LIB_DIR/generators.sh" ] && . "$LIB_DIR/generators.sh"
threats=$(collect_threats 2>/dev/null)
analysis=$(analyze_threats "$threats" 2>/dev/null)
result='{"rules":{'
first=1
if [ "$target" = "all" ] || [ "$target" = "mitmproxy" ]; then
[ $first -eq 0 ] && result="${result},"
first=0
mitm=$(generate_mitmproxy_filters "$analysis" "$threats" 2>/dev/null | base64 -w 0)
result="${result}\"mitmproxy\":\"$mitm\""
fi
if [ "$target" = "all" ] || [ "$target" = "crowdsec" ]; then
[ $first -eq 0 ] && result="${result},"
first=0
cs=$(generate_crowdsec_scenario "$analysis" "$threats" 2>/dev/null | base64 -w 0)
result="${result}\"crowdsec\":\"$cs\""
fi
if [ "$target" = "all" ] || [ "$target" = "waf" ]; then
[ $first -eq 0 ] && result="${result},"
first=0
waf=$(generate_waf_rules "$analysis" "$threats" 2>/dev/null | base64 -w 0)
result="${result}\"waf\":\"$waf\""
fi
result="${result}}}"
echo "$result"
;;
approve_rule)
read -r input
rule_id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null)
if [ -z "$rule_id" ]; then
echo '{"error":"No rule ID provided"}'
exit 0
fi
if approve_pending_rule "$rule_id" 2>/dev/null; then
echo '{"success":true}'
else
echo '{"success":false,"error":"Failed to approve rule"}'
fi
;;
reject_rule)
read -r input
rule_id=$(echo "$input" | jsonfilter -e '@.id' 2>/dev/null)
if [ -z "$rule_id" ]; then
echo '{"error":"No rule ID provided"}'
exit 0
fi
reject_pending_rule "$rule_id" 2>/dev/null
echo '{"success":true}'
;;
run_cycle)
# Trigger analysis cycle
/usr/bin/threat-analyst run >/dev/null 2>&1 &
echo '{"started":true}'
;;
*)
echo '{"error":"Unknown method"}'
;;
esac
;;
esac