|
|
@@ -0,0 +1,329 @@
|
|
|
+--[[
|
|
|
+ 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()
|