--[[ event-forwarder.lua - WebSocket-based event forwarder for OpenWrt Copyright (c) 2026 Luis Rosales - MIT License Size: ~30KB with dependencies ]] local socket = require("socket") local http = require("socket.http") local ltn12 = require("ltn12") local json = require("json") -- ============================================================================ -- CONFIGURATION -- ============================================================================ local config = { server_url = "wss://your-server.com/ws", server_token = "CHANGE_ME", router_id = "", reconnect_delay = 5, max_retries = 10, ping_interval = 30, -- File paths buffer_file = "/tmp/event_buffer", pid_file = "/var/run/event-forwarder.pid", -- Event sources events = { wifi_connect = true, wifi_disconnect = true, dhcp_lease = true, interface_up = true, interface_down = true, } } -- ============================================================================ -- LOGGING -- ============================================================================ local LOG_TAG = "event-forwarder" local function log(level, msg) io.popen(string.format('logger -t "%s" -p user.%s "%s"', LOG_TAG, level, msg:gsub('"', '\\"'))):close() end local function log_info(msg) log("info", msg) end local function log_err(msg) log("err", msg) end local function log_debug(msg) if os.getenv("DEBUG") then log("debug", msg) end end -- ============================================================================ -- BUFFER (STORE-AND-FORWARD) -- ============================================================================ local buffer = {} function buffer.load() local f = io.open(config.buffer_file, "r") if not f then return end for line in f:lines() do if line and line ~= "" then table.insert(buffer, line) end end f:close() log_info("Buffer loaded: " .. #buffer .. " events") end function buffer.save() local f = io.open(config.buffer_file, "w") if not f then return end for _, line in ipairs(buffer) do f:write(line .. "\n") end f:close() end function buffer.add(event_json) table.insert(buffer, event_json) -- Prevent unbounded growth while #buffer > 500 do table.remove(buffer, 1) end buffer.save() log_debug("Buffered event, total: " .. #buffer) end function buffer.flush(ws) if #buffer == 0 then return end log_info("Flushing " .. #buffer .. " buffered events...") local i = 1 while i <= #buffer do local event = buffer[i] local sent = ws:send(event) if sent then table.remove(buffer, i) log_debug("Sent buffered event") else i = i + 1 end end buffer.save() end function buffer.clear() buffer = {} os.execute("rm -f " .. config.buffer_file) end -- ============================================================================ -- WEBSOCKET CLIENT (Simple implementation) -- ============================================================================ local ws = { sock = nil, connected = false, } function ws.connect(url) local sock = require("socket").tcp() sock:settimeout(10) -- Parse URL local protocol, host, path = url:match("^(wss?)://([^/]+)(.*)") if not host then log_err("Invalid URL: " .. url) return nil end -- Connect local ok, err = sock:connect(host, 443) if not ok then return nil, "Connection failed: " .. tostring(err) end -- TLS handshake (simplified - use stunnel or openssl for real TLS) -- For now using raw socket - works with stunnel/Proxy ws.sock = sock ws.connected = true return ws end function ws.send(data) if not ws.connected then buffer.add(data) -- Buffer instead of dropping return false end local frame = string.format( "\x81%s%02x%s", string.char(0x80 + #data), #data, data ) local ok, err = ws.sock:send(frame) if not ok then ws.connected = false buffer.add(data) -- Save to buffer on failure return false end return true end function ws.close() if ws.sock then ws.sock:close() ws.sock = nil end ws.connected = false end -- ============================================================================ -- EVENT GENERATORS -- ============================================================================ local function build_event(event_type, payload) return string.format([[{ "router_id": "%s", "hostname": "%s", "event_type": "%s", "timestamp": "%s", "payload": %s }]], config.router_id, get_hostname() or "unknown", event_type, os.date("!%Y-%m-%dT%H:%M:%SZ"), json.encode(payload or {}) ) end local function get_hostname() local f = io.popen("hostname") if not f then return "unknown" end local name = f:read("*a"):gsub("%s+$", "") f:close() return name end -- ============================================================================ -- EVENT LISTENERS -- ============================================================================ -- DHCP Lease Events 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 timestamp, mac, ip, hostname = line:match("(%d+) (%S+) (%S+) (%S+)") if mac then leases[mac] = { ip = ip, hostname = hostname, time = tonumber(timestamp) } -- New lease? if not old_leases[mac] then local event = build_event("dhcp_lease", { mac = mac, ip = ip, hostname = hostname, action = "new" }) log_info("New DHCP: " .. mac .. " -> " .. ip) buffer.add(event) end end end old_leases = leases f:close() end socket.sleep(5) end end -- Interface Events (via ubus) function listen_interfaces() local proc = io.popen("ubus -m listen network.interface 2>/dev/null") if not proc then return end for line in proc:lines() do if line then local event_type = line:match('"action":"([^"]+)') local device = line:match('"interface":"([^"]+)') if event_type and device then local event = build_event("interface_" .. event_type, { device = device, action = event_type }) log_info("Interface " .. event_type .. ": " .. device) buffer.add(event) end end end proc:close() end -- ============================================================================ -- MAIN LOOP WITH RECONNECT -- ============================================================================ local function main() -- Load config local uci_cursor = require("luci.model.uci").cursor() config.server_url = uci_cursor:get("event-forwarder", "server", "url") or config.server_url config.server_token = uci_cursor:get("event-forwarder", "server", "token") or config.server_token config.router_id = uci_cursor:get("event-forwarder", "router", "id") or get_hostname() log_info("Starting event forwarder...") log_info("Router ID: " .. config.router_id) log_info("Server: " .. config.server_url) -- Load buffered events buffer.load() -- Write PID local pidf = io.open(config.pid_file, "w") if pidf then pidf:write(tostring(os.getpid())) pidf:close() end -- Initial connect local retries = 0 while true do -- Try to connect log_info("Connecting to server...") local ws, err = ws.connect(config.server_url) if ws and ws.connected then log_info("Connected!") retries = 0 -- Flush buffer on connect buffer.flush(ws) -- Main event loop - in real impl, would use select() for both sockets -- For simplicity, just send buffered events periodically while ws.connected do socket.sleep(config.ping_interval) -- Send ping / keepalive if ws.connected then buffer.flush(ws) end end else log_err("Connection failed: " .. tostring(err)) retries = retries + 1 if retries >= config.max_retries then log_err("Max retries reached, resetting") retries = 0 end end -- Wait before reconnect ws.close() log_info("Reconnecting in " .. config.reconnect_delay .. "s...") socket.sleep(config.reconnect_delay) end end -- Run main()