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