|
|
@@ -1,14 +1,12 @@
|
|
|
--[[
|
|
|
- client2server-luv - Lua WebSocket Event Forwarder for OpenWrt with luv async
|
|
|
-
|
|
|
+ client2server-luv - client2server-minimal.lua with luv async
|
|
|
+
|
|
|
Features:
|
|
|
- - WebSocket connection to central server
|
|
|
- - Auto-reconnect on disconnect
|
|
|
- - Local buffer (store-and-forward while offline)
|
|
|
- - DHCP/WiFi/Interface event tracking
|
|
|
- - ASYNC monitoring via luv (libuv bindings)
|
|
|
- - Parallel polling for 50+ devices
|
|
|
-
|
|
|
+ - DHCP + WiFi + SSID monitoring via ubus
|
|
|
+ - ASYNC with luv (libuv bindings) - no blocking sleeps
|
|
|
+ - State management for change detection
|
|
|
+ - Direct HTTP POST to server
|
|
|
+
|
|
|
Copyright (c) 2026 Luis Rosales - MIT License
|
|
|
]]
|
|
|
|
|
|
@@ -19,668 +17,347 @@
|
|
|
-- Try to load luv (libuv bindings for async operations)
|
|
|
local luv_ok, luv = pcall(require, "luv")
|
|
|
|
|
|
--- ============================================================================
|
|
|
--- HTTP POST (direct curl - works on minimal OpenWrt)
|
|
|
--- ============================================================================
|
|
|
-
|
|
|
-local function http_post(event_type, data)
|
|
|
- local json = '{"router_id":"' .. cfg.router_id .. '","event":"' .. event_type .. '","data":' .. data .. '}'
|
|
|
- local url = cfg.url:gsub("wss", "https"):gsub("ws", "http")
|
|
|
- if url == cfg.url then url = cfg.url .. "/api/events" end
|
|
|
-
|
|
|
- local cmd = string.format(
|
|
|
- 'curl -s -X POST "%s" -H "Content-Type: application/json" -d "%s" 2>/dev/null',
|
|
|
- url, json:gsub('"', '\\"')
|
|
|
- )
|
|
|
- local f = io.popen(cmd, "r")
|
|
|
- local result = f and f:read("*a") or ""
|
|
|
- if f then f:close() end
|
|
|
-
|
|
|
- if result and result:match("OK") then
|
|
|
- log("info", "Event " .. event_type .. " OK")
|
|
|
- return true
|
|
|
- else
|
|
|
- log("warn", "Event " .. event_type .. " fail: " .. result:sub(1, 50))
|
|
|
- return false
|
|
|
- end
|
|
|
-end
|
|
|
-
|
|
|
-- ============================================================================
|
|
|
-- CONFIG
|
|
|
-- ============================================================================
|
|
|
|
|
|
local cfg = {
|
|
|
- url = os.getenv("SERVER_URL") or "wss://your-server.com/ws",
|
|
|
- token = os.getenv("SERVER_TOKEN") or "secret-token",
|
|
|
+ server_url = os.getenv("SERVER_URL") or "http://163.245.193.47:3843",
|
|
|
router_id = os.getenv("ROUTER_ID") or "unknown",
|
|
|
- reconnect_delay = 5,
|
|
|
- ping_interval = 30,
|
|
|
- buffer_file = "/tmp/event_buffer",
|
|
|
- max_buffer = 100,
|
|
|
- poll_interval = 30, -- device poll interval (seconds)
|
|
|
- wan_poll_interval = 30, -- WAN poll interval (seconds)
|
|
|
- dhcp_poll_interval = 5, -- DHCP poll interval (seconds)
|
|
|
- poll_timeout = 5000, -- poll timeout (ms)
|
|
|
+ check_interval = 10, -- seconds
|
|
|
+ state_file = "/tmp/client2server_state",
|
|
|
}
|
|
|
|
|
|
--- Load from UCI if available
|
|
|
-pcall(function()
|
|
|
- local uci = require("luci.model.uci").cursor()
|
|
|
- cfg.url = uci:get("event-forwarder", "server", "url") or cfg.url
|
|
|
- cfg.token = uci:get("event-forwarder", "server", "token") or cfg.token
|
|
|
- cfg.router_id = uci:get("event-forwarder", "router", "id") or cfg.router_id
|
|
|
-end)
|
|
|
-
|
|
|
-- ============================================================================
|
|
|
-- UTILITIES
|
|
|
-- ============================================================================
|
|
|
|
|
|
-local function log(level, msg)
|
|
|
- os.execute(string.format('logger -t "client2server-luv" -p user.%s "%s" 2>/dev/null', level, msg))
|
|
|
+local function log_info(msg)
|
|
|
+ os.execute('logger -t client2server -p user.info "' .. msg .. '"')
|
|
|
end
|
|
|
|
|
|
-local function get_hostname()
|
|
|
- -- Try multiple methods to get hostname
|
|
|
- local f = io.popen("cat /proc/sys/kernel/hostname 2>/dev/null || hostname 2>/dev/null || cat /etc/hostname 2>/dev/null || echo unknown")
|
|
|
- local h = f and f:read("*a") or "unknown"
|
|
|
- if f then f:close() end
|
|
|
- return (h:gsub("%s+$", ""))
|
|
|
+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
|
|
|
|
|
|
-cfg.router_id = cfg.router_id == "unknown" and get_hostname() or cfg.router_id
|
|
|
-
|
|
|
-local function json_encode(t)
|
|
|
+-- Validate MAC address (proper format with valid hex)
|
|
|
+local function is_valid_mac(mac)
|
|
|
+ if not mac or #mac ~= 17 then return false end
|
|
|
local parts = {}
|
|
|
- for k, v in pairs(t) do
|
|
|
- if type(v) == "string" then
|
|
|
- table.insert(parts, string.format('"%s": "%s"', k, v:gsub('"', '\\"')))
|
|
|
- elseif type(v) == "number" then
|
|
|
- table.insert(parts, string.format('"%s": %s', k, tostring(v)))
|
|
|
- elseif type(v) == "boolean" then
|
|
|
- table.insert(parts, string.format('"%s": %s', k, tostring(v)))
|
|
|
- end
|
|
|
+ for part in mac:gmatch("[a-fA-F0-9][a-fA-F0-9]") do
|
|
|
+ table.insert(parts, part)
|
|
|
end
|
|
|
- return "{" .. table.concat(parts, ",") .. "}"
|
|
|
+ if #parts ~= 6 then return false end
|
|
|
+ -- First byte: not 00 or FF
|
|
|
+ local first = tonumber(parts[1], 16)
|
|
|
+ if first == 0 or first == 255 then return false end
|
|
|
+ -- Second byte: not FF (multicast)
|
|
|
+ local second = tonumber(parts[2], 16)
|
|
|
+ if second == 255 then return false end
|
|
|
+ return true
|
|
|
end
|
|
|
|
|
|
-local function json_decode(str)
|
|
|
- local result = {}
|
|
|
- for k, v in str:gmatch('"([^"]+)":%s*([^},]+)') do
|
|
|
- v = v:gsub('[%s"]+', '')
|
|
|
- if v == "true" or v == "false" then
|
|
|
- result[k] = v == "true"
|
|
|
- elseif tonumber(v) then
|
|
|
- result[k] = tonumber(v)
|
|
|
- else
|
|
|
- result[k] = v
|
|
|
- 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
|
|
|
- return result
|
|
|
end
|
|
|
|
|
|
-- ============================================================================
|
|
|
--- BUFFER (OFFLINE SUPPORT)
|
|
|
+-- STATE MANAGEMENT
|
|
|
-- ============================================================================
|
|
|
|
|
|
-local buffer = {
|
|
|
- events = {},
|
|
|
- dirty = false,
|
|
|
-}
|
|
|
-
|
|
|
-function buffer.load()
|
|
|
- local f = io.open(cfg.buffer_file, "r")
|
|
|
- if not f then return end
|
|
|
-
|
|
|
- for line in f:lines() do
|
|
|
- if line and line ~= "" then
|
|
|
- table.insert(buffer.events, line)
|
|
|
+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
|
|
|
- f:close()
|
|
|
- log("info", "Loaded " .. #buffer.events .. " buffered events")
|
|
|
+ return { clients = {}, ssid_enabled = {}, dhcp = {} }
|
|
|
end
|
|
|
|
|
|
-function buffer.save()
|
|
|
- local f = io.open(cfg.buffer_file, "w")
|
|
|
- if not f then return end
|
|
|
-
|
|
|
- for _, event in ipairs(buffer.events) do
|
|
|
- f:write(event .. "\n")
|
|
|
- end
|
|
|
- f:close()
|
|
|
- buffer.dirty = false
|
|
|
-end
|
|
|
-
|
|
|
-function buffer.add(json_event)
|
|
|
- table.insert(buffer.events, json_event)
|
|
|
-
|
|
|
- -- Limit buffer size
|
|
|
- while #buffer.events > cfg.max_buffer do
|
|
|
- table.remove(buffer.events, 1)
|
|
|
- end
|
|
|
-
|
|
|
- buffer.dirty = true
|
|
|
-end
|
|
|
-
|
|
|
-function buffer.flush(send_fn)
|
|
|
- if #buffer.events == 0 then return end
|
|
|
-
|
|
|
- local to_send = buffer.events
|
|
|
- buffer.events = {}
|
|
|
- buffer.dirty = false
|
|
|
-
|
|
|
- for _, event in ipairs(to_send) do
|
|
|
- local ok, err = pcall(send_fn, event)
|
|
|
- if not ok or err then
|
|
|
- -- Re-add to buffer on failure
|
|
|
- table.insert(buffer.events, event)
|
|
|
+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
|
|
|
- end
|
|
|
-
|
|
|
- buffer.save()
|
|
|
-end
|
|
|
-
|
|
|
-function buffer.clear()
|
|
|
- buffer.events = {}
|
|
|
- buffer.dirty = false
|
|
|
- os.remove(cfg.buffer_file)
|
|
|
-end
|
|
|
-
|
|
|
--- ============================================================================
|
|
|
--- WEBSOCKET (with fallback)
|
|
|
--- ============================================================================
|
|
|
-
|
|
|
-local ws = {}
|
|
|
-
|
|
|
-function ws.connect(url)
|
|
|
- if ws_client then
|
|
|
- local sock, err = ws_client.connect(url)
|
|
|
- if err then
|
|
|
- log("err", "WS connect error: " .. err)
|
|
|
- return nil
|
|
|
+ for iface in pairs(state.ssid_enabled) do
|
|
|
+ f:write("ssid:" .. iface .. "\n")
|
|
|
end
|
|
|
- return sock
|
|
|
- end
|
|
|
- return nil
|
|
|
-end
|
|
|
-
|
|
|
-function ws.send(sock, data)
|
|
|
- if sock then
|
|
|
- return sock:send(data)
|
|
|
- end
|
|
|
- return false, "no socket"
|
|
|
-end
|
|
|
-
|
|
|
-function ws.close(sock)
|
|
|
- if sock then
|
|
|
- sock:close()
|
|
|
+ for mac, info in pairs(state.dhcp) do
|
|
|
+ f:write("dhcp:" .. mac .. "->" .. info.ip .. "\n")
|
|
|
+ end
|
|
|
+ f:close()
|
|
|
end
|
|
|
end
|
|
|
|
|
|
-function ws.connected(sock)
|
|
|
- return sock ~= nil
|
|
|
-end
|
|
|
-
|
|
|
-function build_event(event_type, payload)
|
|
|
- return json_encode({
|
|
|
- type = event_type,
|
|
|
- router_id = cfg.router_id,
|
|
|
- timestamp = os.time(),
|
|
|
- payload = payload
|
|
|
- })
|
|
|
-end
|
|
|
-
|
|
|
-- ============================================================================
|
|
|
--- ASYNC POLLING WITH LUV
|
|
|
+-- WIFI: Get hostapd interfaces + SSID + clients
|
|
|
-- ============================================================================
|
|
|
|
|
|
--- Device polling results storage
|
|
|
-local poll_results = {}
|
|
|
-local poll_count = 0
|
|
|
-local poll_total = 0
|
|
|
-
|
|
|
--- Poll a single device via ubus
|
|
|
-local function poll_single_device(device_id, device_ip)
|
|
|
- local cmd = string.format(
|
|
|
- 'ubus call network.interface.%s status 2>/dev/null',
|
|
|
- device_id
|
|
|
- )
|
|
|
-
|
|
|
- local f = io.popen(cmd)
|
|
|
- if not f then
|
|
|
- return { status = "error", ip = device_ip }
|
|
|
+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
|
|
|
-
|
|
|
- local result = f:read("*all")
|
|
|
- f:close()
|
|
|
-
|
|
|
- local parsed = json_decode(result)
|
|
|
- parsed.status = "online"
|
|
|
- parsed.ip = device_ip
|
|
|
-
|
|
|
- return parsed
|
|
|
+ return interfaces
|
|
|
end
|
|
|
|
|
|
--- Async parallel polling with luv
|
|
|
-local function poll_devices_parallel(device_list)
|
|
|
- if not luv then
|
|
|
- -- Fallback: sequential
|
|
|
- for _, dev in ipairs(device_list) do
|
|
|
- poll_results[dev.id] = poll_single_device(dev.id, dev.ip)
|
|
|
+local function get_ssid_name(iface)
|
|
|
+ local wlan_map = {
|
|
|
+ ["hostapd.wlan0-1"] = "wlan0-1",
|
|
|
+ ["hostapd.wlan0-2"] = "wlan0-2",
|
|
|
+ }
|
|
|
+ local wlan_iface = wlan_map[iface] or iface
|
|
|
+
|
|
|
+ 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
|
|
|
- return
|
|
|
end
|
|
|
+ return iface
|
|
|
+end
|
|
|
|
|
|
- poll_results = {}
|
|
|
- poll_count = 0
|
|
|
- poll_total = #device_list
|
|
|
-
|
|
|
- log("info", "Starting parallel poll of " .. poll_total .. " devices")
|
|
|
-
|
|
|
- -- Poll each device in parallel using luv async
|
|
|
- for _, dev in ipairs(device_list) do
|
|
|
- local device_id = dev.id
|
|
|
- local device_ip = dev.ip
|
|
|
-
|
|
|
- -- Run in async task
|
|
|
- luv.new_task(function()
|
|
|
- local result = poll_single_device(device_id, device_ip)
|
|
|
- poll_results[device_id] = result
|
|
|
-
|
|
|
- poll_count = poll_count + 1
|
|
|
- if poll_count == poll_total then
|
|
|
- log("info", "All " .. poll_total .. " devices polled")
|
|
|
- -- Process results here if needed
|
|
|
+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-%d)")
|
|
|
+ if iface then current_iface = iface end
|
|
|
+ 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
|
|
|
end
|
|
|
+ return ssids
|
|
|
end
|
|
|
|
|
|
--- ============================================================================
|
|
|
--- WIFI EVENTS (hostapd via ubus)
|
|
|
--- ============================================================================
|
|
|
-
|
|
|
-local function monitor_wifi_events()
|
|
|
- if not luv then
|
|
|
- log("warn", "luv not available for WiFi monitoring")
|
|
|
- return
|
|
|
+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
|
|
|
-
|
|
|
- -- Use luv to watch ubus for wireless events
|
|
|
- -- Note: ubus doesn't support event subscription directly,
|
|
|
- -- so we poll hostapd status periodically
|
|
|
-
|
|
|
- local timer = luv.new_timer()
|
|
|
- local last_clients = {}
|
|
|
-
|
|
|
- luv.timer_start(timer, 10000, 10000, function()
|
|
|
- -- Poll wireless clients
|
|
|
- local f = io.popen("ubus call hostapd.wlan0-1 get_clients 2>/dev/null")
|
|
|
+
|
|
|
+ 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("*all")
|
|
|
+ local result = f:read("*a")
|
|
|
f:close()
|
|
|
-
|
|
|
- if result and result ~= "" then
|
|
|
- -- Parse clients and detect changes
|
|
|
- local current_clients = {}
|
|
|
- for mac in result:gmatch('"([^"]+)":') do
|
|
|
- current_clients[mac] = true
|
|
|
- end
|
|
|
-
|
|
|
- -- Detect new connections
|
|
|
- for mac, _ in pairs(current_clients) do
|
|
|
- if not last_clients[mac] then
|
|
|
- local ev = build_event("wifi_connect", {
|
|
|
- mac = mac,
|
|
|
- interface = "wlan0-1"
|
|
|
- })
|
|
|
- log("info", "WiFi connected: " .. mac)
|
|
|
- buffer.add(ev)
|
|
|
+ if result:find('"clients"') and not result:find('"clients":%s*{}') then
|
|
|
+ local mac_count = 0
|
|
|
+ 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] }
|
|
|
+ mac_count = mac_count + 1
|
|
|
end
|
|
|
end
|
|
|
-
|
|
|
- -- Detect disconnections
|
|
|
- for mac, _ in pairs(last_clients) do
|
|
|
- if not current_clients[mac] then
|
|
|
- local ev = build_event("wifi_disconnect", {
|
|
|
- mac = mac,
|
|
|
- interface = "wlan0-1"
|
|
|
- })
|
|
|
- log("info", "WiFi disconnected: " .. mac)
|
|
|
- buffer.add(ev)
|
|
|
- end
|
|
|
- end
|
|
|
-
|
|
|
- last_clients = current_clients
|
|
|
+ count = count + mac_count
|
|
|
end
|
|
|
end
|
|
|
- end)
|
|
|
-
|
|
|
- log("info", "WiFi event monitor started")
|
|
|
-end
|
|
|
-
|
|
|
--- ============================================================================
|
|
|
--- DHCP LEASES HELPERS (must be before monitor functions)
|
|
|
--- ============================================================================
|
|
|
-
|
|
|
--- Helper: read current leases
|
|
|
-local function read_leases()
|
|
|
- local leases = {}
|
|
|
- local f = io.open("/var/lib/dnsmasq/dnsmasq.leases", "r")
|
|
|
- if not f then return leases end
|
|
|
-
|
|
|
- for line in f:lines() do
|
|
|
- local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
|
|
|
- if mac then
|
|
|
- leases[mac] = { ip = ip, hostname = name, time = tonumber(ts) }
|
|
|
- end
|
|
|
end
|
|
|
- f:close()
|
|
|
- return leases
|
|
|
+ return clients
|
|
|
end
|
|
|
|
|
|
--- Helper: process leases and detect changes
|
|
|
-local function process_leases(old_leases, callback)
|
|
|
- local leases = read_leases()
|
|
|
-
|
|
|
- -- New leases
|
|
|
- for mac, info in pairs(leases) do
|
|
|
- if not old_leases[mac] then
|
|
|
- callback(mac, info.ip, info.hostname, "new")
|
|
|
- end
|
|
|
- end
|
|
|
-
|
|
|
- -- Expired leases
|
|
|
- for mac, info in pairs(old_leases) do
|
|
|
- if not leases[mac] then
|
|
|
- callback(mac, info.ip, info.hostname, "expired")
|
|
|
- end
|
|
|
+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 LEASES MONITOR
|
|
|
+-- DHCP: Get leases via ubus
|
|
|
-- ============================================================================
|
|
|
|
|
|
-local function monitor_dhcp_leases()
|
|
|
- if not luv then
|
|
|
- -- Fallback: sequential file polling
|
|
|
- monitor_dhcp_sequential()
|
|
|
- return
|
|
|
- end
|
|
|
-
|
|
|
- -- Use timer-based polling instead of fs_event (more compatible)
|
|
|
- local timer = luv.new_timer()
|
|
|
- local old_leases = {}
|
|
|
+local function get_dhcp_leases()
|
|
|
+ local leases = {}
|
|
|
|
|
|
- luv.timer_start(timer, cfg.dhcp_poll_interval * 1000, cfg.dhcp_poll_interval * 1000, function()
|
|
|
- -- File changed, process leases
|
|
|
- process_leases(old_leases, function(mac, ip, hostname, action)
|
|
|
- local ev = build_event("dhcp_lease", {
|
|
|
- mac = mac,
|
|
|
- ip = ip,
|
|
|
- hostname = hostname,
|
|
|
- action = action
|
|
|
- })
|
|
|
- log("info", "DHCP: " .. action .. " - " .. mac .. " -> " .. ip)
|
|
|
- buffer.add(ev)
|
|
|
- end)
|
|
|
-
|
|
|
- old_leases = read_leases()
|
|
|
- end)
|
|
|
+ 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
|
|
|
|
|
|
- log("info", "DHCP lease monitor started")
|
|
|
-end
|
|
|
-
|
|
|
--- Fallback: sequential DHCP monitoring
|
|
|
-local function monitor_dhcp_sequential()
|
|
|
- local old_leases = {}
|
|
|
-
|
|
|
- while true do
|
|
|
- process_leases(old_leases, function(mac, ip, hostname, action)
|
|
|
- local ev = build_event("dhcp_lease", {
|
|
|
- mac = mac,
|
|
|
- ip = ip,
|
|
|
- hostname = hostname,
|
|
|
- action = action
|
|
|
- })
|
|
|
- log("info", "DHCP: " .. action .. " - " .. mac .. " -> " .. ip)
|
|
|
- buffer.add(ev)
|
|
|
- end)
|
|
|
-
|
|
|
- old_leases = read_leases()
|
|
|
- os.execute("sleep " .. cfg.dhcp_poll_interval)
|
|
|
+ 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 and is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac, hostname=h} end
|
|
|
+ end
|
|
|
+ f:close()
|
|
|
end
|
|
|
+ return leases
|
|
|
end
|
|
|
|
|
|
-- ============================================================================
|
|
|
--- WAN MONITORING
|
|
|
+-- MAIN LOOP (with luv async)
|
|
|
-- ============================================================================
|
|
|
|
|
|
-local last_wan_state = nil
|
|
|
+local state = { clients = {}, ssid_enabled = {}, dhcp = {} }
|
|
|
|
|
|
-local function monitor_wan()
|
|
|
- if not luv then
|
|
|
- -- Fallback: sequential WAN polling
|
|
|
- monitor_wan_sequential()
|
|
|
- return
|
|
|
+local function check_all()
|
|
|
+ -- 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
|
|
|
-
|
|
|
- local timer = luv.new_timer()
|
|
|
-
|
|
|
- luv.timer_start(timer, cfg.wan_poll_interval * 1000, cfg.wan_poll_interval * 1000, function()
|
|
|
- local f = io.popen("ubus call network.interface.wan status 2>/dev/null")
|
|
|
- if f then
|
|
|
- local status = f:read("*all")
|
|
|
- f:close()
|
|
|
-
|
|
|
- local is_up = status:match('"up":%s*true') ~= nil
|
|
|
-
|
|
|
- if is_up ~= last_wan_state then
|
|
|
- local ev = build_event("wan_status", {
|
|
|
- up = is_up
|
|
|
- })
|
|
|
- log("info", "WAN: " .. (is_up and "up" or "down"))
|
|
|
- buffer.add(ev)
|
|
|
- last_wan_state = is_up
|
|
|
- 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)
|
|
|
-
|
|
|
- log("info", "WAN monitor started")
|
|
|
-end
|
|
|
-
|
|
|
--- Fallback: sequential WAN monitoring
|
|
|
-local function monitor_wan_sequential()
|
|
|
- while true do
|
|
|
- local f = io.popen("ubus call network.interface.wan status 2>/dev/null")
|
|
|
- if f then
|
|
|
- local status = f:read("*all")
|
|
|
- f:close()
|
|
|
-
|
|
|
- local is_up = status:match('"up":%s*true') ~= nil
|
|
|
-
|
|
|
- if is_up ~= last_wan_state then
|
|
|
- local ev = build_event("wan_status", {
|
|
|
- up = is_up
|
|
|
- })
|
|
|
- log("info", "WAN: " .. (is_up and "up" or "down"))
|
|
|
- buffer.add(ev)
|
|
|
- last_wan_state = is_up
|
|
|
- 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
|
|
|
-
|
|
|
- os.execute("sleep " .. cfg.wan_poll_interval)
|
|
|
end
|
|
|
-end
|
|
|
-
|
|
|
--- ============================================================================
|
|
|
--- NETWORK STATUS POLLING
|
|
|
--- ============================================================================
|
|
|
-
|
|
|
-local function poll_network_status()
|
|
|
- if not luv then
|
|
|
- -- Sequential fallback
|
|
|
- local f = io.popen("ubus call network getStatus 2>/dev/null")
|
|
|
- if f then f:close() end
|
|
|
- return
|
|
|
+ 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
|
|
|
-
|
|
|
- local timer = luv.new_timer()
|
|
|
-
|
|
|
- luv.timer_start(timer, cfg.poll_interval * 1000, cfg.poll_interval * 1000, function()
|
|
|
- local f = io.popen("ubus call network getStatus 2>/dev/null")
|
|
|
- if f then
|
|
|
- local status = f:read("*all")
|
|
|
- f:close()
|
|
|
-
|
|
|
- if status and status ~= "" then
|
|
|
- local ev = build_event("network_status", {
|
|
|
- status = status
|
|
|
- })
|
|
|
- buffer.add(ev)
|
|
|
- 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)
|
|
|
-
|
|
|
- log("info", "Network status poller started")
|
|
|
+ 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
|
|
|
|
|
|
-- ============================================================================
|
|
|
--- MAIN EVENT LOOP
|
|
|
+-- MAIN
|
|
|
-- ============================================================================
|
|
|
|
|
|
-local function main()
|
|
|
- log("info", "client2server-luv starting...")
|
|
|
- log("info", "Router: " .. cfg.router_id)
|
|
|
- log("info", "Server: " .. cfg.url)
|
|
|
-
|
|
|
+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 - using async mode")
|
|
|
+ log_info("luv available - using async mode")
|
|
|
else
|
|
|
- log("warn", "luv NOT available - using blocking mode")
|
|
|
+ log_info("luv NOT available - using blocking mode")
|
|
|
end
|
|
|
-
|
|
|
- -- Load buffered events
|
|
|
- buffer.load()
|
|
|
-
|
|
|
- -- Save PID
|
|
|
- local pf = io.open("/var/run/client2server-luv.pid", "w")
|
|
|
- if pf then
|
|
|
- local f = io.popen("echo $$")
|
|
|
- local pid = f and f:read("*a") or "0"
|
|
|
- if f then f:close() end
|
|
|
- pf:write(pid:gsub("%s+", ""))
|
|
|
- pf:close()
|
|
|
- end
|
|
|
-
|
|
|
- local sock = nil
|
|
|
- local retries = 0
|
|
|
-
|
|
|
+
|
|
|
+ -- 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
|
|
|
- -- Use luv event loop
|
|
|
- -- luv.run() starts the event loop. The callbacks we registered
|
|
|
- -- (timers, fs_events) will run automatically.
|
|
|
- local function start_monitors()
|
|
|
- -- Start all monitors in parallel
|
|
|
- monitor_wifi_events()
|
|
|
- monitor_dhcp_leases()
|
|
|
- monitor_wan()
|
|
|
- poll_network_status()
|
|
|
-
|
|
|
- -- Keep the event loop running
|
|
|
- local idle = luv.new_idle()
|
|
|
- luv.idle_start(idle, function()
|
|
|
- -- Idle work - just keep loop alive
|
|
|
- end)
|
|
|
- end
|
|
|
-
|
|
|
- -- Start monitors first, then run the event loop with WebSocket
|
|
|
- local function run_event_loop()
|
|
|
- -- Register all monitors
|
|
|
- monitor_wifi_events()
|
|
|
- monitor_dhcp_leases()
|
|
|
- monitor_wan()
|
|
|
- poll_network_status()
|
|
|
+ -- ASYNC MODE with luv
|
|
|
+ local timer = luv.new_timer()
|
|
|
+
|
|
|
+ local function run_loop()
|
|
|
+ check_all()
|
|
|
|
|
|
- -- Keep the event loop running with idle
|
|
|
- local idle = luv.new_idle()
|
|
|
- luv.idle_start(idle, function()
|
|
|
- -- Idle work - check WebSocket periodically
|
|
|
- end)
|
|
|
-
|
|
|
- -- Direct HTTP POST on each event (like minimal version)
|
|
|
- log("info", "Using direct HTTP POST (curl)")
|
|
|
-
|
|
|
- -- The monitors call buffer.add() which stores events.
|
|
|
- -- We need a periodic flush that calls http_post()
|
|
|
- local flush_timer = luv.new_timer()
|
|
|
- luv.timer_start(flush_timer, 10000, 10000, function()
|
|
|
- -- Flush buffered events
|
|
|
- for _, event_json in ipairs(buffer.events) do
|
|
|
- local event_type = "unknown"
|
|
|
- event_type = event_json:match('"type":"([^"]+)"') or event_type
|
|
|
- local data = event_json -- Already JSON
|
|
|
- http_post(event_type, data)
|
|
|
- end
|
|
|
- buffer.clear()
|
|
|
+ -- Schedule next check
|
|
|
+ luv.timer_start(timer, cfg.check_interval * 1000, cfg.check_interval * 1000, function()
|
|
|
+ run_loop()
|
|
|
end)
|
|
|
end
|
|
|
-
|
|
|
+
|
|
|
+ -- Start first check after initial delay
|
|
|
+ luv.timer_start(timer, cfg.check_interval * 1000, 0, function()
|
|
|
+ run_loop()
|
|
|
+ end)
|
|
|
+
|
|
|
-- Run the event loop
|
|
|
- local ok, err = pcall(function() luv.run(run_event_loop) end)
|
|
|
- if not ok then
|
|
|
- log("err", "luv.run error: " .. tostring(err))
|
|
|
- -- Fallback: sequential mode
|
|
|
- while true do
|
|
|
- os.execute("sleep 60")
|
|
|
- end
|
|
|
- end
|
|
|
+ luv.run()
|
|
|
+
|
|
|
else
|
|
|
- -- Fallback: sequential mode
|
|
|
+ -- BLOCKING MODE (fallback)
|
|
|
while true do
|
|
|
- -- Sequential monitoring
|
|
|
- monitor_dhcp_sequential()
|
|
|
- monitor_wan_sequential()
|
|
|
-
|
|
|
- -- WebSocket connection
|
|
|
- log("info", "Connecting to server...")
|
|
|
-
|
|
|
- if ws_client then
|
|
|
- sock = ws.connect(cfg.url)
|
|
|
- end
|
|
|
-
|
|
|
- if sock then
|
|
|
- log("info", "Connected!")
|
|
|
- retries = 0
|
|
|
-
|
|
|
- buffer.flush(function(data)
|
|
|
- return ws.send(sock, data)
|
|
|
- end)
|
|
|
-
|
|
|
- local loop_count = 0
|
|
|
- while ws.connected(sock) and loop_count < (cfg.ping_interval / 5) do
|
|
|
- os.execute("sleep 5")
|
|
|
- loop_count = loop_count + 1
|
|
|
-
|
|
|
- buffer.flush(function(data)
|
|
|
- return ws.send(sock, data)
|
|
|
- end)
|
|
|
- end
|
|
|
- else
|
|
|
- log("err", "Connection failed")
|
|
|
- retries = retries + 1
|
|
|
- end
|
|
|
-
|
|
|
- ws.close(sock)
|
|
|
- buffer.save()
|
|
|
- os.execute("sleep " .. cfg.reconnect_delay)
|
|
|
+ check_all()
|
|
|
+ local timer = luv.new_timer()
|
|
|
+ luv.timer_start(timer, cfg.check_interval * 1000, 0, function() end)
|
|
|
+ luv.run()
|
|
|
+ -- Fallback if no luv
|
|
|
+ os.execute("sleep " .. cfg.check_interval)
|
|
|
end
|
|
|
end
|
|
|
end
|
|
|
|
|
|
--- ============================================================================
|
|
|
--- START
|
|
|
--- ============================================================================
|
|
|
-
|
|
|
main()
|