client2server-unified.lua 26 KB

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