client2server-unified.lua 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. --[[
  2. client2server-unified.lua - All-in-one event forwarder for OpenWrt
  3. Copyright (c) 2026 Luis Rosales - MIT License
  4. Combines:
  5. - client2server: DHCP/WiFi events → WebSocket
  6. - wan-watcher: WAN link + DHCP monitoring
  7. Features:
  8. - WebSocket connection with auto-reconnect
  9. - Local buffer (store-and-forward while offline)
  10. - DHCP lease events
  11. - WAN link state monitoring
  12. - DHCP lease changes (new ISP detection)
  13. Size: ~15KB (shared codebase, no duplication)
  14. ]]
  15. -- ============================================================================
  16. -- CONFIG
  17. -- ============================================================================
  18. local cfg = {
  19. -- Server
  20. server_url = os.getenv("SERVER_URL") or "wss://your-server.com/ws",
  21. server_token = os.getenv("SERVER_TOKEN") or "secret-token",
  22. router_id = os.getenv("ROUTER_ID") or "",
  23. -- Connection
  24. reconnect_delay = 5,
  25. ping_interval = 30,
  26. max_retries = 10,
  27. -- Monitoring
  28. check_interval = 5,
  29. wan_interface = "wan",
  30. wan_device = "eth0",
  31. -- Files
  32. buffer_file = "/tmp/event_buffer",
  33. status_file = "/var/run/client2server.status",
  34. pid_file = "/var/run/client2server.pid",
  35. max_buffer = 100,
  36. }
  37. -- Load from UCI if available
  38. pcall(function()
  39. local uci = require("luci.model.uci").cursor()
  40. cfg.server_url = uci:get("client2server", "server", "url") or cfg.server_url
  41. cfg.server_token = uci:get("client2server", "server", "token") or cfg.server_token
  42. cfg.router_id = uci:get("client2server", "router", "id") or cfg.router_id
  43. cfg.wan_interface = uci:get("client2server", "wan", "interface") or cfg.wan_interface
  44. cfg.wan_device = uci:get("client2server", "wan", "device") or cfg.wan_device
  45. end)
  46. -- Set router_id from hostname if not set
  47. if cfg.router_id == "" then
  48. local f = io.popen("hostname")
  49. cfg.router_id = f and (f:read("*a") or ""):gsub("%s+$", "") or "unknown"
  50. if f then f:close() end
  51. end
  52. -- ============================================================================
  53. -- LOGGING
  54. -- ============================================================================
  55. local function log(level, msg)
  56. os.execute(string.format('logger -t "client2server" -p user.%s "%s"', level, msg:gsub('"', '\\"')))
  57. end
  58. local function log_info(msg) log("info", msg) end
  59. local function log_err(msg) log("err", msg) end
  60. -- ============================================================================
  61. -- JSON (Minimal implementation)
  62. -- ============================================================================
  63. local function json_encode(t)
  64. local parts = {}
  65. for k, v in pairs(t) do
  66. if type(v) == "string" then
  67. table.insert(parts, string.format('"%s": "%s"', k, v:gsub('"', '\\"')))
  68. elseif type(v) == "number" then
  69. table.insert(parts, string.format('"%s": %s', k, tostring(v)))
  70. elseif type(v) == "boolean" then
  71. table.insert(parts, string.format('"%s": %s', k, tostring(v)))
  72. elseif type(v) == "table" then
  73. -- Nested object
  74. table.insert(parts, string.format('"%s": %s', k, json_encode(v)))
  75. end
  76. end
  77. return "{" .. table.concat(parts, ",") .. "}"
  78. end
  79. -- ============================================================================
  80. -- BUFFER (Offline Support)
  81. -- ============================================================================
  82. local buffer = { events = {} }
  83. function buffer.load()
  84. local f = io.open(cfg.buffer_file, "r")
  85. if not f then return end
  86. for line in f:lines() do
  87. if line and line ~= "" then
  88. table.insert(buffer.events, line)
  89. end
  90. end
  91. f:close()
  92. log_info("Loaded " .. #buffer.events .. " buffered events")
  93. end
  94. function buffer.save()
  95. if #buffer.events == 0 then
  96. os.execute("rm -f " .. cfg.buffer_file)
  97. return
  98. end
  99. local f = io.open(cfg.buffer_file, "w")
  100. if not f then return end
  101. for _, ev in ipairs(buffer.events) do
  102. f:write(ev .. "\n")
  103. end
  104. f:close()
  105. end
  106. function buffer.add(json_event)
  107. table.insert(buffer.events, json_event)
  108. while #buffer.events > cfg.max_buffer do
  109. table.remove(buffer.events, 1)
  110. end
  111. buffer.save()
  112. end
  113. function buffer.flush(send_fn)
  114. if #buffer.events == 0 then return end
  115. log_info("Flushing " .. #buffer.events .. " buffered events...")
  116. local i = 1
  117. while i <= #buffer.events do
  118. local ok = send_fn(buffer.events[i])
  119. if ok then
  120. table.remove(buffer.events, i)
  121. else
  122. i = i + 1
  123. end
  124. end
  125. buffer.save()
  126. log_info("Flush complete, " .. #buffer.events .. " remaining")
  127. end
  128. -- ============================================================================
  129. -- WEBSOCKET (Simplified)
  130. -- ============================================================================
  131. local ws = { sock = nil, connected = false }
  132. function ws.send(data)
  133. if not ws.connected then
  134. buffer.add(data)
  135. return false
  136. end
  137. -- Simple frame construction
  138. local frame = string.format("\x81\x80%s", data)
  139. local success, err = pcall(function()
  140. ws.sock:send(frame)
  141. end)
  142. if not success then
  143. ws.connected = false
  144. buffer.add(data)
  145. return false
  146. end
  147. return true
  148. end
  149. function ws.connect(url)
  150. -- Extract host from wss://... URL
  151. local host = url:match("wss?://([^/]+)")
  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, 443)
  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
  163. pcall(ws.sock.close, ws.sock)
  164. ws.sock = nil
  165. end
  166. ws.connected = false
  167. end
  168. -- ============================================================================
  169. -- EVENT BUILDERS
  170. -- ============================================================================
  171. function build_event(event_type, payload)
  172. payload = payload or {}
  173. payload.timestamp = os.date("!%Y-%m-%dT%H:%M:%SZ")
  174. return json_encode({
  175. router_id = cfg.router_id,
  176. hostname = cfg.router_id,
  177. event_type = event_type,
  178. payload = payload,
  179. })
  180. end
  181. -- ============================================================================
  182. -- DATA SOURCES
  183. -- ============================================================================
  184. -- DHCP Leases
  185. local dhcp_leases = {}
  186. function check_dhcp()
  187. local f = io.open("/var/lib/dnsmasq/dnsmasq.leases", "r")
  188. if not f then return nil end
  189. local current = {}
  190. for line in f:lines() do
  191. local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
  192. if mac then
  193. current[mac] = { ip = ip, hostname = name, time = ts }
  194. if not dhcp_leases[mac] then
  195. -- NEW lease
  196. log_info("DHCP new: " .. mac .. " -> " .. ip)
  197. return {
  198. event = "dhcp_lease_new",
  199. mac = mac,
  200. ip = ip,
  201. hostname = name,
  202. }
  203. end
  204. end
  205. end
  206. f:close()
  207. -- Check for expired leases
  208. for mac in pairs(dhcp_leases) do
  209. if not current[mac] then
  210. local expired = dhcp_leases[mac]
  211. log_info("DHCP expire: " .. mac)
  212. dhcp_leases[mac] = nil
  213. return {
  214. event = "dhcp_lease_expire",
  215. mac = mac,
  216. old_ip = expired.ip,
  217. }
  218. end
  219. end
  220. dhcp_leases = current
  221. return nil
  222. end
  223. -- WAN Link State
  224. local link_last = nil
  225. local ip_last = nil
  226. function check_wan()
  227. -- Physical link
  228. local f = io.open("/sys/class/net/" .. cfg.wan_device .. "/carrier", "r")
  229. local link_now = f and (f:read("*a") or ""):find("^1") or false
  230. if f then f:close() end
  231. -- Link change
  232. if link_now ~= link_last then
  233. link_last = link_now
  234. return {
  235. event = link_now and "wan_link_up" or "wan_link_down",
  236. device = cfg.wan_device,
  237. message = link_now and "Physical link detected" or "Physical link lost",
  238. }
  239. end
  240. -- Check DHCP IP via ubus
  241. local info = nil
  242. f = io.popen("ubus call network.interface." .. cfg.wan_interface .. " status 2>/dev/null")
  243. if f then
  244. local status = f:read("*a")
  245. f:close()
  246. if status then
  247. local ip = status:match('"address"%s*:%s*"([^"]+)"')
  248. if ip then info = { ip = ip } end
  249. end
  250. end
  251. -- IP change (connected to new network)
  252. if info and info.ip and info.ip ~= ip_last then
  253. local old_ip = ip_last
  254. ip_last = info.ip
  255. return {
  256. event = old_ip and "wan_dhcp_changed" or "wan_dhcp_new",
  257. device = cfg.wan_interface,
  258. old_ip = old_ip,
  259. new_ip = info.ip,
  260. message = old_ip and ("IP changed: " .. old_ip .. " -> " .. info.ip) or ("New IP: " .. info.ip),
  261. }
  262. end
  263. return nil
  264. end
  265. -- ============================================================================
  266. -- MAIN LOOP
  267. -- ============================================================================
  268. local function main()
  269. log_info("Starting client2server-unified...")
  270. log_info("Router: " .. cfg.router_id)
  271. log_info("Server: " .. cfg.server_url)
  272. log_info("WAN: " .. cfg.wan_interface .. " (" .. cfg.wan_device .. ")")
  273. -- Load buffer
  274. buffer.load()
  275. -- Save PID
  276. local pf = io.open(cfg.pid_file, "w")
  277. if pf then
  278. pf:write(tostring(os.getpid()))
  279. pf:close()
  280. end
  281. -- Initial WAN state
  282. local f = io.open("/sys/class/net/" .. cfg.wan_device .. "/carrier", "r")
  283. link_last = f and (f:read("*a") or ""):find("^1") or false
  284. if f then f:close() end
  285. -- Main loop with periodic checks
  286. local sock = nil
  287. local retries = 0
  288. local check_counter = 0
  289. while true do
  290. -- Attempt connection if not connected
  291. if not sock or not ws.connected then
  292. log_info("Connecting...")
  293. sock = ws.connect(cfg.server_url)
  294. if sock then
  295. log_info("Connected!")
  296. retries = 0
  297. buffer.flush(function(d) return ws.send(d) end)
  298. else
  299. retries = retries + 1
  300. if retries >= cfg.max_retries then
  301. log_err("Max retries, resetting")
  302. retries = 0
  303. end
  304. end
  305. end
  306. socket.sleep(cfg.check_interval)
  307. check_counter = check_counter + 1
  308. -- Check every cycle
  309. local events = {}
  310. -- 1. DHCP
  311. local ev = check_dhcp()
  312. if ev then table.insert(events, build_event(ev.event, {
  313. device = "dhcp",
  314. mac = ev.mac,
  315. ip = ev.ip,
  316. hostname = ev.hostname,
  317. old_ip = ev.old_ip,
  318. }) end
  319. -- 2. WAN (every cycle)
  320. ev = check_wan()
  321. if ev then table.insert(events, build_event(ev.event, {
  322. device = ev.device,
  323. old_ip = ev.old_ip,
  324. new_ip = ev.new_ip,
  325. }) end
  326. -- Send buffered + current events
  327. for _, event_json in ipairs(events) do
  328. log_info("Event: " .. event_json)
  329. ws.send(event_json)
  330. end
  331. -- Also flush any pending buffered
  332. if ws.connected then
  333. buffer.flush(function(d) return ws.send(d) end)
  334. end
  335. end
  336. end
  337. main()