--[[ client2server - Lua WebSocket Event Forwarder for OpenWrt Features: - WebSocket connection to central server - Auto-reconnect on disconnect - Local buffer (store-and-forward while offline) - DHCP/WiFi/Interface event tracking Copyright (c) 2026 Luis Rosales - MIT License ]] -- ============================================================================ -- REQUIREMENTS -- ============================================================================ -- Try to load websocket library, fallback to simple HTTP local ws_client = nil local has_websocket, websocket = pcall(require, "websocket") if has_websocket then ws_client = websocket.client.sync() end -- ============================================================================ -- CONFIG -- ============================================================================ local cfg = { url = os.getenv("SERVER_URL") or "wss://your-server.com/ws", token = os.getenv("SERVER_TOKEN") or "secret-token", router_id = os.getenv("ROUTER_ID") or "unknown", reconnect_delay = 5, ping_interval = 30, buffer_file = "/tmp/event_buffer", max_buffer = 100, } -- Load from UCI if available pcall(function() local uci = require("luci.model.uci").cursor() cfg.url = uci:get("event-forwarder", "server", "url") or cfg.url cfg.token = uci:get("event-forwarder", "server", "token") or cfg.token cfg.router_id = uci:get("event-forwarder", "router", "id") or cfg.router_id end) -- ============================================================================ -- UTILITIES -- ============================================================================ local function log(level, msg) os.execute(string.format('logger -t "client2server" -p user.%s "%s" 2>/dev/null', level, msg)) end local function get_hostname() local f = io.popen("hostname") local h = f and f:read("*a"):gsub("%s+$", "") or "unknown" if f then f:close() end return h end cfg.router_id = cfg.router_id == "unknown" and get_hostname() or cfg.router_id local function json_encode(t) local parts = {} for k, v in pairs(t) do if type(v) == "string" then table.insert(parts, string.format('"%s": "%s"', k, v:gsub('"', '\\"'))) elseif type(v) == "number" then table.insert(parts, string.format('"%s": %s', k, tostring(v))) elseif type(v) == "boolean" then table.insert(parts, string.format('"%s": %s', k, tostring(v))) end end return "{" .. table.concat(parts, ",") .. "}" end -- ============================================================================ -- BUFFER (OFFLINE SUPPORT) -- ============================================================================ local buffer = { events = {}, dirty = false, } function buffer.load() local f = io.open(cfg.buffer_file, "r") if not f then return end for line in f:lines() do if line and line ~= "" then table.insert(buffer.events, line) end end f:close() log("info", "Loaded " .. #buffer.events .. " buffered events") end function buffer.save() if not buffer.dirty then return end local f = io.open(cfg.buffer_file, "w") if not f then return end for _, ev in ipairs(buffer.events) do f:write(ev .. "\n") end f:close() buffer.dirty = false end function buffer.add(json_event) table.insert(buffer.events, json_event) -- Trim if too big while #buffer.events > cfg.max_buffer do table.remove(buffer.events, 1) end buffer.dirty = true buffer.save() end function buffer.flush(send_fn) if #buffer.events == 0 then return end log("info", "Flushing " .. #buffer.events .. " buffered events...") local i = 1 while i <= #buffer.events do local ok = send_fn(buffer.events[i]) if ok then table.remove(buffer.events, i) buffer.dirty = true else i = i + 1 end end buffer.save() end function buffer.clear() buffer.events = {} os.execute("rm -f " .. cfg.buffer_file) buffer.dirty = false end -- ============================================================================ -- WEBSOCKET CLIENT -- ============================================================================ local ws = { sock = nil, connected = false, } function ws.connect(url) if not ws_client then log("err", "websocket library not installed") return nil end local ok, sock = pcall(ws_client.connect, ws_client, url) if ok then ws.sock = sock ws.connected = true end return ws.sock end function ws.send(sock, data) if not sock or not ws.connected then buffer.add(data) return false, "not connected" end local ok, err = pcall(sock.send, sock, data) if not ok then ws.connected = false buffer.add(data) return false, err end return true end function ws.close(sock) if sock then pcall(sock.close, sock) end ws.sock = nil ws.connected = false end -- ============================================================================ -- EVENT BUILDERS -- ============================================================================ function build_event(event_type, payload) payload = payload or {} payload.timestamp = os.date("!%Y-%m-%dT%H:%M:%SZ") return json_encode({ router_id = cfg.router_id, hostname = get_hostname(), event_type = event_type, payload = payload, }) end -- ============================================================================ -- EVENT LISTENERS -- ============================================================================ local function listen_dhcp() local lease_file = "/var/lib/dnsmasq/dnsmasq.leases" local old_leases = {} while true do local f = io.open(lease_file, "r") if f then local leases = {} for line in f:lines() do local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)") if mac then leases[mac] = { ip = ip, hostname = name, time = tonumber(ts) } -- New lease? if not old_leases[mac] then local ev = build_event("dhcp_lease", { mac = mac, ip = ip, hostname = name, action = "new" }) log("info", "DHCP: " .. mac .. " -> " .. ip) buffer.add(ev) end end end old_leases = leases f:close() end os.execute("sleep 5") end end local function poll_network() -- Poll network status local f = io.popen("ubus call network getStatus 2>/dev/null") if f then f:close() end end -- ============================================================================ -- MAIN LOOP -- ============================================================================ local function main() log("info", "client2server starting...") log("info", "Router: " .. cfg.router_id) log("info", "Server: " .. cfg.url) -- Load buffered events buffer.load() -- Save PID local pf = io.open("/var/run/client2server.pid", "w") if pf then pf:write(tostring(os.getpid())) pf:close() end local sock = nil local retries = 0 -- Event listening coroutines -- In practice, would fork these or use procd/inotify while true do -- Attempt connection log("info", "Connecting to server...") if ws_client then sock = ws.connect(cfg.url) end if sock then log("info", "Connected!") retries = 0 -- Flush buffer buffer.flush(function(data) return ws.send(sock, data) end) -- Keep alive loop local loop_count = 0 while ws.connected and loop_count < (cfg.ping_interval / 5) do os.execute("sleep 5") loop_count = loop_count + 1 -- Periodic flush buffer.flush(function(data) return ws.send(sock, data) end) end else log("err", "Connection failed") retries = retries + 1 end -- Cleanup and reconnect ws.close(sock) sock = nil log("info", "Reconnecting in " .. cfg.reconnect_delay .. "s...") os.execute("sleep " .. cfg.reconnect_delay) end end -- Run main()