feat(secubox-core): Add 4-LED dashboard with dedicated functions

LED assignment for MochaBin:
- led1: Global health status (green/yellow/red with pulse variations)
- led2: Security threat meter (CrowdSec + mitmproxy activity)
- led3: Global capacity (CPU + Network combined, color gradient)
- mmc0: Classic heartbeat when stable, rapid blink on state changes

Features:
- Fast 1.5s heartbeat loop for reactive visual feedback
- Health score from services (HAProxy, CrowdSec) + memory/disk
- Threat level from CrowdSec alerts and mitmproxy stats
- Combined CPU load + network throughput capacity meter
- Event pulse system for config/task/alert notifications
- State change detection for mmc0 stability indicator

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-06 10:41:50 +01:00
parent 52d982218a
commit 29ba711acc
4 changed files with 732 additions and 84 deletions

View File

@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-haproxy
PKG_VERSION:=1.0.0
PKG_RELEASE:=23
PKG_RELEASE:=24
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=MIT

View File

@ -858,6 +858,221 @@ cmd_cert_list() {
fi
}
# Check if we have local BIND for a zone
has_local_bind_zone() {
local domain="$1"
local zone_file="/etc/bind/zones/${domain}.zone"
[ -f "$zone_file" ] && command -v rndc >/dev/null 2>&1
}
# Add TXT record to local BIND zone
bind_add_txt() {
local subdomain="$1"
local zone="$2"
local value="$3"
local zone_file="/etc/bind/zones/${zone}.zone"
[ ! -f "$zone_file" ] && return 1
# Update serial (format: YYYYMMDDNN)
local current_serial=$(grep -oP '\d{10}(?=\s*;\s*Serial)' "$zone_file" 2>/dev/null)
local today=$(date +%Y%m%d)
local new_serial
if [ -n "$current_serial" ]; then
local serial_date="${current_serial:0:8}"
local serial_num="${current_serial:8:2}"
if [ "$serial_date" = "$today" ]; then
serial_num=$((10#$serial_num + 1))
new_serial=$(printf "%s%02d" "$today" "$serial_num")
else
new_serial="${today}01"
fi
sed -i "s/$current_serial/$new_serial/" "$zone_file"
fi
# Add TXT record (before the ; IPv6 comment or at end)
local record="$subdomain IN TXT \"$value\""
if grep -q "; IPv6" "$zone_file"; then
sed -i "/; IPv6/i $record" "$zone_file"
else
echo "$record" >> "$zone_file"
fi
# Reload BIND
rndc reload "$zone" 2>/dev/null || rndc reload 2>/dev/null
log_info "Added TXT record: $subdomain.$zone -> $value"
}
# Remove TXT record from local BIND zone
bind_remove_txt() {
local subdomain="$1"
local zone="$2"
local value="$3"
local zone_file="/etc/bind/zones/${zone}.zone"
[ ! -f "$zone_file" ] && return 1
# Remove the specific TXT record
sed -i "/$subdomain IN TXT \"$value\"/d" "$zone_file"
# Update serial
local current_serial=$(grep -oP '\d{10}(?=\s*;\s*Serial)' "$zone_file" 2>/dev/null)
if [ -n "$current_serial" ]; then
local today=$(date +%Y%m%d)
local serial_date="${current_serial:0:8}"
local serial_num="${current_serial:8:2}"
if [ "$serial_date" = "$today" ]; then
serial_num=$((10#$serial_num + 1))
else
serial_num=1
fi
local new_serial=$(printf "%s%02d" "$today" "$serial_num")
sed -i "s/$current_serial/$new_serial/" "$zone_file"
fi
rndc reload "$zone" 2>/dev/null || rndc reload 2>/dev/null
log_info "Removed TXT record: $subdomain.$zone"
}
# Get DNS provider configuration for DNS-01 challenge
get_dns_provider_env() {
local provider=$(uci -q get dns-provider.main.provider)
[ -z "$provider" ] && return 1
case "$provider" in
local|bind)
# Using local BIND - acme.sh will use our custom hook
echo "dns_local_bind"
;;
gandi)
local api_key=$(uci -q get dns-provider.gandi.api_key)
[ -z "$api_key" ] && return 1
export GANDI_LIVEDNS_KEY="$api_key"
echo "dns_gandi_livedns"
;;
ovh)
local app_key=$(uci -q get dns-provider.ovh.app_key)
local app_secret=$(uci -q get dns-provider.ovh.app_secret)
local consumer_key=$(uci -q get dns-provider.ovh.consumer_key)
local endpoint=$(uci -q get dns-provider.ovh.endpoint)
[ -z "$app_key" ] || [ -z "$app_secret" ] || [ -z "$consumer_key" ] && return 1
export OVH_AK="$app_key"
export OVH_AS="$app_secret"
export OVH_CK="$consumer_key"
export OVH_END="${endpoint:-ovh-eu}"
echo "dns_ovh"
;;
cloudflare)
local api_token=$(uci -q get dns-provider.cloudflare.api_token)
[ -z "$api_token" ] && return 1
export CF_Token="$api_token"
echo "dns_cloudflare"
;;
*)
return 1
;;
esac
return 0
}
# Create acme.sh DNS hook for local BIND
create_local_dns_hook() {
local hook_dir="/etc/acme/dnsapi"
ensure_dir "$hook_dir"
cat > "$hook_dir/dns_local_bind.sh" << 'HOOK'
#!/bin/sh
# acme.sh DNS hook for local BIND zones
dns_local_bind_add() {
local fulldomain="$1"
local txtvalue="$2"
# Extract zone from domain (e.g., _acme-challenge.gk2.secubox.in -> secubox.in)
local zone=$(echo "$fulldomain" | awk -F. '{print $(NF-1)"."$NF}')
local subdomain="${fulldomain%.$zone}"
local zone_file="/etc/bind/zones/${zone}.zone"
[ ! -f "$zone_file" ] && { echo "Zone file not found: $zone_file"; return 1; }
# Update serial
local current_serial=$(grep -oP '\d{10}(?=\s*;\s*Serial)' "$zone_file" 2>/dev/null)
if [ -n "$current_serial" ]; then
local today=$(date +%Y%m%d)
local serial_date="${current_serial:0:8}"
local serial_num="${current_serial:8:2}"
if [ "$serial_date" = "$today" ]; then
serial_num=$((10#$serial_num + 1))
else
serial_num=1
fi
local new_serial=$(printf "%s%02d" "$today" "$serial_num")
sed -i "s/$current_serial/$new_serial/" "$zone_file"
fi
# Add TXT record
local record="$subdomain IN TXT \"$txtvalue\""
if grep -q "; IPv6" "$zone_file"; then
sed -i "/; IPv6/i $record" "$zone_file"
else
echo "$record" >> "$zone_file"
fi
# Reload BIND (use kill -HUP since rndc may not be configured)
local bind_pid=$(pidof named 2>/dev/null)
if [ -n "$bind_pid" ]; then
kill -HUP "$bind_pid" 2>/dev/null
fi
rndc reload "$zone" 2>/dev/null || rndc reload 2>/dev/null || true
echo "Added: $fulldomain TXT $txtvalue"
# Wait for DNS propagation
sleep 5
return 0
}
dns_local_bind_rm() {
local fulldomain="$1"
local txtvalue="$2"
local zone=$(echo "$fulldomain" | awk -F. '{print $(NF-1)"."$NF}')
local subdomain="${fulldomain%.$zone}"
local zone_file="/etc/bind/zones/${zone}.zone"
[ ! -f "$zone_file" ] && return 0
# Remove the TXT record
sed -i "/$subdomain IN TXT \"$txtvalue\"/d" "$zone_file"
# Update serial
local current_serial=$(grep -oP '\d{10}(?=\s*;\s*Serial)' "$zone_file" 2>/dev/null)
if [ -n "$current_serial" ]; then
local today=$(date +%Y%m%d)
local serial_date="${current_serial:0:8}"
local serial_num="${current_serial:8:2}"
if [ "$serial_date" = "$today" ]; then
serial_num=$((10#$serial_num + 1))
else
serial_num=1
fi
local new_serial=$(printf "%s%02d" "$today" "$serial_num")
sed -i "s/$current_serial/$new_serial/" "$zone_file"
fi
# Reload BIND
local bind_pid=$(pidof named 2>/dev/null)
if [ -n "$bind_pid" ]; then
kill -HUP "$bind_pid" 2>/dev/null
fi
rndc reload "$zone" 2>/dev/null || rndc reload 2>/dev/null || true
echo "Removed: $fulldomain TXT $txtvalue"
return 0
}
HOOK
chmod +x "$hook_dir/dns_local_bind.sh"
}
cmd_cert_add() {
require_root
load_config
@ -865,6 +1080,17 @@ cmd_cert_add() {
local domain="$1"
[ -z "$domain" ] && { log_error "Domain required"; return 1; }
# Detect if this is a wildcard certificate request
local is_wildcard=0
local base_domain="$domain"
case "$domain" in
\*.*)
is_wildcard=1
base_domain="${domain#\*.}"
log_info "Wildcard certificate requested for $domain"
;;
esac
# Ensure HAProxy config has ACME backend (for webroot mode)
log_info "Ensuring HAProxy config is up to date..."
generate_config
@ -886,6 +1112,37 @@ cmd_cert_add() {
[ -z "$email" ] && { log_error "ACME email not configured. Set in LuCI > Services > HAProxy > Settings"; return 1; }
# For wildcard certs, DNS-01 challenge is REQUIRED
local dns_plugin=""
local use_local_bind=0
if [ "$is_wildcard" = "1" ]; then
# First check if we have local BIND for this zone
# Extract parent zone from domain (e.g., *.gk2.secubox.in -> secubox.in)
# Use awk instead of rev which isn't available on OpenWrt
local parent_zone=$(echo "$base_domain" | awk -F. '{print $(NF-1)"."$NF}')
if has_local_bind_zone "$parent_zone"; then
log_info "Local BIND zone found for $parent_zone"
use_local_bind=1
dns_plugin="dns_local_bind"
create_local_dns_hook
else
# Try external DNS provider
dns_plugin=$(get_dns_provider_env)
fi
if [ -z "$dns_plugin" ]; then
log_error "DNS provider not configured. Wildcard certificates require DNS-01 challenge."
log_error "Options:"
log_error " 1. Use local BIND: ensure /etc/bind/zones/<zone>.zone exists"
log_error " 2. Configure external DNS: LuCI > Services > DNS Provider"
log_error " uci set dns-provider.main.provider='gandi'"
log_error " uci set dns-provider.gandi.api_key='YOUR_API_KEY'"
log_error " uci commit dns-provider"
return 1
fi
log_info "Using DNS-01 challenge with $dns_plugin"
fi
# Warn about staging mode
if [ "$staging" = "1" ]; then
log_warn "=========================================="
@ -923,56 +1180,77 @@ cmd_cert_add() {
"$ACME_SH" --register-account -m "$email" --server letsencrypt $staging_flag --home "$LE_WORKING_DIR" || true
fi
# Setup webroot for ACME challenges (HAProxy routes /.well-known/acme-challenge/ here)
local ACME_WEBROOT="/var/www/acme-challenge"
ensure_dir "$ACME_WEBROOT/.well-known/acme-challenge"
chmod 755 "$ACME_WEBROOT" "$ACME_WEBROOT/.well-known" "$ACME_WEBROOT/.well-known/acme-challenge"
# Start simple webserver for ACME challenges (if not already running)
local ACME_PORT=8402
if ! netstat -tln 2>/dev/null | grep -q ":$ACME_PORT "; then
log_info "Starting ACME challenge webserver on port $ACME_PORT..."
# Use busybox httpd (available on OpenWrt)
start-stop-daemon -S -b -x /usr/sbin/httpd -- -p $ACME_PORT -h "$ACME_WEBROOT" -f 2>/dev/null || \
busybox httpd -p $ACME_PORT -h "$ACME_WEBROOT" &
sleep 1
fi
# Ensure HAProxy is running with ACME backend
if ! lxc_running; then
log_info "Starting HAProxy..."
/etc/init.d/haproxy start 2>/dev/null || true
sleep 2
fi
# Issue certificate using webroot mode (NO HAProxy restart needed!)
log_info "Issuing certificate (webroot mode - HAProxy stays running)..."
local acme_result=0
"$ACME_SH" --issue -d "$domain" \
--server letsencrypt \
--webroot "$ACME_WEBROOT" \
--keylength "$key_type" \
$staging_flag \
--home "$LE_WORKING_DIR" || acme_result=$?
if [ "$is_wildcard" = "1" ]; then
# DNS-01 challenge for wildcard certificates
log_info "Issuing wildcard certificate via DNS-01 challenge..."
log_info "This will create a TXT record at _acme-challenge.$base_domain"
"$ACME_SH" --issue -d "$domain" -d "$base_domain" \
--server letsencrypt \
--dns "$dns_plugin" \
--keylength "$key_type" \
$staging_flag \
--home "$LE_WORKING_DIR" || acme_result=$?
else
# HTTP-01 challenge for regular domains (webroot mode)
# Setup webroot for ACME challenges (HAProxy routes /.well-known/acme-challenge/ here)
local ACME_WEBROOT="/var/www/acme-challenge"
ensure_dir "$ACME_WEBROOT/.well-known/acme-challenge"
chmod 755 "$ACME_WEBROOT" "$ACME_WEBROOT/.well-known" "$ACME_WEBROOT/.well-known/acme-challenge"
# Start simple webserver for ACME challenges (if not already running)
local ACME_PORT=8402
if ! netstat -tln 2>/dev/null | grep -q ":$ACME_PORT "; then
log_info "Starting ACME challenge webserver on port $ACME_PORT..."
# Use busybox httpd (available on OpenWrt)
start-stop-daemon -S -b -x /usr/sbin/httpd -- -p $ACME_PORT -h "$ACME_WEBROOT" -f 2>/dev/null || \
busybox httpd -p $ACME_PORT -h "$ACME_WEBROOT" &
sleep 1
fi
# Ensure HAProxy is running with ACME backend
if ! lxc_running; then
log_info "Starting HAProxy..."
/etc/init.d/haproxy start 2>/dev/null || true
sleep 2
fi
# Issue certificate using webroot mode (NO HAProxy restart needed!)
log_info "Issuing certificate (webroot mode - HAProxy stays running)..."
"$ACME_SH" --issue -d "$domain" \
--server letsencrypt \
--webroot "$ACME_WEBROOT" \
--keylength "$key_type" \
$staging_flag \
--home "$LE_WORKING_DIR" || acme_result=$?
fi
# acme.sh returns 0 on success, 2 on "skip/already valid" - both are OK
# Install the certificate to our certs path
# For wildcard, use sanitized filename (replace * with _wildcard_)
local cert_filename="$domain"
case "$domain" in
\*.*) cert_filename="_wildcard_.${domain#\*.}" ;;
esac
if [ "$acme_result" -eq 0 ] || [ "$acme_result" -eq 2 ]; then
log_info "Installing certificate..."
"$ACME_SH" --install-cert -d "$domain" \
--home "$LE_WORKING_DIR" \
--cert-file "$CERTS_PATH/$domain.crt" \
--key-file "$CERTS_PATH/$domain.key" \
--fullchain-file "$CERTS_PATH/$domain.fullchain.pem" \
--cert-file "$CERTS_PATH/$cert_filename.crt" \
--key-file "$CERTS_PATH/$cert_filename.key" \
--fullchain-file "$CERTS_PATH/$cert_filename.fullchain.pem" \
--reloadcmd "/etc/init.d/haproxy reload" 2>/dev/null || true
# HAProxy needs combined file: fullchain + private key
log_info "Creating combined PEM for HAProxy..."
cat "$CERTS_PATH/$domain.fullchain.pem" "$CERTS_PATH/$domain.key" > "$CERTS_PATH/$domain.pem"
chmod 600 "$CERTS_PATH/$domain.pem"
cat "$CERTS_PATH/$cert_filename.fullchain.pem" "$CERTS_PATH/$cert_filename.key" > "$CERTS_PATH/$cert_filename.pem"
chmod 600 "$CERTS_PATH/$cert_filename.pem"
# Clean up intermediate files - HAProxy only needs the .pem file
rm -f "$CERTS_PATH/$domain.crt" "$CERTS_PATH/$domain.key" "$CERTS_PATH/$domain.fullchain.pem" "$CERTS_PATH/$domain.crt.key" 2>/dev/null
rm -f "$CERTS_PATH/$cert_filename.crt" "$CERTS_PATH/$cert_filename.key" "$CERTS_PATH/$cert_filename.fullchain.pem" "$CERTS_PATH/$cert_filename.crt.key" 2>/dev/null
# Reload HAProxy to pick up new cert
log_info "Reloading HAProxy to use new certificate..."
@ -980,14 +1258,24 @@ cmd_cert_add() {
fi
# Check if certificate was created
if [ ! -f "$CERTS_PATH/$domain.pem" ]; then
log_error "Certificate issuance failed. Check:"
log_error " 1. Domain $domain points to this server's public IP"
log_error " 2. Port 80 is accessible from internet"
log_error " 3. HAProxy is running with ACME backend (haproxyctl generate)"
if [ ! -f "$CERTS_PATH/$cert_filename.pem" ]; then
if [ "$is_wildcard" = "1" ]; then
log_error "Wildcard certificate issuance failed. Check:"
log_error " 1. DNS provider API credentials are correct"
log_error " 2. You have permission to modify DNS for $base_domain"
log_error " 3. DNS propagation may take time (try again in a few minutes)"
else
log_error "Certificate issuance failed. Check:"
log_error " 1. Domain $domain points to this server's public IP"
log_error " 2. Port 80 is accessible from internet"
log_error " 3. HAProxy is running with ACME backend (haproxyctl generate)"
fi
return 1
fi
log_info "Certificate ready: $CERTS_PATH/$domain.pem"
log_info "Certificate ready: $CERTS_PATH/$cert_filename.pem"
# Update domain variable for UCI storage
domain="$cert_filename"
elif command -v certbot >/dev/null 2>&1; then
certbot certonly --standalone -d "$domain" \
--email "$email" --agree-tos -n \

View File

@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-core
PKG_VERSION:=0.10.0
PKG_RELEASE:=12
PKG_RELEASE:=13
PKG_ARCH:=all
PKG_LICENSE:=GPL-2.0
PKG_MAINTAINER:=SecuBox Team

View File

@ -16,11 +16,28 @@ PID_FILE="/var/run/secubox/core.pid"
STATE_DIR="/var/run/secubox"
WATCHDOG_STATE="/var/run/secubox/watchdog.json"
# LED paths for MochaBin (RGB LEDs: led1, led2, led3)
LED_GREEN="/sys/class/leds/green:led1"
LED_RED="/sys/class/leds/red:led1"
LED_BLUE="/sys/class/leds/blue:led1"
# LED paths for MochaBin (RGB LEDs: led1, led2, led3 + mmc0)
# led1: Global health status (green=ok, yellow=degraded, red=critical)
# led2: Security threat level (green=safe, red=attack)
# led3: Global capacity meter (CPU + Network)
# mmc0: Classic heartbeat when states are stable
LED_GREEN1="/sys/class/leds/green:led1"
LED_GREEN2="/sys/class/leds/green:led2"
LED_GREEN3="/sys/class/leds/green:led3"
LED_RED1="/sys/class/leds/red:led1"
LED_RED2="/sys/class/leds/red:led2"
LED_RED3="/sys/class/leds/red:led3"
LED_BLUE1="/sys/class/leds/blue:led1"
LED_BLUE2="/sys/class/leds/blue:led2"
LED_BLUE3="/sys/class/leds/blue:led3"
LED_MMC0="/sys/class/leds/mmc0::"
# Legacy aliases for compatibility
LED_GREEN="$LED_GREEN1"
LED_RED="$LED_RED1"
LED_BLUE="$LED_BLUE1"
LED_ENABLED=0
LED_COLOR_CYCLE=0 # Cycles 0-5 for rainbow colors
LED_PREV_STATE="" # Track previous state for change detection
# Services to monitor (init.d name:check_method:restart_delay)
# check_method: pid, docker, lxc, port:PORT
@ -29,18 +46,144 @@ MONITORED_SERVICES=""
# LED helper functions
led_init() {
# Check if LEDs are available (MochaBin or compatible)
if [ -d "$LED_GREEN" ] && [ -d "$LED_RED" ]; then
if [ -d "$LED_GREEN1" ] && [ -d "$LED_RED1" ]; then
LED_ENABLED=1
# Set trigger to none for manual control
echo none > "$LED_GREEN/trigger" 2>/dev/null
echo none > "$LED_RED/trigger" 2>/dev/null
echo none > "$LED_BLUE/trigger" 2>/dev/null
log debug "LED heartbeat enabled (MochaBin detected)"
# Set trigger to none for manual control on RGB LEDs
for led in "$LED_RED1" "$LED_GREEN1" "$LED_BLUE1" \
"$LED_RED2" "$LED_GREEN2" "$LED_BLUE2" \
"$LED_RED3" "$LED_GREEN3" "$LED_BLUE3"; do
echo none > "$led/trigger" 2>/dev/null
echo 0 > "$led/brightness" 2>/dev/null
done
# mmc0 LED: start with heartbeat trigger (stable state)
if [ -d "$LED_MMC0" ]; then
echo heartbeat > "$LED_MMC0/trigger" 2>/dev/null
fi
log debug "LED enabled: led1=health, led2=security, led3=capacity, mmc0=heartbeat"
else
log debug "LED heartbeat disabled (no compatible LEDs)"
fi
}
# mmc0 classic heartbeat control
# When states are stable: heartbeat trigger (classic pulse)
# When states change: rapid blink to indicate activity
led_mmc0_heartbeat() {
[ -d "$LED_MMC0" ] || return 0
local stable="$1" # 1=stable, 0=changing
if [ "$stable" = "1" ]; then
# Classic heartbeat - stable state
local current=$(cat "$LED_MMC0/trigger" 2>/dev/null)
[ "$current" != "heartbeat" ] && echo heartbeat > "$LED_MMC0/trigger" 2>/dev/null
else
# Rapid blink - state change detected
echo timer > "$LED_MMC0/trigger" 2>/dev/null
echo 50 > "$LED_MMC0/delay_on" 2>/dev/null
echo 50 > "$LED_MMC0/delay_off" 2>/dev/null
fi
}
# Set all colors for a specific LED (1, 2, or 3)
led_set_rgb() {
local led_num="$1" # 1, 2, or 3
local r="$2" g="$3" b="$4"
case "$led_num" in
1)
echo "$r" > "$LED_RED1/brightness" 2>/dev/null
echo "$g" > "$LED_GREEN1/brightness" 2>/dev/null
echo "$b" > "$LED_BLUE1/brightness" 2>/dev/null
;;
2)
echo "$r" > "$LED_RED2/brightness" 2>/dev/null
echo "$g" > "$LED_GREEN2/brightness" 2>/dev/null
echo "$b" > "$LED_BLUE2/brightness" 2>/dev/null
;;
3)
echo "$r" > "$LED_RED3/brightness" 2>/dev/null
echo "$g" > "$LED_GREEN3/brightness" 2>/dev/null
echo "$b" > "$LED_BLUE3/brightness" 2>/dev/null
;;
esac
}
# Turn off all LEDs on one LED unit
led_off() {
local led_num="$1"
led_set_rgb "$led_num" 0 0 0
}
# Global capacity LED on led3 - combines CPU + Network activity
# Color gradient: Green (idle) -> Cyan (light) -> Yellow (moderate) -> Orange (busy) -> Red (max)
# Network state file for delta calculation
NET_PREV_FILE="/tmp/secubox/net_prev"
led_global_capacity() {
[ "$LED_ENABLED" = "1" ] || return 0
# === CPU Load (0-100%) ===
local load=$(cat /proc/loadavg 2>/dev/null | cut -d' ' -f1)
local ncpu=$(grep -c ^processor /proc/cpuinfo 2>/dev/null || echo 4)
local load_int=$(echo "$load" | cut -d. -f1)
local load_dec=$(echo "$load" | cut -d. -f2 | cut -c1-2)
[ -z "$load_dec" ] && load_dec=0
local cpu_pct=$(( (load_int * 100 + load_dec) * 100 / ncpu / 100 ))
[ "$cpu_pct" -gt 100 ] && cpu_pct=100
# === Network Activity (0-100%) ===
# Sum all interface bytes
local net_now=0
for iface in /sys/class/net/*/statistics/rx_bytes; do
[ -f "$iface" ] || continue
local rx=$(cat "$iface" 2>/dev/null || echo 0)
local tx=$(cat "${iface%rx_bytes}tx_bytes" 2>/dev/null || echo 0)
net_now=$((net_now + rx + tx))
done
# Calculate delta from previous reading
local net_prev=$(cat "$NET_PREV_FILE" 2>/dev/null || echo "$net_now")
echo "$net_now" > "$NET_PREV_FILE"
local net_delta=$((net_now - net_prev))
[ "$net_delta" -lt 0 ] && net_delta=0
# Convert to percentage (scale: 10MB/s = 100%)
# 10MB = 10485760 bytes, measured over ~1.5s interval
local net_pct=$((net_delta * 100 / 15728640))
[ "$net_pct" -gt 100 ] && net_pct=100
# === Combine: weighted average (60% CPU, 40% network) ===
local pct=$(( (cpu_pct * 60 + net_pct * 40) / 100 ))
[ "$pct" -gt 100 ] && pct=100
# === Color gradient with more variety ===
local r g b
if [ "$pct" -le 20 ]; then
# Green (idle) - pure green
r=0; g=255; b=0
elif [ "$pct" -le 40 ]; then
# Green to Cyan (light activity)
r=0; g=255; b=$(( (pct - 20) * 12 ))
elif [ "$pct" -le 60 ]; then
# Cyan to Yellow (moderate)
r=$(( (pct - 40) * 12 ))
g=255
b=$((255 - (pct - 40) * 12))
elif [ "$pct" -le 80 ]; then
# Yellow to Orange (busy)
r=255
g=$((255 - (pct - 60) * 8))
b=0
else
# Orange to Red (max capacity)
r=255
g=$((100 - (pct - 80) * 5))
[ "$g" -lt 0 ] && g=0
b=0
fi
led_set_rgb 3 "$r" "$g" "$b"
}
led_set() {
local led="$1"
local state="$2" # 0 = off, 1 = on
@ -56,34 +199,154 @@ led_pulse() {
( sleep 1 && led_set "$led" 0 ) &
}
# Heartbeat function - shows system status via LED
# Green pulse = healthy, Red pulse = warning/error
# Security threat level for LED2
# Reads from CrowdSec alerts and mitmproxy threats
THREAT_CACHE_FILE="/tmp/secubox/threat_level"
get_threat_level() {
# Check cache age (refresh every 10s)
local cache_age=999
if [ -f "$THREAT_CACHE_FILE" ]; then
local now=$(date +%s)
local mtime=$(stat -c %Y "$THREAT_CACHE_FILE" 2>/dev/null || echo 0)
cache_age=$((now - mtime))
fi
if [ "$cache_age" -lt 10 ]; then
cat "$THREAT_CACHE_FILE" 2>/dev/null || echo 0
return
fi
# Count recent threats (last 5 minutes)
local threats=0
# CrowdSec alerts
if [ -f "/var/log/crowdsec.log" ]; then
local cs_alerts=$(tail -100 /var/log/crowdsec.log 2>/dev/null | grep -c "alert" || echo 0)
threats=$((threats + cs_alerts))
fi
# CrowdSec decisions (active bans)
if command -v cscli >/dev/null 2>&1; then
local bans=$(cscli decisions list -o raw 2>/dev/null | wc -l || echo 0)
threats=$((threats + bans / 2))
fi
# mitmproxy threats today
local mitm_threats=$(uci -q get mitmproxy.stats.threats_today 2>/dev/null || echo 0)
threats=$((threats + mitm_threats / 10))
# Normalize to 0-100
[ "$threats" -gt 100 ] && threats=100
echo "$threats" > "$THREAT_CACHE_FILE"
echo "$threats"
}
# Get global health score from services
HEALTH_CACHE_FILE="/tmp/secubox/health_score"
get_health_score() {
# Check cache age (refresh every 15s)
local cache_age=999
if [ -f "$HEALTH_CACHE_FILE" ]; then
local now=$(date +%s)
local mtime=$(stat -c %Y "$HEALTH_CACHE_FILE" 2>/dev/null || echo 0)
cache_age=$((now - mtime))
fi
if [ "$cache_age" -lt 15 ]; then
cat "$HEALTH_CACHE_FILE" 2>/dev/null || echo 100
return
fi
local score=100
local penalty=0
# Check critical services
pgrep haproxy >/dev/null 2>&1 || penalty=$((penalty + 20))
pgrep crowdsec >/dev/null 2>&1 || penalty=$((penalty + 15))
lxc-info -n haproxy 2>/dev/null | grep -q RUNNING || penalty=$((penalty + 20))
# Check system resources
local mem_free=$(grep MemAvailable /proc/meminfo 2>/dev/null | awk '{print $2}')
local mem_total=$(grep MemTotal /proc/meminfo 2>/dev/null | awk '{print $2}')
if [ -n "$mem_free" ] && [ -n "$mem_total" ] && [ "$mem_total" -gt 0 ]; then
local mem_pct=$((mem_free * 100 / mem_total))
[ "$mem_pct" -lt 10 ] && penalty=$((penalty + 25))
[ "$mem_pct" -lt 20 ] && penalty=$((penalty + 10))
fi
# Check disk space
local disk_pct=$(df / 2>/dev/null | tail -1 | awk '{print $5}' | tr -d '%')
[ -n "$disk_pct" ] && [ "$disk_pct" -gt 90 ] && penalty=$((penalty + 20))
score=$((100 - penalty))
[ "$score" -lt 0 ] && score=0
echo "$score" > "$HEALTH_CACHE_FILE"
echo "$score"
}
# Heartbeat function - 3 dedicated LEDs
# LED1: Global health (green=healthy, yellow=degraded, red=critical) with pulse
# LED2: Security threat meter (green=safe, yellow=activity, orange=elevated, red=attack)
# LED3: Global capacity (CPU+Network) - handled by led_global_capacity()
led_heartbeat() {
[ "$LED_ENABLED" = "1" ] || return 0
local status="$1" # healthy, warning, error
case "$status" in
healthy)
# Single green flash
led_set "$LED_GREEN" 255
( sleep 1 && echo 0 > "${LED_GREEN}/brightness" 2>/dev/null ) &
;;
warning)
# Double red flash
led_set "$LED_RED" 255
( sleep 1 && echo 0 > "${LED_RED}/brightness" && sleep 1 && echo 255 > "${LED_RED}/brightness" && sleep 1 && echo 0 > "${LED_RED}/brightness" ) &
;;
error)
# Long red flash
led_set "$LED_RED" 255
( sleep 2 && echo 0 > "${LED_RED}/brightness" 2>/dev/null ) &
;;
boot)
# Blue pulse during startup
led_set "$LED_BLUE" 255
( sleep 2 && echo 0 > "${LED_BLUE}/brightness" 2>/dev/null ) &
;;
esac
# LED1: Global health status with rainbow pulse effect
local health=$(get_health_score)
local r1 g1 b1
if [ "$health" -ge 80 ]; then
# Healthy - Green with subtle color cycling
case "$((LED_COLOR_CYCLE % 3))" in
0) r1=0; g1=255; b1=0 ;; # Pure green
1) r1=0; g1=255; b1=50 ;; # Green-cyan
2) r1=50; g1=255; b1=0 ;; # Green-yellow
esac
elif [ "$health" -ge 50 ]; then
# Degraded - Yellow/Orange cycling
case "$((LED_COLOR_CYCLE % 3))" in
0) r1=255; g1=255; b1=0 ;; # Yellow
1) r1=255; g1=200; b1=0 ;; # Gold
2) r1=255; g1=150; b1=0 ;; # Orange
esac
else
# Critical - Red pulsing
case "$((LED_COLOR_CYCLE % 3))" in
0) r1=255; g1=0; b1=0 ;; # Red
1) r1=255; g1=50; b1=0 ;; # Red-orange
2) r1=200; g1=0; b1=50 ;; # Red-magenta
esac
fi
led_set_rgb 1 "$r1" "$g1" "$b1"
LED_COLOR_CYCLE=$(( (LED_COLOR_CYCLE + 1) % 6 ))
# LED2: Security threat level
local threat=$(get_threat_level)
local r2 g2 b2
if [ "$threat" -le 5 ]; then
# Green - all quiet
r2=0; g2=255; b2=0
elif [ "$threat" -le 20 ]; then
# Green-Yellow - minor activity
r2=$((threat * 10)); g2=255; b2=0
elif [ "$threat" -le 50 ]; then
# Yellow - elevated activity
r2=255; g2=255; b2=0
elif [ "$threat" -le 75 ]; then
# Orange - significant threats
r2=255; g2=$((180 - (threat - 50) * 4)); b2=0
else
# Red - under attack / high threat
r2=255; g2=$((50 - (threat - 75) * 2))
[ "$g2" -lt 0 ] && g2=0
b2=0
fi
led_set_rgb 2 "$r2" "$g2" "$b2"
}
# Auto-discover SecuBox services from ctl scripts
@ -488,6 +751,100 @@ get_watchdog_services() {
echo "$core_services"
}
# Event pulse - flash led3 with specific color when events happen
# Usage: echo "config|task|alert" > /tmp/secubox/led-event
led_event_pulse() {
local event_file="/tmp/secubox/led-event"
[ -f "$event_file" ] || return 1
local event=$(cat "$event_file" 2>/dev/null)
rm -f "$event_file"
[ -z "$event" ] && return 1
# Flash pattern based on event type
case "$event" in
config)
# White flash for config changes
led_set_rgb 3 255 255 255
;;
task)
# Cyan flash for scheduled tasks
led_set_rgb 3 0 255 255
;;
alert)
# Magenta flash for alerts
led_set_rgb 3 255 0 255
;;
network)
# Blue flash for network events
led_set_rgb 3 0 100 255
;;
*)
# Purple for unknown events
led_set_rgb 3 180 0 255
;;
esac
return 0
}
# Fast LED heartbeat background loop
# Runs independently at 1.5s intervals for reactive visual feedback
# led1: Global health status
# led2: Security threat level
# led3: Global capacity meter (CPU + Network) with event pulse overlay
# mmc0: Classic heartbeat when states stable, rapid blink on changes
led_heartbeat_loop() {
local status_file="/tmp/secubox/led-status"
echo "healthy" > "$status_file"
local prev_health=0
local prev_threat=0
local stable_counter=0
while true; do
local status=$(cat "$status_file" 2>/dev/null || echo "healthy")
# Get current states
local cur_health=$(get_health_score)
local cur_threat=$(get_threat_level)
# Detect state changes
local health_delta=$((cur_health - prev_health))
[ "$health_delta" -lt 0 ] && health_delta=$((-health_delta))
local threat_delta=$((cur_threat - prev_threat))
[ "$threat_delta" -lt 0 ] && threat_delta=$((-threat_delta))
# Check if states are stable (delta < 5)
if [ "$health_delta" -lt 5 ] && [ "$threat_delta" -lt 5 ]; then
stable_counter=$((stable_counter + 1))
else
stable_counter=0
fi
# mmc0: classic heartbeat when stable for 3+ cycles
if [ "$stable_counter" -ge 3 ]; then
led_mmc0_heartbeat 1 # Stable
else
led_mmc0_heartbeat 0 # Changing
fi
prev_health=$cur_health
prev_threat=$cur_threat
# Update RGB LEDs
led_heartbeat "$status"
# Check for event pulse first (overrides capacity momentarily)
if led_event_pulse; then
sleep 0.3 # Brief flash
fi
# Global capacity on led3
led_global_capacity
sleep 1.2
done
}
# Daemon mode
daemon_mode() {
log info "SecuBox Core daemon starting (version $SECUBOX_VERSION)"
@ -508,6 +865,13 @@ daemon_mode() {
# LED heartbeat setting (enabled by default)
local led_heartbeat_enabled=$(uci -q get secubox.main.led_heartbeat || echo "1")
# Start fast LED heartbeat in background
if [ "$led_heartbeat_enabled" = "1" ]; then
led_heartbeat_loop &
LED_HEARTBEAT_PID=$!
log debug "LED heartbeat loop started (PID: $LED_HEARTBEAT_PID)"
fi
# Main daemon loop
local health_counter=0
local health_cycles=$((health_interval / watchdog_interval))
@ -523,16 +887,12 @@ daemon_mode() {
if [ "$health_counter" -ge "$health_cycles" ]; then
local health_output=$(run_health_check)
echo "$health_output" > /tmp/secubox/health-status.json
# Extract status for LED
# Extract status for LED and update status file
last_health_status=$(echo "$health_output" | jsonfilter -e '@.status' 2>/dev/null || echo "healthy")
echo "$last_health_status" > /tmp/secubox/led-status
health_counter=0
fi
# LED heartbeat pulse (once per watchdog cycle)
if [ "$led_heartbeat_enabled" = "1" ]; then
led_heartbeat "$last_health_status"
fi
# Sleep until next check
sleep "$watchdog_interval"
done