-- client2server-minimal.lua - Unified DHCP + WiFi + SSID monitoring via ubus local cfg = { server_url = "http://163.245.193.47:3843", router_id = "Guayabal", check_interval = 10, 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 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("^(.+)|(.+)|(.+)$") -- Validate MAC when loading from state 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("^(.+)->(.+)$") -- Validate MAC for DHCP too 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 ALL hostapd interfaces + SSID names ------------------------------------------------- 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 -- Get SSID name for an interface via iw dev (most reliable) local function get_ssid_name(iface) -- Map hostapd interface to wlan interface local wlan_map = { ["hostapd.wlan0-1"] = "wlan0-1", ["hostapd.wlan0-2"] = "wlan0-2", } local wlan_iface = wlan_map[iface] or iface -- Use iw dev to get exact SSID 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+$", "") -- trim end end -- Fallback: try iw dev without specific interface f = io.popen("iw dev 2>/dev/null") if f then local result = f:read("*a") f:close() -- Extract SSIDs from output for line in result:gmatch("[^\n]+") do local ssid = line:match("ssid%s+(.+)") if ssid and ssid ~= "" then return ssid end end end return iface end -- Get all SSIDs mapped to interfaces local function get_all_ssids() local ssids = {} local interfaces = get_hostapd_interfaces() for _, iface in ipairs(interfaces) do local ssid = get_ssid_name(iface) ssids[iface] = { ssid = ssid } end return ssids end -- Get all SSID statuses via iw 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 -- Detect interface from "Interface wlanX-1" local iface = line:match("Interface%s+(wlan%d-%d)") if iface then current_iface = iface end -- Extract SSID local ssid = line:match("ssid%s+(.+)") if ssid and current_iface then local hostapd_iface = "hostapd." .. current_iface ssids[hostapd_iface] = { enabled = true, ssid = ssid } end end end return ssids end -- Get clients from ALL interfaces WITH their SSID -- Validate MAC address (proper format with valid hex) local function is_valid_mac(mac) if not mac or #mac ~= 17 then return false end -- Check format XX:XX:XX:XX:XX:XX local parts = {} for part in mac:gmatch("[a-fA-F0-9][a-fA-F0-9]") do table.insert(parts, part) end -- Must have exactly 6 parts if #parts ~= 6 then return false end -- First byte must not be 00 or FF (invalid/broadcast) local first = tonumber(parts[1], 16) if first == 0 or first == 255 then return false end -- Second byte must not be FF (multicast/broadcast address) local second = tonumber(parts[2], 16) if second == 255 then return false end return true end local function get_wifi_clients() local clients = {} local interfaces = get_hostapd_interfaces() -- Get SSID mapping first local ssid_map = {} for _, iface in ipairs(interfaces) do ssid_map[iface] = get_ssid_name(iface) end -- Query each interface separately 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() -- Check if there are actually clients (result contains "clients": {}) if result:find('"clients"') and not result:find('"clients":%s*{}') then -- Extract MAC addresses - match pattern for valid MACs 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 -- Validate MAC before adding if is_valid_mac(mac) and not clients[mac] then clients[mac] = { iface = iface, ssid = ssid_map[iface] } end end end end end return clients end -- Get SSID status for ALL interfaces local function get_ssid_status() local status = {} local ssids = get_all_ssids() for iface, info in pairs(ssids) do if info.enabled then status[iface] = true end end return status end ------------------------------------------------- -- DHCP: Get leases via ubus ------------------------------------------------- local function get_dhcp_leases() local leases = {} -- Try ubus first 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 leases[mac] = {ip=ip, mac=mac} end return leases end end -- Fallback: read lease file f = io.popen("cat /tmp/dhcp.leases 2>/dev/null") if f then for line in f:lines() do local e, mac, ip, h, c = line:match("^(%S+) (%S+) (%S+) (%S+) (%S+)") if mac then leases[mac] = {ip=ip, mac=mac, hostname=h} end end f:close() end return leases end ------------------------------------------------- -- Main Loop ------------------------------------------------- function main() log_info("Starting client2server-minimal...") log_info("Router: " .. cfg.router_id) local 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 .. '}') while true do os.execute("sleep " .. cfg.check_interval) ------------------------------------------------- -- 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_ssid_status() for iface 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 save_state(state) end end main()