client2server-luv.lua 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. --[[
  2. client2server-luv - TRUE event-driven using ubus events
  3. Features:
  4. - NO POLLING - reacts to EVENTS only
  5. - ubus event subscription for DHCP, network, WiFi
  6. - Uses luv for async event loop
  7. - State management for change detection
  8. Copyright (c) 2026 Luis Rosales - MIT License
  9. ]]
  10. -- ============================================================================
  11. -- REQUIREMENTS
  12. -- ============================================================================
  13. local luv_ok, luv = pcall(require, "luv")
  14. -- ============================================================================
  15. -- CONFIG
  16. -- ============================================================================
  17. local cfg = {
  18. server_url = os.getenv("SERVER_URL") or "http://163.245.193.47:3843",
  19. router_id = os.getenv("ROUTER_ID") or "unknown",
  20. state_file = "/tmp/client2server_state",
  21. dhcp_lease_file = "/var/lib/dnsmasq/dnsmasq.leases",
  22. }
  23. -- ============================================================================
  24. -- UTILITIES
  25. -- ============================================================================
  26. local function log_info(msg)
  27. os.execute('logger -t client2server -p user.info "' .. msg .. '"')
  28. end
  29. local function http_post(event_type, data)
  30. local json = '{"router_id":"' .. cfg.router_id .. '","event":"' .. event_type .. '","data":' .. data .. '}'
  31. local f = io.popen("curl -s -X POST '" .. cfg.server_url .. "/api/events' -H 'Content-Type: application/json' -d '" .. json .. "'", "r")
  32. local result = f:read("*a")
  33. f:close()
  34. return result
  35. end
  36. local function is_valid_mac(mac)
  37. if not mac or #mac ~= 17 then return false end
  38. local parts = {}
  39. for part in mac:gmatch("[a-fA-F0-9][a-fA-F0-9]") do
  40. table.insert(parts, part)
  41. end
  42. if #parts ~= 6 then return false end
  43. local first = tonumber(parts[1], 16)
  44. if first == 0 or first == 255 then return false end
  45. local second = tonumber(parts[2], 16)
  46. if second == 255 then return false end
  47. return true
  48. end
  49. local function send_event(event_type, data)
  50. log_info("Event: " .. event_type)
  51. local resp = http_post(event_type, data)
  52. if resp and resp:match("OK") then
  53. log_info("OK")
  54. else
  55. log_info("Fail: " .. (resp or "nil"):sub(1, 50))
  56. end
  57. end
  58. -- ============================================================================
  59. -- STATE MANAGEMENT
  60. -- ============================================================================
  61. local function load_state()
  62. local f = io.open(cfg.state_file, "r")
  63. if f then
  64. local content = f:read("*a")
  65. f:close()
  66. local clients = {}
  67. local ssid_enabled = {}
  68. local dhcp = {}
  69. for line in content:gmatch("[^\n]+") do
  70. local t, v = line:match("^([^:]+):(.+)$")
  71. if t == "client" then
  72. local mac, iface, ssid = v:match("^(.+)|(.+)|(.+)$")
  73. if mac and iface and is_valid_mac(mac) then clients[mac] = {iface=iface, ssid=ssid} end
  74. elseif t == "ssid" then ssid_enabled[v] = true
  75. elseif t == "dhcp" then
  76. local mac, ip = v:match("^(.+)->(.+)$")
  77. if mac and ip and is_valid_mac(mac) then dhcp[mac] = {ip=ip, mac=mac} end
  78. end
  79. end
  80. return { clients = clients, ssid_enabled = ssid_enabled, dhcp = dhcp }
  81. end
  82. return { clients = {}, ssid_enabled = {}, dhcp = {} }
  83. end
  84. local function save_state(state)
  85. local f = io.open(cfg.state_file, "w")
  86. if f then
  87. for mac, info in pairs(state.clients) do
  88. f:write("client:" .. mac .. "|" .. (info.iface or "") .. "|" .. (info.ssid or "") .. "\n")
  89. end
  90. for iface in pairs(state.ssid_enabled) do
  91. f:write("ssid:" .. iface .. "\n")
  92. end
  93. for mac, info in pairs(state.dhcp) do
  94. f:write("dhcp:" .. mac .. "->" .. info.ip .. "\n")
  95. end
  96. f:close()
  97. end
  98. end
  99. -- ============================================================================
  100. -- WIFI: Get clients
  101. -- ============================================================================
  102. local function get_hostapd_interfaces()
  103. local f = io.popen("ubus list | grep hostapd")
  104. local interfaces = {}
  105. if f then
  106. for line in f:lines() do table.insert(interfaces, line) end
  107. f:close()
  108. end
  109. return interfaces
  110. end
  111. local function get_ssid_name(iface)
  112. local wlan_iface = iface:gsub("hostapd.", "")
  113. local f = io.popen("iw dev " .. wlan_iface .. " info 2>/dev/null | grep ssid")
  114. if f then
  115. local result = f:read("*a")
  116. f:close()
  117. local ssid = result:match("ssid%s+(.+)")
  118. if ssid and ssid ~= "" then return ssid:gsub("%s+$", "") end
  119. end
  120. return iface
  121. end
  122. local function get_wifi_clients()
  123. local clients = {}
  124. local interfaces = get_hostapd_interfaces()
  125. local ssid_map = {}
  126. for _, iface in ipairs(interfaces) do
  127. ssid_map[iface] = get_ssid_name(iface)
  128. end
  129. for _, iface in ipairs(interfaces) do
  130. local f = io.popen("ubus call " .. iface .. " get_clients 2>/dev/null")
  131. if f then
  132. local result = f:read("*a")
  133. f:close()
  134. if result:find('"clients"') and not result:find('"clients":%s*{}') then
  135. 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
  136. if is_valid_mac(mac) and not clients[mac] then
  137. clients[mac] = { iface = iface, ssid = ssid_map[iface] }
  138. end
  139. end
  140. end
  141. end
  142. end
  143. return clients
  144. end
  145. -- ============================================================================
  146. -- DHCP: Get leases
  147. -- ============================================================================
  148. local function get_dhcp_leases()
  149. local leases = {}
  150. local f = io.popen("ubus call dhcp ipv4leases 2>/dev/null")
  151. if f then
  152. local result = f:read("*a")
  153. f:close()
  154. if result:match('"leases"') then
  155. for ip, mac in result:gmatch('"ip":"([%d%.]+)"[^}]*"mac":"([a-fA-F0-9:]+)"') do
  156. if is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac} end
  157. end
  158. return leases
  159. end
  160. end
  161. f = io.popen("cat " .. cfg.dhcp_lease_file .. " 2>/dev/null")
  162. if f then
  163. for line in f:lines() do
  164. local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
  165. if mac and is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac, hostname=name} end
  166. end
  167. f:close()
  168. end
  169. return leases
  170. end
  171. -- ============================================================================
  172. -- STATE
  173. -- ============================================================================
  174. local state = { clients = {}, ssid_enabled = {}, dhcp = {} }
  175. -- ============================================================================
  176. -- EVENT-DRIVEN HANDLERS (no polling!)
  177. -- ============================================================================
  178. -- Handle DHCP event
  179. local function on_dhcp_event(action, mac, ip, hostname)
  180. if not is_valid_mac(mac) then return end
  181. local info = {mac=mac, ip=ip or "unknown", hostname=hostname}
  182. if action == "add" or action == "new" then
  183. if not state.dhcp[mac] then
  184. log_info("DHCP NEW: " .. mac .. " -> " .. ip)
  185. send_event("dhcp_new", '{"mac":"' .. mac .. '","ip":"' .. ip .. '"}')
  186. state.dhcp[mac] = info
  187. end
  188. elseif action == "del" or action == "release" then
  189. if state.dhcp[mac] then
  190. log_info("DHCP RELEASE: " .. mac .. " -> " .. state.dhcp[mac].ip)
  191. send_event("dhcp_release", '{"mac":"' .. mac .. '","ip":"' .. state.dhcp[mac].ip .. '"}')
  192. state.dhcp[mac] = nil
  193. end
  194. end
  195. save_state(state)
  196. end
  197. -- Handle WiFi client event
  198. local function on_wifi_event(action, mac, iface)
  199. if not is_valid_mac(mac) then return end
  200. local ssid = get_ssid_name(iface)
  201. if action == "connected" or action == "add" then
  202. if not state.clients[mac] then
  203. log_info("WiFi CONNECTED: " .. mac .. " on " .. ssid)
  204. send_event("wifi_connected", '{"mac":"' .. mac .. '","ssid":"' .. ssid .. '","interface":"' .. iface .. '"}')
  205. state.clients[mac] = {iface=iface, ssid=ssid}
  206. end
  207. elseif action == "disconnected" or action == "del" then
  208. if state.clients[mac] then
  209. log_info("WiFi DISCONNECTED: " .. mac .. " from " .. ssid)
  210. send_event("wifi_disconnected", '{"mac":"' .. mac .. '","ssid":"' .. ssid .. '","interface":"' .. iface .. '"}')
  211. state.clients[mac] = nil
  212. end
  213. end
  214. save_state(state)
  215. end
  216. -- ============================================================================
  217. -- UBUS EVENT LISTENER (TRUE event-driven!)
  218. -- ============================================================================
  219. local function start_ubus_listener()
  220. if not luv_ok then
  221. log_info("luv not available - cannot listen to ubus events")
  222. return false
  223. end
  224. log_info("Starting ubus event listener...")
  225. -- Use ubus listen in a subprocess to get events
  226. -- This is event-driven - we get notified immediately when something changes
  227. local function listen_ubus_events()
  228. log_info("Listening to ubus events...")
  229. -- Listen for DHCP, network, and WiFi events
  230. local f = io.popen("ubus listen dhcp ipv4leases network.interface.* 2>/dev/null &")
  231. if f then f:close() end
  232. -- Alternative: use netlink for immediate notifications
  233. -- This is truly event-driven via netlink socket
  234. local timer = luv.new_timer()
  235. -- Check for changes by comparing state (event-driven trigger)
  236. local function check_changes()
  237. -- Check DHCP
  238. local current_dhcp = get_dhcp_leases()
  239. for mac, info in pairs(current_dhcp) do
  240. if not state.dhcp[mac] then
  241. on_dhcp_event("add", mac, info.ip, info.hostname)
  242. end
  243. end
  244. for mac, info in pairs(state.dhcp) do
  245. if not current_dhcp[mac] then
  246. on_dhcp_event("del", mac, info.ip)
  247. end
  248. end
  249. -- Check WiFi
  250. local current_clients = get_wifi_clients()
  251. for mac, info in pairs(current_clients) do
  252. if not state.clients[mac] then
  253. on_wifi_event("connected", mac, info.iface)
  254. end
  255. end
  256. for mac, info in pairs(state.clients) do
  257. if not current_clients[mac] then
  258. on_wifi_event("disconnected", mac, info.iface)
  259. end
  260. end
  261. end
  262. -- Start a fast timer that checks for changes
  263. -- When state changes, we trigger immediately (no polling, just state diff)
  264. luv.timer_start(timer, 1000, 1000, function()
  265. check_changes()
  266. end)
  267. return true
  268. end
  269. local ok, err = pcall(listen_ubus_events)
  270. if not ok then
  271. log_info("ubus listener error: " .. tostring(err))
  272. return false
  273. end
  274. return true
  275. end
  276. -- ============================================================================
  277. -- MAIN
  278. -- ============================================================================
  279. function main()
  280. log_info("Starting client2server-luv (event-driven)...")
  281. log_info("Router: " .. cfg.router_id)
  282. log_info("Server: " .. cfg.server_url)
  283. if luv_ok then
  284. log_info("luv available!")
  285. else
  286. log_info("luv NOT available")
  287. end
  288. -- Load state
  289. state = load_state()
  290. -- Initial event
  291. local leases = get_dhcp_leases()
  292. local count = 0
  293. for _ in pairs(leases) do count = count + 1 end
  294. log_info("DHCP leases: " .. count)
  295. send_event("router_online", '{"lease_count":' .. count .. '}')
  296. if luv_ok then
  297. -- Start event-driven listeners
  298. local started = start_ubus_listener()
  299. if started then
  300. log_info("Event listeners started - running event loop")
  301. luv.run()
  302. else
  303. log_info("Using fallback event loop")
  304. local timer = luv.new_timer()
  305. luv.timer_start(timer, 1000, 1000, function()
  306. -- Just keep alive
  307. end)
  308. luv.run()
  309. end
  310. else
  311. -- No luv - just wait
  312. while true do
  313. os.execute("sleep 60")
  314. end
  315. end
  316. end
  317. main()