client2server.lua 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. --[[
  2. client2server - Lua WebSocket Event Forwarder for OpenWrt
  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. Copyright (c) 2026 Luis Rosales - MIT License
  9. ]]
  10. -- ============================================================================
  11. -- REQUIREMENTS
  12. -- ============================================================================
  13. -- Try to load websocket library, fallback to simple HTTP
  14. local ws_client = nil
  15. local has_websocket, websocket = pcall(require, "websocket")
  16. if has_websocket then
  17. ws_client = websocket.client.sync()
  18. end
  19. -- ============================================================================
  20. -- CONFIG
  21. -- ============================================================================
  22. local cfg = {
  23. url = os.getenv("SERVER_URL") or "wss://your-server.com/ws",
  24. token = os.getenv("SERVER_TOKEN") or "secret-token",
  25. router_id = os.getenv("ROUTER_ID") or "unknown",
  26. reconnect_delay = 5,
  27. ping_interval = 30,
  28. buffer_file = "/tmp/event_buffer",
  29. max_buffer = 100,
  30. }
  31. -- Load from UCI if available
  32. pcall(function()
  33. local uci = require("luci.model.uci").cursor()
  34. cfg.url = uci:get("event-forwarder", "server", "url") or cfg.url
  35. cfg.token = uci:get("event-forwarder", "server", "token") or cfg.token
  36. cfg.router_id = uci:get("event-forwarder", "router", "id") or cfg.router_id
  37. end)
  38. -- ============================================================================
  39. -- UTILITIES
  40. -- ============================================================================
  41. local function log(level, msg)
  42. os.execute(string.format('logger -t "client2server" -p user.%s "%s" 2>/dev/null', level, msg))
  43. end
  44. local function get_hostname()
  45. local f = io.popen("hostname")
  46. local h = f and f:read("*a"):gsub("%s+$", "") or "unknown"
  47. if f then f:close() end
  48. return h
  49. end
  50. cfg.router_id = cfg.router_id == "unknown" and get_hostname() or cfg.router_id
  51. local function json_encode(t)
  52. local parts = {}
  53. for k, v in pairs(t) do
  54. if type(v) == "string" then
  55. table.insert(parts, string.format('"%s": "%s"', k, v:gsub('"', '\\"')))
  56. elseif type(v) == "number" then
  57. table.insert(parts, string.format('"%s": %s', k, tostring(v)))
  58. elseif type(v) == "boolean" then
  59. table.insert(parts, string.format('"%s": %s', k, tostring(v)))
  60. end
  61. end
  62. return "{" .. table.concat(parts, ",") .. "}"
  63. end
  64. -- ============================================================================
  65. -- BUFFER (OFFLINE SUPPORT)
  66. -- ============================================================================
  67. local buffer = {
  68. events = {},
  69. dirty = false,
  70. }
  71. function buffer.load()
  72. local f = io.open(cfg.buffer_file, "r")
  73. if not f then return end
  74. for line in f:lines() do
  75. if line and line ~= "" then
  76. table.insert(buffer.events, line)
  77. end
  78. end
  79. f:close()
  80. log("info", "Loaded " .. #buffer.events .. " buffered events")
  81. end
  82. function buffer.save()
  83. if not buffer.dirty then return end
  84. local f = io.open(cfg.buffer_file, "w")
  85. if not f then return end
  86. for _, ev in ipairs(buffer.events) do
  87. f:write(ev .. "\n")
  88. end
  89. f:close()
  90. buffer.dirty = false
  91. end
  92. function buffer.add(json_event)
  93. table.insert(buffer.events, json_event)
  94. -- Trim if too big
  95. while #buffer.events > cfg.max_buffer do
  96. table.remove(buffer.events, 1)
  97. end
  98. buffer.dirty = true
  99. buffer.save()
  100. end
  101. function buffer.flush(send_fn)
  102. if #buffer.events == 0 then return end
  103. log("info", "Flushing " .. #buffer.events .. " buffered events...")
  104. local i = 1
  105. while i <= #buffer.events do
  106. local ok = send_fn(buffer.events[i])
  107. if ok then
  108. table.remove(buffer.events, i)
  109. buffer.dirty = true
  110. else
  111. i = i + 1
  112. end
  113. end
  114. buffer.save()
  115. end
  116. function buffer.clear()
  117. buffer.events = {}
  118. os.execute("rm -f " .. cfg.buffer_file)
  119. buffer.dirty = false
  120. end
  121. -- ============================================================================
  122. -- WEBSOCKET CLIENT
  123. -- ============================================================================
  124. local ws = {
  125. sock = nil,
  126. connected = false,
  127. }
  128. function ws.connect(url)
  129. if not ws_client then
  130. log("err", "websocket library not installed")
  131. return nil
  132. end
  133. local ok, sock = pcall(ws_client.connect, ws_client, url)
  134. if ok then
  135. ws.sock = sock
  136. ws.connected = true
  137. end
  138. return ws.sock
  139. end
  140. function ws.send(sock, data)
  141. if not sock or not ws.connected then
  142. buffer.add(data)
  143. return false, "not connected"
  144. end
  145. local ok, err = pcall(sock.send, sock, data)
  146. if not ok then
  147. ws.connected = false
  148. buffer.add(data)
  149. return false, err
  150. end
  151. return true
  152. end
  153. function ws.close(sock)
  154. if sock then
  155. pcall(sock.close, sock)
  156. end
  157. ws.sock = nil
  158. ws.connected = false
  159. end
  160. -- ============================================================================
  161. -- EVENT BUILDERS
  162. -- ============================================================================
  163. function build_event(event_type, payload)
  164. payload = payload or {}
  165. payload.timestamp = os.date("!%Y-%m-%dT%H:%M:%SZ")
  166. return json_encode({
  167. router_id = cfg.router_id,
  168. hostname = get_hostname(),
  169. event_type = event_type,
  170. payload = payload,
  171. })
  172. end
  173. -- ============================================================================
  174. -- EVENT LISTENERS
  175. -- ============================================================================
  176. local function listen_dhcp()
  177. local lease_file = "/var/lib/dnsmasq/dnsmasq.leases"
  178. local old_leases = {}
  179. while true do
  180. local f = io.open(lease_file, "r")
  181. if f then
  182. local leases = {}
  183. for line in f:lines() do
  184. local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
  185. if mac then
  186. leases[mac] = { ip = ip, hostname = name, time = tonumber(ts) }
  187. -- New lease?
  188. if not old_leases[mac] then
  189. local ev = build_event("dhcp_lease", {
  190. mac = mac,
  191. ip = ip,
  192. hostname = name,
  193. action = "new"
  194. })
  195. log("info", "DHCP: " .. mac .. " -> " .. ip)
  196. buffer.add(ev)
  197. end
  198. end
  199. end
  200. old_leases = leases
  201. f:close()
  202. end
  203. os.execute("sleep 5")
  204. end
  205. end
  206. local function poll_network()
  207. -- Poll network status
  208. local f = io.popen("ubus call network getStatus 2>/dev/null")
  209. if f then f:close() end
  210. end
  211. -- ============================================================================
  212. -- MAIN LOOP
  213. -- ============================================================================
  214. local function main()
  215. log("info", "client2server starting...")
  216. log("info", "Router: " .. cfg.router_id)
  217. log("info", "Server: " .. cfg.url)
  218. -- Load buffered events
  219. buffer.load()
  220. -- Save PID
  221. local pf = io.open("/var/run/client2server.pid", "w")
  222. if pf then
  223. pf:write(tostring(os.getpid()))
  224. pf:close()
  225. end
  226. local sock = nil
  227. local retries = 0
  228. -- Event listening coroutines
  229. -- In practice, would fork these or use procd/inotify
  230. while true do
  231. -- Attempt connection
  232. log("info", "Connecting to server...")
  233. if ws_client then
  234. sock = ws.connect(cfg.url)
  235. end
  236. if sock then
  237. log("info", "Connected!")
  238. retries = 0
  239. -- Flush buffer
  240. buffer.flush(function(data)
  241. return ws.send(sock, data)
  242. end)
  243. -- Keep alive loop
  244. local loop_count = 0
  245. while ws.connected and loop_count < (cfg.ping_interval / 5) do
  246. os.execute("sleep 5")
  247. loop_count = loop_count + 1
  248. -- Periodic flush
  249. buffer.flush(function(data)
  250. return ws.send(sock, data)
  251. end)
  252. end
  253. else
  254. log("err", "Connection failed")
  255. retries = retries + 1
  256. end
  257. -- Cleanup and reconnect
  258. ws.close(sock)
  259. sock = nil
  260. log("info", "Reconnecting in " .. cfg.reconnect_delay .. "s...")
  261. os.execute("sleep " .. cfg.reconnect_delay)
  262. end
  263. end
  264. -- Run
  265. main()