client2server-luv.lua 22 KB

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