--[[ client2server-unified.lua - Bidirectional event forwarder for OpenWrt Copyright (c) 2026 Luis Rosales - MIT License Features (bidirectional): - WebSocket connection with auto-reconnect - Local buffer (store-and-forward while offline) - DHCP lease events - WAN link state monitoring - Command listener (receive settings from server) - Executes commands: UCI set, shell commands Port: 3843 (for listening for commands) ]] -- ============================================================================ -- CONFIG -- ============================================================================ local cfg = { server_url = os.getenv("SERVER_URL") or "wss://your-server.com:3843", server_token = os.getenv("SERVER_TOKEN") or "secret-token", router_id = os.getenv("ROUTER_ID") or "", reconnect_delay = 5, ping_interval = 30, max_retries = 10, check_interval = 5, wan_interface = "wan", wan_device = "eth0", buffer_file = "/tmp/event_buffer", pid_file = "/var/run/client2server.pid", max_buffer = 100, -- Command server cmd_port = 3843, } -- Load from UCI 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) 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 -- ============================================================================ 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 table.insert(parts, string.format('"%s": %s', k, json_encode(v))) end end return "{" .. table.concat(parts, ",") .. "}" end local function json_decode(str) local result = {} for key, value in str:gmatch('"([^"]+)":%s*"([^"]*)"') do result[key] = value end for key, value in str:gmatch('"([^"]+)":%s*(%d+)') do result[key] = tonumber(value) end return result end -- ============================================================================ -- BUFFER -- ============================================================================ 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() end -- ============================================================================ -- WEBSOCKET -- ============================================================================ local ws = { sock = nil, connected = false } function ws.send(data) if not ws.connected then buffer.add(data) return false end 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) local host = url:match("wss?://([^:/]+)") local port = url:match(":%d+") or ":443" port = tonumber(port:sub(2)) or 443 if not host then return nil end local sock = require("socket").tcp() sock:settimeout(10) local ok, err = pcall(sock.connect, sock, host, port) 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) end ws.sock = nil ws.connected = false end -- ============================================================================ -- COMMAND EXECUTOR -- ============================================================================ function execute_command(cmd_obj) local cmd = cmd_obj.command local args = cmd_obj.args or {} log_info("Executing command: " .. cmd) local result = { success = false, output = "", error = "" } if cmd == "uci_set" then -- uci set network.lan.ipaddr='192.168.1.1' local config = args.config local section = args.section local option = args.option local value = args.value if config and section and option and value then local c = string.format("uci set %s.%s.%s='%s'", config, section, option, value) local f = io.popen(c) result.output = f and f:read("*a") or "" if f then f:close() end -- Commit os.execute("uci commit " .. config) result.success = true else result.error = "Missing params" end elseif cmd == "shell" then -- Arbitrary shell command local shell_cmd = args.command if shell_cmd then local f = io.popen(shell_cmd) result.output = f and f:read("*a") or "" if f then f:close() end result.success = true else result.error = "No command provided" end elseif cmd == "reboot" then os.execute("sync && reboot &") result.success = true result.output = "Reboot scheduled" elseif cmd == "wifi_restart" then os.execute("/etc/init.d/network restart") os.execute("/etc/init.d/wireless restart") result.success = true elseif cmd == "status" then -- Return router status local f = io.popen("ubus call network getStatus") result.output = f and f:read("*a") or "{}" if f then f:close() end result.success = true else result.error = "Unknown command: " .. cmd end return result end -- ============================================================================ -- HTTP COMMAND SERVER (Port 3843) -- ============================================================================ local function start_cmd_server() -- Fork a simple HTTP server for commands -- Uses Lua's built-in socket or spawns netcat listener -- Actually, commands come through WebSocket from server -- This port is for direct HTTP commands if WebSocket fails log_info("Command server ready on port " .. cfg.cmd_port) end -- Handle incoming HTTP command (fallback) function handle_http_cmd(request) -- Parse: GET /cmd?command=uci_set&args[config]=network&args[section]=lan&... -- Or POST with JSON body local cmd_json = request:match('({.+})') if cmd_json then local cmd_obj = json_decode(cmd_json) local result = execute_command(cmd_obj) return json_encode(result) end return json_encode({ error = "Invalid request" }) 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 -- ============================================================================ local dhcp_leases = {} local link_last = nil local ip_last = nil 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 return { event = "dhcp_lease_new", mac = mac, ip = ip, hostname = name } end end end f:close() for mac in pairs(dhcp_leases) do if not current[mac] then local expired = dhcp_leases[mac] dhcp_leases[mac] = nil return { event = "dhcp_lease_expire", mac = mac, old_ip = expired.ip } end end dhcp_leases = current return nil end function check_wan() 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 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 } end f = io.popen("ubus call network.interface." .. cfg.wan_interface .. " status 2>/dev/null") local info, ip_now = nil, nil if f then local status = f:read("*a") f:close() ip_now = status and status:match('"address"%s*:%s*"([^"]+)"') if ip_now then info = { ip = ip_now } end end if info and ip_now and ip_now ~= ip_last then local old_ip = ip_last ip_last = ip_now return { event = old_ip and "wan_dhcp_changed" or "wan_dhcp_new", old_ip = old_ip, new_ip = ip_now } end return nil end -- ============================================================================ -- MAIN LOOP -- ============================================================================ local function main() log_info("Starting client2server (bidirectional)...") log_info("Router: " .. cfg.router_id) log_info("Server: " .. cfg.server_url) log_info("Command port: " .. cfg.cmd_port) buffer.load() local pf = io.open(cfg.pid_file, "w") if pf then pf:write(tostring(os.getpid())); pf:close() end 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 local sock = nil local retries = 0 while true do if not sock or not ws.connected then log_info("Connecting to " .. cfg.server_url .. "...") 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 end end require("socket").sleep(cfg.check_interval) local events = {} local ev = check_dhcp() if ev then table.insert(events, build_event(ev.event, { device = "dhcp", mac = ev.mac, ip = ev.ip })) end 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 for _, event_json in ipairs(events) do ws.send(event_json) end if ws.connected then buffer.flush(function(d) return ws.send(d) end) end end end main()