client2server-unified.lua 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. --[[
  2. client2server-unified.lua - Bidirectional event forwarder for OpenWrt
  3. Copyright (c) 2026 Luis Rosales - MIT License
  4. Features (bidirectional):
  5. - WebSocket connection with auto-reconnect
  6. - Local buffer (store-and-forward while offline)
  7. - DHCP lease events
  8. - WAN link state monitoring
  9. - Command listener (receive settings from server)
  10. - Executes commands: UCI set, shell commands
  11. Port: 3843 (for listening for commands)
  12. ]]
  13. -- ============================================================================
  14. -- CONFIG
  15. -- ============================================================================
  16. local cfg = {
  17. server_url = os.getenv("SERVER_URL") or "wss://your-server.com:3843",
  18. server_token = os.getenv("SERVER_TOKEN") or "secret-token",
  19. router_id = os.getenv("ROUTER_ID") or "",
  20. reconnect_delay = 5,
  21. ping_interval = 30,
  22. max_retries = 10,
  23. check_interval = 5,
  24. wan_interface = "wan",
  25. wan_device = "eth0",
  26. buffer_file = "/tmp/event_buffer",
  27. pid_file = "/var/run/client2server.pid",
  28. max_buffer = 100,
  29. -- Command server
  30. cmd_port = 3843,
  31. }
  32. -- Load from UCI
  33. pcall(function()
  34. local uci = require("luci.model.uci").cursor()
  35. cfg.server_url = uci:get("client2server", "server", "url") or cfg.server_url
  36. cfg.server_token = uci:get("client2server", "server", "token") or cfg.server_token
  37. cfg.router_id = uci:get("client2server", "router", "id") or cfg.router_id
  38. cfg.wan_interface = uci:get("client2server", "wan", "interface") or cfg.wan_interface
  39. cfg.wan_device = uci:get("client2server", "wan", "device") or cfg.wan_device
  40. end)
  41. if cfg.router_id == "" then
  42. local f = io.popen("hostname")
  43. cfg.router_id = f and (f:read("*a") or ""):gsub("%s+$", "") or "unknown"
  44. if f then f:close() end
  45. end
  46. -- ============================================================================
  47. -- LOGGING
  48. -- ============================================================================
  49. local function log(level, msg)
  50. os.execute(string.format('logger -t "client2server" -p user.%s "%s"', level, msg:gsub('"', '\\"')))
  51. end
  52. local function log_info(msg) log("info", msg) end
  53. local function log_err(msg) log("err", msg) end
  54. -- ============================================================================
  55. -- JSON
  56. -- ============================================================================
  57. local function json_encode(t)
  58. local parts = {}
  59. for k, v in pairs(t) do
  60. if type(v) == "string" then
  61. table.insert(parts, string.format('"%s": "%s"', k, v:gsub('"', '\\"')))
  62. elseif type(v) == "number" then
  63. table.insert(parts, string.format('"%s": %s', k, tostring(v)))
  64. elseif type(v) == "boolean" then
  65. table.insert(parts, string.format('"%s": %s', k, tostring(v)))
  66. elseif type(v) == "table" then
  67. table.insert(parts, string.format('"%s": %s', k, json_encode(v)))
  68. end
  69. end
  70. return "{" .. table.concat(parts, ",") .. "}"
  71. end
  72. local function json_decode(str)
  73. local result = {}
  74. for key, value in str:gmatch('"([^"]+)":%s*"([^"]*)"') do
  75. result[key] = value
  76. end
  77. for key, value in str:gmatch('"([^"]+)":%s*(%d+)') do
  78. result[key] = tonumber(value)
  79. end
  80. return result
  81. end
  82. -- ============================================================================
  83. -- BUFFER
  84. -- ============================================================================
  85. local buffer = { events = {} }
  86. function buffer.load()
  87. local f = io.open(cfg.buffer_file, "r")
  88. if not f then return end
  89. for line in f:lines() do
  90. if line and line ~= "" then
  91. table.insert(buffer.events, line)
  92. end
  93. end
  94. f:close()
  95. log_info("Loaded " .. #buffer.events .. " buffered events")
  96. end
  97. function buffer.save()
  98. if #buffer.events == 0 then
  99. os.execute("rm -f " .. cfg.buffer_file)
  100. return
  101. end
  102. local f = io.open(cfg.buffer_file, "w")
  103. if not f then return end
  104. for _, ev in ipairs(buffer.events) do
  105. f:write(ev .. "\n")
  106. end
  107. f:close()
  108. end
  109. function buffer.add(json_event)
  110. table.insert(buffer.events, json_event)
  111. while #buffer.events > cfg.max_buffer do
  112. table.remove(buffer.events, 1)
  113. end
  114. buffer.save()
  115. end
  116. function buffer.flush(send_fn)
  117. if #buffer.events == 0 then return end
  118. log_info("Flushing " .. #buffer.events .. " buffered events...")
  119. local i = 1
  120. while i <= #buffer.events do
  121. local ok = send_fn(buffer.events[i])
  122. if ok then
  123. table.remove(buffer.events, i)
  124. else
  125. i = i + 1
  126. end
  127. end
  128. buffer.save()
  129. end
  130. -- ============================================================================
  131. -- WEBSOCKET
  132. -- ============================================================================
  133. local ws = { sock = nil, connected = false }
  134. function ws.send(data)
  135. if not ws.connected then
  136. buffer.add(data)
  137. return false
  138. end
  139. local frame = string.format("\x81\x80%s", data)
  140. local success, err = pcall(function() ws.sock:send(frame) end)
  141. if not success then
  142. ws.connected = false
  143. buffer.add(data)
  144. return false
  145. end
  146. return true
  147. end
  148. function ws.connect(url)
  149. local host = url:match("wss?://([^:/]+)")
  150. local port = url:match(":%d+") or ":443"
  151. port = tonumber(port:sub(2)) or 443
  152. if not host then return nil end
  153. local sock = require("socket").tcp()
  154. sock:settimeout(10)
  155. local ok, err = pcall(sock.connect, sock, host, port)
  156. if not ok then return nil end
  157. ws.sock = sock
  158. ws.connected = true
  159. return sock
  160. end
  161. function ws.close()
  162. if ws.sock then pcall(ws.sock.close, ws.sock) end
  163. ws.sock = nil
  164. ws.connected = false
  165. end
  166. -- ============================================================================
  167. -- COMMAND EXECUTOR
  168. -- ============================================================================
  169. function execute_command(cmd_obj)
  170. local cmd = cmd_obj.command
  171. local args = cmd_obj.args or {}
  172. log_info("Executing command: " .. cmd)
  173. local result = { success = false, output = "", error = "" }
  174. if cmd == "uci_set" then
  175. -- uci set network.lan.ipaddr='192.168.1.1'
  176. local config = args.config
  177. local section = args.section
  178. local option = args.option
  179. local value = args.value
  180. if config and section and option and value then
  181. local c = string.format("uci set %s.%s.%s='%s'", config, section, option, value)
  182. local f = io.popen(c)
  183. result.output = f and f:read("*a") or ""
  184. if f then f:close() end
  185. -- Commit
  186. os.execute("uci commit " .. config)
  187. result.success = true
  188. else
  189. result.error = "Missing params"
  190. end
  191. elseif cmd == "shell" then
  192. -- Arbitrary shell command
  193. local shell_cmd = args.command
  194. if shell_cmd then
  195. local f = io.popen(shell_cmd)
  196. result.output = f and f:read("*a") or ""
  197. if f then f:close() end
  198. result.success = true
  199. else
  200. result.error = "No command provided"
  201. end
  202. elseif cmd == "reboot" then
  203. os.execute("sync && reboot &")
  204. result.success = true
  205. result.output = "Reboot scheduled"
  206. elseif cmd == "wifi_restart" then
  207. os.execute("/etc/init.d/network restart")
  208. os.execute("/etc/init.d/wireless restart")
  209. result.success = true
  210. elseif cmd == "status" then
  211. -- Return router status
  212. local f = io.popen("ubus call network getStatus")
  213. result.output = f and f:read("*a") or "{}"
  214. if f then f:close() end
  215. result.success = true
  216. else
  217. result.error = "Unknown command: " .. cmd
  218. end
  219. return result
  220. end
  221. -- ============================================================================
  222. -- HTTP COMMAND SERVER (Port 3843)
  223. -- ============================================================================
  224. local function start_cmd_server()
  225. -- Fork a simple HTTP server for commands
  226. -- Uses Lua's built-in socket or spawns netcat listener
  227. -- Actually, commands come through WebSocket from server
  228. -- This port is for direct HTTP commands if WebSocket fails
  229. log_info("Command server ready on port " .. cfg.cmd_port)
  230. end
  231. -- Handle incoming HTTP command (fallback)
  232. function handle_http_cmd(request)
  233. -- Parse: GET /cmd?command=uci_set&args[config]=network&args[section]=lan&...
  234. -- Or POST with JSON body
  235. local cmd_json = request:match('({.+})')
  236. if cmd_json then
  237. local cmd_obj = json_decode(cmd_json)
  238. local result = execute_command(cmd_obj)
  239. return json_encode(result)
  240. end
  241. return json_encode({ error = "Invalid request" })
  242. end
  243. -- ============================================================================
  244. -- EVENT BUILDERS
  245. -- ============================================================================
  246. function build_event(event_type, payload)
  247. payload = payload or {}
  248. payload.timestamp = os.date("!%Y-%m-%dT%H:%M:%SZ")
  249. return json_encode({
  250. router_id = cfg.router_id,
  251. hostname = cfg.router_id,
  252. event_type = event_type,
  253. payload = payload,
  254. })
  255. end
  256. -- ============================================================================
  257. -- DATA SOURCES
  258. -- ============================================================================
  259. local dhcp_leases = {}
  260. local link_last = nil
  261. local ip_last = nil
  262. function check_dhcp()
  263. local f = io.open("/var/lib/dnsmasq/dnsmasq.leases", "r")
  264. if not f then return nil end
  265. local current = {}
  266. for line in f:lines() do
  267. local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
  268. if mac then
  269. current[mac] = { ip = ip, hostname = name, time = ts }
  270. if not dhcp_leases[mac] then
  271. return { event = "dhcp_lease_new", mac = mac, ip = ip, hostname = name }
  272. end
  273. end
  274. end
  275. f:close()
  276. for mac in pairs(dhcp_leases) do
  277. if not current[mac] then
  278. local expired = dhcp_leases[mac]
  279. dhcp_leases[mac] = nil
  280. return { event = "dhcp_lease_expire", mac = mac, old_ip = expired.ip }
  281. end
  282. end
  283. dhcp_leases = current
  284. return nil
  285. end
  286. function check_wan()
  287. local f = io.open("/sys/class/net/" .. cfg.wan_device .. "/carrier", "r")
  288. local link_now = f and (f:read("*a") or ""):find("^1") or false
  289. if f then f:close() end
  290. if link_now ~= link_last then
  291. link_last = link_now
  292. return { event = link_now and "wan_link_up" or "wan_link_down", device = cfg.wan_device }
  293. end
  294. f = io.popen("ubus call network.interface." .. cfg.wan_interface .. " status 2>/dev/null")
  295. local info, ip_now = nil, nil
  296. if f then
  297. local status = f:read("*a")
  298. f:close()
  299. ip_now = status and status:match('"address"%s*:%s*"([^"]+)"')
  300. if ip_now then info = { ip = ip_now } end
  301. end
  302. if info and ip_now and ip_now ~= ip_last then
  303. local old_ip = ip_last
  304. ip_last = ip_now
  305. return { event = old_ip and "wan_dhcp_changed" or "wan_dhcp_new", old_ip = old_ip, new_ip = ip_now }
  306. end
  307. return nil
  308. end
  309. -- ============================================================================
  310. -- MAIN LOOP
  311. -- ============================================================================
  312. local function main()
  313. log_info("Starting client2server (bidirectional)...")
  314. log_info("Router: " .. cfg.router_id)
  315. log_info("Server: " .. cfg.server_url)
  316. log_info("Command port: " .. cfg.cmd_port)
  317. buffer.load()
  318. local pf = io.open(cfg.pid_file, "w")
  319. if pf then pf:write(tostring(os.getpid())); pf:close() end
  320. f = io.open("/sys/class/net/" .. cfg.wan_device .. "/carrier", "r")
  321. link_last = f and (f:read("*a") or ""):find("^1") or false
  322. if f then f:close() end
  323. local sock = nil
  324. local retries = 0
  325. while true do
  326. if not sock or not ws.connected then
  327. log_info("Connecting to " .. cfg.server_url .. "...")
  328. sock = ws.connect(cfg.server_url)
  329. if sock then
  330. log_info("Connected!")
  331. retries = 0
  332. buffer.flush(function(d) return ws.send(d) end)
  333. else
  334. retries = retries + 1
  335. end
  336. end
  337. require("socket").sleep(cfg.check_interval)
  338. local events = {}
  339. local ev = check_dhcp()
  340. if ev then table.insert(events, build_event(ev.event, { device = "dhcp", mac = ev.mac, ip = ev.ip })) end
  341. ev = check_wan()
  342. if ev then table.insert(events, build_event(ev.event, { device = ev.device, old_ip = ev.old_ip, new_ip = ev.new_ip })) end
  343. for _, event_json in ipairs(events) do
  344. ws.send(event_json)
  345. end
  346. if ws.connected then
  347. buffer.flush(function(d) return ws.send(d) end)
  348. end
  349. end
  350. end
  351. main()