client2server-minimal.lua 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. -- client2server-minimal.lua - Unified DHCP + WiFi + SSID monitoring via ubus
  2. local cfg = {
  3. server_url = "http://163.245.193.47:3843",
  4. router_id = "Guayabal",
  5. check_interval = 10,
  6. state_file = "/tmp/client2server_state",
  7. }
  8. -------------------------------------------------
  9. -- Utilities
  10. -------------------------------------------------
  11. local function log_info(msg)
  12. os.execute('logger -t client2server -p user.info "' .. msg .. '"')
  13. end
  14. local function http_post(event_type, data)
  15. local json = '{"router_id":"' .. cfg.router_id .. '","event":"' .. event_type .. '","data":' .. data .. '}'
  16. local f = io.popen("curl -s -X POST '" .. cfg.server_url .. "/api/events' -H 'Content-Type: application/json' -d '" .. json .. "'", "r")
  17. local result = f:read("*a")
  18. f:close()
  19. return result
  20. end
  21. local function send_event(event_type, data)
  22. log_info("Event: " .. event_type)
  23. local resp = http_post(event_type, data)
  24. if resp and resp:match("OK") then
  25. log_info("OK")
  26. else
  27. log_info("Fail: " .. (resp or "nil"):sub(1, 50))
  28. end
  29. end
  30. -------------------------------------------------
  31. -- State Management
  32. -------------------------------------------------
  33. local function load_state()
  34. local f = io.open(cfg.state_file, "r")
  35. if f then
  36. local content = f:read("*a")
  37. f:close()
  38. local clients = {}
  39. local ssid_enabled = {}
  40. local dhcp = {}
  41. for line in content:gmatch("[^\n]+") do
  42. local t, v = line:match("^([^:]+):(.+)$")
  43. if t == "client" then
  44. local mac, iface, ssid = v:match("^(.+)|(.+)|(.+)$")
  45. -- Validate MAC when loading from state
  46. if mac and iface and is_valid_mac(mac) then clients[mac] = {iface=iface, ssid=ssid} end
  47. elseif t == "ssid" then ssid_enabled[v] = true
  48. elseif t == "dhcp" then
  49. local mac, ip = v:match("^(.+)->(.+)$")
  50. -- Validate MAC for DHCP too
  51. if mac and ip and is_valid_mac(mac) then dhcp[mac] = {ip=ip, mac=mac} end
  52. end
  53. end
  54. return { clients = clients, ssid_enabled = ssid_enabled, dhcp = dhcp }
  55. end
  56. return { clients = {}, ssid_enabled = {}, dhcp = {} }
  57. end
  58. local function save_state(state)
  59. local f = io.open(cfg.state_file, "w")
  60. if f then
  61. for mac, info in pairs(state.clients) do
  62. f:write("client:" .. mac .. "|" .. (info.iface or "") .. "|" .. (info.ssid or "") .. "\n")
  63. end
  64. for iface in pairs(state.ssid_enabled) do
  65. f:write("ssid:" .. iface .. "\n")
  66. end
  67. for mac, info in pairs(state.dhcp) do
  68. f:write("dhcp:" .. mac .. "->" .. info.ip .. "\n")
  69. end
  70. f:close()
  71. end
  72. end
  73. -------------------------------------------------
  74. -- WiFi: Get ALL hostapd interfaces + SSID names
  75. -------------------------------------------------
  76. local function get_hostapd_interfaces()
  77. local f = io.popen("ubus list | grep hostapd")
  78. local interfaces = {}
  79. if f then
  80. for line in f:lines() do
  81. table.insert(interfaces, line)
  82. end
  83. f:close()
  84. end
  85. return interfaces
  86. end
  87. -- Get SSID name for an interface via iw dev (most reliable)
  88. local function get_ssid_name(iface)
  89. -- Map hostapd interface to wlan interface
  90. local wlan_map = {
  91. ["hostapd.wlan0-1"] = "wlan0-1",
  92. ["hostapd.wlan0-2"] = "wlan0-2",
  93. }
  94. local wlan_iface = wlan_map[iface] or iface
  95. -- Use iw dev to get exact SSID
  96. local f = io.popen("iw dev " .. wlan_iface .. " info 2>/dev/null | grep ssid")
  97. if f then
  98. local result = f:read("*a")
  99. f:close()
  100. local ssid = result:match("ssid%s+(.+)")
  101. if ssid and ssid ~= "" then
  102. return ssid:gsub("%s+$", "") -- trim
  103. end
  104. end
  105. -- Fallback: try iw dev without specific interface
  106. f = io.popen("iw dev 2>/dev/null")
  107. if f then
  108. local result = f:read("*a")
  109. f:close()
  110. -- Extract SSIDs from output
  111. for line in result:gmatch("[^\n]+") do
  112. local ssid = line:match("ssid%s+(.+)")
  113. if ssid and ssid ~= "" then
  114. return ssid
  115. end
  116. end
  117. end
  118. return iface
  119. end
  120. -- Get all SSIDs mapped to interfaces
  121. local function get_all_ssids()
  122. local ssids = {}
  123. local interfaces = get_hostapd_interfaces()
  124. for _, iface in ipairs(interfaces) do
  125. local ssid = get_ssid_name(iface)
  126. ssids[iface] = { ssid = ssid }
  127. end
  128. return ssids
  129. end
  130. -- Get all SSID statuses via iw
  131. local function get_all_ssids()
  132. local ssids = {}
  133. local f = io.popen("iw dev 2>/dev/null")
  134. if f then
  135. local result = f:read("*a")
  136. f:close()
  137. local current_iface = nil
  138. for line in result:gmatch("[^\n]+") do
  139. -- Detect interface from "Interface wlanX-1"
  140. local iface = line:match("Interface%s+(wlan%d-%d)")
  141. if iface then
  142. current_iface = iface
  143. end
  144. -- Extract SSID
  145. local ssid = line:match("ssid%s+(.+)")
  146. if ssid and current_iface then
  147. local hostapd_iface = "hostapd." .. current_iface
  148. ssids[hostapd_iface] = { enabled = true, ssid = ssid }
  149. end
  150. end
  151. end
  152. return ssids
  153. end
  154. -- Get clients from ALL interfaces WITH their SSID
  155. -- Validate MAC address (proper format with valid hex)
  156. local function is_valid_mac(mac)
  157. if not mac or #mac ~= 17 then return false end
  158. -- Check format XX:XX:XX:XX:XX:XX
  159. local parts = {}
  160. for part in mac:gmatch("[a-fA-F0-9][a-fA-F0-9]") do
  161. table.insert(parts, part)
  162. end
  163. -- Must have exactly 6 parts
  164. if #parts ~= 6 then return false end
  165. -- First byte must not be 00 or FF (invalid/broadcast)
  166. local first = tonumber(parts[1], 16)
  167. if first == 0 or first == 255 then return false end
  168. -- Second byte must not be FF (multicast/broadcast address)
  169. local second = tonumber(parts[2], 16)
  170. if second == 255 then return false end
  171. return true
  172. end
  173. local function get_wifi_clients()
  174. local clients = {}
  175. local interfaces = get_hostapd_interfaces()
  176. -- Get SSID mapping first
  177. local ssid_map = {}
  178. for _, iface in ipairs(interfaces) do
  179. ssid_map[iface] = get_ssid_name(iface)
  180. end
  181. -- Query each interface separately
  182. for _, iface in ipairs(interfaces) do
  183. local f = io.popen("ubus call " .. iface .. " get_clients 2>/dev/null")
  184. if f then
  185. local result = f:read("*a")
  186. f:close()
  187. -- Check if there are actually clients (result contains "clients": {})
  188. if result:find('"clients"') and not result:find('"clients":%s*{}') then
  189. -- Extract MAC addresses - match pattern for valid MACs
  190. for mac in result:gmatch('([a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9])') do
  191. -- Validate MAC before adding
  192. if is_valid_mac(mac) and not clients[mac] then
  193. clients[mac] = { iface = iface, ssid = ssid_map[iface] }
  194. end
  195. end
  196. end
  197. end
  198. end
  199. return clients
  200. end
  201. -- Get SSID status for ALL interfaces
  202. local function get_ssid_status()
  203. local status = {}
  204. local ssids = get_all_ssids()
  205. for iface, info in pairs(ssids) do
  206. if info.enabled then
  207. status[iface] = true
  208. end
  209. end
  210. return status
  211. end
  212. -------------------------------------------------
  213. -- DHCP: Get leases via ubus
  214. -------------------------------------------------
  215. local function get_dhcp_leases()
  216. local leases = {}
  217. -- Try ubus first
  218. local f = io.popen("ubus call dhcp ipv4leases 2>/dev/null")
  219. if f then
  220. local result = f:read("*a")
  221. f:close()
  222. if result:match('"leases"') then
  223. for ip, mac in result:gmatch('"ip":"([%d%.]+)"[^}]*"mac":"([a-fA-F0-9:]+)"') do
  224. leases[mac] = {ip=ip, mac=mac}
  225. end
  226. return leases
  227. end
  228. end
  229. -- Fallback: read lease file
  230. f = io.popen("cat /tmp/dhcp.leases 2>/dev/null")
  231. if f then
  232. for line in f:lines() do
  233. local e, mac, ip, h, c = line:match("^(%S+) (%S+) (%S+) (%S+) (%S+)")
  234. if mac then leases[mac] = {ip=ip, mac=mac, hostname=h} end
  235. end
  236. f:close()
  237. end
  238. return leases
  239. end
  240. -------------------------------------------------
  241. -- Main Loop
  242. -------------------------------------------------
  243. function main()
  244. log_info("Starting client2server-minimal...")
  245. log_info("Router: " .. cfg.router_id)
  246. local state = load_state()
  247. -- Initial event
  248. local leases = get_dhcp_leases()
  249. local count = 0
  250. for _ in pairs(leases) do count = count + 1 end
  251. log_info("DHCP leases: " .. count)
  252. send_event("router_online", '{"lease_count":' .. count .. '}')
  253. while true do
  254. os.execute("sleep " .. cfg.check_interval)
  255. -------------------------------------------------
  256. -- WiFi Clients
  257. -------------------------------------------------
  258. local current_clients = get_wifi_clients()
  259. for mac, info in pairs(current_clients) do
  260. if not state.clients[mac] then
  261. log_info("WiFi CONNECTED: " .. mac .. " on " .. info.ssid)
  262. send_event("wifi_connected", '{"mac":"' .. mac .. '","ssid":"' .. info.ssid .. '","interface":"' .. info.iface .. '"}')
  263. end
  264. end
  265. for mac, info in pairs(state.clients) do
  266. if not current_clients[mac] then
  267. log_info("WiFi DISCONNECTED: " .. mac .. " from " .. info.ssid)
  268. send_event("wifi_disconnected", '{"mac":"' .. mac .. '","ssid":"' .. info.ssid .. '","interface":"' .. info.iface .. '"}')
  269. end
  270. end
  271. state.clients = current_clients
  272. -------------------------------------------------
  273. -- SSID Status
  274. -------------------------------------------------
  275. local current_ssid = get_ssid_status()
  276. for iface in pairs(current_ssid) do
  277. if not state.ssid_enabled[iface] then
  278. local ssid = get_ssid_name(iface)
  279. log_info("SSID ENABLED: " .. ssid .. " (" .. iface .. ")")
  280. send_event("ssid_enabled", '{"interface":"' .. iface .. '","ssid":"' .. ssid .. '"}')
  281. end
  282. end
  283. for iface in pairs(state.ssid_enabled) do
  284. if not current_ssid[iface] then
  285. local ssid = get_ssid_name(iface)
  286. log_info("SSID DISABLED: " .. ssid .. " (" .. iface .. ")")
  287. send_event("ssid_disabled", '{"interface":"' .. iface .. '","ssid":"' .. ssid .. '"}')
  288. end
  289. end
  290. state.ssid_enabled = current_ssid
  291. -------------------------------------------------
  292. -- DHCP Leases
  293. -------------------------------------------------
  294. local current_dhcp = get_dhcp_leases()
  295. for mac, info in pairs(current_dhcp) do
  296. if not state.dhcp[mac] then
  297. log_info("DHCP NEW: " .. mac .. " -> " .. info.ip)
  298. send_event("dhcp_new", '{"mac":"' .. mac .. '","ip":"' .. info.ip .. '"}')
  299. end
  300. end
  301. for mac, info in pairs(state.dhcp) do
  302. if not current_dhcp[mac] then
  303. log_info("DHCP RELEASE: " .. mac .. " -> " .. info.ip)
  304. send_event("dhcp_release", '{"mac":"' .. mac .. '","ip":"' .. info.ip .. '"}')
  305. end
  306. end
  307. state.dhcp = current_dhcp
  308. -- Save state
  309. save_state(state)
  310. end
  311. end
  312. main()