--[[ client2server-luv - client2server-minimal.lua with TRUE luv async 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 - State management for change detection Copyright (c) 2026 Luis Rosales - MIT License ]] -- ============================================================================ -- REQUIREMENTS -- ============================================================================ local luv_ok, luv = pcall(require, "luv") -- ============================================================================ -- CONFIG -- ============================================================================ local cfg = { server_url = os.getenv("SERVER_URL") or "http://163.245.193.47:3843", router_id = os.getenv("ROUTER_ID") or "unknown", check_interval = 10, dhcp_lease_file = "/var/lib/dnsmasq/dnsmasq.leases", state_file = "/tmp/client2server_state", } -- ============================================================================ -- UTILITIES -- ============================================================================ local function log_info(msg) os.execute('logger -t client2server -p user.info "' .. msg .. '"') end local function http_post(event_type, data) local json = '{"router_id":"' .. cfg.router_id .. '","event":"' .. event_type .. '","data":' .. data .. '}' local f = io.popen("curl -s -X POST '" .. cfg.server_url .. "/api/events' -H 'Content-Type: application/json' -d '" .. json .. "'", "r") local result = f:read("*a") f:close() return result end local function is_valid_mac(mac) if not mac or #mac ~= 17 then return false end local parts = {} for part in mac:gmatch("[a-fA-F0-9][a-fA-F0-9]") do table.insert(parts, part) end if #parts ~= 6 then return false end local first = tonumber(parts[1], 16) if first == 0 or first == 255 then return false end local second = tonumber(parts[2], 16) if second == 255 then return false end return true end local function send_event(event_type, data) log_info("Event: " .. event_type) local resp = http_post(event_type, data) if resp and resp:match("OK") then log_info("OK") else log_info("Fail: " .. (resp or "nil"):sub(1, 50)) end end -- ============================================================================ -- STATE MANAGEMENT -- ============================================================================ local function load_state() local f = io.open(cfg.state_file, "r") if f then local content = f:read("*a") f:close() local clients = {} local ssid_enabled = {} local dhcp = {} for line in content:gmatch("[^\n]+") do local t, v = line:match("^([^:]+):(.+)$") if t == "client" then local mac, iface, ssid = v:match("^(.+)|(.+)|(.+)$") if mac and iface and is_valid_mac(mac) then clients[mac] = {iface=iface, ssid=ssid} end elseif t == "ssid" then ssid_enabled[v] = true elseif t == "dhcp" then local mac, ip = v:match("^(.+)->(.+)$") if mac and ip and is_valid_mac(mac) then dhcp[mac] = {ip=ip, mac=mac} end end end return { clients = clients, ssid_enabled = ssid_enabled, dhcp = dhcp } end return { clients = {}, ssid_enabled = {}, dhcp = {} } end local function save_state(state) local f = io.open(cfg.state_file, "w") if f then for mac, info in pairs(state.clients) do f:write("client:" .. mac .. "|" .. (info.iface or "") .. "|" .. (info.ssid or "") .. "\n") end for iface in pairs(state.ssid_enabled) do f:write("ssid:" .. iface .. "\n") end for mac, info in pairs(state.dhcp) do f:write("dhcp:" .. mac .. "->" .. info.ip .. "\n") end f:close() end end -- ============================================================================ -- WIFI: Get interfaces + clients + SSID -- ============================================================================ local function get_hostapd_interfaces() local f = io.popen("ubus list | grep hostapd") local interfaces = {} if f then for line in f:lines() do table.insert(interfaces, line) end f:close() end return interfaces end 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 f = io.popen("iw dev " .. wlan_iface .. " info 2>/dev/null | grep ssid") if f then local result = f:read("*a") f:close() local ssid = result:match("ssid%s+(.+)") if ssid and ssid ~= "" then return ssid:gsub("%s+$", "") end end return iface 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 clients = {} local interfaces = get_hostapd_interfaces() local ssid_map = {} for _, iface in ipairs(interfaces) do ssid_map[iface] = get_ssid_name(iface) end local count = 0 for _, iface in ipairs(interfaces) do local f = io.popen("ubus call " .. iface .. " get_clients 2>/dev/null") if f then local result = f:read("*a") f:close() if result:find('"clients"') and not result:find('"clients":%s*{}') then 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 clients[mac] = { iface = iface, ssid = ssid_map[iface] } count = count + 1 end end end end end return clients end -- ============================================================================ -- DHCP: Get leases -- ============================================================================ local function get_dhcp_leases() local leases = {} local f = io.popen("ubus call dhcp ipv4leases 2>/dev/null") if f then local result = f:read("*a") f:close() if result:match('"leases"') then for ip, mac in result:gmatch('"ip":"([%d%.]+)"[^}]*"mac":"([a-fA-F0-9:]+)"') do if is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac} end end return leases end end -- Fallback: read lease file f = io.popen("cat " .. cfg.dhcp_lease_file .. " 2>/dev/null") if f then for line in f:lines() do 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 f:close() end return leases end -- ============================================================================ -- ASYNC WATCHERS (TRUE EVENT-DRIVEN) -- ============================================================================ local state = { clients = {}, ssid_enabled = {}, dhcp = {} } -- Check and report changes local function check_and_report() -- WiFi Clients local current_clients = get_wifi_clients() 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 -- 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 .. '"}') 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 .. '"}') end end state.ssid_enabled = current_ssid -- 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 .. '"}') 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 .. '"}') end end state.dhcp = current_dhcp save_state(state) 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 -- ============================================================================ function main() log_info("Starting client2server-luv (async)...") log_info("Router: " .. cfg.router_id) log_info("Server: " .. cfg.server_url) if luv_ok then log_info("luv available - ASYNC mode!") else log_info("luv NOT available - blocking mode") end -- Load state state = load_state() -- Initial event local leases = get_dhcp_leases() local count = 0 for _ in pairs(leases) do count = count + 1 end log_info("DHCP leases: " .. count) send_event("router_online", '{"lease_count":' .. count .. '}') if luv_ok then -- ASYNC MODE local started = start_async_watchers() 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 else -- BLOCKING MODE while true do check_and_report() os.execute("sleep " .. cfg.check_interval) end end end main()