client2server-luv.lua 20 KB

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