diff --git a/package/secubox/luci-app-mac-guardian/htdocs/luci-static/resources/view/mac-guardian/dashboard.js b/package/secubox/luci-app-mac-guardian/htdocs/luci-static/resources/view/mac-guardian/dashboard.js index bd0f28f6..3eca262d 100644 --- a/package/secubox/luci-app-mac-guardian/htdocs/luci-static/resources/view/mac-guardian/dashboard.js +++ b/package/secubox/luci-app-mac-guardian/htdocs/luci-static/resources/view/mac-guardian/dashboard.js @@ -56,6 +56,18 @@ var callBlock = rpc.declare({ params: ['mac'] }); +var callDhcpStatus = rpc.declare({ + object: 'luci.mac-guardian', + method: 'dhcp_status', + expect: { '': {} } +}); + +var callDhcpCleanup = rpc.declare({ + object: 'luci.mac-guardian', + method: 'dhcp_cleanup', + expect: { '': {} } +}); + function formatDate(ts) { if (!ts || ts === 0) return '-'; var d = new Date(ts * 1000); @@ -83,7 +95,8 @@ return view.extend({ uci.load('mac-guardian'), callStatus(), callGetClients(), - callGetEvents(10) + callGetEvents(10), + callDhcpStatus() ]); }, @@ -91,6 +104,7 @@ return view.extend({ var status = data[1]; var clientData = data[2]; var eventData = data[3]; + var dhcpStatus = data[4] || {}; var clients = (clientData && clientData.clients) ? clientData.clients : []; var events = (eventData && eventData.events) ? eventData.events : []; var m, s, o; @@ -143,6 +157,17 @@ return view.extend({ } html += ''; + // DHCP Protection card + var dhcpColor = dhcpStatus.enabled ? '#080' : '#888'; + var dhcpLabel = dhcpStatus.enabled ? 'Enabled' : 'Disabled'; + html += '
'; + html += '

DHCP Protection

'; + html += '

Status: ' + dhcpLabel + '

'; + html += '

Leases: ' + (dhcpStatus.leases || 0) + '

'; + html += '

Conflicts: ' + (dhcpStatus.conflicts || 0) + '

'; + html += '

Stale: ' + (dhcpStatus.stale || 0) + '

'; + html += '
'; + html += ''; return html; }; @@ -175,6 +200,19 @@ return view.extend({ }); }; + o = s.option(form.Button, '_dhcp_cleanup', _('DHCP Cleanup')); + o.inputtitle = _('Clean Up'); + o.inputstyle = 'reload'; + o.onclick = function() { + ui.showModal(_('Cleaning'), [ + E('p', { 'class': 'spinning' }, _('Running DHCP lease maintenance...')) + ]); + return callDhcpCleanup().then(function() { + ui.hideModal(); + window.location.reload(); + }); + }; + // ========================================== // Clients Table // ========================================== diff --git a/package/secubox/luci-app-mac-guardian/root/usr/libexec/rpcd/luci.mac-guardian b/package/secubox/luci-app-mac-guardian/root/usr/libexec/rpcd/luci.mac-guardian index 775ba895..bf1b1546 100644 --- a/package/secubox/luci-app-mac-guardian/root/usr/libexec/rpcd/luci.mac-guardian +++ b/package/secubox/luci-app-mac-guardian/root/usr/libexec/rpcd/luci.mac-guardian @@ -8,7 +8,7 @@ MG_LOGFILE="/var/log/mac-guardian.log" case "$1" in list) - echo '{"status":{},"get_clients":{},"get_events":{"count":"int"},"scan":{},"start":{},"stop":{},"restart":{},"trust":{"mac":"str"},"block":{"mac":"str"}}' + echo '{"status":{},"get_clients":{},"get_events":{"count":"int"},"scan":{},"start":{},"stop":{},"restart":{},"trust":{"mac":"str"},"block":{"mac":"str"},"dhcp_status":{},"dhcp_cleanup":{}}' ;; call) case "$2" in @@ -183,6 +183,46 @@ case "$1" in echo '{"success":false,"error":"missing mac"}' fi ;; + + dhcp_status) + json_init + + dhcp_enabled=$(uci -q get mac-guardian.dhcp.enabled) + json_add_boolean "enabled" ${dhcp_enabled:-1} + + leases=0 + conflicts=0 + stale=0 + + if [ -f /tmp/dhcp.leases ] && [ -s /tmp/dhcp.leases ]; then + leases=$(wc -l < /tmp/dhcp.leases) + + # Count hostname conflicts + conflicts=$(awk '{print $4}' /tmp/dhcp.leases | grep -v '^\*$' | sort | uniq -d | wc -l) + + # Count stale leases + now=$(date +%s) + stale_timeout=$(uci -q get mac-guardian.dhcp.stale_timeout) + stale_timeout=${stale_timeout:-3600} + cutoff=$((now - stale_timeout)) + stale=$(awk -v cutoff="$cutoff" '$1 < cutoff' /tmp/dhcp.leases | wc -l) + fi + + json_add_int "leases" $leases + json_add_int "conflicts" $conflicts + json_add_int "stale" $stale + json_dump + ;; + + dhcp_cleanup) + /usr/sbin/mac-guardian dhcp-cleanup >/dev/null 2>&1 + removed=0 + + json_init + json_add_boolean "success" 1 + json_add_int "removed" $removed + json_dump + ;; esac ;; esac diff --git a/package/secubox/secubox-app-mac-guardian/files/etc/config/mac-guardian b/package/secubox/secubox-app-mac-guardian/files/etc/config/mac-guardian index ef613b94..012818cc 100644 --- a/package/secubox/secubox-app-mac-guardian/files/etc/config/mac-guardian +++ b/package/secubox/secubox-app-mac-guardian/files/etc/config/mac-guardian @@ -21,6 +21,14 @@ config whitelist 'whitelist' # list mac 'aa:bb:cc:dd:ee:ff' # list oui '00:50:E4' +config dhcp_protection 'dhcp' + option enabled '1' + option cleanup_stale '1' + option dedup_hostnames '1' + option flood_threshold '10' + option flood_window '60' + option stale_timeout '3600' + config reporting 'reporting' option stats_file '/var/run/mac-guardian/stats.json' option stats_interval '60' diff --git a/package/secubox/secubox-app-mac-guardian/files/etc/hotplug.d/hostapd/20-mac-guardian b/package/secubox/secubox-app-mac-guardian/files/etc/hotplug.d/hostapd/20-mac-guardian index 1c7a0e52..46473869 100644 --- a/package/secubox/secubox-app-mac-guardian/files/etc/hotplug.d/hostapd/20-mac-guardian +++ b/package/secubox/secubox-app-mac-guardian/files/etc/hotplug.d/hostapd/20-mac-guardian @@ -46,7 +46,7 @@ config_get enabled main enabled 0 fi ;; AP-STA-DISCONNECTED) - # Lightweight: just update last_seen + # Update last_seen and clean up stale DHCP lease for this MAC if mg_validate_mac "$mac"; then mg_lock && { local existing @@ -56,6 +56,7 @@ config_get enabled main enabled 0 hostname=$(mg_resolve_hostname "$mac") mg_db_upsert "$mac" "$iface" "$hostname" fi + mg_dhcp_cleanup_stale_mac "$mac" mg_unlock } fi diff --git a/package/secubox/secubox-app-mac-guardian/files/usr/lib/secubox/mac-guardian/functions.sh b/package/secubox/secubox-app-mac-guardian/files/usr/lib/secubox/mac-guardian/functions.sh index ce670ac1..90d9959a 100644 --- a/package/secubox/secubox-app-mac-guardian/files/usr/lib/secubox/mac-guardian/functions.sh +++ b/package/secubox/secubox-app-mac-guardian/files/usr/lib/secubox/mac-guardian/functions.sh @@ -28,6 +28,14 @@ MG_STATS_FILE="/var/run/mac-guardian/stats.json" MG_STATS_INTERVAL=60 MG_MAX_LOG_SIZE=524288 +# DHCP protection config +MG_DHCP_ENABLED=1 +MG_DHCP_CLEANUP_STALE=1 +MG_DHCP_DEDUP_HOSTNAMES=1 +MG_DHCP_FLOOD_THRESHOLD=10 +MG_DHCP_FLOOD_WINDOW=60 +MG_DHCP_STALE_TIMEOUT=3600 + # Whitelist arrays stored as newline-separated strings MG_WL_MACS="" MG_WL_OUIS="" @@ -79,6 +87,14 @@ mg_load_config() { config_get MG_STATS_INTERVAL reporting stats_interval 60 config_get MG_MAX_LOG_SIZE reporting max_log_size 524288 + # dhcp protection section + config_get MG_DHCP_ENABLED dhcp enabled 1 + config_get MG_DHCP_CLEANUP_STALE dhcp cleanup_stale 1 + config_get MG_DHCP_DEDUP_HOSTNAMES dhcp dedup_hostnames 1 + config_get MG_DHCP_FLOOD_THRESHOLD dhcp flood_threshold 10 + config_get MG_DHCP_FLOOD_WINDOW dhcp flood_window 60 + config_get MG_DHCP_STALE_TIMEOUT dhcp stale_timeout 3600 + # whitelist lists MG_WL_MACS="" MG_WL_OUIS="" @@ -340,6 +356,146 @@ mg_resolve_hostname() { echo "$hostname" } +# ============================================================ +# DHCP Lease Protection +# ============================================================ + +MG_DHCP_LEASES="/tmp/dhcp.leases" + +mg_dhcp_read_leases() { + local tmpfile="${MG_RUNDIR}/dhcp_leases.$$" + if [ -f "$MG_DHCP_LEASES" ] && [ -s "$MG_DHCP_LEASES" ]; then + cp "$MG_DHCP_LEASES" "$tmpfile" + else + : > "$tmpfile" + fi + echo "$tmpfile" +} + +mg_dhcp_find_hostname_dupes() { + [ "$MG_DHCP_DEDUP_HOSTNAMES" != "1" ] && return 0 + [ ! -f "$MG_DHCP_LEASES" ] || [ ! -s "$MG_DHCP_LEASES" ] && return 0 + + local leases_copy + leases_copy=$(mg_dhcp_read_leases) + + # Format: timestamp mac ip hostname clientid + # Find hostnames that appear with more than one MAC + local dupes_file="${MG_RUNDIR}/hostname_dupes.$$" + awk '{print $4, $2, $1}' "$leases_copy" | \ + sort -k1,1 -k3,3n | \ + awk ' + { + hostname=$1; mac=$2; ts=$3 + if (hostname != "*" && hostname != "" && hostname == prev_host && mac != prev_mac) { + # Duplicate hostname with different MAC - print the older one + if (ts < prev_ts) { + print mac + } else { + print prev_mac + } + } + prev_host=hostname; prev_mac=mac; prev_ts=ts + }' | sort -u > "$dupes_file" + + if [ -s "$dupes_file" ]; then + while read -r dup_mac; do + [ -z "$dup_mac" ] && continue + local hostname + hostname=$(awk -v m="$dup_mac" 'tolower($2)==tolower(m) {print $4; exit}' "$leases_copy") + mg_log_event "dhcp_hostname_conflict" "$dup_mac" "" "hostname=${hostname}" + mg_dhcp_remove_lease "$dup_mac" + done < "$dupes_file" + fi + + rm -f "$dupes_file" "$leases_copy" +} + +mg_dhcp_cleanup_stale() { + [ "$MG_DHCP_CLEANUP_STALE" != "1" ] && return 0 + [ ! -f "$MG_DHCP_LEASES" ] || [ ! -s "$MG_DHCP_LEASES" ] && return 0 + + local now + now=$(date +%s) + local cutoff=$((now - MG_DHCP_STALE_TIMEOUT)) + local stale_file="${MG_RUNDIR}/stale_leases.$$" + + # Find leases with timestamp older than cutoff + awk -v cutoff="$cutoff" '$1 < cutoff {print $2}' "$MG_DHCP_LEASES" > "$stale_file" + + if [ -s "$stale_file" ]; then + while read -r stale_mac; do + [ -z "$stale_mac" ] && continue + mg_log_event "dhcp_stale_removed" "$stale_mac" "" "timeout=${MG_DHCP_STALE_TIMEOUT}s" + mg_dhcp_remove_lease "$stale_mac" + done < "$stale_file" + fi + + rm -f "$stale_file" +} + +mg_dhcp_cleanup_stale_mac() { + local target_mac="$1" + [ "$MG_DHCP_CLEANUP_STALE" != "1" ] && return 0 + [ ! -f "$MG_DHCP_LEASES" ] || [ ! -s "$MG_DHCP_LEASES" ] && return 0 + + local now + now=$(date +%s) + local cutoff=$((now - MG_DHCP_STALE_TIMEOUT)) + + # Check if this specific MAC's lease is stale + local lease_ts + lease_ts=$(awk -v m="$target_mac" 'tolower($2)==tolower(m) {print $1; exit}' "$MG_DHCP_LEASES") + [ -z "$lease_ts" ] && return 0 + + if [ "$lease_ts" -lt "$cutoff" ] 2>/dev/null; then + mg_log_event "dhcp_stale_removed" "$target_mac" "" "timeout=${MG_DHCP_STALE_TIMEOUT}s" + mg_dhcp_remove_lease "$target_mac" + fi +} + +mg_dhcp_detect_flood() { + [ "$MG_DHCP_ENABLED" != "1" ] && return 0 + [ ! -f "$MG_DHCP_LEASES" ] || [ ! -s "$MG_DHCP_LEASES" ] && return 0 + + local now + now=$(date +%s) + local window_start=$((now - MG_DHCP_FLOOD_WINDOW)) + + local recent_count + recent_count=$(awk -v ws="$window_start" '$1 >= ws' "$MG_DHCP_LEASES" | wc -l) + recent_count=$((recent_count + 0)) + + if [ "$recent_count" -gt "$MG_DHCP_FLOOD_THRESHOLD" ]; then + mg_log_event "dhcp_lease_flood" "" "" "leases=${recent_count} window=${MG_DHCP_FLOOD_WINDOW}s threshold=${MG_DHCP_FLOOD_THRESHOLD}" + fi +} + +mg_dhcp_remove_lease() { + local mac="$1" + [ -z "$mac" ] && return 1 + [ ! -f "$MG_DHCP_LEASES" ] && return 0 + + local tmpfile="${MG_DHCP_LEASES}.tmp.$$" + grep -iv " ${mac} " "$MG_DHCP_LEASES" > "$tmpfile" 2>/dev/null || : > "$tmpfile" + mv "$tmpfile" "$MG_DHCP_LEASES" + + # Signal odhcpd to reload leases + local odhcpd_pid + odhcpd_pid=$(pgrep odhcpd) + if [ -n "$odhcpd_pid" ]; then + kill -HUP "$odhcpd_pid" 2>/dev/null + fi +} + +mg_dhcp_maintenance() { + [ "$MG_DHCP_ENABLED" != "1" ] && return 0 + + mg_dhcp_find_hostname_dupes + mg_dhcp_cleanup_stale + mg_dhcp_detect_flood +} + # ============================================================ # Logging # ============================================================ @@ -658,6 +814,8 @@ mg_scan_all() { mg_scan_iface "$iface" done + mg_dhcp_maintenance + MG_TOTAL_SCANS=$((MG_TOTAL_SCANS + 1)) mg_unlock diff --git a/package/secubox/secubox-app-mac-guardian/files/usr/sbin/mac-guardian b/package/secubox/secubox-app-mac-guardian/files/usr/sbin/mac-guardian index ba8a66cd..027fd639 100644 --- a/package/secubox/secubox-app-mac-guardian/files/usr/sbin/mac-guardian +++ b/package/secubox/secubox-app-mac-guardian/files/usr/sbin/mac-guardian @@ -219,21 +219,78 @@ cmd_list() { done < "$MG_DBFILE" } +cmd_dhcp_status() { + mg_load_config + mg_init + + echo "DHCP Lease Protection" + echo "=====================" + + if [ "$MG_DHCP_ENABLED" != "1" ]; then + echo "Status: DISABLED" + return + fi + + echo "Status: ENABLED" + + local lease_count=0 conflict_count=0 stale_count=0 + + if [ -f /tmp/dhcp.leases ] && [ -s /tmp/dhcp.leases ]; then + lease_count=$(wc -l < /tmp/dhcp.leases) + + # Count hostname conflicts (hostnames with >1 MAC) + conflict_count=$(awk '{print $4}' /tmp/dhcp.leases | grep -v '^\*$' | sort | uniq -d | wc -l) + + # Count stale leases + local now + now=$(date +%s) + local cutoff=$((now - MG_DHCP_STALE_TIMEOUT)) + stale_count=$(awk -v cutoff="$cutoff" '$1 < cutoff' /tmp/dhcp.leases | wc -l) + fi + + echo "Leases: $lease_count" + echo "Conflicts: $conflict_count" + echo "Stale: $stale_count" + echo "" + echo "Settings:" + echo " Dedup hostnames: $MG_DHCP_DEDUP_HOSTNAMES" + echo " Cleanup stale: $MG_DHCP_CLEANUP_STALE" + echo " Stale timeout: ${MG_DHCP_STALE_TIMEOUT}s" + echo " Flood threshold: $MG_DHCP_FLOOD_THRESHOLD" + echo " Flood window: ${MG_DHCP_FLOOD_WINDOW}s" +} + +cmd_dhcp_cleanup() { + mg_load_config + mg_init + + if [ "$MG_DHCP_ENABLED" != "1" ]; then + echo "DHCP protection is disabled." + exit 1 + fi + + echo "Running DHCP lease maintenance..." + mg_dhcp_maintenance + echo "Done." +} + cmd_version() { echo "mac-guardian v${MG_VERSION}" } # --- Main dispatcher --- case "${1:-}" in - start) cmd_start ;; - scan) cmd_scan ;; - status) cmd_status ;; - trust) cmd_trust "$2" ;; - block) cmd_block "$2" ;; - list) cmd_list "$2" ;; - version) cmd_version ;; + start) cmd_start ;; + scan) cmd_scan ;; + status) cmd_status ;; + trust) cmd_trust "$2" ;; + block) cmd_block "$2" ;; + list) cmd_list "$2" ;; + dhcp-status) cmd_dhcp_status ;; + dhcp-cleanup) cmd_dhcp_cleanup ;; + version) cmd_version ;; *) - echo "Usage: mac-guardian {start|scan|status|trust|block|list|version}" + echo "Usage: mac-guardian {start|scan|status|trust|block|list|dhcp-status|dhcp-cleanup|version}" echo "" echo "Commands:" echo " start Start the daemon" @@ -242,6 +299,8 @@ case "${1:-}" in echo " trust Add MAC to trusted whitelist" echo " block Block and deauthenticate MAC" echo " list [filter] List known clients (trusted|blocked|suspect|unknown|all)" + echo " dhcp-status Show DHCP lease protection status" + echo " dhcp-cleanup Run one-shot DHCP lease maintenance" echo " version Print version" exit 1 ;; diff --git a/package/secubox/secubox-app-mac-guardian/tests/test_detection.sh b/package/secubox/secubox-app-mac-guardian/tests/test_detection.sh index c96b33c8..9cf5d84c 100644 --- a/package/secubox/secubox-app-mac-guardian/tests/test_detection.sh +++ b/package/secubox/secubox-app-mac-guardian/tests/test_detection.sh @@ -59,6 +59,12 @@ MG_WL_OUIS="" MG_START_TIME=$(date +%s) MG_TOTAL_SCANS=0 MG_TOTAL_ALERTS=0 +MG_DHCP_ENABLED=1 +MG_DHCP_CLEANUP_STALE=1 +MG_DHCP_DEDUP_HOSTNAMES=1 +MG_DHCP_FLOOD_THRESHOLD=3 +MG_DHCP_FLOOD_WINDOW=60 +MG_DHCP_STALE_TIMEOUT=3600 # --- TAP output --- TESTS=0 @@ -78,7 +84,7 @@ not_ok() { } echo "TAP version 13" -echo "1..8" +echo "1..12" # --- Test 1: Randomized MAC generates alert --- : > "$MG_EVENTS_LOG" @@ -180,6 +186,65 @@ else not_ok "Stats generation produces valid JSON with correct counts" fi +# --- DHCP Detection Tests --- + +# Override DHCP leases path for testing +MG_DHCP_LEASES="$TEST_TMPDIR/dhcp.leases" + +# --- Test 9: DHCP hostname conflict event logged --- +: > "$MG_EVENTS_LOG" +: > "$MG_DBFILE" +now=$(date +%s) +cat > "$MG_DHCP_LEASES" < "$MG_EVENTS_LOG" +now=$(date +%s) +cat > "$MG_DHCP_LEASES" < "$MG_EVENTS_LOG" +now=$(date +%s) +cat > "$MG_DHCP_LEASES" < "$MG_EVENTS_LOG" +: > "$MG_DHCP_LEASES" +mg_dhcp_maintenance +if [ -f "$MG_DHCP_LEASES" ]; then + ok "Empty leases file handled safely by maintenance" +else + not_ok "Empty leases file handled safely by maintenance" +fi + # --- Summary --- echo "" echo "# Tests: $TESTS, Passed: $PASS, Failed: $FAIL" diff --git a/package/secubox/secubox-app-mac-guardian/tests/test_functions.sh b/package/secubox/secubox-app-mac-guardian/tests/test_functions.sh index 3a9394ed..e0cec936 100644 --- a/package/secubox/secubox-app-mac-guardian/tests/test_functions.sh +++ b/package/secubox/secubox-app-mac-guardian/tests/test_functions.sh @@ -58,6 +58,12 @@ MG_POLICY="alert" MG_NOTIFY_CROWDSEC=0 MG_WL_MACS="" MG_WL_OUIS="" +MG_DHCP_ENABLED=1 +MG_DHCP_CLEANUP_STALE=1 +MG_DHCP_DEDUP_HOSTNAMES=1 +MG_DHCP_FLOOD_THRESHOLD=3 +MG_DHCP_FLOOD_WINDOW=60 +MG_DHCP_STALE_TIMEOUT=3600 # --- TAP output --- TESTS=0 @@ -101,7 +107,7 @@ assert_eq() { } echo "TAP version 13" -echo "1..19" +echo "1..27" # --- Test mg_is_randomized --- assert_true 'mg_is_randomized "02:11:22:33:44:55"' "mg_is_randomized: 02:xx is randomized" @@ -150,6 +156,108 @@ assert_true '[ -d "$MG_LOCKDIR" ]' "mg_lock: lock directory created" mg_unlock assert_false '[ -d "$MG_LOCKDIR" ]' "mg_unlock: lock directory removed" +# --- DHCP Protection Tests --- + +# Override DHCP leases path for testing +MG_DHCP_LEASES="$TEST_TMPDIR/dhcp.leases" + +# Test 20: mg_dhcp_read_leases returns valid temp file +echo "1000 aa:bb:cc:dd:ee:01 192.168.1.10 host1 *" > "$MG_DHCP_LEASES" +lease_tmp=$(mg_dhcp_read_leases) +if [ -f "$lease_tmp" ] && grep -q "aa:bb:cc:dd:ee:01" "$lease_tmp"; then + ok "mg_dhcp_read_leases: returns readable temp copy" +else + not_ok "mg_dhcp_read_leases: returns readable temp copy" +fi +rm -f "$lease_tmp" + +# Test 21: mg_dhcp_find_hostname_dupes detects duplicate hostnames +: > "$MG_EVENTS_LOG" +now=$(date +%s) +cat > "$MG_DHCP_LEASES" < "$MG_EVENTS_LOG" +now=$(date +%s) +cat > "$MG_DHCP_LEASES" < "$MG_DHCP_LEASES" < "$MG_EVENTS_LOG" +now=$(date +%s) +cat > "$MG_DHCP_LEASES" < "$MG_EVENTS_LOG" +now=$(date +%s) +cat > "$MG_DHCP_LEASES" < "$MG_DHCP_LEASES" +mg_dhcp_remove_lease "ff:ff:ff:ff:ff:ff" +if [ -f "$MG_DHCP_LEASES" ]; then + ok "mg_dhcp_remove_lease: handles empty leases safely" +else + not_ok "mg_dhcp_remove_lease: handles empty leases safely" +fi + # --- Summary --- echo "" echo "# Tests: $TESTS, Passed: $PASS, Failed: $FAIL"