client2server-luv.lua 22 KB

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