wan-watcher.lua 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. --[[
  2. wan-watcher.lua - Monitor WAN link state and DHCP changes
  3. Copyright (c) 2026 Luis Rosales - MIT License
  4. Use case: Detect when router is moved from one modem to another
  5. - Physical link flap (cable unplugged/plugged)
  6. - New DHCP lease (connected to new ISP)
  7. Runs as daemon, reports events to central server
  8. ]]
  9. local socket = require("socket")
  10. local json = require("json")
  11. -- ============================================================================
  12. -- CONFIG
  13. -- ============================================================================
  14. local cfg = {
  15. wan_interface = "wan", -- UCI interface name
  16. physical_device = "eth0", -- Physical interface (e.g., eth0, wan)
  17. check_interval = 5, -- Seconds between checks
  18. -- Server (optional - could just buffer like client2server)
  19. server_url = os.getenv("SERVER_URL") or "",
  20. server_token = os.getenv("SERVER_TOKEN") or "",
  21. -- Paths
  22. status_file = "/var/run/wan-watcher.status",
  23. pid_file = "/var/run/wan-watcher.pid",
  24. }
  25. -- Load config from UCI
  26. pcall(function()
  27. local uci = require("luci.model.uci").cursor()
  28. cfg.wan_interface = uci:get("wan-watcher", "general", "interface") or cfg.wan_interface
  29. cfg.physical_device = uci:get("wan-watcher", "general", "device") or cfg.physical_device
  30. cfg.server_url = uci:get("wan-watcher", "server", "url") or cfg.server_url
  31. cfg.server_token = uci:get("wan-watcher", "server", "token") or cfg.server_token
  32. end)
  33. -- ============================================================================
  34. -- UTILITIES
  35. -- ============================================================================
  36. local function log(msg)
  37. os.execute(string.format('logger -t "wan-watcher" -p user.info "%s"', msg:gsub('"', '\\"')))
  38. end
  39. local function log_err(msg)
  40. os.execute(string.format('logger -t "wan-watcher" -p user.err "%s"', msg:gsub('"', '\\"')))
  41. end
  42. local function get_hostname()
  43. local f = io.popen("hostname")
  44. local h = f and f:read("*a"):gsub("%s+$", "") or "unknown"
  45. if f then f:close() end
  46. return h
  47. end
  48. -- ============================================================================
  49. -- WAN STATUS CHECKS
  50. -- ============================================================================
  51. -- Check physical link state
  52. function check_link(device)
  53. local f = io.popen("cat /sys/class/net/" .. device .. "/operstate 2>/dev/null")
  54. if not f then return nil end
  55. local state = f:read("*a"):gsub("%s+$", "")
  56. f:close()
  57. return state -- "up", "down", "unknown", "dormant", etc.
  58. end
  59. -- Check if interface has carrier
  60. function has_carrier(device)
  61. local f = io.popen("cat /sys/class/net/" .. device .. "/carrier 2>/dev/null")
  62. if not f then return false end
  63. local carrier = f:read("*a"):gsub("%s+$", "")
  64. f:close()
  65. return carrier == "1"
  66. end
  67. -- Get current DHCP lease info
  68. function get_dhcp_info(interface)
  69. local uci = require("luci.model.uci").cursor()
  70. local cursor = uci.cursor()
  71. -- Get interface data from network config
  72. local proto = cursor:get("network", interface, "proto")
  73. -- Get IP from ubus
  74. local f = io.popen("ubus call network.interface." .. interface .. " status 2>/dev/null")
  75. if not f then return nil end
  76. local status = f:read("*a")
  77. f:close()
  78. if not status then return nil end
  79. -- Parse JSON manually (without json library)
  80. local ip = status:match('"address"%s*:%s*"([^"]+)"')
  81. local prefix = status:match('"prefix"%s*:%s*(%d+)')
  82. return {
  83. ip = ip,
  84. prefix = tonumber(prefix),
  85. proto = proto,
  86. }
  87. end
  88. -- ============================================================================
  89. -- REPORTING
  90. -- ============================================================================
  91. function report_event(event_type, data)
  92. local event = {
  93. router_id = get_hostname(),
  94. event_type = event_type,
  95. timestamp = os.date("!%Y-%m-%dT%H:%M:%SZ"),
  96. payload = data,
  97. }
  98. local json_str = json.encode(event)
  99. log(event_type .. ": " .. (data.message or ""))
  100. -- Send to server if configured
  101. if cfg.server_url ~= "" and cfg.server_token ~= "" then
  102. local cmd = string.format(
  103. 'curl -s -X POST "%s" -H "Authorization: Bearer %s" -H "Content-Type: application/json" -d "%s" 2>/dev/null',
  104. cfg.server_url,
  105. cfg.server_token,
  106. json_str:gsub('"', '\\"')
  107. )
  108. os.execute(cmd .. " &")
  109. end
  110. -- Also could buffer for offline (reuse client2server logic)
  111. return event
  112. end
  113. -- ============================================================================
  114. -- SAVE STATE
  115. -- ============================================================================
  116. local function save_state(state)
  117. local f = io.open(cfg.status_file, "w")
  118. if f then
  119. f:write(state .. "\n")
  120. f:close()
  121. end
  122. end
  123. local function load_state()
  124. local f = io.open(cfg.status_file, "r")
  125. if not f then return nil end
  126. local state = f:read("*a"):gsub("%s+$", "")
  127. f:close()
  128. return state
  129. end
  130. -- ============================================================================
  131. -- MAIN LOOP
  132. -- ============================================================================
  133. local function main()
  134. log("Starting WAN watcher...")
  135. log("Interface: " .. cfg.wan_interface .. " (" .. cfg.physical_device .. ")")
  136. -- Save PID
  137. local pf = io.open(cfg.pid_file, "w")
  138. if pf then
  139. pf:write(tostring(os.getpid()))
  140. pf:close()
  141. end
  142. -- Initial states
  143. local last_link_state = nil
  144. local last_ip = nil
  145. -- Check initial link state
  146. last_link_state = has_carrier(cfg.physical_device)
  147. local info = get_dhcp_info(cfg.wan_interface)
  148. if info then last_ip = info.ip end
  149. save_state(last_link_state and "up" or "down")
  150. log("Initial state: link=" .. tostring(last_link_state) .. ", ip=" .. tostring(last_ip))
  151. -- Main monitoring loop
  152. while true do
  153. socket.sleep(cfg.check_interval)
  154. -- Check physical link
  155. local current_link = has_carrier(cfg.physical_device)
  156. local current_ip_info = get_dhcp_info(cfg.wan_interface)
  157. local current_ip = current_ip_info and current_ip_info.ip or nil
  158. -- Link state change
  159. if current_link ~= last_link_state then
  160. if current_link then
  161. -- Link came UP
  162. report_event("wan_link_up", {
  163. device = cfg.physical_device,
  164. message = "Physical link detected on " .. cfg.physical_device,
  165. })
  166. else
  167. -- Link went DOWN
  168. report_event("wan_link_down", {
  169. device = cfg.physical_device,
  170. message = "Physical link lost on " .. cfg.physical_device,
  171. })
  172. end
  173. last_link_state = current_link
  174. save_state(current_link and "up" or "down")
  175. end
  176. -- DHCP lease change (new IP)
  177. if current_ip and current_ip ~= last_ip then
  178. if last_ip then
  179. -- IP changed!
  180. report_event("wan_dhcp_changed", {
  181. old_ip = last_ip,
  182. new_ip = current_ip,
  183. message = "New DHCP lease: " .. (last_ip or "none") .. " -> " .. current_ip,
  184. })
  185. else
  186. -- Got first IP
  187. report_event("wan_dhcp_renew", {
  188. new_ip = current_ip,
  189. message = "DHCP lease obtained: " .. current_ip,
  190. })
  191. end
  192. last_ip = current_ip
  193. end
  194. -- No link but had IP before - connection dropped
  195. if not current_link and last_ip and current_ip ~= nil then
  196. report_event("wan_dropped", {
  197. ip = last_ip,
  198. message = "WAN connection dropped (still held lease)",
  199. })
  200. end
  201. end
  202. end
  203. -- Run
  204. main()