|
@@ -1,11 +1,12 @@
|
|
|
--[[
|
|
--[[
|
|
|
- client2server-luv - client2server-minimal.lua with luv async
|
|
|
|
|
|
|
+ client2server-luv - client2server-minimal.lua with TRUE luv async
|
|
|
|
|
|
|
|
Features:
|
|
Features:
|
|
|
- DHCP + WiFi + SSID monitoring via ubus
|
|
- DHCP + WiFi + SSID monitoring via ubus
|
|
|
- - ASYNC with luv (libuv bindings) - no blocking sleeps
|
|
|
|
|
|
|
+ - ASYNC: react IMMEDIATELY when events happen (not polling!)
|
|
|
|
|
+ - luv fs_event for file watching
|
|
|
|
|
+ - luv timer for immediate triggers
|
|
|
- State management for change detection
|
|
- State management for change detection
|
|
|
- - Direct HTTP POST to server
|
|
|
|
|
|
|
|
|
|
Copyright (c) 2026 Luis Rosales - MIT License
|
|
Copyright (c) 2026 Luis Rosales - MIT License
|
|
|
]]
|
|
]]
|
|
@@ -14,7 +15,6 @@
|
|
|
-- REQUIREMENTS
|
|
-- REQUIREMENTS
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
|
|
|
|
|
--- Try to load luv (libuv bindings for async operations)
|
|
|
|
|
local luv_ok, luv = pcall(require, "luv")
|
|
local luv_ok, luv = pcall(require, "luv")
|
|
|
|
|
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
@@ -24,7 +24,8 @@ local luv_ok, luv = pcall(require, "luv")
|
|
|
local cfg = {
|
|
local cfg = {
|
|
|
server_url = os.getenv("SERVER_URL") or "http://163.245.193.47:3843",
|
|
server_url = os.getenv("SERVER_URL") or "http://163.245.193.47:3843",
|
|
|
router_id = os.getenv("ROUTER_ID") or "unknown",
|
|
router_id = os.getenv("ROUTER_ID") or "unknown",
|
|
|
- check_interval = 10, -- seconds
|
|
|
|
|
|
|
+ check_interval = 10,
|
|
|
|
|
+ dhcp_lease_file = "/var/lib/dnsmasq/dnsmasq.leases",
|
|
|
state_file = "/tmp/client2server_state",
|
|
state_file = "/tmp/client2server_state",
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -44,7 +45,6 @@ local function http_post(event_type, data)
|
|
|
return result
|
|
return result
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
--- Validate MAC address (proper format with valid hex)
|
|
|
|
|
local function is_valid_mac(mac)
|
|
local function is_valid_mac(mac)
|
|
|
if not mac or #mac ~= 17 then return false end
|
|
if not mac or #mac ~= 17 then return false end
|
|
|
local parts = {}
|
|
local parts = {}
|
|
@@ -52,10 +52,8 @@ local function is_valid_mac(mac)
|
|
|
table.insert(parts, part)
|
|
table.insert(parts, part)
|
|
|
end
|
|
end
|
|
|
if #parts ~= 6 then return false end
|
|
if #parts ~= 6 then return false end
|
|
|
- -- First byte: not 00 or FF
|
|
|
|
|
local first = tonumber(parts[1], 16)
|
|
local first = tonumber(parts[1], 16)
|
|
|
if first == 0 or first == 255 then return false end
|
|
if first == 0 or first == 255 then return false end
|
|
|
- -- Second byte: not FF (multicast)
|
|
|
|
|
local second = tonumber(parts[2], 16)
|
|
local second = tonumber(parts[2], 16)
|
|
|
if second == 255 then return false end
|
|
if second == 255 then return false end
|
|
|
return true
|
|
return true
|
|
@@ -116,19 +114,16 @@ local function save_state(state)
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
--- WIFI: Get hostapd interfaces + SSID + clients
|
|
|
|
|
|
|
+-- WIFI: Get interfaces + clients + SSID
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
|
|
|
|
|
local function get_hostapd_interfaces()
|
|
local function get_hostapd_interfaces()
|
|
|
local f = io.popen("ubus list | grep hostapd")
|
|
local f = io.popen("ubus list | grep hostapd")
|
|
|
local interfaces = {}
|
|
local interfaces = {}
|
|
|
if f then
|
|
if f then
|
|
|
- for line in f:lines() do
|
|
|
|
|
- table.insert(interfaces, line)
|
|
|
|
|
- end
|
|
|
|
|
|
|
+ for line in f:lines() do table.insert(interfaces, line) end
|
|
|
f:close()
|
|
f:close()
|
|
|
end
|
|
end
|
|
|
- log_info("hostapd interfaces: " .. table.concat(interfaces, ", "))
|
|
|
|
|
return interfaces
|
|
return interfaces
|
|
|
end
|
|
end
|
|
|
|
|
|
|
@@ -136,17 +131,16 @@ local function get_ssid_name(iface)
|
|
|
local wlan_map = {
|
|
local wlan_map = {
|
|
|
["hostapd.wlan0-1"] = "wlan0-1",
|
|
["hostapd.wlan0-1"] = "wlan0-1",
|
|
|
["hostapd.wlan0-2"] = "wlan0-2",
|
|
["hostapd.wlan0-2"] = "wlan0-2",
|
|
|
|
|
+ ["hostapd.wlan1-1"] = "wlan1-1",
|
|
|
}
|
|
}
|
|
|
- local wlan_iface = wlan_map[iface] or iface
|
|
|
|
|
|
|
+ local wlan_iface = wlan_map[iface] or iface:gsub("hostapd.", "")
|
|
|
|
|
|
|
|
local f = io.popen("iw dev " .. wlan_iface .. " info 2>/dev/null | grep ssid")
|
|
local f = io.popen("iw dev " .. wlan_iface .. " info 2>/dev/null | grep ssid")
|
|
|
if f then
|
|
if f then
|
|
|
local result = f:read("*a")
|
|
local result = f:read("*a")
|
|
|
f:close()
|
|
f:close()
|
|
|
local ssid = result:match("ssid%s+(.+)")
|
|
local ssid = result:match("ssid%s+(.+)")
|
|
|
- if ssid and ssid ~= "" then
|
|
|
|
|
- return ssid:gsub("%s+$", "")
|
|
|
|
|
- end
|
|
|
|
|
|
|
+ if ssid and ssid ~= "" then return ssid:gsub("%s+$", "") end
|
|
|
end
|
|
end
|
|
|
return iface
|
|
return iface
|
|
|
end
|
|
end
|
|
@@ -158,16 +152,12 @@ local function get_all_ssids()
|
|
|
local result = f:read("*a")
|
|
local result = f:read("*a")
|
|
|
f:close()
|
|
f:close()
|
|
|
local current_iface = nil
|
|
local current_iface = nil
|
|
|
- log_info("iw dev output: " .. result:sub(1, 200))
|
|
|
|
|
for line in result:gmatch("[^\n]+") do
|
|
for line in result:gmatch("[^\n]+") do
|
|
|
- -- Match ANY wlan interface (wlan0, wlan0-1, wlan1, etc.)
|
|
|
|
|
local iface = line:match("Interface%s+(wlan%d[^%s]*)")
|
|
local iface = line:match("Interface%s+(wlan%d[^%s]*)")
|
|
|
if iface then current_iface = iface end
|
|
if iface then current_iface = iface end
|
|
|
local ssid = line:match("ssid%s+(.+)")
|
|
local ssid = line:match("ssid%s+(.+)")
|
|
|
if ssid and current_iface then
|
|
if ssid and current_iface then
|
|
|
- local hostapd_iface = "hostapd." .. current_iface
|
|
|
|
|
- ssids[hostapd_iface] = { enabled = true, ssid = ssid }
|
|
|
|
|
- log_info("SSID found: " .. ssid .. " on " .. hostapd_iface)
|
|
|
|
|
|
|
+ ssids["hostapd." .. current_iface] = { enabled = true, ssid = ssid }
|
|
|
end
|
|
end
|
|
|
end
|
|
end
|
|
|
end
|
|
end
|
|
@@ -185,39 +175,25 @@ local function get_wifi_clients()
|
|
|
|
|
|
|
|
local count = 0
|
|
local count = 0
|
|
|
for _, iface in ipairs(interfaces) do
|
|
for _, iface in ipairs(interfaces) do
|
|
|
- log_info("Querying WiFi clients on: " .. iface)
|
|
|
|
|
local f = io.popen("ubus call " .. iface .. " get_clients 2>/dev/null")
|
|
local f = io.popen("ubus call " .. iface .. " get_clients 2>/dev/null")
|
|
|
if f then
|
|
if f then
|
|
|
local result = f:read("*a")
|
|
local result = f:read("*a")
|
|
|
f:close()
|
|
f:close()
|
|
|
- log_info(iface .. " clients result: " .. result:sub(1, 200))
|
|
|
|
|
if result:find('"clients"') and not result:find('"clients":%s*{}') then
|
|
if result:find('"clients"') and not result:find('"clients":%s*{}') then
|
|
|
- local mac_count = 0
|
|
|
|
|
for mac in result:gmatch('([a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9])') do
|
|
for mac in result:gmatch('([a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9])') do
|
|
|
if is_valid_mac(mac) and not clients[mac] then
|
|
if is_valid_mac(mac) and not clients[mac] then
|
|
|
clients[mac] = { iface = iface, ssid = ssid_map[iface] }
|
|
clients[mac] = { iface = iface, ssid = ssid_map[iface] }
|
|
|
- mac_count = mac_count + 1
|
|
|
|
|
|
|
+ count = count + 1
|
|
|
end
|
|
end
|
|
|
end
|
|
end
|
|
|
- count = count + mac_count
|
|
|
|
|
end
|
|
end
|
|
|
end
|
|
end
|
|
|
end
|
|
end
|
|
|
- log_info("Total WiFi clients found: " .. count)
|
|
|
|
|
return clients
|
|
return clients
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
-local function get_ssid_status()
|
|
|
|
|
- local status = {}
|
|
|
|
|
- local ssids = get_all_ssids()
|
|
|
|
|
- for iface, info in pairs(ssids) do
|
|
|
|
|
- if info.enabled then status[iface] = true end
|
|
|
|
|
- end
|
|
|
|
|
- return status
|
|
|
|
|
-end
|
|
|
|
|
-
|
|
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
--- DHCP: Get leases via ubus
|
|
|
|
|
|
|
+-- DHCP: Get leases
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
|
|
|
|
|
local function get_dhcp_leases()
|
|
local function get_dhcp_leases()
|
|
@@ -235,11 +211,12 @@ local function get_dhcp_leases()
|
|
|
end
|
|
end
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
- f = io.popen("cat /tmp/dhcp.leases 2>/dev/null")
|
|
|
|
|
|
|
+ -- Fallback: read lease file
|
|
|
|
|
+ f = io.popen("cat " .. cfg.dhcp_lease_file .. " 2>/dev/null")
|
|
|
if f then
|
|
if f then
|
|
|
for line in f:lines() do
|
|
for line in f:lines() do
|
|
|
- local e, mac, ip, h, c = line:match("^(%S+) (%S+) (%S+) (%S+) (%S+)")
|
|
|
|
|
- if mac and is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac, hostname=h} end
|
|
|
|
|
|
|
+ local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
|
|
|
|
|
+ if mac and is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac, hostname=name} end
|
|
|
end
|
|
end
|
|
|
f:close()
|
|
f:close()
|
|
|
end
|
|
end
|
|
@@ -247,12 +224,13 @@ local function get_dhcp_leases()
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
--- MAIN LOOP (with luv async)
|
|
|
|
|
|
|
+-- ASYNC WATCHERS (TRUE EVENT-DRIVEN)
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
|
|
|
|
|
local state = { clients = {}, ssid_enabled = {}, dhcp = {} }
|
|
local state = { clients = {}, ssid_enabled = {}, dhcp = {} }
|
|
|
|
|
|
|
|
-local function check_all()
|
|
|
|
|
|
|
+-- Check and report changes
|
|
|
|
|
+local function check_and_report()
|
|
|
-- WiFi Clients
|
|
-- WiFi Clients
|
|
|
local current_clients = get_wifi_clients()
|
|
local current_clients = get_wifi_clients()
|
|
|
|
|
|
|
@@ -271,8 +249,8 @@ local function check_all()
|
|
|
state.clients = current_clients
|
|
state.clients = current_clients
|
|
|
|
|
|
|
|
-- SSID Status
|
|
-- SSID Status
|
|
|
- local current_ssid = get_ssid_status()
|
|
|
|
|
- for iface in pairs(current_ssid) do
|
|
|
|
|
|
|
+ local current_ssid = get_all_ssids()
|
|
|
|
|
+ for iface, info in pairs(current_ssid) do
|
|
|
if not state.ssid_enabled[iface] then
|
|
if not state.ssid_enabled[iface] then
|
|
|
local ssid = get_ssid_name(iface)
|
|
local ssid = get_ssid_name(iface)
|
|
|
log_info("SSID ENABLED: " .. ssid .. " (" .. iface .. ")")
|
|
log_info("SSID ENABLED: " .. ssid .. " (" .. iface .. ")")
|
|
@@ -304,10 +282,59 @@ local function check_all()
|
|
|
end
|
|
end
|
|
|
state.dhcp = current_dhcp
|
|
state.dhcp = current_dhcp
|
|
|
|
|
|
|
|
- -- Save state
|
|
|
|
|
save_state(state)
|
|
save_state(state)
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
|
|
+-- ============================================================================
|
|
|
|
|
+-- ASYNC EVENT HANDLERS (immediate reaction)
|
|
|
|
|
+-- ============================================================================
|
|
|
|
|
+
|
|
|
|
|
+local function start_async_watchers()
|
|
|
|
|
+ if not luv_ok then return false end
|
|
|
|
|
+
|
|
|
|
|
+ log_info("Starting async watchers...")
|
|
|
|
|
+
|
|
|
|
|
+ -- 1. DHCP: Watch lease file with fs_event (REACT IMMEDIATELY)
|
|
|
|
|
+ local fs_event = luv.new_fs_event()
|
|
|
|
|
+ local function watch_dhcp()
|
|
|
|
|
+ log_info("DHCP lease file changed - checking...")
|
|
|
|
|
+ check_and_report()
|
|
|
|
|
+ -- Re-watch
|
|
|
|
|
+ luv.fs_event_start(fs_event, cfg.dhcp_lease_file, function(err)
|
|
|
|
|
+ if err then
|
|
|
|
|
+ log_info("DHCP fs_event error: " .. err)
|
|
|
|
|
+ else
|
|
|
|
|
+ watch_dhcp()
|
|
|
|
|
+ end
|
|
|
|
|
+ end)
|
|
|
|
|
+ end
|
|
|
|
|
+ -- Start watching
|
|
|
|
|
+ luv.fs_event_start(fs_event, cfg.dhcp_lease_file, function(err)
|
|
|
|
|
+ if err then
|
|
|
|
|
+ log_info("DHCP watch error: " .. err)
|
|
|
|
|
+ else
|
|
|
|
|
+ log_info("Watching DHCP leases: " .. cfg.dhcp_lease_file)
|
|
|
|
|
+ watch_dhcp()
|
|
|
|
|
+ end
|
|
|
|
|
+ end)
|
|
|
|
|
+
|
|
|
|
|
+ -- 2. WiFi: Timer-based but frequent (every 5s)
|
|
|
|
|
+ local wifi_timer = luv.new_timer()
|
|
|
|
|
+ luv.timer_start(wifi_timer, 5000, 5000, function()
|
|
|
|
|
+ log_info("WiFi check (timer)...")
|
|
|
|
|
+ check_and_report()
|
|
|
|
|
+ end)
|
|
|
|
|
+
|
|
|
|
|
+ -- 3. SSID: Less frequent (every 30s)
|
|
|
|
|
+ local ssid_timer = luv.new_timer()
|
|
|
|
|
+ luv.timer_start(ssid_timer, 30000, 30000, function()
|
|
|
|
|
+ log_info("SSID check (timer)...")
|
|
|
|
|
+ check_and_report()
|
|
|
|
|
+ end)
|
|
|
|
|
+
|
|
|
|
|
+ return true
|
|
|
|
|
+end
|
|
|
|
|
+
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
-- MAIN
|
|
-- MAIN
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
@@ -318,9 +345,9 @@ function main()
|
|
|
log_info("Server: " .. cfg.server_url)
|
|
log_info("Server: " .. cfg.server_url)
|
|
|
|
|
|
|
|
if luv_ok then
|
|
if luv_ok then
|
|
|
- log_info("luv available - using async mode")
|
|
|
|
|
|
|
+ log_info("luv available - ASYNC mode!")
|
|
|
else
|
|
else
|
|
|
- log_info("luv NOT available - using blocking mode")
|
|
|
|
|
|
|
+ log_info("luv NOT available - blocking mode")
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
-- Load state
|
|
-- Load state
|
|
@@ -334,34 +361,27 @@ function main()
|
|
|
send_event("router_online", '{"lease_count":' .. count .. '}')
|
|
send_event("router_online", '{"lease_count":' .. count .. '}')
|
|
|
|
|
|
|
|
if luv_ok then
|
|
if luv_ok then
|
|
|
- -- ASYNC MODE with luv
|
|
|
|
|
- local timer = luv.new_timer()
|
|
|
|
|
|
|
+ -- ASYNC MODE
|
|
|
|
|
+ local started = start_async_watchers()
|
|
|
|
|
|
|
|
- local function run_loop()
|
|
|
|
|
- check_all()
|
|
|
|
|
-
|
|
|
|
|
- -- Schedule next check
|
|
|
|
|
- luv.timer_start(timer, cfg.check_interval * 1000, cfg.check_interval * 1000, function()
|
|
|
|
|
- run_loop()
|
|
|
|
|
- end)
|
|
|
|
|
|
|
+ if started then
|
|
|
|
|
+ log_info("Async watchers started - running event loop")
|
|
|
|
|
+ luv.run()
|
|
|
|
|
+ else
|
|
|
|
|
+ -- Fallback to timer-based
|
|
|
|
|
+ log_info("Using timer-based fallback")
|
|
|
|
|
+ local timer = luv.new_timer()
|
|
|
|
|
+ local function run_loop()
|
|
|
|
|
+ check_and_report()
|
|
|
|
|
+ luv.timer_start(timer, cfg.check_interval * 1000, 0, function() run_loop() end)
|
|
|
|
|
+ end
|
|
|
|
|
+ luv.timer_start(timer, cfg.check_interval * 1000, 0, function() run_loop() end)
|
|
|
|
|
+ luv.run()
|
|
|
end
|
|
end
|
|
|
-
|
|
|
|
|
- -- Start first check after initial delay
|
|
|
|
|
- luv.timer_start(timer, cfg.check_interval * 1000, 0, function()
|
|
|
|
|
- run_loop()
|
|
|
|
|
- end)
|
|
|
|
|
-
|
|
|
|
|
- -- Run the event loop
|
|
|
|
|
- luv.run()
|
|
|
|
|
-
|
|
|
|
|
else
|
|
else
|
|
|
- -- BLOCKING MODE (fallback)
|
|
|
|
|
|
|
+ -- BLOCKING MODE
|
|
|
while true do
|
|
while true do
|
|
|
- check_all()
|
|
|
|
|
- local timer = luv.new_timer()
|
|
|
|
|
- luv.timer_start(timer, cfg.check_interval * 1000, 0, function() end)
|
|
|
|
|
- luv.run()
|
|
|
|
|
- -- Fallback if no luv
|
|
|
|
|
|
|
+ check_and_report()
|
|
|
os.execute("sleep " .. cfg.check_interval)
|
|
os.execute("sleep " .. cfg.check_interval)
|
|
|
end
|
|
end
|
|
|
end
|
|
end
|