client2server-unified.lua 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. --[[
  2. client2server-unified.lua - Bidirectional event forwarder for OpenWrt
  3. Copyright (c) 2026 Luis Rosales - MIT License
  4. Uses coroutines for concurrent event monitoring + command execution
  5. ]]
  6. -- ============================================================================
  7. -- CONFIG
  8. -- ============================================================================
  9. local cfg = {
  10. server_url = os.getenv("SERVER_URL") or "wss://your-server.com:3843",
  11. server_token = os.getenv("SERVER_TOKEN") or "secret-token",
  12. router_id = os.getenv("ROUTER_ID") or "",
  13. check_interval = 5,
  14. wan_interface = "wan",
  15. wan_device = "eth0",
  16. buffer_file = "/tmp/event_buffer",
  17. pid_file = "/var/run/client2server.pid",
  18. max_buffer = 100,
  19. }
  20. -- Load UCI config
  21. pcall(function()
  22. local uci = require("luci.model.uci").cursor()
  23. cfg.server_url = uci:get("client2server", "server", "url") or cfg.server_url
  24. cfg.server_token = uci:get("client2server", "server", "token") or cfg.server_token
  25. cfg.router_id = uci:get("client2server", "router", "id") or cfg.router_id
  26. cfg.wan_interface = uci:get("client2server", "wan", "interface") or cfg.wan_interface
  27. cfg.wan_device = uci:get("client2server", "wan", "device") or cfg.wan_device
  28. end)
  29. if cfg.router_id == "" then
  30. local f = io.popen("hostname")
  31. cfg.router_id = f and (f:read("*a") or ""):gsub("%s+$", "") or "unknown"
  32. if f then f:close() end
  33. end
  34. -- ============================================================================
  35. -- LOGGING
  36. -- ============================================================================
  37. local function log(level, msg)
  38. os.execute(string.format('logger -t "client2server" -p user.%s "%s"', level, msg:gsub('"', '\\"')))
  39. end
  40. local function log_info(msg) log("info", msg) end
  41. local function log_err(msg) log("err", msg) end
  42. -- ============================================================================
  43. -- JSON
  44. -- ============================================================================
  45. local function json_encode(t)
  46. local parts = {}
  47. for k, v in pairs(t) do
  48. if type(v) == "string" then
  49. table.insert(parts, string.format('"%s": "%s"', k, v:gsub('"', '\\"')))
  50. elseif type(v) == "number" then
  51. table.insert(parts, string.format('"%s": %s', k, tostring(v)))
  52. elseif type(v) == "boolean" then
  53. table.insert(parts, string.format('"%s": %s', k, tostring(v)))
  54. elseif type(v) == "table" then
  55. table.insert(parts, string.format('"%s": %s', k, json_encode(v)))
  56. end
  57. end
  58. return "{" .. table.concat(parts, ",") .. "}"
  59. end
  60. local function json_decode(str)
  61. local result = {}
  62. for k, v in str:gmatch('"([^"]+)":%s*"([^"]*)"') do result[k] = v end
  63. for k, v in str:gmatch('"([^"]+)":%s*(%d+)') do result[k] = tonumber(v) end
  64. for k, v in str:gmatch('"([^"]+)":%s*(%a+)') do result[k] = v end
  65. return result
  66. end
  67. -- ============================================================================
  68. -- BUFFER
  69. -- ============================================================================
  70. local buffer = { events = {}, dirty = false }
  71. function buffer.init()
  72. local f = io.open(cfg.buffer_file, "r")
  73. if f then
  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. end
  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 f:write(ev .. "\n") end
  87. f:close()
  88. buffer.dirty = false
  89. end
  90. function buffer.add(json_event)
  91. -- Warn if buffer is getting full
  92. if #buffer.events > cfg.max_buffer * 0.8 then
  93. log_err("Buffer almost full: " .. #buffer.events .. "/" .. cfg.max_buffer)
  94. end
  95. table.insert(buffer.events, json_event)
  96. if #buffer.events > cfg.max_buffer then
  97. -- Buffer full - drop oldest to make room
  98. table.remove(buffer.events, 1)
  99. log_err("Buffer overflow: dropping oldest event")
  100. end
  101. buffer.dirty = true
  102. end
  103. function buffer.flush(send_fn)
  104. if #buffer.events == 0 then return end
  105. local i = 1
  106. while i <= #buffer.events do
  107. if send_fn(buffer.events[i]) then
  108. table.remove(buffer.events, i)
  109. buffer.dirty = true
  110. else i = i + 1 end
  111. end
  112. buffer.save()
  113. end
  114. -- ============================================================================
  115. -- WEBSOCKET
  116. -- ============================================================================
  117. local ws = { sock = nil, connected = false }
  118. function ws.connect(url)
  119. local host = url:match("wss?://([^:/]+)")
  120. local port = url:match(":%d+") or ":443"
  121. if not host then return nil end
  122. local sock = require("socket").tcp()
  123. sock:settimeout(10)
  124. if not pcall(sock.connect, sock, host, tonumber(port:sub(2)) then return nil end
  125. ws.sock = sock
  126. ws.connected = true
  127. return sock
  128. end
  129. function ws.send(data)
  130. if not ws.connected then buffer.add(data); return false end
  131. local frame = string.format("\x81\x80%s", data)
  132. if not pcall(function() ws.sock:send(frame) end) then
  133. ws.connected = false
  134. buffer.add(data)
  135. return false
  136. end
  137. return true
  138. end
  139. -- Read from websocket (non-blocking-ish)
  140. function ws.recv()
  141. if not ws.connected then return nil end
  142. -- Set non-blocking
  143. ws.sock:settimeout(0.1)
  144. local data, err = ws.sock:receive("*l")
  145. ws.sock:settimeout(10)
  146. if err and err ~= "timeout" then
  147. ws.connected = false
  148. return nil
  149. end
  150. return data
  151. end
  152. function ws.close()
  153. if ws.sock then pcall(ws.sock.close, ws.sock) end
  154. ws.sock = nil
  155. ws.connected = false
  156. end
  157. -- ============================================================================
  158. -- COMMAND EXECUTOR
  159. -- ============================================================================
  160. function execute_command(cmd_obj)
  161. local cmd = cmd_obj.command or ""
  162. local args = cmd_obj.args or {}
  163. log_info("Executing: " .. cmd)
  164. local result = { success = false, output = "", error = "" }
  165. if cmd == "uci_set" then
  166. local config = args.config
  167. local section = args.section
  168. local option = args.option
  169. local value = args.value
  170. if config and section and option and value then
  171. local c = string.format("uci set %s.%s.%s='%s'", config, section, option, value)
  172. local f = io.popen(c)
  173. result.output = f and f:read("*a") or ""
  174. if f then f:close() end
  175. os.execute("uci commit " .. config)
  176. result.success = true
  177. log_info("UCI set: " .. config .. "." .. section .. "." .. option .. " = " .. value)
  178. else
  179. result.error = "Missing params"
  180. end
  181. elseif cmd == "shell" then
  182. local shell_cmd = args.command
  183. if shell_cmd then
  184. local f = io.popen(shell_cmd)
  185. result.output = f and f:read("*a") or ""
  186. if f then f:close() end
  187. result.success = true
  188. else
  189. result.error = "No command"
  190. end
  191. elseif cmd == "reboot" then
  192. os.execute("sync && reboot &")
  193. result.success = true
  194. result.output = "Reboot scheduled"
  195. elseif cmd == "wifi_restart" then
  196. os.execute("/etc/init.d/network restart")
  197. os.execute("/etc/init.d/wireless restart")
  198. result.success = true
  199. elseif cmd == "status" then
  200. local f = io.popen("ubus call network getStatus")
  201. result.output = f and f:read("*a") or "{}"
  202. if f then f:close() end
  203. result.success = true
  204. else
  205. result.error = "Unknown: " .. cmd
  206. end
  207. return result
  208. end
  209. -- ============================================================================
  210. -- COROUTINES
  211. -- ============================================================================
  212. -- Monitor DHCP
  213. local function co_dhcp()
  214. local leases = {}
  215. while true do
  216. local f = io.open("/var/lib/dnsmasq/dnsmasq.leases", "r")
  217. if f then
  218. local current = {}
  219. for line in f:lines() do
  220. local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
  221. if mac then
  222. current[mac] = { ip = ip, hostname = name }
  223. if not leases[mac] then
  224. local ev = json_encode({
  225. router_id = cfg.router_id, hostname = cfg.router_id,
  226. event_type = "dhcp_lease_new",
  227. payload = { mac = mac, ip = ip, hostname = name }
  228. })
  229. ws.send(ev)
  230. log_info("DHCP: " .. mac .. " -> " .. ip)
  231. end
  232. end
  233. end
  234. f:close()
  235. for mac in pairs(leases) do
  236. if not current[mac] then
  237. local ev = json_encode({
  238. router_id = cfg.router_id, hostname = cfg.router_id,
  239. event_type = "dhcp_lease_expire",
  240. payload = { mac = mac, old_ip = leases[mac].ip }
  241. })
  242. ws.send(ev)
  243. log_info("DHCP expire: " .. mac)
  244. end
  245. end
  246. leases = current
  247. end
  248. coroutine.yield(cfg.check_interval)
  249. end
  250. end
  251. -- Monitor WAN
  252. local function co_wan()
  253. local link_last, ip_last = nil, nil
  254. local f = io.open("/sys/class/net/" .. cfg.wan_device .. "/carrier", "r")
  255. if f then link_last = (f:read("*a") or "":find("^1") == 1; f:close() end
  256. while true do
  257. -- Link
  258. f = io.open("/sys/class/net/" .. cfg.wan_device .. "/carrier", "r")
  259. local link_now = f and ((f:read("*a") or ""):find("^1") == 1) or false
  260. if f then f:close() end
  261. if link_now ~= link_last then
  262. link_last = link_now
  263. local ev = json_encode({
  264. router_id = cfg.router_id, hostname = cfg.router_id,
  265. event_type = link_now and "wan_link_up" or "wan_link_down",
  266. payload = { device = cfg.wan_device }
  267. })
  268. ws.send(ev)
  269. log_info("WAN link: " .. (link_now and "up" or "down"))
  270. end
  271. -- IP
  272. f = io.popen("ubus call network.interface." .. cfg.wan_interface .. " status 2>/dev/null")
  273. local ip_now = nil
  274. if f then
  275. local st = f:read("*a")
  276. f:close()
  277. ip_now = st and st:match('"address"%s*:%s*"([^"]+)"')
  278. end
  279. if ip_now and ip_now ~= ip_last then
  280. local ev = json_encode({
  281. router_id = cfg.router_id, hostname = cfg.router_id,
  282. event_type = ip_last and "wan_dhcp_changed" or "wan_dhcp_new",
  283. payload = { old_ip = ip_last, new_ip = ip_now }
  284. })
  285. ws.send(ev)
  286. log_info("WAN IP: " .. (ip_last or "none") .. " -> " .. ip_now)
  287. ip_last = ip_now
  288. end
  289. coroutine.yield(cfg.check_interval)
  290. end
  291. end
  292. -- 🔥 COROUTINE: Command Listener (handles server → router commands)
  293. local function co_commands()
  294. while true do
  295. if ws.connected then
  296. -- Check for incoming data
  297. local data = ws.recv()
  298. if data and data:match("^{") then
  299. log_info("Received command: " .. data:sub(1, 100))
  300. -- Parse JSON command
  301. local cmd_obj = json_decode(data)
  302. if cmd_obj.command then
  303. -- Execute command
  304. local result = execute_command(cmd_obj)
  305. -- Send result back
  306. local resp = json_encode({
  307. router_id = cfg.router_id,
  308. event_type = "command_result",
  309. payload = {
  310. command = cmd_obj.command,
  311. command_id = cmd_obj.id or "",
  312. success = result.success,
  313. output = result.output,
  314. error = result.error
  315. }
  316. })
  317. ws.send(resp)
  318. log_info("Command done: " .. cmd_obj.command .. " = " .. (result.success and "OK" or result.error))
  319. end
  320. end
  321. end
  322. coroutine.yield(1) -- Check every second for commands
  323. end
  324. end
  325. -- COROUTINE: Auto-reconnect (keeps connection alive)
  326. local function co_connect()
  327. local retry_delay = 30 -- Start at 30s
  328. local max_delay = 300 -- Max 5 minutes
  329. while true do
  330. -- Urgency based on buffer state
  331. local buffer_fullness = #buffer.events / cfg.max_buffer
  332. if buffer_fullness > 0.8 then
  333. log_err("Buffer critical at " .. string.format("%.0f%%", buffer_fullness * 100) .. " - aggressive reconnect")
  334. retry_delay = 10 -- Aggressive when buffer filling
  335. end
  336. if not ws.connected then
  337. log_info("Connecting to " .. cfg.server_url .. "...")
  338. local sock = ws.connect(cfg.server_url)
  339. if sock then
  340. ws.connected = true
  341. retry_delay = 30 -- Reset on success
  342. log_info("Connected!")
  343. buffer.flush(function(d) return ws.send(d) end)
  344. else
  345. log_err("Connection failed, retry in " .. retry_delay .. "s")
  346. coroutine.yield(retry_delay)
  347. retry_delay = math.min(retry_delay * 2, max_delay)
  348. end
  349. else
  350. -- Even when connected, periodically flush to clear buffer buildup
  351. buffer.flush(function(d) return ws.send(d) end)
  352. coroutine.yield(30)
  353. end
  354. end
  355. end
  356. -- ============================================================================
  357. -- MAIN
  358. -- ============================================================================
  359. local function scheduler()
  360. local cos = {
  361. co_dhcp = coroutine.create(co_dhcp),
  362. co_wan = coroutine.create(co_wan),
  363. co_commands = coroutine.create(co_commands),
  364. co_connect = coroutine.create(co_connect), -- Auto-reconnect!
  365. }
  366. while true do
  367. -- Resume all coroutines
  368. for name, co in pairs(cos) do
  369. if coroutine.status(co) == "suspended" then
  370. local _, delay = coroutine.resume(co)
  371. -- Small stagger to avoid spikes
  372. if delay then os.execute("sleep 0.1") end
  373. end
  374. end
  375. -- Flush buffer when connected
  376. if ws.connected then
  377. buffer.flush(function(d) return ws.send(d) end)
  378. end
  379. os.execute("sleep 1")
  380. end
  381. end
  382. local function main()
  383. log_info("Starting client2server...")
  384. log_info("Router: " .. cfg.router_id)
  385. log_info("Server: " .. cfg.server_url)
  386. buffer.init()
  387. local pf = io.open(cfg.pid_file, "w")
  388. if pf then pf:write(tostring(os.getpid())); pf:close() end
  389. -- Connect
  390. log_info("Initial connection...")
  391. local sock = ws.connect(cfg.server_url)
  392. if sock then
  393. ws.connected = true
  394. log_info("Connected!")
  395. buffer.flush(function(d) return ws.send(d) end)
  396. else
  397. log_err("Connection failed")
  398. end
  399. scheduler()
  400. end
  401. main()