From 29ba711acc7e86b46d4fc36e214c71a402419328 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Fri, 6 Feb 2026 10:41:50 +0100 Subject: [PATCH] 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 --- package/secubox/secubox-app-haproxy/Makefile | 2 +- .../files/usr/sbin/haproxyctl | 372 +++++++++++++-- package/secubox/secubox-core/Makefile | 2 +- .../secubox-core/root/usr/sbin/secubox-core | 440 ++++++++++++++++-- 4 files changed, 732 insertions(+), 84 deletions(-) diff --git a/package/secubox/secubox-app-haproxy/Makefile b/package/secubox/secubox-app-haproxy/Makefile index d252cb58..0f437adf 100644 --- a/package/secubox/secubox-app-haproxy/Makefile +++ b/package/secubox/secubox-app-haproxy/Makefile @@ -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 PKG_LICENSE:=MIT diff --git a/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl b/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl index 68eaa890..af165441 100644 --- a/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl +++ b/package/secubox/secubox-app-haproxy/files/usr/sbin/haproxyctl @@ -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 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 \ diff --git a/package/secubox/secubox-core/Makefile b/package/secubox/secubox-core/Makefile index c6ddb185..c3a267f3 100644 --- a/package/secubox/secubox-core/Makefile +++ b/package/secubox/secubox-core/Makefile @@ -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 diff --git a/package/secubox/secubox-core/root/usr/sbin/secubox-core b/package/secubox/secubox-core/root/usr/sbin/secubox-core index 22f83a10..6e315f0a 100755 --- a/package/secubox/secubox-core/root/usr/sbin/secubox-core +++ b/package/secubox/secubox-core/root/usr/sbin/secubox-core @@ -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