feat(mac-guardian): Rename to secubox-app-mac-guardian and add LuCI interface

Rename package folder to follow secubox-app-* convention and add
luci-app-mac-guardian with KISS dashboard: status cards, client table
with trust/block actions, recent alerts, and configuration form.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-02-03 16:06:48 +01:00
parent aeb4825b25
commit 373d77368e
18 changed files with 615 additions and 6 deletions

View File

@ -0,0 +1,29 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI MAC Guardian - WiFi MAC Security Monitor
LUCI_DEPENDS:=+secubox-app-mac-guardian
LUCI_PKGARCH:=all
PKG_NAME:=luci-app-mac-guardian
PKG_VERSION:=0.5.0
PKG_RELEASE:=1
PKG_MAINTAINER:=Gandalf <contact@cybermind.fr>
PKG_LICENSE:=GPL-3.0-or-later
include $(TOPDIR)/feeds/luci/luci.mk
define Package/luci-app-mac-guardian/install
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-mac-guardian.json $(1)/usr/share/luci/menu.d/
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-mac-guardian.json $(1)/usr/share/rpcd/acl.d/
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/mac-guardian
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/mac-guardian/*.js $(1)/www/luci-static/resources/view/mac-guardian/
$(INSTALL_DIR) $(1)/usr/libexec/rpcd
$(INSTALL_BIN) ./root/usr/libexec/rpcd/luci.mac-guardian $(1)/usr/libexec/rpcd/
endef
$(eval $(call BuildPackage,luci-app-mac-guardian))

View File

@ -0,0 +1,350 @@
'use strict';
'require view';
'require form';
'require uci';
'require rpc';
'require ui';
var callStatus = rpc.declare({
object: 'luci.mac-guardian',
method: 'status',
expect: { '': {} }
});
var callGetClients = rpc.declare({
object: 'luci.mac-guardian',
method: 'get_clients',
expect: { '': {} }
});
var callGetEvents = rpc.declare({
object: 'luci.mac-guardian',
method: 'get_events',
params: ['count'],
expect: { '': {} }
});
var callScan = rpc.declare({
object: 'luci.mac-guardian',
method: 'scan'
});
var callStart = rpc.declare({
object: 'luci.mac-guardian',
method: 'start'
});
var callStop = rpc.declare({
object: 'luci.mac-guardian',
method: 'stop'
});
var callRestart = rpc.declare({
object: 'luci.mac-guardian',
method: 'restart'
});
var callTrust = rpc.declare({
object: 'luci.mac-guardian',
method: 'trust',
params: ['mac']
});
var callBlock = rpc.declare({
object: 'luci.mac-guardian',
method: 'block',
params: ['mac']
});
function formatDate(ts) {
if (!ts || ts === 0) return '-';
var d = new Date(ts * 1000);
var pad = function(n) { return n < 10 ? '0' + n : n; };
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function statusBadge(status) {
var colors = {
'trusted': '#080',
'suspect': '#c60',
'blocked': '#c00',
'unknown': '#888'
};
var color = colors[status] || '#888';
return '<span style="display:inline-block;padding:2px 8px;border-radius:3px;' +
'background:' + color + ';color:#fff;font-size:12px;font-weight:bold;">' +
status + '</span>';
}
return view.extend({
load: function() {
return Promise.all([
uci.load('mac-guardian'),
callStatus(),
callGetClients(),
callGetEvents(10)
]);
},
render: function(data) {
var status = data[1];
var clientData = data[2];
var eventData = data[3];
var clients = (clientData && clientData.clients) ? clientData.clients : [];
var events = (eventData && eventData.events) ? eventData.events : [];
var m, s, o;
m = new form.Map('mac-guardian', _('MAC Guardian'),
_('WiFi MAC address security monitor. Detects randomized MACs, spoofing, and MAC floods.'));
// ==========================================
// Status Section
// ==========================================
s = m.section(form.NamedSection, 'main', 'mac-guardian', _('Status'));
s.anonymous = true;
o = s.option(form.DummyValue, '_status');
o.rawhtml = true;
o.cfgvalue = function() {
var svcColor = status.service_status === 'running' ? '#080' : '#c00';
var svcLabel = status.service_status === 'running' ? 'Running' : 'Stopped';
var html = '<div style="display:flex;gap:25px;flex-wrap:wrap;">';
// Service card
html += '<div style="min-width:160px;">';
html += '<h4 style="margin:0 0 8px 0;border-bottom:1px solid #ddd;padding-bottom:4px;">Service</h4>';
html += '<p><b>Status:</b> <span style="color:' + svcColor + ';font-weight:bold;">' + svcLabel + '</span></p>';
html += '<p><b>Policy:</b> ' + (status.policy || 'alert') + '</p>';
html += '<p><b>Interval:</b> ' + (status.scan_interval || 30) + 's</p>';
html += '</div>';
// Clients card
var cl = status.clients || {};
html += '<div style="min-width:160px;">';
html += '<h4 style="margin:0 0 8px 0;border-bottom:1px solid #ddd;padding-bottom:4px;">Clients</h4>';
html += '<p><b>Total:</b> ' + (cl.total || 0) + '</p>';
html += '<p><b>Trusted:</b> <span style="color:#080;">' + (cl.trusted || 0) + '</span></p>';
html += '<p><b>Suspect:</b> <span style="color:#c60;">' + (cl.suspect || 0) + '</span></p>';
html += '<p><b>Blocked:</b> <span style="color:#c00;">' + (cl.blocked || 0) + '</span></p>';
html += '</div>';
// Interfaces card
var ifaces = status.interfaces || [];
html += '<div style="min-width:160px;">';
html += '<h4 style="margin:0 0 8px 0;border-bottom:1px solid #ddd;padding-bottom:4px;">WiFi Interfaces</h4>';
if (ifaces.length === 0) {
html += '<p style="color:#888;">None detected</p>';
} else {
for (var i = 0; i < ifaces.length; i++) {
html += '<p><b>' + ifaces[i].name + '</b> (' + ifaces[i].essid + ') - ' + ifaces[i].stations + ' STA</p>';
}
}
html += '</div>';
html += '</div>';
return html;
};
// Control buttons
o = s.option(form.Button, '_start', _('Start'));
o.inputtitle = _('Start');
o.inputstyle = 'apply';
o.onclick = function() {
return callStart().then(function() { window.location.reload(); });
};
o = s.option(form.Button, '_stop', _('Stop'));
o.inputtitle = _('Stop');
o.inputstyle = 'remove';
o.onclick = function() {
return callStop().then(function() { window.location.reload(); });
};
o = s.option(form.Button, '_scan', _('Scan Now'));
o.inputtitle = _('Scan');
o.inputstyle = 'reload';
o.onclick = function() {
ui.showModal(_('Scanning'), [
E('p', { 'class': 'spinning' }, _('Scanning WiFi interfaces...'))
]);
return callScan().then(function() {
ui.hideModal();
window.location.reload();
});
};
// ==========================================
// Clients Table
// ==========================================
s = m.section(form.NamedSection, 'main', 'mac-guardian', _('Known Clients'));
s.anonymous = true;
o = s.option(form.DummyValue, '_clients');
o.rawhtml = true;
o.cfgvalue = function() {
if (clients.length === 0) {
return '<p style="color:#888;">No clients detected yet. Run a scan or wait for devices to connect.</p>';
}
var html = '<div style="overflow-x:auto;">';
html += '<table class="table" style="width:100%;border-collapse:collapse;">';
html += '<tr class="tr table-titles">';
html += '<th class="th">MAC</th>';
html += '<th class="th">Vendor</th>';
html += '<th class="th">Hostname</th>';
html += '<th class="th">Interface</th>';
html += '<th class="th">First Seen</th>';
html += '<th class="th">Last Seen</th>';
html += '<th class="th">Status</th>';
html += '<th class="th">Actions</th>';
html += '</tr>';
for (var i = 0; i < clients.length; i++) {
var c = clients[i];
var macDisplay = c.mac;
if (c.randomized) {
macDisplay += ' <span title="Randomized MAC" style="color:#c60;font-weight:bold;">R</span>';
}
html += '<tr class="tr">';
html += '<td class="td" style="font-family:monospace;">' + macDisplay + '</td>';
html += '<td class="td">' + (c.vendor || '-') + '</td>';
html += '<td class="td">' + (c.hostname || '-') + '</td>';
html += '<td class="td">' + (c.iface || '-') + '</td>';
html += '<td class="td">' + formatDate(c.first_seen) + '</td>';
html += '<td class="td">' + formatDate(c.last_seen) + '</td>';
html += '<td class="td">' + statusBadge(c.status) + '</td>';
html += '<td class="td">';
if (c.status !== 'trusted') {
html += '<button class="cbi-button cbi-button-apply" data-mac="' + c.mac + '" data-action="trust" style="margin-right:4px;">Trust</button>';
}
if (c.status !== 'blocked') {
html += '<button class="cbi-button cbi-button-remove" data-mac="' + c.mac + '" data-action="block">Block</button>';
}
html += '</td>';
html += '</tr>';
}
html += '</table>';
html += '</div>';
return html;
};
// ==========================================
// Recent Alerts
// ==========================================
s = m.section(form.NamedSection, 'main', 'mac-guardian', _('Recent Alerts'));
s.anonymous = true;
o = s.option(form.DummyValue, '_events');
o.rawhtml = true;
o.cfgvalue = function() {
if (events.length === 0) {
return '<p style="color:#888;">No alerts recorded.</p>';
}
var html = '<div style="overflow-x:auto;">';
html += '<table class="table" style="width:100%;border-collapse:collapse;">';
html += '<tr class="tr table-titles">';
html += '<th class="th">Time</th>';
html += '<th class="th">Event</th>';
html += '<th class="th">MAC</th>';
html += '<th class="th">Interface</th>';
html += '<th class="th">Details</th>';
html += '</tr>';
for (var i = events.length - 1; i >= 0; i--) {
try {
var ev = JSON.parse(events[i]);
html += '<tr class="tr">';
html += '<td class="td" style="white-space:nowrap;">' + (ev.ts || '-') + '</td>';
html += '<td class="td"><b>' + (ev.event || '-') + '</b></td>';
html += '<td class="td" style="font-family:monospace;">' + (ev.mac || '-') + '</td>';
html += '<td class="td">' + (ev.iface || '-') + '</td>';
html += '<td class="td" style="font-size:12px;">' + (ev.details || '-') + '</td>';
html += '</tr>';
} catch(e) {
continue;
}
}
html += '</table>';
html += '</div>';
return html;
};
// ==========================================
// Configuration
// ==========================================
s = m.section(form.NamedSection, 'main', 'mac-guardian', _('Configuration'));
s.anonymous = true;
o = s.option(form.Flag, 'enabled', _('Enabled'),
_('Enable MAC Guardian service'));
o.rmempty = false;
o = s.option(form.Value, 'scan_interval', _('Scan Interval'),
_('Seconds between WiFi scans'));
o.datatype = 'uinteger';
o.default = '30';
o.placeholder = '30';
s = m.section(form.NamedSection, 'detection', 'detection', _('Detection'));
s.anonymous = true;
o = s.option(form.Flag, 'random_mac', _('Detect Randomized MACs'),
_('Alert on locally-administered (randomized) MAC addresses'));
o.default = '1';
o = s.option(form.Flag, 'spoof_detection', _('Detect Spoofing'),
_('Alert when a MAC address appears on a different interface'));
o.default = '1';
o = s.option(form.Flag, 'mac_flip', _('Detect MAC Floods'),
_('Alert when many new MACs appear in a short window'));
o.default = '1';
s = m.section(form.NamedSection, 'enforcement', 'enforcement', _('Enforcement'));
s.anonymous = true;
o = s.option(form.ListValue, 'policy', _('Policy'),
_('Action to take on detected threats'));
o.value('alert', _('Alert only'));
o.value('quarantine', _('Quarantine (drop traffic)'));
o.value('deny', _('Deny (drop + deauthenticate)'));
o.default = 'alert';
// ==========================================
// Bind action buttons
// ==========================================
var rendered = m.render();
return rendered.then(function(node) {
node.addEventListener('click', function(ev) {
var btn = ev.target.closest('[data-action]');
if (!btn) return;
var mac = btn.getAttribute('data-mac');
var action = btn.getAttribute('data-action');
if (action === 'trust') {
callTrust(mac).then(function() {
ui.addNotification(null, E('p', _('MAC %s trusted').format(mac)), 'success');
window.location.reload();
});
} else if (action === 'block') {
if (confirm(_('Block and deauthenticate %s?').format(mac))) {
callBlock(mac).then(function() {
ui.addNotification(null, E('p', _('MAC %s blocked').format(mac)), 'success');
window.location.reload();
});
}
}
});
return node;
});
}
});

View File

@ -0,0 +1,190 @@
#!/bin/sh
. /lib/functions.sh
. /usr/share/libubox/jshn.sh
MG_DBFILE="/var/run/mac-guardian/known.db"
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"}}'
;;
call)
case "$2" in
status)
json_init
enabled=$(uci -q get mac-guardian.main.enabled)
policy=$(uci -q get mac-guardian.enforcement.policy)
scan_interval=$(uci -q get mac-guardian.main.scan_interval)
detect_random=$(uci -q get mac-guardian.detection.random_mac)
detect_spoof=$(uci -q get mac-guardian.detection.spoof_detection)
json_add_boolean "enabled" ${enabled:-0}
json_add_string "policy" "${policy:-alert}"
json_add_int "scan_interval" ${scan_interval:-30}
json_add_boolean "detect_random" ${detect_random:-1}
json_add_boolean "detect_spoof" ${detect_spoof:-1}
# Service running?
if pgrep mac-guardian >/dev/null 2>&1; then
json_add_string "service_status" "running"
else
json_add_string "service_status" "stopped"
fi
# WiFi interfaces
json_add_array "interfaces"
if command -v iwinfo >/dev/null 2>&1; then
iwinfo 2>/dev/null | grep "ESSID" | while read -r line; do
iface=$(echo "$line" | awk '{print $1}')
essid=$(echo "$line" | sed 's/.*ESSID: "\(.*\)"/\1/')
sta_count=$(iwinfo "$iface" assoclist 2>/dev/null | grep -cE '[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}')
json_add_object ""
json_add_string "name" "$iface"
json_add_string "essid" "$essid"
json_add_int "stations" ${sta_count:-0}
json_close_object
done
fi
json_close_array
# DB stats
total=0
trusted=0
suspect=0
blocked=0
unknown=0
if [ -f "$MG_DBFILE" ] && [ -s "$MG_DBFILE" ]; then
total=$(wc -l < "$MG_DBFILE")
trusted=$(grep -c '|trusted$' "$MG_DBFILE" 2>/dev/null || echo 0)
suspect=$(grep -c '|suspect$' "$MG_DBFILE" 2>/dev/null || echo 0)
blocked=$(grep -c '|blocked$' "$MG_DBFILE" 2>/dev/null || echo 0)
unknown=$(grep -c '|unknown$' "$MG_DBFILE" 2>/dev/null || echo 0)
fi
json_add_object "clients"
json_add_int "total" $total
json_add_int "trusted" $trusted
json_add_int "suspect" $suspect
json_add_int "blocked" $blocked
json_add_int "unknown" $unknown
json_close_object
json_dump
;;
get_clients)
json_init
json_add_array "clients"
if [ -f "$MG_DBFILE" ] && [ -s "$MG_DBFILE" ]; then
while IFS='|' read -r mac oui first_seen last_seen iface hostname status; do
[ -z "$mac" ] && continue
json_add_object ""
json_add_string "mac" "$mac"
json_add_string "oui" "$oui"
json_add_int "first_seen" ${first_seen:-0}
json_add_int "last_seen" ${last_seen:-0}
json_add_string "iface" "$iface"
json_add_string "hostname" "${hostname:--}"
json_add_string "status" "$status"
# OUI vendor lookup
vendor=""
if [ -f /usr/lib/secubox/mac-guardian/oui.tsv ]; then
oui_upper=$(echo "$oui" | tr 'a-f' 'A-F')
vendor=$(grep -i "^${oui_upper} " /usr/lib/secubox/mac-guardian/oui.tsv 2>/dev/null | cut -f2 | head -1)
fi
json_add_string "vendor" "${vendor:--}"
# Randomized check
first_octet=$(echo "$mac" | cut -d: -f1)
is_rand=0
[ $((0x$first_octet & 0x02)) -ne 0 ] && is_rand=1
json_add_boolean "randomized" $is_rand
json_close_object
done < "$MG_DBFILE"
fi
json_close_array
json_dump
;;
get_events)
read -r input
count=$(echo "$input" | jsonfilter -e '@.count' 2>/dev/null)
[ -z "$count" ] && count=20
json_init
json_add_array "events"
if [ -f "$MG_LOGFILE" ] && [ -s "$MG_LOGFILE" ]; then
tail -"$count" "$MG_LOGFILE" 2>/dev/null | while read -r line; do
json_add_string "" "$line"
done
fi
json_close_array
json_dump
;;
scan)
/usr/sbin/mac-guardian scan >/dev/null 2>&1
json_init
json_add_boolean "success" 1
json_dump
;;
start)
/etc/init.d/mac-guardian start >/dev/null 2>&1
json_init
json_add_boolean "success" 1
json_dump
;;
stop)
/etc/init.d/mac-guardian stop >/dev/null 2>&1
json_init
json_add_boolean "success" 1
json_dump
;;
restart)
/etc/init.d/mac-guardian restart >/dev/null 2>&1
json_init
json_add_boolean "success" 1
json_dump
;;
trust)
read -r input
mac=$(echo "$input" | jsonfilter -e '@.mac' 2>/dev/null)
if [ -n "$mac" ]; then
/usr/sbin/mac-guardian trust "$mac" >/dev/null 2>&1
json_init
json_add_boolean "success" 1
json_dump
else
echo '{"success":false,"error":"missing mac"}'
fi
;;
block)
read -r input
mac=$(echo "$input" | jsonfilter -e '@.mac' 2>/dev/null)
if [ -n "$mac" ]; then
/usr/sbin/mac-guardian block "$mac" >/dev/null 2>&1
json_init
json_add_boolean "success" 1
json_dump
else
echo '{"success":false,"error":"missing mac"}'
fi
;;
esac
;;
esac
exit 0

View File

@ -0,0 +1,14 @@
{
"admin/services/mac-guardian": {
"title": "MAC Guardian",
"order": 70,
"action": {
"type": "view",
"path": "mac-guardian/dashboard"
},
"depends": {
"acl": ["luci-app-mac-guardian"],
"uci": {"mac-guardian": true}
}
}
}

View File

@ -0,0 +1,26 @@
{
"luci-app-mac-guardian": {
"description": "Grant access to MAC Guardian WiFi security monitor",
"read": {
"file": {
"/etc/config/mac-guardian": ["read"],
"/var/run/mac-guardian/known.db": ["read"],
"/var/log/mac-guardian.log": ["read"]
},
"ubus": {
"file": ["read", "stat"],
"luci.mac-guardian": ["*"]
},
"uci": ["mac-guardian"]
},
"write": {
"file": {
"/etc/config/mac-guardian": ["write"]
},
"ubus": {
"luci.mac-guardian": ["*"]
},
"uci": ["mac-guardian"]
}
}
}

View File

@ -1,6 +1,6 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=mac-guardian PKG_NAME:=secubox-app-mac-guardian
PKG_VERSION:=0.5.0 PKG_VERSION:=0.5.0
PKG_RELEASE:=1 PKG_RELEASE:=1
@ -9,7 +9,7 @@ PKG_LICENSE:=GPL-3.0-or-later
include $(INCLUDE_DIR)/package.mk include $(INCLUDE_DIR)/package.mk
define Package/mac-guardian define Package/secubox-app-mac-guardian
SECTION:=net SECTION:=net
CATEGORY:=Network CATEGORY:=Network
SUBMENU:=SecuBox SUBMENU:=SecuBox
@ -18,21 +18,21 @@ define Package/mac-guardian
PKGARCH:=all PKGARCH:=all
endef endef
define Package/mac-guardian/description define Package/secubox-app-mac-guardian/description
WiFi MAC address security monitor for SecuBox. WiFi MAC address security monitor for SecuBox.
Detects randomized MACs, OUI anomalies, MAC floods, Detects randomized MACs, OUI anomalies, MAC floods,
and spoofing. Integrates with CrowdSec and provides and spoofing. Integrates with CrowdSec and provides
real-time hostapd hotplug detection. real-time hostapd hotplug detection.
endef endef
define Package/mac-guardian/conffiles define Package/secubox-app-mac-guardian/conffiles
/etc/config/mac-guardian /etc/config/mac-guardian
endef endef
define Build/Compile define Build/Compile
endef endef
define Package/mac-guardian/install define Package/secubox-app-mac-guardian/install
$(INSTALL_DIR) $(1)/usr/sbin $(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) ./files/usr/sbin/mac-guardian $(1)/usr/sbin/ $(INSTALL_BIN) ./files/usr/sbin/mac-guardian $(1)/usr/sbin/
@ -60,4 +60,4 @@ define Package/mac-guardian/install
$(INSTALL_DATA) ./files/etc/crowdsec/scenarios/secubox-mac-spoof.yaml $(1)/etc/crowdsec/scenarios/ $(INSTALL_DATA) ./files/etc/crowdsec/scenarios/secubox-mac-spoof.yaml $(1)/etc/crowdsec/scenarios/
endef endef
$(eval $(call BuildPackage,mac-guardian)) $(eval $(call BuildPackage,secubox-app-mac-guardian))