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