--[[ client2server-luv - TRUE event-driven using ubus events Features: - NO POLLING - reacts to EVENTS only - ubus event subscription for DHCP, network, WiFi - Uses luv for async event loop - 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", state_file = "/tmp/client2server_state", dhcp_lease_file = "/var/lib/dnsmasq/dnsmasq.leases", } -- ============================================================================ -- 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 clients -- ============================================================================ 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_iface = 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_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 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] } 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 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 -- ============================================================================ -- STATE -- ============================================================================ local state = { clients = {}, ssid_enabled = {}, dhcp = {} } -- ============================================================================ -- 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 local info = {mac=mac, ip=ip or "unknown", hostname=hostname} 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 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 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 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 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 save_state(state) end -- ============================================================================ -- UBUS EVENT LISTENER (TRUE event-driven!) -- ============================================================================ local function start_ubus_listen() if not luv_ok then log_info("luv not available") return false end log_info("Starting ubus listen (event-driven)...") -- Use ubus listen in background process - this is TRUE event-driven! -- ubus listen blocks and emits events when they happen local function ubus_listen_loop() -- Spawn ubus listen as background process -- It will emit JSON events to stdout when DHCP/WiFi events happen local pipe = io.popen("ubus listen dhcp ipv4leases network.interface.* hostapd.* 2>&1", "r") if not pipe then log_info("ubus listen failed") return false end log_info("ubus listen started - waiting for events...") -- Read events as they come (blocking but event-driven) while true do local line = pipe:read("*line") if not line then break end log_info("ubus event: " .. line:sub(1, 200)) -- Parse and handle event local event_type = line:match('"type":"([^"]+)"') or "unknown" if event_type == "ipv4leases" or event_type == "dhcp" then -- DHCP event - check for changes 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 elseif event_type:match("hostapd") then -- WiFi event 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 end pipe:close() log_info("ubus listen ended") end -- Run ubus listen in background thread luv.new_task(function() ubus_listen_loop() end) return true end -- ============================================================================ -- MAIN -- ============================================================================ function main() log_info("Starting client2server-luv (event-driven)...") log_info("Router: " .. cfg.router_id) log_info("Server: " .. cfg.server_url) if luv_ok then log_info("luv available!") else log_info("luv NOT available") 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 -- Start event-driven listeners start_ubus_listen() log_info("Running event loop - waiting for events...") luv.run() else -- No luv - just wait while true do os.execute("sleep 60") end end end main()