|
|
@@ -2,15 +2,7 @@
|
|
|
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)
|
|
|
+ Uses coroutines for concurrent event monitoring
|
|
|
]]
|
|
|
|
|
|
-- ============================================================================
|
|
|
@@ -21,24 +13,15 @@ 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
|
|
|
+-- Load UCI config
|
|
|
pcall(function()
|
|
|
local uci = require("luci.model.uci").cursor()
|
|
|
cfg.server_url = uci:get("client2server", "server", "url") or cfg.server_url
|
|
|
@@ -46,6 +29,7 @@ pcall(function()
|
|
|
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
|
|
|
+ cfg.check_interval = tonumber(uci:get("client2server", "general", "check_interval")) or cfg.check_interval
|
|
|
end)
|
|
|
|
|
|
if cfg.router_id == "" then
|
|
|
@@ -85,54 +69,41 @@ local function json_encode(t)
|
|
|
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 = {} }
|
|
|
+local buffer = { events = {}, dirty = false }
|
|
|
|
|
|
-function buffer.load()
|
|
|
+function buffer.init()
|
|
|
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)
|
|
|
+ if f then
|
|
|
+ for line in f:lines() do
|
|
|
+ if line and line ~= "" then
|
|
|
+ table.insert(buffer.events, line)
|
|
|
+ end
|
|
|
end
|
|
|
+ f:close()
|
|
|
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
|
|
|
+ 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)
|
|
|
- while #buffer.events > cfg.max_buffer do
|
|
|
+ if #buffer.events > cfg.max_buffer then
|
|
|
table.remove(buffer.events, 1)
|
|
|
end
|
|
|
- buffer.save()
|
|
|
+ buffer.dirty = true
|
|
|
end
|
|
|
|
|
|
function buffer.flush(send_fn)
|
|
|
@@ -140,9 +111,9 @@ function buffer.flush(send_fn)
|
|
|
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
|
|
|
+ if send_fn(buffer.events[i]) then
|
|
|
table.remove(buffer.events, i)
|
|
|
+ buffer.dirty = true
|
|
|
else
|
|
|
i = i + 1
|
|
|
end
|
|
|
@@ -156,31 +127,15 @@ end
|
|
|
|
|
|
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)
|
|
|
+ local ok = pcall(sock.connect, sock, host, tonumber(port:sub(2)) or 443)
|
|
|
if not ok then return nil end
|
|
|
|
|
|
ws.sock = sock
|
|
|
@@ -188,6 +143,20 @@ function ws.connect(url)
|
|
|
return sock
|
|
|
end
|
|
|
|
|
|
+function ws.send(data)
|
|
|
+ if not ws.connected then
|
|
|
+ buffer.add(data)
|
|
|
+ return false
|
|
|
+ end
|
|
|
+ local frame = string.format("\x81\x80%s", data)
|
|
|
+ if pcall(function() ws.sock:send(frame) end) then
|
|
|
+ return true
|
|
|
+ end
|
|
|
+ ws.connected = false
|
|
|
+ buffer.add(data)
|
|
|
+ return false
|
|
|
+end
|
|
|
+
|
|
|
function ws.close()
|
|
|
if ws.sock then pcall(ws.sock.close, ws.sock) end
|
|
|
ws.sock = nil
|
|
|
@@ -195,231 +164,192 @@ function ws.close()
|
|
|
end
|
|
|
|
|
|
-- ============================================================================
|
|
|
--- COMMAND EXECUTOR
|
|
|
+-- COROUTINES
|
|
|
-- ============================================================================
|
|
|
|
|
|
-function execute_command(cmd_obj)
|
|
|
- local cmd = cmd_obj.command
|
|
|
- local args = cmd_obj.args or {}
|
|
|
+-- Coroutine: Monitor DHCP leases
|
|
|
+local function co_dhcp()
|
|
|
+ local leases = {}
|
|
|
|
|
|
- log_info("Executing command: " .. cmd)
|
|
|
+ while true do
|
|
|
+ local f = io.open("/var/lib/dnsmasq/dnsmasq.leases", "r")
|
|
|
+ if f then
|
|
|
+ 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 leases[mac] then
|
|
|
+ -- New lease
|
|
|
+ local ev = json_encode({
|
|
|
+ router_id = cfg.router_id,
|
|
|
+ hostname = cfg.router_id,
|
|
|
+ event_type = "dhcp_lease_new",
|
|
|
+ payload = { mac = mac, ip = ip, hostname = name }
|
|
|
+ })
|
|
|
+ ws.send(ev)
|
|
|
+ log_info("DHCP: " .. mac .. " -> " .. ip)
|
|
|
+ end
|
|
|
+ end
|
|
|
+ end
|
|
|
+ f:close()
|
|
|
+
|
|
|
+ -- Check for expired
|
|
|
+ for mac in pairs(leases) do
|
|
|
+ if not current[mac] then
|
|
|
+ local exp = leases[mac]
|
|
|
+ local ev = json_encode({
|
|
|
+ router_id = cfg.router_id,
|
|
|
+ hostname = cfg.router_id,
|
|
|
+ event_type = "dhcp_lease_expire",
|
|
|
+ payload = { mac = mac, old_ip = exp.ip }
|
|
|
+ })
|
|
|
+ ws.send(ev)
|
|
|
+ log_info("DHCP expired: " .. mac)
|
|
|
+ end
|
|
|
+ end
|
|
|
+
|
|
|
+ leases = current
|
|
|
+ end
|
|
|
+
|
|
|
+ coroutine.yield(cfg.check_interval)
|
|
|
+ end
|
|
|
+end
|
|
|
+
|
|
|
+-- Coroutine: Monitor WAN state
|
|
|
+local function co_wan()
|
|
|
+ local link_last = nil
|
|
|
+ local ip_last = nil
|
|
|
|
|
|
- local result = { success = false, output = "", error = "" }
|
|
|
+ -- Initial link state
|
|
|
+ local f = io.open("/sys/class/net/" .. cfg.wan_device .. "/carrier", "r")
|
|
|
+ if f then
|
|
|
+ link_last = (f:read("*a") or ""):find("^1") == 1
|
|
|
+ f:close()
|
|
|
+ end
|
|
|
|
|
|
- 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
|
|
|
+ while true do
|
|
|
+ -- Check physical link
|
|
|
+ f = io.open("/sys/class/net/" .. cfg.wan_device .. "/carrier", "r")
|
|
|
+ local link_now = f and ((f:read("*a") or ""):find("^1") == 1 or false) or false
|
|
|
+ if f then f:close() end
|
|
|
|
|
|
- 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"
|
|
|
+ if link_now ~= link_last then
|
|
|
+ link_last = link_now
|
|
|
+ local ev = json_encode({
|
|
|
+ router_id = cfg.router_id,
|
|
|
+ hostname = cfg.router_id,
|
|
|
+ event_type = link_now and "wan_link_up" or "wan_link_down",
|
|
|
+ payload = { device = cfg.wan_device }
|
|
|
+ })
|
|
|
+ ws.send(ev)
|
|
|
+ log_info("WAN link: " .. (link_now and "up" or "down"))
|
|
|
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"
|
|
|
+ -- Check DHCP IP
|
|
|
+ f = io.popen("ubus call network.interface." .. cfg.wan_interface .. " status 2>/dev/null")
|
|
|
+ local ip_now = nil
|
|
|
+ if f then
|
|
|
+ local status = f:read("*a")
|
|
|
+ f:close()
|
|
|
+ ip_now = status and status:match('"address"%s*:%s*"([^"]+)"')
|
|
|
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
|
|
|
+ if ip_now and ip_now ~= ip_last then
|
|
|
+ local old_ip = ip_last
|
|
|
+ ip_last = ip_now
|
|
|
+
|
|
|
+ local ev = json_encode({
|
|
|
+ router_id = cfg.router_id,
|
|
|
+ hostname = cfg.router_id,
|
|
|
+ event_type = old_ip and "wan_dhcp_changed" or "wan_dhcp_new",
|
|
|
+ payload = { old_ip = old_ip, new_ip = ip_now }
|
|
|
+ })
|
|
|
+ ws.send(ev)
|
|
|
+ log_info("WAN IP: " .. (old_ip or "none") .. " -> " .. ip_now)
|
|
|
+ end
|
|
|
|
|
|
- else
|
|
|
- result.error = "Unknown command: " .. cmd
|
|
|
+ coroutine.yield(cfg.check_interval)
|
|
|
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)
|
|
|
+-- Coroutine: Monitor commands from server
|
|
|
+local function co_commands()
|
|
|
+ while true do
|
|
|
+ -- This would typically be event-driven from WebSocket
|
|
|
+ -- For now, simple sleep
|
|
|
+ coroutine.yield(cfg.check_interval)
|
|
|
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
|
|
|
+-- MAIN (Coroutine Scheduler)
|
|
|
-- ============================================================================
|
|
|
|
|
|
-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 }
|
|
|
+local function scheduler()
|
|
|
+ -- Create coroutines
|
|
|
+ local co_list = {
|
|
|
+ coroutine.create(co_dhcp),
|
|
|
+ coroutine.create(co_wan),
|
|
|
+ -- co_commands, -- Uncomment when command receiver implemented
|
|
|
+ }
|
|
|
+
|
|
|
+ local function dispatch()
|
|
|
+ for _, co in ipairs(co_list) do
|
|
|
+ if coroutine.status(co) == "suspended" then
|
|
|
+ local _, delay = coroutine.resume(co)
|
|
|
+ delay = delay or cfg.check_interval
|
|
|
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 }
|
|
|
+ -- Simple scheduler: iterate and resume each
|
|
|
+ while true do
|
|
|
+ dispatch()
|
|
|
+
|
|
|
+ -- Also flush buffer periodically
|
|
|
+ if ws.connected then
|
|
|
+ buffer.flush(function(d) return ws.send(d) end)
|
|
|
+ end
|
|
|
+
|
|
|
+ -- Sleep before next round
|
|
|
+ os.execute("sleep " .. cfg.check_interval)
|
|
|
end
|
|
|
-
|
|
|
- return nil
|
|
|
end
|
|
|
|
|
|
-- ============================================================================
|
|
|
--- MAIN LOOP
|
|
|
+-- INITIALIZATION
|
|
|
-- ============================================================================
|
|
|
|
|
|
local function main()
|
|
|
- log_info("Starting client2server (bidirectional)...")
|
|
|
+ log_info("Starting client2server...")
|
|
|
log_info("Router: " .. cfg.router_id)
|
|
|
log_info("Server: " .. cfg.server_url)
|
|
|
- log_info("Command port: " .. cfg.cmd_port)
|
|
|
+ log_info("Check interval: " .. cfg.check_interval .. "s")
|
|
|
|
|
|
- buffer.load()
|
|
|
+ buffer.init()
|
|
|
|
|
|
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
|
|
|
+ if pf then
|
|
|
+ pf:write(tostring(os.getpid()))
|
|
|
+ pf:close()
|
|
|
+ end
|
|
|
|
|
|
- local sock = nil
|
|
|
- local retries = 0
|
|
|
+ -- Connect in main
|
|
|
+ log_info("Connecting to " .. cfg.server_url .. "...")
|
|
|
+ local sock = ws.connect(cfg.server_url)
|
|
|
|
|
|
- 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
|
|
|
+ if sock then
|
|
|
+ ws.connected = true
|
|
|
+ log_info("Connected!")
|
|
|
+ buffer.flush(function(d) return ws.send(d) end)
|
|
|
+ else
|
|
|
+ log_err("Connection failed, will retry in loop")
|
|
|
end
|
|
|
+
|
|
|
+ -- Start scheduler (runs coroutines)
|
|
|
+ scheduler()
|
|
|
end
|
|
|
|
|
|
main()
|