client2server-luv.lua 12 KB

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