client2server-luv.lua 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. --[[
  2. client2server-luv - Lua WebSocket Event Forwarder for OpenWrt with luv async
  3. Features:
  4. - WebSocket connection to central server
  5. - Auto-reconnect on disconnect
  6. - Local buffer (store-and-forward while offline)
  7. - DHCP/WiFi/Interface event tracking
  8. - ASYNC monitoring via luv (libuv bindings)
  9. - Parallel polling for 50+ devices
  10. Copyright (c) 2026 Luis Rosales - MIT License
  11. ]]
  12. -- ============================================================================
  13. -- REQUIREMENTS
  14. -- ============================================================================
  15. -- Try to load luv (libuv bindings for async operations)
  16. local luv_ok, luv = pcall(require, "luv")
  17. -- Try to load websocket library, fallback to simple HTTP
  18. local ws_client = nil
  19. local has_websocket, websocket = pcall(require, "websocket")
  20. if has_websocket then
  21. ws_client = websocket.client.sync()
  22. end
  23. -- ============================================================================
  24. -- CONFIG
  25. -- ============================================================================
  26. local cfg = {
  27. url = os.getenv("SERVER_URL") or "wss://your-server.com/ws",
  28. token = os.getenv("SERVER_TOKEN") or "secret-token",
  29. router_id = os.getenv("ROUTER_ID") or "unknown",
  30. reconnect_delay = 5,
  31. ping_interval = 30,
  32. buffer_file = "/tmp/event_buffer",
  33. max_buffer = 100,
  34. poll_interval = 30, -- device poll interval (seconds)
  35. wan_poll_interval = 30, -- WAN poll interval (seconds)
  36. dhcp_poll_interval = 5, -- DHCP poll interval (seconds)
  37. poll_timeout = 5000, -- poll timeout (ms)
  38. }
  39. -- Load from UCI if available
  40. pcall(function()
  41. local uci = require("luci.model.uci").cursor()
  42. cfg.url = uci:get("event-forwarder", "server", "url") or cfg.url
  43. cfg.token = uci:get("event-forwarder", "server", "token") or cfg.token
  44. cfg.router_id = uci:get("event-forwarder", "router", "id") or cfg.router_id
  45. end)
  46. -- ============================================================================
  47. -- UTILITIES
  48. -- ============================================================================
  49. local function log(level, msg)
  50. os.execute(string.format('logger -t "client2server-luv" -p user.%s "%s" 2>/dev/null', level, msg))
  51. end
  52. local function get_hostname()
  53. local f = io.popen("hostname")
  54. local h = f and f:read("*a"):gsub("%s+$", "") or "unknown"
  55. if f then f:close() end
  56. return h
  57. end
  58. cfg.router_id = cfg.router_id == "unknown" and get_hostname() or cfg.router_id
  59. local function json_encode(t)
  60. local parts = {}
  61. for k, v in pairs(t) do
  62. if type(v) == "string" then
  63. table.insert(parts, string.format('"%s": "%s"', k, v:gsub('"', '\\"')))
  64. elseif type(v) == "number" then
  65. table.insert(parts, string.format('"%s": %s', k, tostring(v)))
  66. elseif type(v) == "boolean" then
  67. table.insert(parts, string.format('"%s": %s', k, tostring(v)))
  68. end
  69. end
  70. return "{" .. table.concat(parts, ",") .. "}"
  71. end
  72. local function json_decode(str)
  73. local result = {}
  74. for k, v in str:gmatch('"([^"]+)":%s*([^},]+)') do
  75. v = v:gsub('[%s"]+', '')
  76. if v == "true" or v == "false" then
  77. result[k] = v == "true"
  78. elseif tonumber(v) then
  79. result[k] = tonumber(v)
  80. else
  81. result[k] = v
  82. end
  83. end
  84. return result
  85. end
  86. -- ============================================================================
  87. -- BUFFER (OFFLINE SUPPORT)
  88. -- ============================================================================
  89. local buffer = {
  90. events = {},
  91. dirty = false,
  92. }
  93. function buffer.load()
  94. local f = io.open(cfg.buffer_file, "r")
  95. if not f then return end
  96. for line in f:lines() do
  97. if line and line ~= "" then
  98. table.insert(buffer.events, line)
  99. end
  100. end
  101. f:close()
  102. log("info", "Loaded " .. #buffer.events .. " buffered events")
  103. end
  104. function buffer.save()
  105. local f = io.open(cfg.buffer_file, "w")
  106. if not f then return end
  107. for _, event in ipairs(buffer.events) do
  108. f:write(event .. "\n")
  109. end
  110. f:close()
  111. buffer.dirty = false
  112. end
  113. function buffer.add(json_event)
  114. table.insert(buffer.events, json_event)
  115. -- Limit buffer size
  116. while #buffer.events > cfg.max_buffer do
  117. table.remove(buffer.events, 1)
  118. end
  119. buffer.dirty = true
  120. end
  121. function buffer.flush(send_fn)
  122. if #buffer.events == 0 then return end
  123. local to_send = buffer.events
  124. buffer.events = {}
  125. buffer.dirty = false
  126. for _, event in ipairs(to_send) do
  127. local ok, err = pcall(send_fn, event)
  128. if not ok or err then
  129. -- Re-add to buffer on failure
  130. table.insert(buffer.events, event)
  131. end
  132. end
  133. buffer.save()
  134. end
  135. function buffer.clear()
  136. buffer.events = {}
  137. buffer.dirty = false
  138. os.remove(cfg.buffer_file)
  139. end
  140. -- ============================================================================
  141. -- WEBSOCKET (with fallback)
  142. -- ============================================================================
  143. local ws = {}
  144. function ws.connect(url)
  145. if ws_client then
  146. local sock, err = ws_client.connect(url)
  147. if err then
  148. log("err", "WS connect error: " .. err)
  149. return nil
  150. end
  151. return sock
  152. end
  153. return nil
  154. end
  155. function ws.send(sock, data)
  156. if sock then
  157. return sock:send(data)
  158. end
  159. return false, "no socket"
  160. end
  161. function ws.close(sock)
  162. if sock then
  163. sock:close()
  164. end
  165. end
  166. function ws.connected(sock)
  167. return sock ~= nil
  168. end
  169. function build_event(event_type, payload)
  170. return json_encode({
  171. type = event_type,
  172. router_id = cfg.router_id,
  173. timestamp = os.time(),
  174. payload = payload
  175. })
  176. end
  177. -- ============================================================================
  178. -- ASYNC POLLING WITH LUV
  179. -- ============================================================================
  180. -- Device polling results storage
  181. local poll_results = {}
  182. local poll_count = 0
  183. local poll_total = 0
  184. -- Poll a single device via ubus
  185. local function poll_single_device(device_id, device_ip)
  186. local cmd = string.format(
  187. 'ubus call network.interface.%s status 2>/dev/null',
  188. device_id
  189. )
  190. local f = io.popen(cmd)
  191. if not f then
  192. return { status = "error", ip = device_ip }
  193. end
  194. local result = f:read("*all")
  195. f:close()
  196. local parsed = json_decode(result)
  197. parsed.status = "online"
  198. parsed.ip = device_ip
  199. return parsed
  200. end
  201. -- Async parallel polling with luv
  202. local function poll_devices_parallel(device_list)
  203. if not luv then
  204. -- Fallback: sequential
  205. for _, dev in ipairs(device_list) do
  206. poll_results[dev.id] = poll_single_device(dev.id, dev.ip)
  207. end
  208. return
  209. end
  210. poll_results = {}
  211. poll_count = 0
  212. poll_total = #device_list
  213. log("info", "Starting parallel poll of " .. poll_total .. " devices")
  214. -- Poll each device in parallel using luv async
  215. for _, dev in ipairs(device_list) do
  216. local device_id = dev.id
  217. local device_ip = dev.ip
  218. -- Run in async task
  219. luv.new_task(function()
  220. local result = poll_single_device(device_id, device_ip)
  221. poll_results[device_id] = result
  222. poll_count = poll_count + 1
  223. if poll_count == poll_total then
  224. log("info", "All " .. poll_total .. " devices polled")
  225. -- Process results here if needed
  226. end
  227. end)
  228. end
  229. end
  230. -- ============================================================================
  231. -- WIFI EVENTS (hostapd via ubus)
  232. -- ============================================================================
  233. local function monitor_wifi_events()
  234. if not luv then
  235. log("warn", "luv not available for WiFi monitoring")
  236. return
  237. end
  238. -- Use luv to watch ubus for wireless events
  239. -- Note: ubus doesn't support event subscription directly,
  240. -- so we poll hostapd status periodically
  241. local timer = luv.new_timer()
  242. local last_clients = {}
  243. luv.timer_start(timer, 10000, 10000, function()
  244. -- Poll wireless clients
  245. local f = io.popen("ubus call hostapd.wlan0-1 get_clients 2>/dev/null")
  246. if f then
  247. local result = f:read("*all")
  248. f:close()
  249. if result and result ~= "" then
  250. -- Parse clients and detect changes
  251. local current_clients = {}
  252. for mac in result:gmatch('"([^"]+)":') do
  253. current_clients[mac] = true
  254. end
  255. -- Detect new connections
  256. for mac, _ in pairs(current_clients) do
  257. if not last_clients[mac] then
  258. local ev = build_event("wifi_connect", {
  259. mac = mac,
  260. interface = "wlan0-1"
  261. })
  262. log("info", "WiFi connected: " .. mac)
  263. buffer.add(ev)
  264. end
  265. end
  266. -- Detect disconnections
  267. for mac, _ in pairs(last_clients) do
  268. if not current_clients[mac] then
  269. local ev = build_event("wifi_disconnect", {
  270. mac = mac,
  271. interface = "wlan0-1"
  272. })
  273. log("info", "WiFi disconnected: " .. mac)
  274. buffer.add(ev)
  275. end
  276. end
  277. last_clients = current_clients
  278. end
  279. end
  280. end)
  281. log("info", "WiFi event monitor started")
  282. end
  283. -- ============================================================================
  284. -- DHCP LEASES (file watching with luv)
  285. -- ============================================================================
  286. local function monitor_dhcp_leases()
  287. if not luv then
  288. -- Fallback: sequential file polling
  289. monitor_dhcp_sequential()
  290. return
  291. end
  292. local lease_file = "/var/lib/dnsmasq/dnsmasq.leases"
  293. local old_leases = {}
  294. local last_mtime = 0
  295. -- Use luv fs_event to watch file changes
  296. local fs_event = luv.new_fs_event()
  297. luv.fs_event_start(fs_event, lease_file, function(err)
  298. if err then
  299. log("err", "DHCP fs_event error: " .. err)
  300. return
  301. end
  302. -- File changed, process leases
  303. process_leases(old_leases, function(mac, ip, hostname, action)
  304. local ev = build_event("dhcp_lease", {
  305. mac = mac,
  306. ip = ip,
  307. hostname = hostname,
  308. action = action
  309. })
  310. log("info", "DHCP: " .. action .. " - " .. mac .. " -> " .. ip)
  311. buffer.add(ev)
  312. end)
  313. old_leases = read_leases()
  314. end)
  315. log("info", "DHCP lease monitor started")
  316. end
  317. -- Helper: read current leases
  318. local function read_leases()
  319. local leases = {}
  320. local f = io.open("/var/lib/dnsmasq/dnsmasq.leases", "r")
  321. if not f then return leases end
  322. for line in f:lines() do
  323. local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
  324. if mac then
  325. leases[mac] = { ip = ip, hostname = name, time = tonumber(ts) }
  326. end
  327. end
  328. f:close()
  329. return leases
  330. end
  331. -- Helper: process leases and detect changes
  332. local function process_leases(old_leases, callback)
  333. local leases = read_leases()
  334. -- New leases
  335. for mac, info in pairs(leases) do
  336. if not old_leases[mac] then
  337. callback(mac, info.ip, info.hostname, "new")
  338. end
  339. end
  340. -- Expired leases
  341. for mac, info in pairs(old_leases) do
  342. if not leases[mac] then
  343. callback(mac, info.ip, info.hostname, "expired")
  344. end
  345. end
  346. end
  347. -- Fallback: sequential DHCP monitoring
  348. local function monitor_dhcp_sequential()
  349. local old_leases = {}
  350. while true do
  351. process_leases(old_leases, function(mac, ip, hostname, action)
  352. local ev = build_event("dhcp_lease", {
  353. mac = mac,
  354. ip = ip,
  355. hostname = hostname,
  356. action = action
  357. })
  358. log("info", "DHCP: " .. action .. " - " .. mac .. " -> " .. ip)
  359. buffer.add(ev)
  360. end)
  361. old_leases = read_leases()
  362. os.execute("sleep " .. cfg.dhcp_poll_interval)
  363. end
  364. end
  365. -- ============================================================================
  366. -- WAN MONITORING
  367. -- ============================================================================
  368. local last_wan_state = nil
  369. local function monitor_wan()
  370. if not luv then
  371. -- Fallback: sequential WAN polling
  372. monitor_wan_sequential()
  373. return
  374. end
  375. local timer = luv.new_timer()
  376. luv.timer_start(timer, cfg.wan_poll_interval * 1000, cfg.wan_poll_interval * 1000, function()
  377. local f = io.popen("ubus call network.interface.wan status 2>/dev/null")
  378. if f then
  379. local status = f:read("*all")
  380. f:close()
  381. local is_up = status:match('"up":%s*true') ~= nil
  382. if is_up ~= last_wan_state then
  383. local ev = build_event("wan_status", {
  384. up = is_up
  385. })
  386. log("info", "WAN: " .. (is_up and "up" or "down"))
  387. buffer.add(ev)
  388. last_wan_state = is_up
  389. end
  390. end
  391. end)
  392. log("info", "WAN monitor started")
  393. end
  394. -- Fallback: sequential WAN monitoring
  395. local function monitor_wan_sequential()
  396. while true do
  397. local f = io.popen("ubus call network.interface.wan status 2>/dev/null")
  398. if f then
  399. local status = f:read("*all")
  400. f:close()
  401. local is_up = status:match('"up":%s*true') ~= nil
  402. if is_up ~= last_wan_state then
  403. local ev = build_event("wan_status", {
  404. up = is_up
  405. })
  406. log("info", "WAN: " .. (is_up and "up" or "down"))
  407. buffer.add(ev)
  408. last_wan_state = is_up
  409. end
  410. end
  411. os.execute("sleep " .. cfg.wan_poll_interval)
  412. end
  413. end
  414. -- ============================================================================
  415. -- NETWORK STATUS POLLING
  416. -- ============================================================================
  417. local function poll_network_status()
  418. if not luv then
  419. -- Sequential fallback
  420. local f = io.popen("ubus call network getStatus 2>/dev/null")
  421. if f then f:close() end
  422. return
  423. end
  424. local timer = luv.new_timer()
  425. luv.timer_start(timer, cfg.poll_interval * 1000, cfg.poll_interval * 1000, function()
  426. local f = io.popen("ubus call network getStatus 2>/dev/null")
  427. if f then
  428. local status = f:read("*all")
  429. f:close()
  430. if status and status ~= "" then
  431. local ev = build_event("network_status", {
  432. status = status
  433. })
  434. buffer.add(ev)
  435. end
  436. end
  437. end)
  438. log("info", "Network status poller started")
  439. end
  440. -- ============================================================================
  441. -- MAIN EVENT LOOP
  442. -- ============================================================================
  443. local function main()
  444. log("info", "client2server-luv starting...")
  445. log("info", "Router: " .. cfg.router_id)
  446. log("info", "Server: " .. cfg.url)
  447. if luv_ok then
  448. log("info", "luv available - using async mode")
  449. else
  450. log("warn", "luv NOT available - using blocking mode")
  451. end
  452. -- Load buffered events
  453. buffer.load()
  454. -- Save PID
  455. local pf = io.open("/var/run/client2server-luv.pid", "w")
  456. if pf then
  457. pf:write(tostring(os.getpid()))
  458. pf:close()
  459. end
  460. local sock = nil
  461. local retries = 0
  462. if luv_ok then
  463. -- Use luv event loop
  464. luv.run(function()
  465. -- Start all monitors in parallel
  466. monitor_wifi_events()
  467. monitor_dhcp_leases()
  468. monitor_wan()
  469. poll_network_status()
  470. -- Keep the event loop running
  471. local idle = luv.new_idle()
  472. luv.idle_start(idle, function()
  473. -- Idle work
  474. end)
  475. -- WebSocket connection and keepalive
  476. while true do
  477. log("info", "Connecting to server...")
  478. if ws_client then
  479. sock = ws.connect(cfg.url)
  480. end
  481. if sock then
  482. log("info", "Connected!")
  483. retries = 0
  484. -- Flush buffer
  485. buffer.flush(function(data)
  486. return ws.send(sock, data)
  487. end)
  488. -- Keep alive loop
  489. local loop_count = 0
  490. while ws.connected(sock) and loop_count < (cfg.ping_interval / 5) do
  491. luv.sleep(5000)
  492. loop_count = loop_count + 1
  493. -- Periodic flush
  494. buffer.flush(function(data)
  495. return ws.send(sock, data)
  496. end)
  497. end
  498. else
  499. log("err", "Connection failed")
  500. retries = retries + 1
  501. end
  502. -- Cleanup and reconnect
  503. ws.close(sock)
  504. sock = nil
  505. -- Save buffer on disconnect
  506. buffer.save()
  507. -- Delay before reconnect
  508. luv.sleep(cfg.reconnect_delay * 1000)
  509. end
  510. end)
  511. else
  512. -- Fallback: sequential mode
  513. while true do
  514. -- Sequential monitoring
  515. monitor_dhcp_sequential()
  516. monitor_wan_sequential()
  517. -- WebSocket connection
  518. log("info", "Connecting to server...")
  519. if ws_client then
  520. sock = ws.connect(cfg.url)
  521. end
  522. if sock then
  523. log("info", "Connected!")
  524. retries = 0
  525. buffer.flush(function(data)
  526. return ws.send(sock, data)
  527. end)
  528. local loop_count = 0
  529. while ws.connected(sock) and loop_count < (cfg.ping_interval / 5) do
  530. os.execute("sleep 5")
  531. loop_count = loop_count + 1
  532. buffer.flush(function(data)
  533. return ws.send(sock, data)
  534. end)
  535. end
  536. else
  537. log("err", "Connection failed")
  538. retries = retries + 1
  539. end
  540. ws.close(sock)
  541. buffer.save()
  542. os.execute("sleep " .. cfg.reconnect_delay)
  543. end
  544. end
  545. end
  546. -- ============================================================================
  547. -- START
  548. -- ============================================================================
  549. main()