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