| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329 |
- --[[
- 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()
|