client2server-luv.lua 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699
  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 HELPERS (must be before monitor functions)
  286. -- ============================================================================
  287. -- Helper: read current leases
  288. local function read_leases()
  289. local leases = {}
  290. local f = io.open("/var/lib/dnsmasq/dnsmasq.leases", "r")
  291. if not f then return leases end
  292. for line in f:lines() do
  293. local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
  294. if mac then
  295. leases[mac] = { ip = ip, hostname = name, time = tonumber(ts) }
  296. end
  297. end
  298. f:close()
  299. return leases
  300. end
  301. -- Helper: process leases and detect changes
  302. local function process_leases(old_leases, callback)
  303. local leases = read_leases()
  304. -- New leases
  305. for mac, info in pairs(leases) do
  306. if not old_leases[mac] then
  307. callback(mac, info.ip, info.hostname, "new")
  308. end
  309. end
  310. -- Expired leases
  311. for mac, info in pairs(old_leases) do
  312. if not leases[mac] then
  313. callback(mac, info.ip, info.hostname, "expired")
  314. end
  315. end
  316. end
  317. -- ============================================================================
  318. -- DHCP LEASES MONITOR
  319. -- ============================================================================
  320. local function monitor_dhcp_leases()
  321. if not luv then
  322. -- Fallback: sequential file polling
  323. monitor_dhcp_sequential()
  324. return
  325. end
  326. -- Use timer-based polling instead of fs_event (more compatible)
  327. local timer = luv.new_timer()
  328. local old_leases = {}
  329. luv.timer_start(timer, cfg.dhcp_poll_interval * 1000, cfg.dhcp_poll_interval * 1000, function()
  330. -- File changed, process leases
  331. process_leases(old_leases, function(mac, ip, hostname, action)
  332. local ev = build_event("dhcp_lease", {
  333. mac = mac,
  334. ip = ip,
  335. hostname = hostname,
  336. action = action
  337. })
  338. log("info", "DHCP: " .. action .. " - " .. mac .. " -> " .. ip)
  339. buffer.add(ev)
  340. end)
  341. old_leases = read_leases()
  342. end)
  343. log("info", "DHCP lease monitor started")
  344. end
  345. -- Fallback: sequential DHCP monitoring
  346. local function monitor_dhcp_sequential()
  347. local old_leases = {}
  348. while true do
  349. process_leases(old_leases, function(mac, ip, hostname, action)
  350. local ev = build_event("dhcp_lease", {
  351. mac = mac,
  352. ip = ip,
  353. hostname = hostname,
  354. action = action
  355. })
  356. log("info", "DHCP: " .. action .. " - " .. mac .. " -> " .. ip)
  357. buffer.add(ev)
  358. end)
  359. old_leases = read_leases()
  360. os.execute("sleep " .. cfg.dhcp_poll_interval)
  361. end
  362. end
  363. -- ============================================================================
  364. -- WAN MONITORING
  365. -- ============================================================================
  366. local last_wan_state = nil
  367. local function monitor_wan()
  368. if not luv then
  369. -- Fallback: sequential WAN polling
  370. monitor_wan_sequential()
  371. return
  372. end
  373. local timer = luv.new_timer()
  374. luv.timer_start(timer, cfg.wan_poll_interval * 1000, cfg.wan_poll_interval * 1000, function()
  375. local f = io.popen("ubus call network.interface.wan status 2>/dev/null")
  376. if f then
  377. local status = f:read("*all")
  378. f:close()
  379. local is_up = status:match('"up":%s*true') ~= nil
  380. if is_up ~= last_wan_state then
  381. local ev = build_event("wan_status", {
  382. up = is_up
  383. })
  384. log("info", "WAN: " .. (is_up and "up" or "down"))
  385. buffer.add(ev)
  386. last_wan_state = is_up
  387. end
  388. end
  389. end)
  390. log("info", "WAN monitor started")
  391. end
  392. -- Fallback: sequential WAN monitoring
  393. local function monitor_wan_sequential()
  394. while true do
  395. local f = io.popen("ubus call network.interface.wan status 2>/dev/null")
  396. if f then
  397. local status = f:read("*all")
  398. f:close()
  399. local is_up = status:match('"up":%s*true') ~= nil
  400. if is_up ~= last_wan_state then
  401. local ev = build_event("wan_status", {
  402. up = is_up
  403. })
  404. log("info", "WAN: " .. (is_up and "up" or "down"))
  405. buffer.add(ev)
  406. last_wan_state = is_up
  407. end
  408. end
  409. os.execute("sleep " .. cfg.wan_poll_interval)
  410. end
  411. end
  412. -- ============================================================================
  413. -- NETWORK STATUS POLLING
  414. -- ============================================================================
  415. local function poll_network_status()
  416. if not luv then
  417. -- Sequential fallback
  418. local f = io.popen("ubus call network getStatus 2>/dev/null")
  419. if f then f:close() end
  420. return
  421. end
  422. local timer = luv.new_timer()
  423. luv.timer_start(timer, cfg.poll_interval * 1000, cfg.poll_interval * 1000, function()
  424. local f = io.popen("ubus call network getStatus 2>/dev/null")
  425. if f then
  426. local status = f:read("*all")
  427. f:close()
  428. if status and status ~= "" then
  429. local ev = build_event("network_status", {
  430. status = status
  431. })
  432. buffer.add(ev)
  433. end
  434. end
  435. end)
  436. log("info", "Network status poller started")
  437. end
  438. -- ============================================================================
  439. -- MAIN EVENT LOOP
  440. -- ============================================================================
  441. local function main()
  442. log("info", "client2server-luv starting...")
  443. log("info", "Router: " .. cfg.router_id)
  444. log("info", "Server: " .. cfg.url)
  445. if luv_ok then
  446. log("info", "luv available - using async mode")
  447. else
  448. log("warn", "luv NOT available - using blocking mode")
  449. end
  450. -- Load buffered events
  451. buffer.load()
  452. -- Save PID
  453. local pf = io.open("/var/run/client2server-luv.pid", "w")
  454. if pf then
  455. local f = io.popen("echo $$")
  456. local pid = f and f:read("*a") or "0"
  457. if f then f:close() end
  458. pf:write(pid:gsub("%s+", ""))
  459. pf:close()
  460. end
  461. local sock = nil
  462. local retries = 0
  463. if luv_ok then
  464. -- Use luv event loop
  465. -- luv.run() starts the event loop. The callbacks we registered
  466. -- (timers, fs_events) will run automatically.
  467. local function start_monitors()
  468. -- Start all monitors in parallel
  469. monitor_wifi_events()
  470. monitor_dhcp_leases()
  471. monitor_wan()
  472. poll_network_status()
  473. -- Keep the event loop running
  474. local idle = luv.new_idle()
  475. luv.idle_start(idle, function()
  476. -- Idle work - just keep loop alive
  477. end)
  478. end
  479. -- Start monitors first, then run the event loop with WebSocket
  480. local function run_event_loop()
  481. -- Register all monitors
  482. monitor_wifi_events()
  483. monitor_dhcp_leases()
  484. monitor_wan()
  485. poll_network_status()
  486. -- Keep the event loop running with idle
  487. local idle = luv.new_idle()
  488. luv.idle_start(idle, function()
  489. -- Idle work - check WebSocket periodically
  490. end)
  491. -- WebSocket connection - try to connect and stay connected
  492. local function ws_loop()
  493. log("info", "Connecting to server...")
  494. if ws_client then
  495. sock = ws.connect(cfg.url)
  496. end
  497. if sock then
  498. log("info", "Connected!")
  499. retries = 0
  500. -- Flush buffer
  501. buffer.flush(function(data)
  502. return ws.send(sock, data)
  503. end)
  504. -- Stay connected - reconnect timer will handle reconnection
  505. local ws_timer = luv.new_timer()
  506. luv.timer_start(ws_timer, 5000, 5000, function()
  507. if ws.connected(sock) then
  508. -- Periodic flush
  509. buffer.flush(function(data)
  510. return ws.send(sock, data)
  511. end)
  512. else
  513. -- Disconnected, reconnect
  514. ws.close(sock)
  515. sock = nil
  516. buffer.save()
  517. ws_loop() -- Reconnect
  518. end
  519. end)
  520. else
  521. log("err", "Connection failed")
  522. retries = retries + 1
  523. -- Retry after delay
  524. local retry_timer = luv.new_timer()
  525. luv.timer_start(retry_timer, cfg.reconnect_delay * 1000, 0, function()
  526. ws_loop()
  527. end)
  528. end
  529. end
  530. -- Start WebSocket loop
  531. ws_loop()
  532. end
  533. -- Run the event loop
  534. local ok, err = pcall(function() luv.run(run_event_loop) end)
  535. if not ok then
  536. log("err", "luv.run error: " .. tostring(err))
  537. -- Fallback: sequential mode
  538. while true do
  539. os.execute("sleep 60")
  540. end
  541. end
  542. else
  543. -- Fallback: sequential mode
  544. while true do
  545. -- Sequential monitoring
  546. monitor_dhcp_sequential()
  547. monitor_wan_sequential()
  548. -- WebSocket connection
  549. log("info", "Connecting to server...")
  550. if ws_client then
  551. sock = ws.connect(cfg.url)
  552. end
  553. if sock then
  554. log("info", "Connected!")
  555. retries = 0
  556. buffer.flush(function(data)
  557. return ws.send(sock, data)
  558. end)
  559. local loop_count = 0
  560. while ws.connected(sock) and loop_count < (cfg.ping_interval / 5) do
  561. os.execute("sleep 5")
  562. loop_count = loop_count + 1
  563. buffer.flush(function(data)
  564. return ws.send(sock, data)
  565. end)
  566. end
  567. else
  568. log("err", "Connection failed")
  569. retries = retries + 1
  570. end
  571. ws.close(sock)
  572. buffer.save()
  573. os.execute("sleep " .. cfg.reconnect_delay)
  574. end
  575. end
  576. end
  577. -- ============================================================================
  578. -- START
  579. -- ============================================================================
  580. main()