client2server-luv.lua 20 KB

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