|
|
@@ -2,7 +2,7 @@
|
|
|
client2server-unified.lua - Bidirectional event forwarder for OpenWrt
|
|
|
Copyright (c) 2026 Luis Rosales - MIT License
|
|
|
|
|
|
- Uses coroutines for concurrent event monitoring
|
|
|
+ Uses coroutines for concurrent event monitoring + command execution
|
|
|
]]
|
|
|
|
|
|
-- ============================================================================
|
|
|
@@ -29,7 +29,6 @@ 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
|
|
|
@@ -69,6 +68,13 @@ local function json_encode(t)
|
|
|
return "{" .. table.concat(parts, ",") .. "}"
|
|
|
end
|
|
|
|
|
|
+local function json_decode(str)
|
|
|
+ local result = {}
|
|
|
+ for k, v in str:gmatch('"([^"]+)":%s*"([^"]*)"') do result[k] = v end
|
|
|
+ for k, v in str:gmatch('"([^"]+)":%s*(%d+)') do result[k] = tonumber(v) end
|
|
|
+ return result
|
|
|
+end
|
|
|
+
|
|
|
-- ============================================================================
|
|
|
-- BUFFER
|
|
|
-- ============================================================================
|
|
|
@@ -91,32 +97,25 @@ function buffer.save()
|
|
|
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
|
|
|
+ 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)
|
|
|
- if #buffer.events > cfg.max_buffer then
|
|
|
- table.remove(buffer.events, 1)
|
|
|
- end
|
|
|
+ if #buffer.events > cfg.max_buffer then table.remove(buffer.events, 1) end
|
|
|
buffer.dirty = true
|
|
|
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
|
|
|
if send_fn(buffer.events[i]) then
|
|
|
table.remove(buffer.events, i)
|
|
|
buffer.dirty = true
|
|
|
- else
|
|
|
- i = i + 1
|
|
|
- end
|
|
|
+ else i = i + 1 end
|
|
|
end
|
|
|
buffer.save()
|
|
|
end
|
|
|
@@ -135,26 +134,39 @@ function ws.connect(url)
|
|
|
local sock = require("socket").tcp()
|
|
|
sock:settimeout(10)
|
|
|
|
|
|
- local ok = pcall(sock.connect, sock, host, tonumber(port:sub(2)) or 443)
|
|
|
- if not ok then return nil end
|
|
|
-
|
|
|
+ if not pcall(sock.connect, sock, host, tonumber(port:sub(2)) then return nil end
|
|
|
ws.sock = sock
|
|
|
ws.connected = true
|
|
|
return sock
|
|
|
end
|
|
|
|
|
|
function ws.send(data)
|
|
|
- if not ws.connected then
|
|
|
+ if not ws.connected then buffer.add(data); return false end
|
|
|
+ local frame = string.format("\x81\x80%s", data)
|
|
|
+ if not pcall(function() ws.sock:send(frame) end) then
|
|
|
+ ws.connected = false
|
|
|
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
|
|
|
+ return true
|
|
|
+end
|
|
|
+
|
|
|
+-- Read from websocket (non-blocking-ish)
|
|
|
+function ws.recv()
|
|
|
+ if not ws.connected then return nil end
|
|
|
+
|
|
|
+ -- Set non-blocking
|
|
|
+ ws.sock:settimeout(0.1)
|
|
|
+
|
|
|
+ local data, err = ws.sock:receive("*l")
|
|
|
+ ws.sock:settimeout(10)
|
|
|
+
|
|
|
+ if err and err ~= "timeout" then
|
|
|
+ ws.connected = false
|
|
|
+ return nil
|
|
|
end
|
|
|
- ws.connected = false
|
|
|
- buffer.add(data)
|
|
|
- return false
|
|
|
+
|
|
|
+ return data
|
|
|
end
|
|
|
|
|
|
function ws.close()
|
|
|
@@ -163,11 +175,75 @@ function ws.close()
|
|
|
ws.connected = false
|
|
|
end
|
|
|
|
|
|
+-- ============================================================================
|
|
|
+-- COMMAND EXECUTOR
|
|
|
+-- ============================================================================
|
|
|
+
|
|
|
+function execute_command(cmd_obj)
|
|
|
+ local cmd = cmd_obj.command or ""
|
|
|
+ local args = cmd_obj.args or {}
|
|
|
+
|
|
|
+ log_info("Executing: " .. cmd)
|
|
|
+
|
|
|
+ local result = { success = false, output = "", error = "" }
|
|
|
+
|
|
|
+ if cmd == "uci_set" then
|
|
|
+ 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
|
|
|
+ os.execute("uci commit " .. config)
|
|
|
+ result.success = true
|
|
|
+ log_info("UCI set: " .. config .. "." .. section .. "." .. option .. " = " .. value)
|
|
|
+ else
|
|
|
+ result.error = "Missing params"
|
|
|
+ end
|
|
|
+
|
|
|
+ elseif cmd == "shell" then
|
|
|
+ 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"
|
|
|
+ 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
|
|
|
+ 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: " .. cmd
|
|
|
+ end
|
|
|
+
|
|
|
+ return result
|
|
|
+end
|
|
|
+
|
|
|
-- ============================================================================
|
|
|
-- COROUTINES
|
|
|
-- ============================================================================
|
|
|
|
|
|
--- Coroutine: Monitor DHCP leases
|
|
|
+-- Monitor DHCP
|
|
|
local function co_dhcp()
|
|
|
local leases = {}
|
|
|
|
|
|
@@ -178,13 +254,10 @@ local function co_dhcp()
|
|
|
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 }
|
|
|
-
|
|
|
+ current[mac] = { ip = ip, hostname = name }
|
|
|
if not leases[mac] then
|
|
|
- -- New lease
|
|
|
local ev = json_encode({
|
|
|
- router_id = cfg.router_id,
|
|
|
- hostname = cfg.router_id,
|
|
|
+ router_id = cfg.router_id, hostname = cfg.router_id,
|
|
|
event_type = "dhcp_lease_new",
|
|
|
payload = { mac = mac, ip = ip, hostname = name }
|
|
|
})
|
|
|
@@ -195,51 +268,40 @@ local function co_dhcp()
|
|
|
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,
|
|
|
+ router_id = cfg.router_id, hostname = cfg.router_id,
|
|
|
event_type = "dhcp_lease_expire",
|
|
|
- payload = { mac = mac, old_ip = exp.ip }
|
|
|
+ payload = { mac = mac, old_ip = leases[mac].ip }
|
|
|
})
|
|
|
ws.send(ev)
|
|
|
- log_info("DHCP expired: " .. mac)
|
|
|
+ log_info("DHCP expire: " .. mac)
|
|
|
end
|
|
|
end
|
|
|
-
|
|
|
leases = current
|
|
|
end
|
|
|
-
|
|
|
coroutine.yield(cfg.check_interval)
|
|
|
end
|
|
|
end
|
|
|
|
|
|
--- Coroutine: Monitor WAN state
|
|
|
+-- Monitor WAN
|
|
|
local function co_wan()
|
|
|
- local link_last = nil
|
|
|
- local ip_last = nil
|
|
|
+ local link_last, ip_last = nil, nil
|
|
|
|
|
|
- -- 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 f then link_last = (f:read("*a") or "":find("^1") == 1; f:close() end
|
|
|
|
|
|
while true do
|
|
|
- -- Check physical link
|
|
|
+ -- 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
|
|
|
+ local link_now = f and ((f:read("*a") or ""):find("^1") == 1) or false
|
|
|
if f then f:close() end
|
|
|
|
|
|
if link_now ~= link_last then
|
|
|
link_last = link_now
|
|
|
local ev = json_encode({
|
|
|
- router_id = cfg.router_id,
|
|
|
- hostname = cfg.router_id,
|
|
|
+ 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 }
|
|
|
})
|
|
|
@@ -247,97 +309,111 @@ local function co_wan()
|
|
|
log_info("WAN link: " .. (link_now and "up" or "down"))
|
|
|
end
|
|
|
|
|
|
- -- Check DHCP IP
|
|
|
+ -- 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")
|
|
|
+ local st = f:read("*a")
|
|
|
f:close()
|
|
|
- ip_now = status and status:match('"address"%s*:%s*"([^"]+)"')
|
|
|
+ ip_now = st and st:match('"address"%s*:%s*"([^"]+)"')
|
|
|
end
|
|
|
|
|
|
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 }
|
|
|
+ router_id = cfg.router_id, hostname = cfg.router_id,
|
|
|
+ event_type = ip_last and "wan_dhcp_changed" or "wan_dhcp_new",
|
|
|
+ payload = { old_ip = ip_last, new_ip = ip_now }
|
|
|
})
|
|
|
ws.send(ev)
|
|
|
- log_info("WAN IP: " .. (old_ip or "none") .. " -> " .. ip_now)
|
|
|
+ log_info("WAN IP: " .. (ip_last or "none") .. " -> " .. ip_now)
|
|
|
+ ip_last = ip_now
|
|
|
end
|
|
|
|
|
|
coroutine.yield(cfg.check_interval)
|
|
|
end
|
|
|
end
|
|
|
|
|
|
--- Coroutine: Monitor commands from server
|
|
|
+-- 🔥 COROUTINE: Command Listener (handles server → router commands)
|
|
|
local function co_commands()
|
|
|
while true do
|
|
|
- -- This would typically be event-driven from WebSocket
|
|
|
- -- For now, simple sleep
|
|
|
- coroutine.yield(cfg.check_interval)
|
|
|
+ if ws.connected then
|
|
|
+ -- Check for incoming data
|
|
|
+ local data = ws.recv()
|
|
|
+
|
|
|
+ if data and data:match("^{") then
|
|
|
+ log_info("Received command: " .. data:sub(1, 100))
|
|
|
+
|
|
|
+ -- Parse JSON command
|
|
|
+ local cmd_obj = json_decode(data)
|
|
|
+
|
|
|
+ if cmd_obj.command then
|
|
|
+ -- Execute command
|
|
|
+ local result = execute_command(cmd_obj)
|
|
|
+
|
|
|
+ -- Send result back
|
|
|
+ local resp = json_encode({
|
|
|
+ router_id = cfg.router_id,
|
|
|
+ event_type = "command_result",
|
|
|
+ payload = {
|
|
|
+ command = cmd_obj.command,
|
|
|
+ success = result.success,
|
|
|
+ output = result.output,
|
|
|
+ error = result.error
|
|
|
+ }
|
|
|
+ })
|
|
|
+ ws.send(resp)
|
|
|
+
|
|
|
+ log_info("Command done: " .. cmd_obj.command .. " = " .. (result.success and "OK" or result.error))
|
|
|
+ end
|
|
|
+ end
|
|
|
+ end
|
|
|
+
|
|
|
+ coroutine.yield(1) -- Check every second for commands
|
|
|
end
|
|
|
end
|
|
|
|
|
|
-- ============================================================================
|
|
|
--- MAIN (Coroutine Scheduler)
|
|
|
+-- MAIN
|
|
|
-- ============================================================================
|
|
|
|
|
|
local function scheduler()
|
|
|
- -- Create coroutines
|
|
|
- local co_list = {
|
|
|
- coroutine.create(co_dhcp),
|
|
|
- coroutine.create(co_wan),
|
|
|
- -- co_commands, -- Uncomment when command receiver implemented
|
|
|
+ local cos = {
|
|
|
+ co_dhcp = coroutine.create(co_dhcp),
|
|
|
+ co_wan = coroutine.create(co_wan),
|
|
|
+ co_commands = coroutine.create(co_commands), -- 🔥 Commands run concurrently!
|
|
|
}
|
|
|
|
|
|
- local function dispatch()
|
|
|
- for _, co in ipairs(co_list) do
|
|
|
+ while true do
|
|
|
+ -- Resume all coroutines
|
|
|
+ for name, co in pairs(cos) do
|
|
|
if coroutine.status(co) == "suspended" then
|
|
|
local _, delay = coroutine.resume(co)
|
|
|
- delay = delay or cfg.check_interval
|
|
|
+ -- Small stagger to avoid spikes
|
|
|
+ if delay then os.execute("sleep 0.1") end
|
|
|
end
|
|
|
end
|
|
|
- end
|
|
|
-
|
|
|
- -- Simple scheduler: iterate and resume each
|
|
|
- while true do
|
|
|
- dispatch()
|
|
|
|
|
|
- -- Also flush buffer periodically
|
|
|
+ -- Flush buffer when connected
|
|
|
if ws.connected then
|
|
|
buffer.flush(function(d) return ws.send(d) end)
|
|
|
end
|
|
|
|
|
|
- -- Sleep before next round
|
|
|
- os.execute("sleep " .. cfg.check_interval)
|
|
|
+ os.execute("sleep 1")
|
|
|
end
|
|
|
end
|
|
|
|
|
|
--- ============================================================================
|
|
|
--- INITIALIZATION
|
|
|
--- ============================================================================
|
|
|
-
|
|
|
local function main()
|
|
|
log_info("Starting client2server...")
|
|
|
log_info("Router: " .. cfg.router_id)
|
|
|
log_info("Server: " .. cfg.server_url)
|
|
|
- log_info("Check interval: " .. cfg.check_interval .. "s")
|
|
|
|
|
|
buffer.init()
|
|
|
|
|
|
local pf = io.open(cfg.pid_file, "w")
|
|
|
- if pf then
|
|
|
- pf:write(tostring(os.getpid()))
|
|
|
- pf:close()
|
|
|
- end
|
|
|
+ if pf then pf:write(tostring(os.getpid())); pf:close() end
|
|
|
|
|
|
- -- Connect in main
|
|
|
- log_info("Connecting to " .. cfg.server_url .. "...")
|
|
|
+ -- Connect
|
|
|
+ log_info("Connecting...")
|
|
|
local sock = ws.connect(cfg.server_url)
|
|
|
|
|
|
if sock then
|
|
|
@@ -345,10 +421,9 @@ local function main()
|
|
|
log_info("Connected!")
|
|
|
buffer.flush(function(d) return ws.send(d) end)
|
|
|
else
|
|
|
- log_err("Connection failed, will retry in loop")
|
|
|
+ log_err("Connection failed")
|
|
|
end
|
|
|
|
|
|
- -- Start scheduler (runs coroutines)
|
|
|
scheduler()
|
|
|
end
|
|
|
|