Просмотр исходного кода

Add wan-watcher.lua - monitor WAN link state and DHCP changes

- Detects physical link up/down (cable plug/unplug)
- Detects new DHCP lease (connected to new modem/ISP)
- Reports: wan_link_up, wan_link_down, wan_dhcp_renew, wan_dhcp_changed
- Runs as standalone daemon
Luis Rosales 2 месяцев назад
Родитель
Сommit
241b90fe59
1 измененных файлов с 255 добавлено и 0 удалено
  1. 255 0
      usr/sbin/wan-watcher.lua

+ 255 - 0
usr/sbin/wan-watcher.lua

@@ -0,0 +1,255 @@
+--[[
+    wan-watcher.lua - Monitor WAN link state and DHCP changes
+    Copyright (c) 2026 Luis Rosales - MIT License
+    
+    Use case: Detect when router is moved from one modem to another
+    - Physical link flap (cable unplugged/plugged)
+    - New DHCP lease (connected to new ISP)
+    
+    Runs as daemon, reports events to central server
+]]
+
+local socket = require("socket")
+local json = require("json")
+
+-- ============================================================================
+-- CONFIG
+-- ============================================================================
+
+local cfg = {
+    wan_interface = "wan",        -- UCI interface name
+    physical_device = "eth0",    -- Physical interface (e.g., eth0, wan)
+    check_interval = 5,          -- Seconds between checks
+    
+    -- Server (optional - could just buffer like client2server)
+    server_url = os.getenv("SERVER_URL") or "",
+    server_token = os.getenv("SERVER_TOKEN") or "",
+    
+    -- Paths
+    status_file = "/var/run/wan-watcher.status",
+    pid_file = "/var/run/wan-watcher.pid",
+}
+
+-- Load config from UCI
+pcall(function()
+    local uci = require("luci.model.uci").cursor()
+    cfg.wan_interface = uci:get("wan-watcher", "general", "interface") or cfg.wan_interface
+    cfg.physical_device = uci:get("wan-watcher", "general", "device") or cfg.physical_device
+    cfg.server_url = uci:get("wan-watcher", "server", "url") or cfg.server_url
+    cfg.server_token = uci:get("wan-watcher", "server", "token") or cfg.server_token
+end)
+
+-- ============================================================================
+-- UTILITIES
+-- ============================================================================
+
+local function log(msg)
+    os.execute(string.format('logger -t "wan-watcher" -p user.info "%s"', msg:gsub('"', '\\"')))
+end
+
+local function log_err(msg)
+    os.execute(string.format('logger -t "wan-watcher" -p user.err "%s"', msg:gsub('"', '\\"')))
+end
+
+local function get_hostname()
+    local f = io.popen("hostname")
+    local h = f and f:read("*a"):gsub("%s+$", "") or "unknown"
+    if f then f:close() end
+    return h
+end
+
+-- ============================================================================
+-- WAN STATUS CHECKS
+-- ============================================================================
+
+-- Check physical link state
+function check_link(device)
+    local f = io.popen("cat /sys/class/net/" .. device .. "/operstate 2>/dev/null")
+    if not f then return nil end
+    
+    local state = f:read("*a"):gsub("%s+$", "")
+    f:close()
+    
+    return state  -- "up", "down", "unknown", "dormant", etc.
+end
+
+-- Check if interface has carrier
+function has_carrier(device)
+    local f = io.popen("cat /sys/class/net/" .. device .. "/carrier 2>/dev/null")
+    if not f then return false end
+    
+    local carrier = f:read("*a"):gsub("%s+$", "")
+    f:close()
+    
+    return carrier == "1"
+end
+
+-- Get current DHCP lease info
+function get_dhcp_info(interface)
+    local uci = require("luci.model.uci").cursor()
+    local cursor = uci.cursor()
+    
+    -- Get interface data from network config
+    local proto = cursor:get("network", interface, "proto")
+    
+    -- Get IP from ubus
+    local f = io.popen("ubus call network.interface." .. interface .. " status 2>/dev/null")
+    if not f then return nil end
+    
+    local status = f:read("*a")
+    f:close()
+    
+    if not status then return nil end
+    
+    -- Parse JSON manually (without json library)
+    local ip = status:match('"address"%s*:%s*"([^"]+)"')
+    local prefix = status:match('"prefix"%s*:%s*(%d+)')
+    
+    return {
+        ip = ip,
+        prefix = tonumber(prefix),
+        proto = proto,
+    }
+end
+
+-- ============================================================================
+-- REPORTING
+-- ============================================================================
+
+function report_event(event_type, data)
+    local event = {
+        router_id = get_hostname(),
+        event_type = event_type,
+        timestamp = os.date("!%Y-%m-%dT%H:%M:%SZ"),
+        payload = data,
+    }
+    
+    local json_str = json.encode(event)
+    
+    log(event_type .. ": " .. (data.message or ""))
+    
+    -- Send to server if configured
+    if cfg.server_url ~= "" and cfg.server_token ~= "" then
+        local cmd = string.format(
+            'curl -s -X POST "%s" -H "Authorization: Bearer %s" -H "Content-Type: application/json" -d "%s" 2>/dev/null',
+            cfg.server_url,
+            cfg.server_token,
+            json_str:gsub('"', '\\"')
+        )
+        os.execute(cmd .. " &")
+    end
+    
+    -- Also could buffer for offline (reuse client2server logic)
+    return event
+end
+
+-- ============================================================================
+-- SAVE STATE
+-- ============================================================================
+
+local function save_state(state)
+    local f = io.open(cfg.status_file, "w")
+    if f then
+        f:write(state .. "\n")
+        f:close()
+    end
+end
+
+local function load_state()
+    local f = io.open(cfg.status_file, "r")
+    if not f then return nil end
+    
+    local state = f:read("*a"):gsub("%s+$", "")
+    f:close()
+    return state
+end
+
+-- ============================================================================
+-- MAIN LOOP
+-- ============================================================================
+
+local function main()
+    log("Starting WAN watcher...")
+    log("Interface: " .. cfg.wan_interface .. " (" .. cfg.physical_device .. ")")
+    
+    -- Save PID
+    local pf = io.open(cfg.pid_file, "w")
+    if pf then
+        pf:write(tostring(os.getpid()))
+        pf:close()
+    end
+    
+    -- Initial states
+    local last_link_state = nil
+    local last_ip = nil
+    
+    -- Check initial link state
+    last_link_state = has_carrier(cfg.physical_device)
+    local info = get_dhcp_info(cfg.wan_interface)
+    if info then last_ip = info.ip end
+    
+    save_state(last_link_state and "up" or "down")
+    
+    log("Initial state: link=" .. tostring(last_link_state) .. ", ip=" .. tostring(last_ip))
+    
+    -- Main monitoring loop
+    while true do
+        socket.sleep(cfg.check_interval)
+        
+        -- Check physical link
+        local current_link = has_carrier(cfg.physical_device)
+        local current_ip_info = get_dhcp_info(cfg.wan_interface)
+        local current_ip = current_ip_info and current_ip_info.ip or nil
+        
+        -- Link state change
+        if current_link ~= last_link_state then
+            if current_link then
+                -- Link came UP
+                report_event("wan_link_up", {
+                    device = cfg.physical_device,
+                    message = "Physical link detected on " .. cfg.physical_device,
+                })
+            else
+                -- Link went DOWN
+                report_event("wan_link_down", {
+                    device = cfg.physical_device,
+                    message = "Physical link lost on " .. cfg.physical_device,
+                })
+            end
+            
+            last_link_state = current_link
+            save_state(current_link and "up" or "down")
+        end
+        
+        -- DHCP lease change (new IP)
+        if current_ip and current_ip ~= last_ip then
+            if last_ip then
+                -- IP changed!
+                report_event("wan_dhcp_changed", {
+                    old_ip = last_ip,
+                    new_ip = current_ip,
+                    message = "New DHCP lease: " .. (last_ip or "none") .. " -> " .. current_ip,
+                })
+            else
+                -- Got first IP
+                report_event("wan_dhcp_renew", {
+                    new_ip = current_ip,
+                    message = "DHCP lease obtained: " .. current_ip,
+                })
+            end
+            
+            last_ip = current_ip
+        end
+        
+        -- No link but had IP before - connection dropped
+        if not current_link and last_ip and current_ip ~= nil then
+            report_event("wan_dropped", {
+                ip = last_ip,
+                message = "WAN connection dropped (still held lease)",
+            })
+        end
+    end
+end
+
+-- Run
+main()