client2server-unified.lua 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  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. -- General
  11. enabled = true,
  12. check_interval = 5,
  13. log_level = "info",
  14. buffer_file = "/tmp/event_buffer",
  15. max_buffer = 100,
  16. -- Server
  17. server_url = os.getenv("SERVER_URL") or "wss://your-server.com:3843",
  18. server_token = os.getenv("SERVER_TOKEN") or "secret-token",
  19. reconnect_delay = 5,
  20. ping_interval = 30,
  21. timeout = 10,
  22. -- Router
  23. router_id = os.getenv("ROUTER_ID") or "",
  24. hostname = "",
  25. -- WAN
  26. wan_interface = "wan",
  27. wan_device = "eth0",
  28. monitor_dhcp = true,
  29. monitor_wan = true,
  30. monitor_ip_change = true,
  31. -- Events
  32. send_dhcp = true,
  33. send_wan = true,
  34. send_uptime = false,
  35. send_version = true,
  36. }
  37. -- Load UCI config (all settings)
  38. pcall(function()
  39. local uci = require("luci.model.uci").cursor()
  40. -- General
  41. cfg.enabled = uci:get("client2server", "general", "enabled") or cfg.enabled
  42. cfg.check_interval = tonumber(uci:get("client2server", "general", "check_interval")) or cfg.check_interval
  43. cfg.log_level = uci:get("client2server", "general", "log_level") or cfg.log_level
  44. cfg.buffer_file = uci:get("client2server", "general", "buffer_file") or cfg.buffer_file
  45. cfg.max_buffer = tonumber(uci:get("client2server", "general", "max_buffer")) or cfg.max_buffer
  46. -- Server
  47. cfg.server_url = uci:get("client2server", "server", "url") or cfg.server_url
  48. cfg.server_token = uci:get("client2server", "server", "token") or cfg.server_token
  49. cfg.reconnect_delay = tonumber(uci:get("client2server", "server", "reconnect_delay")) or cfg.reconnect_delay
  50. cfg.ping_interval = tonumber(uci:get("client2server", "server", "ping_interval")) or cfg.ping_interval
  51. cfg.timeout = tonumber(uci:get("client2server", "server", "timeout")) or cfg.timeout
  52. -- Router
  53. cfg.router_id = uci:get("client2server", "router", "id") or cfg.router_id
  54. cfg.hostname = uci:get("client2server", "router", "hostname") or cfg.hostname
  55. -- WAN
  56. cfg.wan_interface = uci:get("client2server", "wan", "interface") or cfg.wan_interface
  57. cfg.wan_device = uci:get("client2server", "wan", "device") or cfg.wan_device
  58. cfg.monitor_dhcp = uci:get("client2server", "wan", "monitor_dhcp") or cfg.monitor_dhcp
  59. cfg.monitor_wan = uci:get("client2server", "wan", "monitor_wan") or cfg.monitor_wan
  60. cfg.monitor_ip_change = uci:get("client2server", "wan", "monitor_ip_change") or cfg.monitor_ip_change
  61. -- Events
  62. cfg.send_dhcp = uci:get("client2server", "events", "send_dhcp") or cfg.send_dhcp
  63. cfg.send_wan = uci:get("client2server", "events", "send_wan") or cfg.send_wan
  64. cfg.send_uptime = uci:get("client2server", "events", "send_uptime") or cfg.send_uptime
  65. cfg.send_version = uci:get("client2server", "events", "send_version") or cfg.send_version
  66. end)
  67. if cfg.router_id == "" then
  68. local f = io.popen("hostname")
  69. cfg.router_id = f and (f:read("*a") or ""):gsub("%s+$", "") or "unknown"
  70. if f then f:close() end
  71. end
  72. -- ============================================================================
  73. -- LOGGING
  74. -- ============================================================================
  75. local function log(level, msg)
  76. os.execute(string.format('logger -t "client2server" -p user.%s "%s"', level, msg:gsub('"', '\\"')))
  77. end
  78. local function log_info(msg) log("info", msg) end
  79. local function log_err(msg) log("err", msg) end
  80. -- ============================================================================
  81. -- JSON
  82. -- ============================================================================
  83. local function json_encode(t)
  84. local parts = {}
  85. for k, v in pairs(t) do
  86. if type(v) == "string" then
  87. table.insert(parts, string.format('"%s": "%s"', k, v:gsub('"', '\\"')))
  88. elseif type(v) == "number" then
  89. table.insert(parts, string.format('"%s": %s', k, tostring(v)))
  90. elseif type(v) == "boolean" then
  91. table.insert(parts, string.format('"%s": %s', k, tostring(v)))
  92. elseif type(v) == "table" then
  93. table.insert(parts, string.format('"%s": %s', k, json_encode(v)))
  94. end
  95. end
  96. return "{" .. table.concat(parts, ",") .. "}"
  97. end
  98. local function json_decode(str)
  99. local result = {}
  100. for k, v in str:gmatch('"([^"]+)":%s*"([^"]*)"') do result[k] = v end
  101. for k, v in str:gmatch('"([^"]+)":%s*(%d+)') do result[k] = tonumber(v) end
  102. for k, v in str:gmatch('"([^"]+)":%s*(%a+)') do result[k] = v end
  103. return result
  104. end
  105. -- ============================================================================
  106. -- BUFFER
  107. -- ============================================================================
  108. local buffer = { events = {}, dirty = false }
  109. function buffer.init()
  110. local f = io.open(cfg.buffer_file, "r")
  111. if f then
  112. for line in f:lines() do
  113. if line and line ~= "" then
  114. table.insert(buffer.events, line)
  115. end
  116. end
  117. f:close()
  118. end
  119. end
  120. function buffer.save()
  121. if not buffer.dirty then return end
  122. local f = io.open(cfg.buffer_file, "w")
  123. if not f then return end
  124. for _, ev in ipairs(buffer.events) do f:write(ev .. "\n") end
  125. f:close()
  126. buffer.dirty = false
  127. end
  128. function buffer.add(json_event)
  129. -- Warn if buffer is getting full
  130. if #buffer.events > cfg.max_buffer * 0.8 then
  131. log_err("Buffer almost full: " .. #buffer.events .. "/" .. cfg.max_buffer)
  132. end
  133. table.insert(buffer.events, json_event)
  134. if #buffer.events > cfg.max_buffer then
  135. -- Buffer full - drop oldest to make room
  136. table.remove(buffer.events, 1)
  137. log_err("Buffer overflow: dropping oldest event")
  138. end
  139. buffer.dirty = true
  140. end
  141. function buffer.flush(send_fn)
  142. if #buffer.events == 0 then return end
  143. local i = 1
  144. while i <= #buffer.events do
  145. if send_fn(buffer.events[i]) then
  146. table.remove(buffer.events, i)
  147. buffer.dirty = true
  148. else i = i + 1 end
  149. end
  150. buffer.save()
  151. end
  152. -- ============================================================================
  153. -- WEBSOCKET (RFC 6455 - Proper WebSocket)
  154. -- ============================================================================
  155. local ws = { sock = nil, connected = false, key = "" }
  156. -- Simple base64 encoder
  157. local function base64_encode(data)
  158. local b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
  159. local result = {}
  160. local i = 1
  161. while i <= #data do
  162. local b1, b2, b3 = string.byte(data, i, i+2)
  163. b2 = b2 or 0
  164. b3 = b3 or 0
  165. table.insert(result, string.sub(b64, math.floor(b1/4)+1, math.floor(b1/4)+1))
  166. table.insert(result, string.sub(b64, ((b1%16)*4) + math.floor(b2/16)+1, ((b1%16)*4) + math.floor(b2/16)+1))
  167. if i+1 > #data then table.insert(result, "=") else
  168. table.insert(result, string.sub(b64, ((b2%16)*4) + math.floor(b3/64)+1, ((b2%16)*4) + math.floor(b3/64)+1))
  169. end
  170. if i+2 > #data then table.insert(result, "=") else
  171. table.insert(result, string.sub(b64, (b3%64)+1, (b3%64)+1))
  172. end
  173. i = i + 3
  174. end
  175. return table.concat(result)
  176. end
  177. -- SHA1 (for WebSocket handshake)
  178. local function sha1_binary(data)
  179. local f = io.popen("echo -n '" .. data:gsub("'", "'\\''") .. "' | openssl sha1 -binary | base64 | tr -d '\\n' 2>/dev/null")
  180. if f then
  181. local result = f:read("*a")
  182. f:close()
  183. return result:gsub("%s+$", "")
  184. end
  185. return ""
  186. end
  187. -- Compute Sec-WebSocket-Accept
  188. local function compute_accept(key)
  189. local combined = key .. "258EAFA5-E914-47DA-95CA-C5C753455362"
  190. return sha1_binary(combined)
  191. end
  192. function ws.connect(url)
  193. local is_ssl = url:match("wss://") ~= nil
  194. local host = url:match("wss?://([^:/]+)")
  195. local port = url:match(":(%d+)") or (is_ssl and "443" or "80")
  196. if not host then return nil end
  197. local sock = require("socket").tcp()
  198. sock:settimeout(10)
  199. local ok, err = sock:connect(host, tonumber(port))
  200. if not ok then
  201. log_err("Cannot connect to " .. host .. ":" .. port .. ": " .. tostring(err))
  202. return nil
  203. end
  204. -- Generate random Sec-WebSocket-Key
  205. local key = ""
  206. for i = 1, 16 do key = key .. string.char(math.random(32, 126)) end
  207. key = base64_encode(key)
  208. ws.key = key
  209. local request = "GET /ws HTTP/1.1\r\n" ..
  210. "Host: " .. host .. ":" .. port .. "\r\n" ..
  211. "Upgrade: websocket\r\n" ..
  212. "Connection: Upgrade\r\n" ..
  213. "Sec-WebSocket-Key: " .. key .. "\r\n" ..
  214. "Sec-WebSocket-Version: 13\r\n" ..
  215. "Origin: http://" .. host .. "\r\n" ..
  216. "\r\n"
  217. sock:send(request)
  218. -- Read response
  219. local response = {}
  220. sock:settimeout(5)
  221. for i = 1, 20 do
  222. local line = sock:receive("*l")
  223. if not line or line == "" then break end
  224. table.insert(response, line)
  225. end
  226. -- Check for 101 Switching Protocols
  227. local ok_response = false
  228. for _, line in ipairs(response) do
  229. if line:match("^HTTP/.* 101") then ok_response = true end
  230. end
  231. if not ok_response then
  232. log_err("WebSocket handshake failed")
  233. sock:close()
  234. return nil
  235. end
  236. ws.sock = sock
  237. ws.connected = true
  238. log_info("WebSocket connected to " .. host .. ":" .. port)
  239. return sock
  240. end
  241. function ws.send(data)
  242. if not ws.connected then buffer.add(data); return false end
  243. -- WebSocket frame: FIN(1) + opcode(1) = 0x81 (text)
  244. local payload = data
  245. local frame = string.char(0x81) .. payload
  246. if not pcall(function() ws.sock:send(frame) end) then
  247. ws.connected = false
  248. buffer.add(data)
  249. return false
  250. end
  251. return true
  252. end
  253. function ws.recv()
  254. if not ws.connected then return nil end
  255. ws.sock:settimeout(0.5)
  256. local data, err = ws.sock:receive("*l")
  257. ws.sock:settimeout(10)
  258. if err and err ~= "timeout" then
  259. ws.connected = false
  260. return nil
  261. end
  262. -- Strip WebSocket frame header (first byte)
  263. if data and #data > 1 then
  264. data = data:sub(2)
  265. end
  266. return data
  267. end
  268. function ws.close()
  269. if ws.sock and ws.connected then
  270. pcall(function() ws.sock:send(string.char(0x88, 0x00)) end)
  271. end
  272. if ws.sock then pcall(ws.sock.close, ws.sock) end
  273. ws.sock = nil
  274. ws.connected = false
  275. end
  276. -- ============================================================================
  277. -- COMMAND EXECUTOR
  278. -- ============================================================================
  279. function execute_command(cmd_obj)
  280. local cmd = cmd_obj.command or ""
  281. local args = cmd_obj.args or {}
  282. log_info("Executing: " .. cmd)
  283. local result = { success = false, output = "", error = "" }
  284. if cmd == "uci_set" then
  285. local config = args.config
  286. local section = args.section
  287. local option = args.option
  288. local value = args.value
  289. if config and section and option and value then
  290. local c = string.format("uci set %s.%s.%s='%s'", config, section, option, value)
  291. local f = io.popen(c)
  292. result.output = f and f:read("*a") or ""
  293. if f then f:close() end
  294. os.execute("uci commit " .. config)
  295. result.success = true
  296. log_info("UCI set: " .. config .. "." .. section .. "." .. option .. " = " .. value)
  297. else
  298. result.error = "Missing params"
  299. end
  300. elseif cmd == "shell" then
  301. local shell_cmd = args.command
  302. if shell_cmd then
  303. local f = io.popen(shell_cmd)
  304. result.output = f and f:read("*a") or ""
  305. if f then f:close() end
  306. result.success = true
  307. else
  308. result.error = "No command"
  309. end
  310. elseif cmd == "reboot" then
  311. os.execute("sync && reboot &")
  312. result.success = true
  313. result.output = "Reboot scheduled"
  314. elseif cmd == "wifi_restart" then
  315. os.execute("/etc/init.d/network restart")
  316. os.execute("/etc/init.d/wireless restart")
  317. result.success = true
  318. elseif cmd == "status" then
  319. local f = io.popen("ubus call network getStatus")
  320. result.output = f and f:read("*a") or "{}"
  321. if f then f:close() end
  322. result.success = true
  323. else
  324. result.error = "Unknown: " .. cmd
  325. end
  326. return result
  327. end
  328. -- ============================================================================
  329. -- COROUTINES
  330. -- ============================================================================
  331. -- Monitor DHCP
  332. local function co_dhcp()
  333. local leases = {}
  334. while true do
  335. local f = io.open("/var/lib/dnsmasq/dnsmasq.leases", "r")
  336. if f then
  337. local current = {}
  338. for line in f:lines() do
  339. local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
  340. if mac then
  341. current[mac] = { ip = ip, hostname = name }
  342. if not leases[mac] then
  343. local ev = json_encode({
  344. router_id = cfg.router_id, hostname = cfg.router_id,
  345. event_type = "dhcp_lease_new",
  346. payload = { mac = mac, ip = ip, hostname = name }
  347. })
  348. ws.send(ev)
  349. log_info("DHCP: " .. mac .. " -> " .. ip)
  350. end
  351. end
  352. end
  353. f:close()
  354. for mac in pairs(leases) do
  355. if not current[mac] then
  356. local ev = json_encode({
  357. router_id = cfg.router_id, hostname = cfg.router_id,
  358. event_type = "dhcp_lease_expire",
  359. payload = { mac = mac, old_ip = leases[mac].ip }
  360. })
  361. ws.send(ev)
  362. log_info("DHCP expire: " .. mac)
  363. end
  364. end
  365. leases = current
  366. end
  367. coroutine.yield(cfg.check_interval)
  368. end
  369. end
  370. -- Monitor WAN
  371. local function co_wan()
  372. local link_last, ip_last = nil, nil
  373. local f = io.open("/sys/class/net/" .. cfg.wan_device .. "/carrier", "r")
  374. if f then link_last = (f:read("*a") or "":find("^1") == 1; f:close() end
  375. while true do
  376. -- Link
  377. f = io.open("/sys/class/net/" .. cfg.wan_device .. "/carrier", "r")
  378. local link_now = f and ((f:read("*a") or ""):find("^1") == 1) or false
  379. if f then f:close() end
  380. if link_now ~= link_last then
  381. link_last = link_now
  382. local ev = json_encode({
  383. router_id = cfg.router_id, hostname = cfg.router_id,
  384. event_type = link_now and "wan_link_up" or "wan_link_down",
  385. payload = { device = cfg.wan_device }
  386. })
  387. ws.send(ev)
  388. log_info("WAN link: " .. (link_now and "up" or "down"))
  389. end
  390. -- IP
  391. f = io.popen("ubus call network.interface." .. cfg.wan_interface .. " status 2>/dev/null")
  392. local ip_now = nil
  393. if f then
  394. local st = f:read("*a")
  395. f:close()
  396. ip_now = st and st:match('"address"%s*:%s*"([^"]+)"')
  397. end
  398. if ip_now and ip_now ~= ip_last then
  399. local ev = json_encode({
  400. router_id = cfg.router_id, hostname = cfg.router_id,
  401. event_type = ip_last and "wan_dhcp_changed" or "wan_dhcp_new",
  402. payload = { old_ip = ip_last, new_ip = ip_now }
  403. })
  404. ws.send(ev)
  405. log_info("WAN IP: " .. (ip_last or "none") .. " -> " .. ip_now)
  406. ip_last = ip_now
  407. end
  408. coroutine.yield(cfg.check_interval)
  409. end
  410. end
  411. -- 🔥 COROUTINE: Command Listener (handles server → router commands)
  412. local function co_commands()
  413. while true do
  414. if ws.connected then
  415. -- Check for incoming data
  416. local data = ws.recv()
  417. if data and data:match("^{") then
  418. log_info("Received command: " .. data:sub(1, 100))
  419. -- Parse JSON command
  420. local cmd_obj = json_decode(data)
  421. if cmd_obj.command then
  422. -- Execute command
  423. local result = execute_command(cmd_obj)
  424. -- Send result back
  425. local resp = json_encode({
  426. router_id = cfg.router_id,
  427. event_type = "command_result",
  428. payload = {
  429. command = cmd_obj.command,
  430. command_id = cmd_obj.id or "",
  431. success = result.success,
  432. output = result.output,
  433. error = result.error
  434. }
  435. })
  436. ws.send(resp)
  437. log_info("Command done: " .. cmd_obj.command .. " = " .. (result.success and "OK" or result.error))
  438. end
  439. end
  440. end
  441. coroutine.yield(1) -- Check every second for commands
  442. end
  443. end
  444. -- COROUTINE: Auto-reconnect (keeps connection alive)
  445. local function co_connect()
  446. local retry_delay = 30 -- Start at 30s
  447. local max_delay = 300 -- Max 5 minutes
  448. while true do
  449. -- Urgency based on buffer state
  450. local buffer_fullness = #buffer.events / cfg.max_buffer
  451. if buffer_fullness > 0.8 then
  452. log_err("Buffer critical at " .. string.format("%.0f%%", buffer_fullness * 100) .. " - aggressive reconnect")
  453. retry_delay = 10 -- Aggressive when buffer filling
  454. end
  455. if not ws.connected then
  456. log_info("Connecting to " .. cfg.server_url .. "...")
  457. local sock = ws.connect(cfg.server_url)
  458. if sock then
  459. ws.connected = true
  460. retry_delay = 30 -- Reset on success
  461. log_info("Connected!")
  462. buffer.flush(function(d) return ws.send(d) end)
  463. else
  464. log_err("Connection failed, retry in " .. retry_delay .. "s")
  465. coroutine.yield(retry_delay)
  466. retry_delay = math.min(retry_delay * 2, max_delay)
  467. end
  468. else
  469. -- Even when connected, periodically flush to clear buffer buildup
  470. buffer.flush(function(d) return ws.send(d) end)
  471. coroutine.yield(30)
  472. end
  473. end
  474. end
  475. -- ============================================================================
  476. -- MAIN
  477. -- ============================================================================
  478. local function scheduler()
  479. local cos = {
  480. co_dhcp = coroutine.create(co_dhcp),
  481. co_wan = coroutine.create(co_wan),
  482. co_commands = coroutine.create(co_commands),
  483. co_connect = coroutine.create(co_connect), -- Auto-reconnect!
  484. }
  485. while true do
  486. -- Resume all coroutines
  487. for name, co in pairs(cos) do
  488. if coroutine.status(co) == "suspended" then
  489. local _, delay = coroutine.resume(co)
  490. -- Small stagger to avoid spikes
  491. if delay then os.execute("sleep 0.1") end
  492. end
  493. end
  494. -- Flush buffer when connected
  495. if ws.connected then
  496. buffer.flush(function(d) return ws.send(d) end)
  497. end
  498. os.execute("sleep 1")
  499. end
  500. end
  501. local function main()
  502. log_info("Starting client2server...")
  503. log_info("Router: " .. cfg.router_id)
  504. log_info("Server: " .. cfg.server_url)
  505. buffer.init()
  506. local pf = io.open(cfg.pid_file, "w")
  507. if pf then pf:write(tostring(os.getpid())); pf:close() end
  508. -- Connect
  509. log_info("Initial connection...")
  510. local sock = ws.connect(cfg.server_url)
  511. if sock then
  512. ws.connected = true
  513. log_info("Connected!")
  514. buffer.flush(function(d) return ws.send(d) end)
  515. else
  516. log_err("Connection failed")
  517. end
  518. scheduler()
  519. end
  520. main()