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