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