Parcourir la source

Add client2server-unified.lua - All-in-one combined script

Combines client2server + wan-watcher features:
- WebSocket with auto-reconnect
- Local buffer (store-and-forward)
- DHCP lease events (new/expire)
- WAN link state monitoring
- DHCP IP changes (new ISP detection)

Benefits:
- Single process (saves resources)
- Shared buffer
- One init script
- ~15KB total
Luis Rosales il y a 2 mois
Parent
commit
ef1734af03
1 fichiers modifiés avec 410 ajouts et 0 suppressions
  1. 410 0
      usr/sbin/client2server-unified.lua

+ 410 - 0
usr/sbin/client2server-unified.lua

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