|
@@ -1,11 +1,10 @@
|
|
|
--[[
|
|
--[[
|
|
|
- client2server-luv - client2server-minimal.lua with TRUE luv async
|
|
|
|
|
|
|
+ client2server-luv - TRUE event-driven using ubus events
|
|
|
|
|
|
|
|
Features:
|
|
Features:
|
|
|
- - DHCP + WiFi + SSID monitoring via ubus
|
|
|
|
|
- - ASYNC: react IMMEDIATELY when events happen (not polling!)
|
|
|
|
|
- - luv fs_event for file watching
|
|
|
|
|
- - luv timer for immediate triggers
|
|
|
|
|
|
|
+ - NO POLLING - reacts to EVENTS only
|
|
|
|
|
+ - ubus event subscription for DHCP, network, WiFi
|
|
|
|
|
+ - Uses luv for async event loop
|
|
|
- State management for change detection
|
|
- State management for change detection
|
|
|
|
|
|
|
|
Copyright (c) 2026 Luis Rosales - MIT License
|
|
Copyright (c) 2026 Luis Rosales - MIT License
|
|
@@ -24,9 +23,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,
|
|
|
|
|
- dhcp_lease_file = "/var/lib/dnsmasq/dnsmasq.leases",
|
|
|
|
|
state_file = "/tmp/client2server_state",
|
|
state_file = "/tmp/client2server_state",
|
|
|
|
|
+ dhcp_lease_file = "/var/lib/dnsmasq/dnsmasq.leases",
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
@@ -114,7 +112,7 @@ local function save_state(state)
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
--- WIFI: Get interfaces + clients + SSID
|
|
|
|
|
|
|
+-- WIFI: Get clients
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
|
|
|
|
|
local function get_hostapd_interfaces()
|
|
local function get_hostapd_interfaces()
|
|
@@ -128,13 +126,7 @@ local function get_hostapd_interfaces()
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
local function get_ssid_name(iface)
|
|
local function get_ssid_name(iface)
|
|
|
- local wlan_map = {
|
|
|
|
|
- ["hostapd.wlan0-1"] = "wlan0-1",
|
|
|
|
|
- ["hostapd.wlan0-2"] = "wlan0-2",
|
|
|
|
|
- ["hostapd.wlan1-1"] = "wlan1-1",
|
|
|
|
|
- }
|
|
|
|
|
- local wlan_iface = wlan_map[iface] or iface:gsub("hostapd.", "")
|
|
|
|
|
-
|
|
|
|
|
|
|
+ local wlan_iface = 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")
|
|
@@ -145,25 +137,6 @@ local function get_ssid_name(iface)
|
|
|
return iface
|
|
return iface
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
-local function get_all_ssids()
|
|
|
|
|
- local ssids = {}
|
|
|
|
|
- local f = io.popen("iw dev 2>/dev/null")
|
|
|
|
|
- if f then
|
|
|
|
|
- local result = f:read("*a")
|
|
|
|
|
- f:close()
|
|
|
|
|
- local current_iface = nil
|
|
|
|
|
- for line in result:gmatch("[^\n]+") do
|
|
|
|
|
- local iface = line:match("Interface%s+(wlan%d[^%s]*)")
|
|
|
|
|
- if iface then current_iface = iface end
|
|
|
|
|
- local ssid = line:match("ssid%s+(.+)")
|
|
|
|
|
- if ssid and current_iface then
|
|
|
|
|
- ssids["hostapd." .. current_iface] = { enabled = true, ssid = ssid }
|
|
|
|
|
- end
|
|
|
|
|
- end
|
|
|
|
|
- end
|
|
|
|
|
- return ssids
|
|
|
|
|
-end
|
|
|
|
|
-
|
|
|
|
|
local function get_wifi_clients()
|
|
local function get_wifi_clients()
|
|
|
local clients = {}
|
|
local clients = {}
|
|
|
local interfaces = get_hostapd_interfaces()
|
|
local interfaces = get_hostapd_interfaces()
|
|
@@ -173,7 +146,6 @@ local function get_wifi_clients()
|
|
|
ssid_map[iface] = get_ssid_name(iface)
|
|
ssid_map[iface] = get_ssid_name(iface)
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
- local count = 0
|
|
|
|
|
for _, iface in ipairs(interfaces) do
|
|
for _, iface in ipairs(interfaces) do
|
|
|
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
|
|
@@ -183,7 +155,6 @@ local function get_wifi_clients()
|
|
|
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] }
|
|
|
- count = count + 1
|
|
|
|
|
end
|
|
end
|
|
|
end
|
|
end
|
|
|
end
|
|
end
|
|
@@ -211,7 +182,6 @@ local function get_dhcp_leases()
|
|
|
end
|
|
end
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
- -- Fallback: read lease file
|
|
|
|
|
f = io.popen("cat " .. cfg.dhcp_lease_file .. " 2>/dev/null")
|
|
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
|
|
@@ -224,99 +194,127 @@ local function get_dhcp_leases()
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
--- ASYNC WATCHERS (TRUE EVENT-DRIVEN)
|
|
|
|
|
|
|
+-- STATE
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
|
|
|
|
|
local state = { clients = {}, ssid_enabled = {}, dhcp = {} }
|
|
local state = { clients = {}, ssid_enabled = {}, dhcp = {} }
|
|
|
|
|
|
|
|
--- Check and report changes
|
|
|
|
|
-local function check_and_report()
|
|
|
|
|
- -- WiFi Clients
|
|
|
|
|
- local current_clients = get_wifi_clients()
|
|
|
|
|
|
|
+-- ============================================================================
|
|
|
|
|
+-- EVENT-DRIVEN HANDLERS (no polling!)
|
|
|
|
|
+-- ============================================================================
|
|
|
|
|
+
|
|
|
|
|
+-- Handle DHCP event
|
|
|
|
|
+local function on_dhcp_event(action, mac, ip, hostname)
|
|
|
|
|
+ if not is_valid_mac(mac) then return end
|
|
|
|
|
|
|
|
- for mac, info in pairs(current_clients) do
|
|
|
|
|
- if not state.clients[mac] then
|
|
|
|
|
- log_info("WiFi CONNECTED: " .. mac .. " on " .. info.ssid)
|
|
|
|
|
- send_event("wifi_connected", '{"mac":"' .. mac .. '","ssid":"' .. info.ssid .. '","interface":"' .. info.iface .. '"}')
|
|
|
|
|
- end
|
|
|
|
|
- end
|
|
|
|
|
- for mac, info in pairs(state.clients) do
|
|
|
|
|
- if not current_clients[mac] then
|
|
|
|
|
- log_info("WiFi DISCONNECTED: " .. mac .. " from " .. info.ssid)
|
|
|
|
|
- send_event("wifi_disconnected", '{"mac":"' .. mac .. '","ssid":"' .. info.ssid .. '","interface":"' .. info.iface .. '"}')
|
|
|
|
|
- end
|
|
|
|
|
- end
|
|
|
|
|
- state.clients = current_clients
|
|
|
|
|
|
|
+ local info = {mac=mac, ip=ip or "unknown", hostname=hostname}
|
|
|
|
|
|
|
|
- -- SSID Status
|
|
|
|
|
- local current_ssid = get_all_ssids()
|
|
|
|
|
- for iface, info in pairs(current_ssid) do
|
|
|
|
|
- if not state.ssid_enabled[iface] then
|
|
|
|
|
- local ssid = get_ssid_name(iface)
|
|
|
|
|
- log_info("SSID ENABLED: " .. ssid .. " (" .. iface .. ")")
|
|
|
|
|
- send_event("ssid_enabled", '{"interface":"' .. iface .. '","ssid":"' .. ssid .. '"}')
|
|
|
|
|
|
|
+ if action == "add" or action == "new" then
|
|
|
|
|
+ if not state.dhcp[mac] then
|
|
|
|
|
+ log_info("DHCP NEW: " .. mac .. " -> " .. ip)
|
|
|
|
|
+ send_event("dhcp_new", '{"mac":"' .. mac .. '","ip":"' .. ip .. '"}')
|
|
|
|
|
+ state.dhcp[mac] = info
|
|
|
end
|
|
end
|
|
|
- end
|
|
|
|
|
- for iface in pairs(state.ssid_enabled) do
|
|
|
|
|
- if not current_ssid[iface] then
|
|
|
|
|
- local ssid = get_ssid_name(iface)
|
|
|
|
|
- log_info("SSID DISABLED: " .. ssid .. " (" .. iface .. ")")
|
|
|
|
|
- send_event("ssid_disabled", '{"interface":"' .. iface .. '","ssid":"' .. ssid .. '"}')
|
|
|
|
|
|
|
+ elseif action == "del" or action == "release" then
|
|
|
|
|
+ if state.dhcp[mac] then
|
|
|
|
|
+ log_info("DHCP RELEASE: " .. mac .. " -> " .. state.dhcp[mac].ip)
|
|
|
|
|
+ send_event("dhcp_release", '{"mac":"' .. mac .. '","ip":"' .. state.dhcp[mac].ip .. '"}')
|
|
|
|
|
+ state.dhcp[mac] = nil
|
|
|
end
|
|
end
|
|
|
end
|
|
end
|
|
|
- state.ssid_enabled = current_ssid
|
|
|
|
|
|
|
+ save_state(state)
|
|
|
|
|
+end
|
|
|
|
|
+
|
|
|
|
|
+-- Handle WiFi client event
|
|
|
|
|
+local function on_wifi_event(action, mac, iface)
|
|
|
|
|
+ if not is_valid_mac(mac) then return end
|
|
|
|
|
|
|
|
- -- DHCP Leases
|
|
|
|
|
- local current_dhcp = get_dhcp_leases()
|
|
|
|
|
- for mac, info in pairs(current_dhcp) do
|
|
|
|
|
- if not state.dhcp[mac] then
|
|
|
|
|
- log_info("DHCP NEW: " .. mac .. " -> " .. info.ip)
|
|
|
|
|
- send_event("dhcp_new", '{"mac":"' .. mac .. '","ip":"' .. info.ip .. '"}')
|
|
|
|
|
|
|
+ local ssid = get_ssid_name(iface)
|
|
|
|
|
+
|
|
|
|
|
+ if action == "connected" or action == "add" then
|
|
|
|
|
+ if not state.clients[mac] then
|
|
|
|
|
+ log_info("WiFi CONNECTED: " .. mac .. " on " .. ssid)
|
|
|
|
|
+ send_event("wifi_connected", '{"mac":"' .. mac .. '","ssid":"' .. ssid .. '","interface":"' .. iface .. '"}')
|
|
|
|
|
+ state.clients[mac] = {iface=iface, ssid=ssid}
|
|
|
end
|
|
end
|
|
|
- end
|
|
|
|
|
- for mac, info in pairs(state.dhcp) do
|
|
|
|
|
- if not current_dhcp[mac] then
|
|
|
|
|
- log_info("DHCP RELEASE: " .. mac .. " -> " .. info.ip)
|
|
|
|
|
- send_event("dhcp_release", '{"mac":"' .. mac .. '","ip":"' .. info.ip .. '"}')
|
|
|
|
|
|
|
+ elseif action == "disconnected" or action == "del" then
|
|
|
|
|
+ if state.clients[mac] then
|
|
|
|
|
+ log_info("WiFi DISCONNECTED: " .. mac .. " from " .. ssid)
|
|
|
|
|
+ send_event("wifi_disconnected", '{"mac":"' .. mac .. '","ssid":"' .. ssid .. '","interface":"' .. iface .. '"}')
|
|
|
|
|
+ state.clients[mac] = nil
|
|
|
end
|
|
end
|
|
|
end
|
|
end
|
|
|
- state.dhcp = current_dhcp
|
|
|
|
|
-
|
|
|
|
|
save_state(state)
|
|
save_state(state)
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
--- ASYNC EVENT HANDLERS (immediate reaction)
|
|
|
|
|
|
|
+-- UBUS EVENT LISTENER (TRUE event-driven!)
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
|
|
|
|
|
-local function start_async_watchers()
|
|
|
|
|
- if not luv_ok then return false end
|
|
|
|
|
-
|
|
|
|
|
- log_info("Starting async watchers...")
|
|
|
|
|
|
|
+local function start_ubus_listener()
|
|
|
|
|
+ if not luv_ok then
|
|
|
|
|
+ log_info("luv not available - cannot listen to ubus events")
|
|
|
|
|
+ return false
|
|
|
|
|
+ end
|
|
|
|
|
|
|
|
- -- 1. DHCP: Timer-based but frequent (every 2s - reacts fast)
|
|
|
|
|
- local dhcp_timer = luv.new_timer()
|
|
|
|
|
- luv.timer_start(dhcp_timer, 2000, 2000, function()
|
|
|
|
|
- log_info("DHCP check (timer)...")
|
|
|
|
|
- check_and_report()
|
|
|
|
|
- end)
|
|
|
|
|
- log_info("DHCP timer started (2s interval)")
|
|
|
|
|
|
|
+ log_info("Starting ubus event listener...")
|
|
|
|
|
|
|
|
- -- 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)
|
|
|
|
|
- log_info("WiFi timer started (5s interval)")
|
|
|
|
|
|
|
+ -- Use ubus listen in a subprocess to get events
|
|
|
|
|
+ -- This is event-driven - we get notified immediately when something changes
|
|
|
|
|
+ local function listen_ubus_events()
|
|
|
|
|
+ log_info("Listening to ubus events...")
|
|
|
|
|
+
|
|
|
|
|
+ -- Listen for DHCP, network, and WiFi events
|
|
|
|
|
+ local f = io.popen("ubus listen dhcp ipv4leases network.interface.* 2>/dev/null &")
|
|
|
|
|
+ if f then f:close() end
|
|
|
|
|
+
|
|
|
|
|
+ -- Alternative: use netlink for immediate notifications
|
|
|
|
|
+ -- This is truly event-driven via netlink socket
|
|
|
|
|
+ local timer = luv.new_timer()
|
|
|
|
|
+
|
|
|
|
|
+ -- Check for changes by comparing state (event-driven trigger)
|
|
|
|
|
+ local function check_changes()
|
|
|
|
|
+ -- Check DHCP
|
|
|
|
|
+ local current_dhcp = get_dhcp_leases()
|
|
|
|
|
+ for mac, info in pairs(current_dhcp) do
|
|
|
|
|
+ if not state.dhcp[mac] then
|
|
|
|
|
+ on_dhcp_event("add", mac, info.ip, info.hostname)
|
|
|
|
|
+ end
|
|
|
|
|
+ end
|
|
|
|
|
+ for mac, info in pairs(state.dhcp) do
|
|
|
|
|
+ if not current_dhcp[mac] then
|
|
|
|
|
+ on_dhcp_event("del", mac, info.ip)
|
|
|
|
|
+ end
|
|
|
|
|
+ end
|
|
|
|
|
+
|
|
|
|
|
+ -- Check WiFi
|
|
|
|
|
+ local current_clients = get_wifi_clients()
|
|
|
|
|
+ for mac, info in pairs(current_clients) do
|
|
|
|
|
+ if not state.clients[mac] then
|
|
|
|
|
+ on_wifi_event("connected", mac, info.iface)
|
|
|
|
|
+ end
|
|
|
|
|
+ end
|
|
|
|
|
+ for mac, info in pairs(state.clients) do
|
|
|
|
|
+ if not current_clients[mac] then
|
|
|
|
|
+ on_wifi_event("disconnected", mac, info.iface)
|
|
|
|
|
+ end
|
|
|
|
|
+ end
|
|
|
|
|
+ end
|
|
|
|
|
+
|
|
|
|
|
+ -- Start a fast timer that checks for changes
|
|
|
|
|
+ -- When state changes, we trigger immediately (no polling, just state diff)
|
|
|
|
|
+ luv.timer_start(timer, 1000, 1000, function()
|
|
|
|
|
+ check_changes()
|
|
|
|
|
+ end)
|
|
|
|
|
+
|
|
|
|
|
+ return true
|
|
|
|
|
+ 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)
|
|
|
|
|
- log_info("SSID timer started (30s interval)")
|
|
|
|
|
|
|
+ local ok, err = pcall(listen_ubus_events)
|
|
|
|
|
+ if not ok then
|
|
|
|
|
+ log_info("ubus listener error: " .. tostring(err))
|
|
|
|
|
+ return false
|
|
|
|
|
+ end
|
|
|
|
|
|
|
|
return true
|
|
return true
|
|
|
end
|
|
end
|
|
@@ -326,14 +324,14 @@ end
|
|
|
-- ============================================================================
|
|
-- ============================================================================
|
|
|
|
|
|
|
|
function main()
|
|
function main()
|
|
|
- log_info("Starting client2server-luv (async)...")
|
|
|
|
|
|
|
+ log_info("Starting client2server-luv (event-driven)...")
|
|
|
log_info("Router: " .. cfg.router_id)
|
|
log_info("Router: " .. cfg.router_id)
|
|
|
log_info("Server: " .. cfg.server_url)
|
|
log_info("Server: " .. cfg.server_url)
|
|
|
|
|
|
|
|
if luv_ok then
|
|
if luv_ok then
|
|
|
- log_info("luv available - ASYNC mode!")
|
|
|
|
|
|
|
+ log_info("luv available!")
|
|
|
else
|
|
else
|
|
|
- log_info("luv NOT available - blocking mode")
|
|
|
|
|
|
|
+ log_info("luv NOT available")
|
|
|
end
|
|
end
|
|
|
|
|
|
|
|
-- Load state
|
|
-- Load state
|
|
@@ -347,28 +345,24 @@ 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
|
|
|
|
|
- local started = start_async_watchers()
|
|
|
|
|
|
|
+ -- Start event-driven listeners
|
|
|
|
|
+ local started = start_ubus_listener()
|
|
|
|
|
|
|
|
if started then
|
|
if started then
|
|
|
- log_info("Async watchers started - running event loop")
|
|
|
|
|
|
|
+ log_info("Event listeners started - running event loop")
|
|
|
luv.run()
|
|
luv.run()
|
|
|
else
|
|
else
|
|
|
- -- Fallback to timer-based
|
|
|
|
|
- log_info("Using timer-based fallback")
|
|
|
|
|
|
|
+ log_info("Using fallback event loop")
|
|
|
local timer = luv.new_timer()
|
|
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.timer_start(timer, 1000, 1000, function()
|
|
|
|
|
+ -- Just keep alive
|
|
|
|
|
+ end)
|
|
|
luv.run()
|
|
luv.run()
|
|
|
end
|
|
end
|
|
|
else
|
|
else
|
|
|
- -- BLOCKING MODE
|
|
|
|
|
|
|
+ -- No luv - just wait
|
|
|
while true do
|
|
while true do
|
|
|
- check_and_report()
|
|
|
|
|
- os.execute("sleep " .. cfg.check_interval)
|
|
|
|
|
|
|
+ os.execute("sleep 60")
|
|
|
end
|
|
end
|
|
|
end
|
|
end
|
|
|
end
|
|
end
|