client2server-luv.lua 13 KB

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