| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390 |
- --[[
- client2server-luv - client2server-minimal.lua with TRUE luv async
-
- Features:
- - DHCP + WiFi + SSID monitoring via ubus
- - ASYNC: react IMMEDIATELY when events happen (not polling!)
- - luv fs_event for file watching
- - luv timer for immediate triggers
- - State management for change detection
-
- Copyright (c) 2026 Luis Rosales - MIT License
- ]]
- -- ============================================================================
- -- REQUIREMENTS
- -- ============================================================================
- local luv_ok, luv = pcall(require, "luv")
- -- ============================================================================
- -- CONFIG
- -- ============================================================================
- local cfg = {
- server_url = os.getenv("SERVER_URL") or "http://163.245.193.47:3843",
- router_id = os.getenv("ROUTER_ID") or "unknown",
- check_interval = 10,
- dhcp_lease_file = "/var/lib/dnsmasq/dnsmasq.leases",
- state_file = "/tmp/client2server_state",
- }
- -- ============================================================================
- -- UTILITIES
- -- ============================================================================
- local function log_info(msg)
- os.execute('logger -t client2server -p user.info "' .. msg .. '"')
- end
- local function http_post(event_type, data)
- local json = '{"router_id":"' .. cfg.router_id .. '","event":"' .. event_type .. '","data":' .. data .. '}'
- local f = io.popen("curl -s -X POST '" .. cfg.server_url .. "/api/events' -H 'Content-Type: application/json' -d '" .. json .. "'", "r")
- local result = f:read("*a")
- f:close()
- return result
- end
- local function is_valid_mac(mac)
- if not mac or #mac ~= 17 then return false end
- local parts = {}
- for part in mac:gmatch("[a-fA-F0-9][a-fA-F0-9]") do
- table.insert(parts, part)
- end
- if #parts ~= 6 then return false end
- local first = tonumber(parts[1], 16)
- if first == 0 or first == 255 then return false end
- local second = tonumber(parts[2], 16)
- if second == 255 then return false end
- return true
- end
- local function send_event(event_type, data)
- log_info("Event: " .. event_type)
- local resp = http_post(event_type, data)
- if resp and resp:match("OK") then
- log_info("OK")
- else
- log_info("Fail: " .. (resp or "nil"):sub(1, 50))
- end
- end
- -- ============================================================================
- -- STATE MANAGEMENT
- -- ============================================================================
- local function load_state()
- local f = io.open(cfg.state_file, "r")
- if f then
- local content = f:read("*a")
- f:close()
- local clients = {}
- local ssid_enabled = {}
- local dhcp = {}
- for line in content:gmatch("[^\n]+") do
- local t, v = line:match("^([^:]+):(.+)$")
- if t == "client" then
- local mac, iface, ssid = v:match("^(.+)|(.+)|(.+)$")
- if mac and iface and is_valid_mac(mac) then clients[mac] = {iface=iface, ssid=ssid} end
- elseif t == "ssid" then ssid_enabled[v] = true
- elseif t == "dhcp" then
- local mac, ip = v:match("^(.+)->(.+)$")
- if mac and ip and is_valid_mac(mac) then dhcp[mac] = {ip=ip, mac=mac} end
- end
- end
- return { clients = clients, ssid_enabled = ssid_enabled, dhcp = dhcp }
- end
- return { clients = {}, ssid_enabled = {}, dhcp = {} }
- end
- local function save_state(state)
- local f = io.open(cfg.state_file, "w")
- if f then
- for mac, info in pairs(state.clients) do
- f:write("client:" .. mac .. "|" .. (info.iface or "") .. "|" .. (info.ssid or "") .. "\n")
- end
- for iface in pairs(state.ssid_enabled) do
- f:write("ssid:" .. iface .. "\n")
- end
- for mac, info in pairs(state.dhcp) do
- f:write("dhcp:" .. mac .. "->" .. info.ip .. "\n")
- end
- f:close()
- end
- end
- -- ============================================================================
- -- WIFI: Get interfaces + clients + SSID
- -- ============================================================================
- local function get_hostapd_interfaces()
- local f = io.popen("ubus list | grep hostapd")
- local interfaces = {}
- if f then
- for line in f:lines() do table.insert(interfaces, line) end
- f:close()
- end
- return interfaces
- end
- local function get_ssid_name(iface)
- local wlan_map = {
- ["hostapd.wlan0-1"] = "wlan0-1",
- ["hostapd.wlan0-2"] = "wlan0-2",
- ["hostapd.wlan1-1"] = "wlan1-1",
- }
- local wlan_iface = wlan_map[iface] or iface:gsub("hostapd.", "")
-
- local f = io.popen("iw dev " .. wlan_iface .. " info 2>/dev/null | grep ssid")
- if f then
- local result = f:read("*a")
- f:close()
- local ssid = result:match("ssid%s+(.+)")
- if ssid and ssid ~= "" then return ssid:gsub("%s+$", "") end
- end
- return iface
- end
- local function get_all_ssids()
- local ssids = {}
- local f = io.popen("iw dev 2>/dev/null")
- if f then
- local result = f:read("*a")
- f:close()
- local current_iface = nil
- for line in result:gmatch("[^\n]+") do
- local iface = line:match("Interface%s+(wlan%d[^%s]*)")
- if iface then current_iface = iface end
- local ssid = line:match("ssid%s+(.+)")
- if ssid and current_iface then
- ssids["hostapd." .. current_iface] = { enabled = true, ssid = ssid }
- end
- end
- end
- return ssids
- end
- local function get_wifi_clients()
- local clients = {}
- local interfaces = get_hostapd_interfaces()
-
- local ssid_map = {}
- for _, iface in ipairs(interfaces) do
- ssid_map[iface] = get_ssid_name(iface)
- end
-
- local count = 0
- for _, iface in ipairs(interfaces) do
- local f = io.popen("ubus call " .. iface .. " get_clients 2>/dev/null")
- if f then
- local result = f:read("*a")
- f:close()
- if result:find('"clients"') and not result:find('"clients":%s*{}') then
- 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
- if is_valid_mac(mac) and not clients[mac] then
- clients[mac] = { iface = iface, ssid = ssid_map[iface] }
- count = count + 1
- end
- end
- end
- end
- end
- return clients
- end
- -- ============================================================================
- -- DHCP: Get leases
- -- ============================================================================
- local function get_dhcp_leases()
- local leases = {}
-
- local f = io.popen("ubus call dhcp ipv4leases 2>/dev/null")
- if f then
- local result = f:read("*a")
- f:close()
- if result:match('"leases"') then
- for ip, mac in result:gmatch('"ip":"([%d%.]+)"[^}]*"mac":"([a-fA-F0-9:]+)"') do
- if is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac} end
- end
- return leases
- end
- end
-
- -- Fallback: read lease file
- f = io.popen("cat " .. cfg.dhcp_lease_file .. " 2>/dev/null")
- if f then
- for line in f:lines() do
- local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
- if mac and is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac, hostname=name} end
- end
- f:close()
- end
- return leases
- end
- -- ============================================================================
- -- ASYNC WATCHERS (TRUE EVENT-DRIVEN)
- -- ============================================================================
- local state = { clients = {}, ssid_enabled = {}, dhcp = {} }
- -- Check and report changes
- local function check_and_report()
- -- WiFi Clients
- local current_clients = get_wifi_clients()
-
- for mac, info in pairs(current_clients) do
- if not state.clients[mac] then
- log_info("WiFi CONNECTED: " .. mac .. " on " .. info.ssid)
- send_event("wifi_connected", '{"mac":"' .. mac .. '","ssid":"' .. info.ssid .. '","interface":"' .. info.iface .. '"}')
- end
- end
- for mac, info in pairs(state.clients) do
- if not current_clients[mac] then
- log_info("WiFi DISCONNECTED: " .. mac .. " from " .. info.ssid)
- send_event("wifi_disconnected", '{"mac":"' .. mac .. '","ssid":"' .. info.ssid .. '","interface":"' .. info.iface .. '"}')
- end
- end
- state.clients = current_clients
-
- -- SSID Status
- local current_ssid = get_all_ssids()
- for iface, info in pairs(current_ssid) do
- if not state.ssid_enabled[iface] then
- local ssid = get_ssid_name(iface)
- log_info("SSID ENABLED: " .. ssid .. " (" .. iface .. ")")
- send_event("ssid_enabled", '{"interface":"' .. iface .. '","ssid":"' .. ssid .. '"}')
- end
- end
- for iface in pairs(state.ssid_enabled) do
- if not current_ssid[iface] then
- local ssid = get_ssid_name(iface)
- log_info("SSID DISABLED: " .. ssid .. " (" .. iface .. ")")
- send_event("ssid_disabled", '{"interface":"' .. iface .. '","ssid":"' .. ssid .. '"}')
- end
- end
- state.ssid_enabled = current_ssid
-
- -- DHCP Leases
- local current_dhcp = get_dhcp_leases()
- for mac, info in pairs(current_dhcp) do
- if not state.dhcp[mac] then
- log_info("DHCP NEW: " .. mac .. " -> " .. info.ip)
- send_event("dhcp_new", '{"mac":"' .. mac .. '","ip":"' .. info.ip .. '"}')
- end
- end
- for mac, info in pairs(state.dhcp) do
- if not current_dhcp[mac] then
- log_info("DHCP RELEASE: " .. mac .. " -> " .. info.ip)
- send_event("dhcp_release", '{"mac":"' .. mac .. '","ip":"' .. info.ip .. '"}')
- end
- end
- state.dhcp = current_dhcp
-
- save_state(state)
- end
- -- ============================================================================
- -- ASYNC EVENT HANDLERS (immediate reaction)
- -- ============================================================================
- local function start_async_watchers()
- if not luv_ok then return false end
-
- log_info("Starting async watchers...")
-
- -- 1. DHCP: Watch lease file with fs_event (REACT IMMEDIATELY)
- local fs_event = luv.new_fs_event()
- local function watch_dhcp()
- log_info("DHCP lease file changed - checking...")
- check_and_report()
- -- Re-watch
- luv.fs_event_start(fs_event, cfg.dhcp_lease_file, function(err)
- if err then
- log_info("DHCP fs_event error: " .. err)
- else
- watch_dhcp()
- end
- end)
- end
- -- Start watching
- luv.fs_event_start(fs_event, cfg.dhcp_lease_file, function(err)
- if err then
- log_info("DHCP watch error: " .. err)
- else
- log_info("Watching DHCP leases: " .. cfg.dhcp_lease_file)
- watch_dhcp()
- end
- end)
-
- -- 2. WiFi: Timer-based but frequent (every 5s)
- local wifi_timer = luv.new_timer()
- luv.timer_start(wifi_timer, 5000, 5000, function()
- log_info("WiFi check (timer)...")
- check_and_report()
- end)
-
- -- 3. SSID: Less frequent (every 30s)
- local ssid_timer = luv.new_timer()
- luv.timer_start(ssid_timer, 30000, 30000, function()
- log_info("SSID check (timer)...")
- check_and_report()
- end)
-
- return true
- end
- -- ============================================================================
- -- MAIN
- -- ============================================================================
- function main()
- log_info("Starting client2server-luv (async)...")
- log_info("Router: " .. cfg.router_id)
- log_info("Server: " .. cfg.server_url)
-
- if luv_ok then
- log_info("luv available - ASYNC mode!")
- else
- log_info("luv NOT available - blocking mode")
- end
-
- -- Load state
- state = load_state()
-
- -- Initial event
- local leases = get_dhcp_leases()
- local count = 0
- for _ in pairs(leases) do count = count + 1 end
- log_info("DHCP leases: " .. count)
- send_event("router_online", '{"lease_count":' .. count .. '}')
-
- if luv_ok then
- -- ASYNC MODE
- local started = start_async_watchers()
-
- if started then
- log_info("Async watchers started - running event loop")
- luv.run()
- else
- -- Fallback to timer-based
- log_info("Using timer-based fallback")
- local timer = luv.new_timer()
- local function run_loop()
- check_and_report()
- luv.timer_start(timer, cfg.check_interval * 1000, 0, function() run_loop() end)
- end
- luv.timer_start(timer, cfg.check_interval * 1000, 0, function() run_loop() end)
- luv.run()
- end
- else
- -- BLOCKING MODE
- while true do
- check_and_report()
- os.execute("sleep " .. cfg.check_interval)
- end
- end
- end
- main()
|