Selaa lähdekoodia

Refactor to use Lua coroutines for concurrent monitoring

- co_dhcp(): Monitors DHCP leases
- co_wan(): Monitors WAN link and IP
- co_commands(): Command listener (placeholder)
- Scheduler dispatches all coroutines
- Lightweight, no threads, no extra deps
Luis Rosales 2 kuukautta sitten
vanhempi
sitoutus
e3bcf1a35a
1 muutettua tiedostoa jossa 179 lisäystä ja 249 poistoa
  1. 179 249
      package/src/client2server-unified.lua

+ 179 - 249
package/src/client2server-unified.lua

@@ -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()