--[[ client2server-unified.lua - All-in-one event forwarder for OpenWrt Copyright (c) 2026 Luis Rosales - MIT License Combines: - client2server: DHCP/WiFi events → WebSocket - wan-watcher: WAN link + DHCP monitoring Features: - WebSocket connection with auto-reconnect - Local buffer (store-and-forward while offline) - DHCP lease events - WAN link state monitoring - DHCP lease changes (new ISP detection) Size: ~15KB (shared codebase, no duplication) ]] -- ============================================================================ -- CONFIG -- ============================================================================ local cfg = { -- Server server_url = os.getenv("SERVER_URL") or "wss://your-server.com/ws", server_token = os.getenv("SERVER_TOKEN") or "secret-token", router_id = os.getenv("ROUTER_ID") or "", -- Connection reconnect_delay = 5, ping_interval = 30, max_retries = 10, -- Monitoring check_interval = 5, wan_interface = "wan", wan_device = "eth0", -- Files buffer_file = "/tmp/event_buffer", status_file = "/var/run/client2server.status", pid_file = "/var/run/client2server.pid", max_buffer = 100, } -- Load from UCI if available pcall(function() local uci = require("luci.model.uci").cursor() cfg.server_url = uci:get("client2server", "server", "url") or cfg.server_url cfg.server_token = uci:get("client2server", "server", "token") or cfg.server_token cfg.router_id = uci:get("client2server", "router", "id") or cfg.router_id cfg.wan_interface = uci:get("client2server", "wan", "interface") or cfg.wan_interface cfg.wan_device = uci:get("client2server", "wan", "device") or cfg.wan_device end) -- Set router_id from hostname if not set if cfg.router_id == "" then local f = io.popen("hostname") cfg.router_id = f and (f:read("*a") or ""):gsub("%s+$", "") or "unknown" if f then f:close() end end -- ============================================================================ -- LOGGING -- ============================================================================ local function log(level, msg) os.execute(string.format('logger -t "client2server" -p user.%s "%s"', level, msg:gsub('"', '\\"'))) end local function log_info(msg) log("info", msg) end local function log_err(msg) log("err", msg) end -- ============================================================================ -- JSON (Minimal implementation) -- ============================================================================ 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))) elseif type(v) == "table" then -- Nested object table.insert(parts, string.format('"%s": %s', k, json_encode(v))) end end return "{" .. table.concat(parts, ",") .. "}" end -- ============================================================================ -- BUFFER (Offline Support) -- ============================================================================ local buffer = { events = {} } 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 #buffer.events == 0 then os.execute("rm -f " .. cfg.buffer_file) 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() end function buffer.add(json_event) table.insert(buffer.events, json_event) while #buffer.events > cfg.max_buffer do table.remove(buffer.events, 1) end 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) else i = i + 1 end end buffer.save() log_info("Flush complete, " .. #buffer.events .. " remaining") end -- ============================================================================ -- WEBSOCKET (Simplified) -- ============================================================================ local ws = { sock = nil, connected = false } function ws.send(data) if not ws.connected then buffer.add(data) return false end -- Simple frame construction local frame = string.format("\x81\x80%s", data) local success, err = pcall(function() ws.sock:send(frame) end) if not success then ws.connected = false buffer.add(data) return false end return true end function ws.connect(url) -- Extract host from wss://... URL local host = url:match("wss?://([^/]+)") if not host then return nil end local sock = require("socket").tcp() sock:settimeout(10) local ok, err = pcall(sock.connect, sock, host, 443) if not ok then return nil end ws.sock = sock ws.connected = true return sock end function ws.close() if ws.sock then pcall(ws.sock.close, ws.sock) ws.sock = nil end 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 = cfg.router_id, event_type = event_type, payload = payload, }) end -- ============================================================================ -- DATA SOURCES -- ============================================================================ -- DHCP Leases local dhcp_leases = {} function check_dhcp() local f = io.open("/var/lib/dnsmasq/dnsmasq.leases", "r") if not f then return nil end local current = {} for line in f:lines() do local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)") if mac then current[mac] = { ip = ip, hostname = name, time = ts } if not dhcp_leases[mac] then -- NEW lease log_info("DHCP new: " .. mac .. " -> " .. ip) return { event = "dhcp_lease_new", mac = mac, ip = ip, hostname = name, } end end end f:close() -- Check for expired leases for mac in pairs(dhcp_leases) do if not current[mac] then local expired = dhcp_leases[mac] log_info("DHCP expire: " .. mac) dhcp_leases[mac] = nil return { event = "dhcp_lease_expire", mac = mac, old_ip = expired.ip, } end end dhcp_leases = current return nil end -- WAN Link State local link_last = nil local ip_last = nil function check_wan() -- Physical link local f = io.open("/sys/class/net/" .. cfg.wan_device .. "/carrier", "r") local link_now = f and (f:read("*a") or ""):find("^1") or false if f then f:close() end -- Link change if link_now ~= link_last then link_last = link_now return { event = link_now and "wan_link_up" or "wan_link_down", device = cfg.wan_device, message = link_now and "Physical link detected" or "Physical link lost", } end -- Check DHCP IP via ubus local info = nil f = io.popen("ubus call network.interface." .. cfg.wan_interface .. " status 2>/dev/null") if f then local status = f:read("*a") f:close() if status then local ip = status:match('"address"%s*:%s*"([^"]+)"') if ip then info = { ip = ip } end end end -- IP change (connected to new network) if info and info.ip and info.ip ~= ip_last then local old_ip = ip_last ip_last = info.ip return { event = old_ip and "wan_dhcp_changed" or "wan_dhcp_new", device = cfg.wan_interface, old_ip = old_ip, new_ip = info.ip, message = old_ip and ("IP changed: " .. old_ip .. " -> " .. info.ip) or ("New IP: " .. info.ip), } end return nil end -- ============================================================================ -- MAIN LOOP -- ============================================================================ local function main() log_info("Starting client2server-unified...") log_info("Router: " .. cfg.router_id) log_info("Server: " .. cfg.server_url) log_info("WAN: " .. cfg.wan_interface .. " (" .. cfg.wan_device .. ")") -- Load buffer buffer.load() -- Save PID local pf = io.open(cfg.pid_file, "w") if pf then pf:write(tostring(os.getpid())) pf:close() end -- Initial WAN state local f = io.open("/sys/class/net/" .. cfg.wan_device .. "/carrier", "r") link_last = f and (f:read("*a") or ""):find("^1") or false if f then f:close() end -- Main loop with periodic checks local sock = nil local retries = 0 local check_counter = 0 while true do -- Attempt connection if not connected if not sock or not ws.connected then log_info("Connecting...") sock = ws.connect(cfg.server_url) if sock then log_info("Connected!") retries = 0 buffer.flush(function(d) return ws.send(d) end) else retries = retries + 1 if retries >= cfg.max_retries then log_err("Max retries, resetting") retries = 0 end end end socket.sleep(cfg.check_interval) check_counter = check_counter + 1 -- Check every cycle local events = {} -- 1. DHCP local ev = check_dhcp() if ev then table.insert(events, build_event(ev.event, { device = "dhcp", mac = ev.mac, ip = ev.ip, hostname = ev.hostname, old_ip = ev.old_ip, }) end -- 2. WAN (every cycle) ev = check_wan() if ev then table.insert(events, build_event(ev.event, { device = ev.device, old_ip = ev.old_ip, new_ip = ev.new_ip, }) end -- Send buffered + current events for _, event_json in ipairs(events) do log_info("Event: " .. event_json) ws.send(event_json) end -- Also flush any pending buffered if ws.connected then buffer.flush(function(d) return ws.send(d) end) end end end main()