client2server-luv.lua 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  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. log_info("hostapd interfaces: " .. table.concat(interfaces, ", "))
  116. return interfaces
  117. end
  118. local function get_ssid_name(iface)
  119. local wlan_map = {
  120. ["hostapd.wlan0-1"] = "wlan0-1",
  121. ["hostapd.wlan0-2"] = "wlan0-2",
  122. }
  123. local wlan_iface = wlan_map[iface] or iface
  124. local f = io.popen("iw dev " .. wlan_iface .. " info 2>/dev/null | grep ssid")
  125. if f then
  126. local result = f:read("*a")
  127. f:close()
  128. local ssid = result:match("ssid%s+(.+)")
  129. if ssid and ssid ~= "" then
  130. return ssid:gsub("%s+$", "")
  131. end
  132. end
  133. return iface
  134. end
  135. local function get_all_ssids()
  136. local ssids = {}
  137. local f = io.popen("iw dev 2>/dev/null")
  138. if f then
  139. local result = f:read("*a")
  140. f:close()
  141. local current_iface = nil
  142. log_info("iw dev output: " .. result:sub(1, 200))
  143. for line in result:gmatch("[^\n]+") do
  144. -- Match ANY wlan interface (wlan0, wlan0-1, wlan1, etc.)
  145. local iface = line:match("Interface%s+(wlan%d[^%s]*)")
  146. if iface then current_iface = iface end
  147. local ssid = line:match("ssid%s+(.+)")
  148. if ssid and current_iface then
  149. local hostapd_iface = "hostapd." .. current_iface
  150. ssids[hostapd_iface] = { enabled = true, ssid = ssid }
  151. log_info("SSID found: " .. ssid .. " on " .. hostapd_iface)
  152. end
  153. end
  154. end
  155. return ssids
  156. end
  157. local function get_wifi_clients()
  158. local clients = {}
  159. local interfaces = get_hostapd_interfaces()
  160. local ssid_map = {}
  161. for _, iface in ipairs(interfaces) do
  162. ssid_map[iface] = get_ssid_name(iface)
  163. end
  164. local count = 0
  165. for _, iface in ipairs(interfaces) do
  166. log_info("Querying WiFi clients on: " .. iface)
  167. local f = io.popen("ubus call " .. iface .. " get_clients 2>/dev/null")
  168. if f then
  169. local result = f:read("*a")
  170. f:close()
  171. log_info(iface .. " clients result: " .. result:sub(1, 200))
  172. if result:find('"clients"') and not result:find('"clients":%s*{}') then
  173. local mac_count = 0
  174. 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
  175. if is_valid_mac(mac) and not clients[mac] then
  176. clients[mac] = { iface = iface, ssid = ssid_map[iface] }
  177. mac_count = mac_count + 1
  178. end
  179. end
  180. count = count + mac_count
  181. end
  182. end
  183. end
  184. log_info("Total WiFi clients found: " .. count)
  185. return clients
  186. end
  187. local function get_ssid_status()
  188. local status = {}
  189. local ssids = get_all_ssids()
  190. for iface, info in pairs(ssids) do
  191. if info.enabled then status[iface] = true end
  192. end
  193. return status
  194. end
  195. -- ============================================================================
  196. -- DHCP: Get leases via ubus
  197. -- ============================================================================
  198. local function get_dhcp_leases()
  199. local leases = {}
  200. local f = io.popen("ubus call dhcp ipv4leases 2>/dev/null")
  201. if f then
  202. local result = f:read("*a")
  203. f:close()
  204. if result:match('"leases"') then
  205. for ip, mac in result:gmatch('"ip":"([%d%.]+)"[^}]*"mac":"([a-fA-F0-9:]+)"') do
  206. if is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac} end
  207. end
  208. return leases
  209. end
  210. end
  211. f = io.popen("cat /tmp/dhcp.leases 2>/dev/null")
  212. if f then
  213. for line in f:lines() do
  214. local e, mac, ip, h, c = line:match("^(%S+) (%S+) (%S+) (%S+) (%S+)")
  215. if mac and is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac, hostname=h} end
  216. end
  217. f:close()
  218. end
  219. return leases
  220. end
  221. -- ============================================================================
  222. -- MAIN LOOP (with luv async)
  223. -- ============================================================================
  224. local state = { clients = {}, ssid_enabled = {}, dhcp = {} }
  225. local function check_all()
  226. -- WiFi Clients
  227. local current_clients = get_wifi_clients()
  228. for mac, info in pairs(current_clients) do
  229. if not state.clients[mac] then
  230. log_info("WiFi CONNECTED: " .. mac .. " on " .. info.ssid)
  231. send_event("wifi_connected", '{"mac":"' .. mac .. '","ssid":"' .. info.ssid .. '","interface":"' .. info.iface .. '"}')
  232. end
  233. end
  234. for mac, info in pairs(state.clients) do
  235. if not current_clients[mac] then
  236. log_info("WiFi DISCONNECTED: " .. mac .. " from " .. info.ssid)
  237. send_event("wifi_disconnected", '{"mac":"' .. mac .. '","ssid":"' .. info.ssid .. '","interface":"' .. info.iface .. '"}')
  238. end
  239. end
  240. state.clients = current_clients
  241. -- SSID Status
  242. local current_ssid = get_ssid_status()
  243. for iface in pairs(current_ssid) do
  244. if not state.ssid_enabled[iface] then
  245. local ssid = get_ssid_name(iface)
  246. log_info("SSID ENABLED: " .. ssid .. " (" .. iface .. ")")
  247. send_event("ssid_enabled", '{"interface":"' .. iface .. '","ssid":"' .. ssid .. '"}')
  248. end
  249. end
  250. for iface in pairs(state.ssid_enabled) do
  251. if not current_ssid[iface] then
  252. local ssid = get_ssid_name(iface)
  253. log_info("SSID DISABLED: " .. ssid .. " (" .. iface .. ")")
  254. send_event("ssid_disabled", '{"interface":"' .. iface .. '","ssid":"' .. ssid .. '"}')
  255. end
  256. end
  257. state.ssid_enabled = current_ssid
  258. -- DHCP Leases
  259. local current_dhcp = get_dhcp_leases()
  260. for mac, info in pairs(current_dhcp) do
  261. if not state.dhcp[mac] then
  262. log_info("DHCP NEW: " .. mac .. " -> " .. info.ip)
  263. send_event("dhcp_new", '{"mac":"' .. mac .. '","ip":"' .. info.ip .. '"}')
  264. end
  265. end
  266. for mac, info in pairs(state.dhcp) do
  267. if not current_dhcp[mac] then
  268. log_info("DHCP RELEASE: " .. mac .. " -> " .. info.ip)
  269. send_event("dhcp_release", '{"mac":"' .. mac .. '","ip":"' .. info.ip .. '"}')
  270. end
  271. end
  272. state.dhcp = current_dhcp
  273. -- Save state
  274. save_state(state)
  275. end
  276. -- ============================================================================
  277. -- MAIN
  278. -- ============================================================================
  279. function main()
  280. log_info("Starting client2server-luv (async)...")
  281. log_info("Router: " .. cfg.router_id)
  282. log_info("Server: " .. cfg.server_url)
  283. if luv_ok then
  284. log_info("luv available - using async mode")
  285. else
  286. log_info("luv NOT available - using blocking mode")
  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. -- ASYNC MODE with luv
  298. local timer = luv.new_timer()
  299. local function run_loop()
  300. check_all()
  301. -- Schedule next check
  302. luv.timer_start(timer, cfg.check_interval * 1000, cfg.check_interval * 1000, function()
  303. run_loop()
  304. end)
  305. end
  306. -- Start first check after initial delay
  307. luv.timer_start(timer, cfg.check_interval * 1000, 0, function()
  308. run_loop()
  309. end)
  310. -- Run the event loop
  311. luv.run()
  312. else
  313. -- BLOCKING MODE (fallback)
  314. while true do
  315. check_all()
  316. local timer = luv.new_timer()
  317. luv.timer_start(timer, cfg.check_interval * 1000, 0, function() end)
  318. luv.run()
  319. -- Fallback if no luv
  320. os.execute("sleep " .. cfg.check_interval)
  321. end
  322. end
  323. end
  324. main()