module("luci.controller.conntrack", package.seeall) function index() if not nixio.fs.access("/proc/net/nf_conntrack") then return end entry({"admin", "network", "conntrack"}, template("conntrack/conntrack"), _("ConnTrack"), 90).dependent = true entry({"admin", "network", "conntrack", "get_settings"}, call("action_get_settings")) entry({"admin", "network", "conntrack", "set_settings"}, call("action_set_settings")) entry({"admin", "network", "conntrack_stream"}, call("action_stream")).dependent = true end function action_get_settings() local uci = require("luci.model.uci").cursor() local jsonc = require("luci.jsonc") luci.http.prepare_content("application/json") local res = { interval = uci:get("conntrack", "settings", "interval") or "1500", rows = uci:get("conntrack", "settings", "rows") or "50", filter_local = uci:get("conntrack", "settings", "filter_local") or "true", show_appname = uci:get("conntrack", "settings", "show_appname") or "false" } luci.http.write(jsonc.stringify(res)) end function action_set_settings() local uci = require("luci.model.uci").cursor() local jsonc = require("luci.jsonc") luci.http.prepare_content("application/json") local raw_body = luci.http.content() if raw_body then local data = jsonc.parse(raw_body) if data and type(data) == "table" then local has_settings = false uci:foreach("conntrack", "global", function(s) if s[".name"] == "settings" then has_settings = true return false end end) if not has_settings then uci:section("conntrack", "global", "settings") end if data.interval then uci:set("conntrack", "settings", "interval", tostring(data.interval)) end if data.rows then uci:set("conntrack", "settings", "rows", tostring(data.rows)) end if data.filter_local ~= nil then uci:set("conntrack", "settings", "filter_local", tostring(data.filter_local)) end if data.show_appname ~= nil then uci:set("conntrack", "settings", "show_appname", tostring(data.show_appname)) end local commit_ok = uci:commit("conntrack") if commit_ok then luci.http.write('{"status":"ok"}') else luci.http.write('{"status":"commit_failed"}') end return end end luci.http.write('{"status":"error"}') end local function nixio_lines_fast(path) local nixio = require("nixio") local fd = nixio.open(path, "r") if not fd then return function() return nil end end local chunks = {} while true do local chunk = fd:read(65536) if not chunk or #chunk == 0 then break end table.insert(chunks, chunk) end fd:close() local content = table.concat(chunks) local pos = 1 local content_len = #content return function() if pos > content_len then return nil end local newline = content:find("\n", pos, true) if newline then local line = content:sub(pos, newline - 1) pos = newline + 1 if #line > 0 and line:sub(-1) == "\r" then line = line:sub(1, -2) end return line else local line = content:sub(pos) pos = content_len + 1 if #line > 0 and line:sub(-1) == "\r" then line = line:sub(1, -2) end return #line > 0 and line or nil end end end local function file_exists(path) local f = io.open(path, "r") if f then f:close() return true else return false end end local function create_file(path) local file, err = io.open(path, "w") if not file then return false else file:close() return true end end local function remove_file(path) if file_exists(path) then os.remove(path) end end -- Experiment: the performance wouldn't be quite good than realtime 'action_strem' function action_stream_ubus() local nixio = require "nixio" local jsonc = require "luci.jsonc" local http = require "luci.http" local conntrack_lock_file = '/tmp/conntrack_event.lock' local interval = tonumber(http.formvalue("interval")) or 1000 local max_rows = tonumber(http.formvalue("rows")) or 50 local filter_src = http.formvalue("filter_src") or "all" local filter_dst = http.formvalue("filter_dst") or "all" local filter_local = http.formvalue("filter_local") or "false" http.header("Content-Type", "text/event-stream; charset=utf-8") http.header("Cache-Control", "no-cache") http.header("Connection", "keep-alive") http.context.redirect = false http.write("retry: 1000\n\n") io.flush() if not file_exists(conntrack_lock_file) then create_file(conntrack_lock_file) end local stat = nixio.fs.stat(conntrack_lock_file) nixio.fs.utimes(conntrack_lock_file, os.time(), stat.atime) local handle = io.popen("ubus listen conntrack_event 2>/dev/null") if not handle then return end for line in handle:lines() do nixio.fs.utimes(conntrack_lock_file, os.time(), stat.atime) local ct = jsonc.parse(line) if ct and ct.conntrack_event then for _, value in ipairs(ct.conntrack_event) do -- [Your custom code goes here] end local success = pcall(function() io.write("data: " .. jsonc.stringify(ct.conntrack_event) .. "\n\n") io.flush() end) if not success then break end end if luci.http.getenv("HTTP_CONNECTION") == "close" then break end end handle:close() end function action_stream() local nixio = require "nixio" local jsonc = require "luci.jsonc" local http = require "luci.http" local interval = tonumber(http.formvalue("interval")) or 1000 local max_rows = tonumber(http.formvalue("rows")) or 50 local filter_src = http.formvalue("filter_src") or "all" local filter_dst = http.formvalue("filter_dst") or "all" local filter_local = http.formvalue("filter_local") or "false" local sort = http.formvalue("sort") or "rate" local lan_prefix = "192.168.1." local lan_ip = luci.util.exec("uci -q get network.lan.ipaddr"):gsub("%s+", "") if lan_ip and lan_ip ~= "" then if lan_ip:match("^192%.168%.") or lan_ip:match("^10%.") or lan_ip:match("^172%.") then lan_prefix = lan_ip:match("^([%d%.]+%.)%d+$") or "192.168.1." end end local function is_local_ip(ip) if not ip then return true end local clean_ip = ip:lower():gsub("%s+", ""):gsub("%[", ""):gsub("%]", "") if clean_ip == "127.0.0.1" or clean_ip == "0.0.0.0" or clean_ip == "::1" or clean_ip == "::" then return true end local no_zeros = clean_ip:gsub("0", "") if no_zeros == ":::::::1" or no_zeros == "::::::::" then return true end if clean_ip:find("^192%.168%.") or clean_ip:find("^10%.") or clean_ip:find("^172%.1[6-9]%.") or clean_ip:find("^172%.2%d%.") or clean_ip:find("^172%.3%.") then return true end if clean_ip:find("^fc") or clean_ip:find("^fd") then return true end if clean_ip:find("^fe[89ab]") then return true end return false end local function normalize_ipv6(ip) if not ip or not ip:find(":") then return ip end local clean_ip = ip:lower():gsub("%s+", "") if clean_ip:find("::") then local _, count = clean_ip:gsub(":", "") local colons_to_add = 8 - count local replacement = ":" for i = 1, colons_to_add do replacement = replacement .. ":" end clean_ip = clean_ip:gsub("::", replacement) end local parts = {} for part in clean_ip:gmatch("([^:]+)") do while #part < 4 do part = "0" .. part end table.insert(parts, part) end while #parts < 8 do table.insert(parts, "0000") end return table.concat(parts, ":") end local lease_map = {} local mac_host_map = {} local ipv6_mac_map = {} local lf = io.open("/tmp/dhcp.leases", "r") if lf then for line in lf:lines() do local mac, ip, host = line:match("^%d+%s+([%a%d%:]+)%s+([%d%.%a%d%:]+)%s+([%w%-_]+)") if not mac or not host then ip, host = line:match("^%d+%s+[%a%d%:]+%s+([%d%.]+)%s+([%w%-_]+)") end if ip and host and host ~= "*" then lease_map[ip] = host end if mac and host and host ~= "*" then mac_host_map[mac:lower()] = host end end lf:close() end local sys_host = luci.util.exec("uci -q get system.@system[0].hostname"):gsub("%s+", "") if sys_host == "" then sys_host = "lan" end if lan_ip and lan_ip ~= "" then lease_map[lan_ip] = sys_host end -- adding ipv6 <-> host mapping local np = io.popen("ip -6 neighbor | grep -E -v 'FAILED'") if np then for line in np:lines() do local ip, mac = line:match("^([%a%d%:]+).-lladdr%s+([%a%d%:]+)") if ip and mac then local clean_ip = ip:lower():gsub("%s+", "") if not is_local_ip(clean_ip) then local norm_ip = normalize_ipv6(clean_ip) ipv6_mac_map[norm_ip] = mac:lower() end end end np:close() end for ipv6, mac in pairs(ipv6_mac_map) do local host = mac_host_map[mac] if host then lease_map[ipv6] = host end end if interval < 500 then interval = 500 end if interval > 10000 then interval = 10000 end http.header("Content-Type", "text/event-stream; charset=utf-8") http.header("Cache-Control", "no-cache") http.header("Connection", "keep-alive") http.context.redirect = false http.write("retry: 1000\n\n") io.flush() local function get_current_time() local sec, usec = nixio.gettimeofday() return sec + (usec / 1000000) end local last_connections = {} local last_timestamp = get_current_time() local layer3, proto, state, src1, dst1, sport1, dport1, bytes1, src2, dst2, sport2, dport2, bytes2, remain local type1, code1, id1, type2, code2, id2 local srckey1, dstkey1, srckey2, dstkey2 while true do local sec = math.floor(interval / 1000) local nsec = (interval % 1000) * 1000000 -- nixio.nanosleep(sec, nsec) local current_timestamp = get_current_time() local current_connections = {} local ip_map = {} -- local f = io.open("/proc/net/nf_conntrack", "r") -- if not f then break end -- for line in f:lines() do for line in nixio_lines_fast("/proc/net/nf_conntrack") do local s_end = line:find("src=", 1, true) local has_state = true local remain = "" local payload = "" if s_end and line:byte(s_end-2) >= 48 and line:byte(s_end-2) <= 57 then has_state = false end if has_state then layer3, proto, state, src1, dst1, sport1, dport1, bytes1, src2, dst2, sport2, dport2, bytes2, remain = line:match("^(%w+)%s+%d+%s+(%w+)%s+%d+%s+%d+%s+(%S+)%s+src=([%a%d%.%:]+)%s+dst=([%a%d%.%:]+)%s+sport=(%d+)%s+dport=(%d+).-bytes=(%d+).-src=([%a%d%.%:]+)%s+dst=([%a%d%.%:]+)%s+sport=(%d+)%s+dport=(%d+).-bytes=(%d+)(.*)") else state = 'UNTRACKED' layer3, proto, src1, dst1, sport1, dport1, bytes1, src2, dst2, sport2, dport2, bytes2, remain = line:match("^(%w+)%s+%d+%s+(%w+)%s+%d+%s+%d+%s+src=([%a%d%.%:]+)%s+dst=([%a%d%.%:]+)%s+sport=(%d+)%s+dport=(%d+).-bytes=(%d+).-src=([%a%d%.%:]+)%s+dst=([%a%d%.%:]+)%s+sport=(%d+)%s+dport=(%d+).-bytes=(%d+)(.*)") end if not layer3 then -- icmp layer3, proto, src1, dst1, type1, code1, id1, bytes1, src2, dst2, type2, code2, id2, bytes2, remain = line:match("^(%w+)%s+%d+%s+(%w+)%s+%d+%s+%d+%s+src=([%a%d%.%:]+)%s+dst=([%a%d%.%:]+)%s+type=(%d+)%s+code=(%d+)%s+id=(%d+).-bytes=(%d+).-src=([%a%d%.%:]+)%s+dst=([%a%d%.%:]+)%s+type=(%d+)%s+code=(%d+)%s+id=(%d+).-bytes=(%d+)(.*)") if not layer3 then -- gre layer3, proto, src1, dst1, srckey1, dstkey1, bytes1, src2, dst2, srckey2, dstkey2, bytes2, remain = line:match("^(%w+)%s+%d+%s+(%w+)%s+%d+%s+%d+%s+src=([%a%d%.%:]+)%s+dst=([%a%d%.%:]+)%s+srckey=(%S+)%s+dstkey=(%S+).-bytes=(%d+).-src=([%a%d%.%:]+)%s+dst=([%a%d%.%:]+)%s+srckey=(%S+)%s+dstkey=(%S+).-bytes=(%d+)(.*)") end if layer3 then sport1, dport1, sport2, dport2 = "", "", "", "" end end if remain then payload = remain:match("payload=(%S+)") or "" end if layer3 then if bytes1 and bytes2 then local display_proto = proto if proto == "udp" and (sport1 == "443" or dport1 == "443" or sport2 == "443" or dport2 == "443") then display_proto = "quic" end if filter_local == "true" and is_local_ip(src1) and is_local_ip(dst1) then -- do nothing else if layer3 == "ipv4" then -- IPv4 DNAT revert -- if src2 == lan_ip or src2 == '127.0.0.1' then src2 = dst1 sport2 = dport1 end -- IPv4 SNAT revert -- if dst2 ~= src1 then dst2 = src1 dport2 = sport1 end end ip_map[src1] = true ip_map[dst1] = true ip_map[src2] = true ip_map[dst2] = true local pass_orig = true if filter_src ~= "all" and src1 ~= filter_src then pass_orig = false end if filter_dst ~= "all" and dst1 ~= filter_dst then pass_orig = false end if pass_orig then local key_orig = string.format("%s_%s_%s:%s->%s:%s_ORIG", layer3, display_proto, src1, sport1, dst1, dport1) current_connections[key_orig] = { l3 = layer3, proto = display_proto, state = state, src = src1, sport = sport1, dst = dst1, dport = dport1, bytes = tonumber(bytes1), speed = 0, payload = payload } end local pass_repl = true if filter_src ~= "all" and src2 ~= filter_src then pass_repl = false end if filter_dst ~= "all" and dst2 ~= filter_dst then pass_repl = false end if pass_repl then local key_repl = string.format("%s_%s_%s:%s->%s:%s_REPL", layer3, display_proto, src2, sport2, dst2, dport2) current_connections[key_repl] = { l3 = layer3, proto = display_proto, state = state, src = src2, sport = sport2, dst = dst2, dport = dport2, bytes = tonumber(bytes2), speed = 0, payload = payload } end end end end end -- f:close() local lines_proc_time = get_current_time() - current_timestamp local delta_time = current_timestamp - last_timestamp if delta_time > 0 then for key, curr in pairs(current_connections) do local prev = last_connections[key] if prev then local diff = curr.bytes - prev.bytes curr.speed = diff >= 0 and math.floor(diff / delta_time) or 0 else curr.speed = 0 end end end local sorted_list = current_connections local sorted_list = {} for _, conn in pairs(current_connections) do -- if conn.speed > 0 then table.insert(sorted_list, conn) -- end end if sort == 'rate' then table.sort(sorted_list, function(a, b) return a.speed > b.speed end) elseif sort == 'flow' then table.sort(sorted_list, function(a, b) return a.bytes > b.bytes end) end local output_list = {} for i = 1, math.min(#sorted_list, max_rows) do table.insert(output_list, sorted_list[i]) end local lan_ips = {} local v4_ips = {} local v6_ips = {} local lan_pattern = "^" .. lan_prefix:gsub("%.", "%%.") for ip in pairs(ip_map) do if ip:find(":") then table.insert(v6_ips, ip) elseif ip:match(lan_pattern) then table.insert(lan_ips, ip) else table.insert(v4_ips, ip) end end table.sort(lan_ips) table.sort(v4_ips) table.sort(v6_ips) local unique_ips = {} for _, ip in ipairs(lan_ips) do table.insert(unique_ips, ip) end for _, ip in ipairs(v4_ips) do table.insert(unique_ips, ip) end for _, ip in ipairs(v6_ips) do table.insert(unique_ips, ip) end local response = { lines_proc_time = string.format("%.2f", lines_proc_time), delta = string.format("%.2f", delta_time), connections = output_list, unique_ips = unique_ips, host_map = lease_map } local json_str = jsonc.stringify(response) local ok = pcall(function() io.write("data: " .. json_str .. "\n\n") io.flush() end) if not ok then break end if luci.http.getenv("HTTP_CONNECTION") == "close" then break end last_connections = current_connections last_timestamp = current_timestamp nixio.nanosleep(sec, nsec) end end