mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-02 19:03:56 +08:00
🤞 Sync 2026-09-27 22:16:29
This commit is contained in:
1 parent
8ede4a8320
commit
618f51e9dc
459 files changed
+44817
-2191
No files matched your search
@@ -0,0 +1,228 @@
|
||||
--- a/net/netfilter/Kconfig 2026-09-15 14:22:18.467281704 +0800
|
||||
+++ b/net/netfilter/Kconfig 2026-09-11 12:51:21.318346650 +0800
|
||||
@@ -443,6 +443,20 @@
|
||||
config NETFILTER_SYNPROXY
|
||||
tristate
|
||||
|
||||
+config NF_CONNTRACK_CUSTOM_TELEMETRY
|
||||
+ bool "Lightweight Layer 7 Payload Telemetry via conntrack"
|
||||
+ depends on NF_CONNTRACK
|
||||
+ default y
|
||||
+ help
|
||||
+ This option intercepts the first payload-carrying packet of any
|
||||
+ new connection (TCP/UDP) and stores its initial 8 bytes. This
|
||||
+ data is then appended to the /proc/net/nf_conntrack stream.
|
||||
+
|
||||
+ Perfect for lightweight gaming/video traffic identification
|
||||
+ without running heavy user-space DPI tools.
|
||||
+
|
||||
+ If unsure, say Y.
|
||||
+
|
||||
endif # NF_CONNTRACK
|
||||
|
||||
config NF_TABLES
|
||||
--- a/include/net/netfilter/nf_conntrack.h 2024-01-26 06:34:33.000000000 +0800
|
||||
+++ b/include/net/netfilter/nf_conntrack.h 2026-09-14 11:54:57.287192651 +0800
|
||||
@@ -103,6 +103,15 @@
|
||||
/* Extensions */
|
||||
struct nf_ct_ext *ext;
|
||||
|
||||
+#if IS_ENABLED(CONFIG_NF_CONNTRACK_CUSTOM_TELEMETRY)
|
||||
+ /*==================================================================
|
||||
+ * CUSTOM FIELDS FOR LIGHTWEIGHT L7 TELEMETRY (STRUCTURE SAFE)
|
||||
+ * Placed safely before the protocol union to avoid alignment bugs.
|
||||
+ *==================================================================*/
|
||||
+ u8 init_payload[64]; /* Stores the 64 bytes of L7 payload that maybe plain text rather than hex string */
|
||||
+ u8 has_custom_payload; /* Boolean flag: 1 if raw hex string was captured, 2 if plain text was captured */
|
||||
+#endif
|
||||
+
|
||||
/* Storage reserved for other modules, must be the last member */
|
||||
union nf_conntrack_proto proto;
|
||||
};
|
||||
--- a/net/netfilter/nf_conntrack_core.c 2026-09-15 14:22:18.467281704 +0800
|
||||
+++ b/net/netfilter/nf_conntrack_core.c 2026-09-15 14:19:35.912229303 +0800
|
||||
@@ -1710,6 +1710,156 @@
|
||||
goto out;
|
||||
}
|
||||
|
||||
+#if IS_ENABLED(CONFIG_NF_CONNTRACK_CUSTOM_TELEMETRY)
|
||||
+ /* =================== CUSTOM L7 TELEMETRY START =================== */
|
||||
+ /* 1. Only process successfully accepted packets if telemetry capture has not locked yet */
|
||||
+ if (ret == NF_ACCEPT && ct != NULL && !ct->has_custom_payload) {
|
||||
+ struct iphdr *iph = ip_hdr(skb);
|
||||
+ if (iph) {
|
||||
+ unsigned int l4_header_len = 0;
|
||||
+
|
||||
+ if (protonum == IPPROTO_TCP) {
|
||||
+ struct tcphdr *th = tcp_hdr(skb);
|
||||
+ if (th && th->doff >= 5 && th->doff <= 15) {
|
||||
+ l4_header_len = th->doff * 4;
|
||||
+ }
|
||||
+ } else if (protonum == IPPROTO_UDP) {
|
||||
+ l4_header_len = sizeof(struct udphdr);
|
||||
+ }
|
||||
+
|
||||
+ if (l4_header_len > 0) {
|
||||
+ unsigned int total_len = ntohs(iph->tot_len);
|
||||
+ unsigned int payload_offset = dataoff + l4_header_len;
|
||||
+
|
||||
+ if (total_len > payload_offset && protonum == IPPROTO_UDP) {
|
||||
+ unsigned int payload_len = total_len - payload_offset;
|
||||
+ if (payload_len >= 8) {
|
||||
+ u8 udp_buf[8];
|
||||
+ unsigned int target_grab_len = (payload_len > 8) ? 8 : payload_len;
|
||||
+ u8 *udp_ptr = skb_header_pointer(skb, payload_offset, target_grab_len, udp_buf);
|
||||
+
|
||||
+ if (udp_ptr) {
|
||||
+ memset(ct->init_payload, 0, 64);
|
||||
+ memcpy(ct->init_payload, udp_ptr, target_grab_len);
|
||||
+ ct->has_custom_payload = 1;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ if (total_len > payload_offset && protonum == IPPROTO_TCP) {
|
||||
+ unsigned int payload_len = total_len - payload_offset;
|
||||
+
|
||||
+ /* PRE-FILTER BYPASS ANCHOR: Fast drop non-TLS or ultra-short corrupted flows */
|
||||
+ if (payload_len > 44) {
|
||||
+ u8 tls_pre_buf[2];
|
||||
+ u8 *tls_pre = skb_header_pointer(skb, payload_offset, 2, tls_pre_buf);
|
||||
+
|
||||
+ if (tls_pre && tls_pre[0] == 0x16 && tls_pre[1] == 0x03) {
|
||||
+ unsigned int offset = 5; /* Step 1: Skip TLS Record Layer Header safely */
|
||||
+ u8 len_buf[2];
|
||||
+ u8 *p;
|
||||
+
|
||||
+ /* Step 2: Extract Handshake Type (1 byte) and confirm it is Client Hello (0x01) */
|
||||
+ p = skb_header_pointer(skb, payload_offset + offset, 1, len_buf);
|
||||
+ if (p && p[0] == 0x01) {
|
||||
+ offset += 4; /* Move past Handshake Type(1B) + Handshake Length(3B) */
|
||||
+ offset += 2; /* Move past Version (2B, e.g., 0x03 0x03) */
|
||||
+ offset += 32; /* FLY-OVER: Instantly blast through 32 bytes of static Random data via simple addition */
|
||||
+
|
||||
+ /* Step 3: Extract dynamic Session ID length field safely (1 byte) */
|
||||
+ p = skb_header_pointer(skb, payload_offset + offset, 1, len_buf);
|
||||
+ if (p) {
|
||||
+ u8 session_id_len = p[0];
|
||||
+ offset += 1 + session_id_len; /* Skip length descriptor + payload data bytes */
|
||||
+
|
||||
+ /* Step 4: Extract dynamic Cipher Suites length field safely (2 bytes) */
|
||||
+ p = skb_header_pointer(skb, payload_offset + offset, 2, len_buf);
|
||||
+ if (p) {
|
||||
+ /* Use safe shift operations to enforce clean endian alignment on all router CPUs */
|
||||
+ u16 cipher_suites_len = ((u16)p[0] << 8) | p[1];
|
||||
+ offset += 2 + cipher_suites_len; /* Leap over Ciphers grid completely */
|
||||
+
|
||||
+ /* Step 5: Extract dynamic Compression Methods length field safely (1 byte) */
|
||||
+ p = skb_header_pointer(skb, payload_offset + offset, 1, len_buf);
|
||||
+ if (p) {
|
||||
+ u8 compression_len = p[0];
|
||||
+ offset += 1 + compression_len; /* Skip Compression descriptors */
|
||||
+
|
||||
+ /* Step 6: We have successfully reached the Extensions Array Block! Read Extensions Total Length (2B) */
|
||||
+ p = skb_header_pointer(skb, payload_offset + offset, 2, len_buf);
|
||||
+ if (p) {
|
||||
+ u16 extensions_total_len = ((u16)p[0] << 8) | p[1];
|
||||
+ unsigned int extensions_end = offset + 2 + extensions_total_len;
|
||||
+ offset += 2; /* Shift layout offset right inside the core extensions list sequence */
|
||||
+
|
||||
+ /* Strict boundary sanity guard against malicious malformed padding packets */
|
||||
+ if (extensions_end <= payload_len) {
|
||||
+ u8 loop_guard = 0;
|
||||
+ u8 ext_buf[4];
|
||||
+ u8 *ext_ptr;
|
||||
+
|
||||
+ /* Step 7: High-Performance O(1) Leap Loop with anti-hang protection */
|
||||
+ while (offset + 4 <= extensions_end && loop_guard++ < 32) {
|
||||
+ ext_ptr = skb_header_pointer(skb, payload_offset + offset, 4, ext_buf);
|
||||
+ if (!ext_ptr) break;
|
||||
+
|
||||
+ u16 ext_type = ((u16)ext_ptr[0] << 8) | ext_ptr[1];
|
||||
+ u16 ext_len = ((u16)ext_ptr[2] << 8) | ext_ptr[3];
|
||||
+
|
||||
+ /* TARGET MATCHED: Extension Type 0x0000 matches Server Name Indication (SNI) perfectly! */
|
||||
+ if (ext_type == 0x0000) {
|
||||
+ unsigned int sni_ptr_offset = offset + 4;
|
||||
+ u8 sni_buf[5];
|
||||
+ p = skb_header_pointer(skb, payload_offset + sni_ptr_offset, 5, sni_buf);
|
||||
+ if (p) {
|
||||
+ /* Skip SNI List Length(2B) + Name Type(1B, 0=host_name) + Server Name Length(2B) */
|
||||
+ u16 server_name_len = ((u16)p[3] << 8) | p[4];
|
||||
+ unsigned int hostname_offset = sni_ptr_offset + 5;
|
||||
+
|
||||
+ if (hostname_offset + server_name_len <= payload_len && server_name_len > 0) {
|
||||
+ u8 name_buf[64];
|
||||
+ u8 target_len = (server_name_len > 63) ? 63 : server_name_len;
|
||||
+ u8 *name_ptr = skb_header_pointer(skb, payload_offset + hostname_offset, target_len, name_buf);
|
||||
+
|
||||
+ if (name_ptr) {
|
||||
+ memset(ct->init_payload, 0, 64);
|
||||
+ memcpy(ct->init_payload, name_ptr, target_len);
|
||||
+ ct->has_custom_payload = 2; /* Status '2' -> SNI text successfully harvested and locked! */
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ break; /* Mission accomplished, kill the loop instantly */
|
||||
+ }
|
||||
+
|
||||
+ /* ULTRASONIC JUMP: Jump directly over heavy cryptography chunks (e.g. skip key_share 1263 bytes instantly) */
|
||||
+ offset += 4 + ext_len;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ /* Fallback Route C: Standard original 8-byte hex binary telemetry backup if no structural domain was found */
|
||||
+ if (!ct->has_custom_payload) {
|
||||
+ u8 fallback_buf[8];
|
||||
+ u8 *fb_ptr = skb_header_pointer(skb, payload_offset, 8, fallback_buf);
|
||||
+ if (fb_ptr) {
|
||||
+ memset(ct->init_payload, 0, 64);
|
||||
+ memcpy(ct->init_payload, fb_ptr, 8);
|
||||
+ ct->has_custom_payload = 1;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ /* ==================== CUSTOM L7 TELEMETRY END ==================== */
|
||||
+#endif
|
||||
+
|
||||
if (ctinfo == IP_CT_ESTABLISHED_REPLY &&
|
||||
!test_and_set_bit(IPS_SEEN_REPLY_BIT, &ct->status))
|
||||
nf_conntrack_event_cache(IPCT_REPLY, ct);
|
||||
--- a/net/netfilter/nf_conntrack_standalone.c 2026-09-15 14:22:17.815253367 +0800
|
||||
+++ b/net/netfilter/nf_conntrack_standalone.c 2026-09-12 16:27:05.964393178 +0800
|
||||
@@ -369,6 +369,25 @@
|
||||
ct_show_zone(s, ct, NF_CT_DEFAULT_ZONE_DIR);
|
||||
ct_show_delta_time(s, ct);
|
||||
|
||||
+#if IS_ENABLED(CONFIG_NF_CONNTRACK_CUSTOM_TELEMETRY)
|
||||
+ /* =================== CUSTOM L7 OUTPUT START =================== */
|
||||
+ if (ct->has_custom_payload) {
|
||||
+ /* If status flag is '2', we successfully isolated an authentic plain-text ASCII string fragment! */
|
||||
+ if (ct->has_custom_payload == 2) {
|
||||
+ /* Enforce strict termination rendering using standard %s formatting safely bounded by your size allocation */
|
||||
+ seq_printf(s, "payload=%s ", (char *)ct->init_payload);
|
||||
+ } else {
|
||||
+ /* Fallback status '1': Print classic 8-byte hexadecimal string sequence safely to avoid data corruption */
|
||||
+ seq_printf(s, "payload=%02x%02x%02x%02x%02x%02x%02x%02x ",
|
||||
+ ct->init_payload[0], ct->init_payload[1],
|
||||
+ ct->init_payload[2], ct->init_payload[3],
|
||||
+ ct->init_payload[4], ct->init_payload[5],
|
||||
+ ct->init_payload[6], ct->init_payload[7]);
|
||||
+ }
|
||||
+ }
|
||||
+ /* ==================== CUSTOM L7 OUTPUT END ==================== */
|
||||
+#endif
|
||||
+
|
||||
seq_printf(s, "use=%u\n", atomic_read(&ct->ct_general.use));
|
||||
|
||||
if (seq_has_overflowed(s))
|
||||
@@ -0,0 +1,18 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
LUCI_TITLE:=Realtime Connection Tracking Stream Monitor
|
||||
LUCI_DEPENDS:=+luci-lib-jsonc
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
LUCI_MINIFY:=0
|
||||
|
||||
PKG_NAME:=luci-app-conntrack
|
||||
PKG_VERSION:=1.0.4
|
||||
PKG_RELEASE:=1
|
||||
|
||||
PKG_MAINTAINER:=simonchen <xinyi78528@163.com>
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
# call BuildPackage - OpenWrt buildroot signature
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<img width="750" height="400" alt="image" src="https://github.com/user-attachments/assets/fdd8b243-608f-4522-abcc-40ce645c249f" />
|
||||
<img width="720" height="600" alt="image" src="https://github.com/user-attachments/assets/f1b1d812-2a47-48a2-a9af-5d16d461327a" />
|
||||
|
||||
# luci-app-conntrack
|
||||
|
||||
[English](#english) | [中文说明](#中文说明)
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
A highly-optimized, lightweight **Realtime Connection Tracking & Network Audit Monitor** for OpenWrt/LEDE. This plugin provides single-direction (TX/RX split), high-frequency, millisecond-level traffic visualization directly within the LuCI web interface.
|
||||
|
||||
### 🚀 Key Features
|
||||
|
||||
* **Zero Kernel Contamination (No kmod):**
|
||||
Unlike legacy traffic accounting tools, it does not inject any custom hooks or proprietary kernel modules. It fully leverages the Linux native `/proc/net/nf_conntrack` subsystem—data that the kernel already maintains for NAT routing—guaranteeing **zero system pollution and maximum stability**.
|
||||
* **UI Features:**
|
||||
* **Realtime Connection Speed:** Displays instant throughput for each traffic direction in real time.
|
||||
* **Bit / B Unit Toggling:** Seamlessly switch between `Bit` (bps/Mbps) and `B` (B/s/MB/s) locally via JS without dropping the SSE pipeline.
|
||||
* **Original/Reply Traffic Splitting:** Tears apart conntrack rows to display precise directional analytics (TX/RX isolation).
|
||||
* **QUIC/HTTP3 Recognition:** Automatically sniffs out modern HTTP/3 streams via UDP 443 heuristic auditing.
|
||||
* **IPv4/v6 double stacks** supported.
|
||||
* **Adaptive Layout with Tooltips:** Prevents UI breakage from long IPv6 addresses using CSS ellipsis combined with native hover tooltips.
|
||||
* **Specific IP / Local & Loopback IP filter** supported.
|
||||
* **Local host name** supported.
|
||||
* **GeoIP info** supported.
|
||||
* **Experiment feature** Show app name(YouTube, Instagram, TikTok, etc.) associated with the connection, this will need to patch kmod-nf-conntrack (patch file: 999-netfilter-custom-payload-telemetry-nf_conntrack.patch).
|
||||
* **Cutomized App Icons***
|
||||
|
||||
### 🛠️ Architecture & Directory Tree
|
||||
|
||||
Conforms strictly to the `luci.mk` unified application build framework:
|
||||
|
||||
```text
|
||||
luci-app-conntrack/
|
||||
├── Makefile # Build script using feeds/luci/luci.mk
|
||||
├── luasrc/
|
||||
│ ├── controller/
|
||||
│ │ └── conntrack.lua # Non-blocking Event-Stream (SSE) backend server
|
||||
│ └── view/
|
||||
│ └── conntrack/
|
||||
│ └── conntrack.htm # Vanilla JS dynamic reconnect frontend view
|
||||
└── po/
|
||||
└── zh-cn/
|
||||
└── conntrack.po # I18n translation source file
|
||||
```
|
||||
|
||||
### 🧱 Installation & Compilation
|
||||
|
||||
```bash
|
||||
# Update compilation feeds
|
||||
./scripts/feeds update -i
|
||||
./scripts/feeds install -f luci-app-conntrack
|
||||
|
||||
# Compile the package independently
|
||||
make package/feeds/luci/luci-app-conntrack/compile V=s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 中文说明
|
||||
|
||||
一款为 OpenWrt/LEDE 深度定制、工业级稳定的**实时连接跟踪与单向流量审计看板**。该插件完全基于轻量级的 Event-Stream (SSE) 长连接架构下运行,无需刷新页面即可在 Web 界面实现真正高频、正反向物理隔离的网络吞吐审计。
|
||||
|
||||
### 🚀 核心优势
|
||||
|
||||
* **零内核污染 (无需 kmod):**
|
||||
不同于传统流量统计插件需要往内核安插臃肿的探针或第三方驱动,本插件完全基于 Linux 内核原生维护的 `/proc/net/nf_conntrack`(连接跟踪表)作为数据源。对系统内核**零开销、零污染**,保障极限负载下的系统稳定性。
|
||||
* **UI 特性:**
|
||||
* **实时连接速率:** 高频显示每一条活跃网络连接当前的实时瞬时物理吞吐速率。
|
||||
* **前端零延迟单位切换:** 表头集成智能交互按钮,支持在 `Bit`(比特率,Mbps)和 `B`(字节率,MB/s)之间 1 毫秒无缝切换,无需掐断或重启后台长连接。
|
||||
* **正反单向流量分离:** 将 conntrack 报文拆分为 Original(发起方)与 Reply(回应方),实现真正的单向 TX/RX 独立审计与降序排列。
|
||||
* **QUIC / HTTP3 智能识别:** 基于启发式行为审计,自动将隐藏在 UDP 443 端口下的现代高并发视频/下载流标记并显示为 `QUIC` 协议。
|
||||
* **IPv4/v6 双栈支持**
|
||||
* **超长 IPv6 弹性布局:** 引入 CSS 精确截断裁切,使超长 IPv6 地址自动变为省略号,鼠标悬停时原生 Tooltip 气泡自动浮现完整内容,排版永不撑破。
|
||||
* **手动指定IP或本地以及回环IP 过滤支持**
|
||||
* **本地主机名显示支持**
|
||||
* **地理IP信息显示支持**
|
||||
* **实验特色** 显示与连接相关的应用名称(YouTube, Instagram, TikTok, 王者荣耀..), 你需要给内核模块 kmod-nf-conntrack 打补丁 (补丁文件: 999-netfilter-custom-payload-telemetry-nf_conntrack.patch).
|
||||
* ** 定制化App图标库 **
|
||||
|
||||
### 🧱 依赖与安装
|
||||
|
||||
本插件在 OpenWrt 源码树下的标准相对路径为 `package/feeds/luci/luci-app-conntrack`:
|
||||
|
||||
```bash
|
||||
# 1. 刷新编译环境 feeds 映射
|
||||
./scripts/feeds update -i
|
||||
./scripts/feeds install -f luci-app-conntrack
|
||||
|
||||
# 2. 单包高效构建
|
||||
make package/feeds/luci/luci-app-conntrack/compile V=s
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
if (typeof (Iconify) != undefined) {
|
||||
Iconify.addIcon('logos:honorofkings', {
|
||||
width: 146,
|
||||
height: 112,
|
||||
|
||||
body: `<g transform="translate(0.000000,112.000000) scale(0.100000,-0.100000)" fill="currentColor" stroke="none">
|
||||
<path d="M635 1105 c-27 -8 -53 -14 -56 -15 -3 0 -40 -33 -81 -74 -59 -58 -79 -85 -93 -126 -17 -52 -17 -61 1 -250 4 -49 2 -75 -10 -104 -9 -22 -15 -40 -13 -42 2 -2 38 32 80 74 l78 78 -22 48 c-16 38 -20 63 -17 112 5 82 41 137 117 176 68 35 127 37 197 4 l52 -24 37 38 37 38 -39 25 c-49 31 -134 57 -182 56 -20 0 -58 -7 -86 -14z"/>
|
||||
<path d="M981 1016 l-84 -84 22 -44 c40 -85 21 -198 -45 -260 -64 -60 -170 -75 -253 -37 l-45 20 -35 -36 -36 -36 23 -19 c46 -38 132 -62 207 -58 73 4 175 40 175 62 0 15 62 76 76 76 6 0 21 23 35 52 25 54 26 60 17 298 -2 66 1 92 15 118 10 17 16 32 15 32 -2 0 -41 -38 -87 -84z"/>
|
||||
<path d="M730 903 c0 -46 4 -62 20 -76 11 -9 20 -28 20 -42 0 -14 -9 -30 -20 -37 -15 -10 -20 -24 -20 -55 0 -50 15 -55 67 -23 82 50 91 191 14 237 -14 9 -38 24 -53 35 l-28 19 0 -58z"/>
|
||||
<path d="M650 908 c-51 -35 -70 -67 -72 -125 -3 -63 17 -95 85 -135 l47 -27 0 57 c0 46 -4 60 -20 70 -11 7 -20 23 -20 37 0 14 9 33 20 42 14 12 20 30 20 60 0 50 -11 54 -60 21z"/>
|
||||
<path d="M485 351 c3 -6 -16 -11 -42 -13 -36 -2 -49 -7 -51 -20 -3 -15 4 -18 42 -18 30 0 48 -5 54 -16 8 -14 10 -14 21 0 11 13 34 16 108 16 85 0 94 2 91 18 -3 14 -16 17 -81 20 -42 2 -77 7 -77 12 0 6 -16 10 -36 10 -19 0 -33 -4 -29 -9z"/>
|
||||
<path d="M765 350 c3 -5 -3 -10 -14 -10 -14 0 -21 -6 -21 -20 0 -23 -8 -22 155 -24 159 -3 204 13 133 48 -35 18 -93 22 -83 6 4 -6 -13 -10 -44 -10 -27 0 -53 5 -56 10 -3 6 -22 10 -41 10 -21 0 -33 -4 -29 -10z"/>
|
||||
<path d="M53 325 c-8 -23 1 -28 59 -29 l53 -1 3 -42 3 -43 -60 0 c-54 0 -61 -2 -61 -20 0 -18 7 -20 60 -20 l60 0 0 -46 0 -45 -60 2 c-51 2 -64 -1 -83 -19 l-21 -22 169 0 170 0 85 85 85 85 97 0 c54 0 98 4 98 8 0 5 -11 18 -23 31 -21 19 -25 20 -37 6 -14 -17 -90 -21 -90 -5 0 6 5 10 10 10 6 0 10 5 10 10 0 18 -56 11 -83 -10 -16 -13 -40 -20 -67 -20 -45 0 -53 -22 -10 -28 21 -3 17 -10 -41 -68 -48 -48 -70 -64 -91 -64 -26 0 -28 2 -28 45 l0 45 55 0 c48 0 55 2 55 20 0 18 -7 20 -55 20 l-55 0 0 46 0 46 55 -4 c51 -3 55 -2 55 20 0 22 -1 22 -155 22 -131 0 -157 -2 -162 -15z"/>
|
||||
<path d="M1070 269 c0 -62 2 -70 15 -59 21 18 21 130 0 130 -12 0 -15 -15 -15 -71z"/>
|
||||
<path d="M1110 277 c0 -51 -4 -68 -20 -82 -11 -10 -20 -30 -20 -44 0 -24 3 -22 55 29 53 52 55 56 55 107 0 40 -4 53 -14 53 -10 0 -16 -14 -19 -42 l-4 -43 -1 43 c-1 30 -6 42 -17 42 -12 0 -15 -14 -15 -63z"/>
|
||||
<path d="M1192 328 c3 -7 15 -15 29 -16 13 -2 34 -13 47 -25 l22 -20 0 36 0 37 -51 0 c-36 0 -49 -4 -47 -12z"/>
|
||||
<path d="M1300 326 c0 -11 9 -16 29 -16 16 0 34 -7 41 -15 19 -23 32 -18 28 13 -3 25 -7 27 -50 30 -38 2 -48 0 -48 -12z"/>
|
||||
<path d="M1210 292 c0 -4 5 -13 10 -18 8 -8 6 -15 -5 -24 -16 -13 -21 -40 -7 -40 5 0 20 13 35 28 27 28 27 29 9 45 -20 18 -42 23 -42 9z"/>
|
||||
<path d="M1320 285 c10 -12 10 -19 2 -27 -7 -7 -12 -21 -12 -32 0 -18 4 -17 32 11 27 27 29 34 18 47 -7 9 -21 16 -32 16 -17 0 -18 -2 -8 -15z"/>
|
||||
<path d="M730 245 c0 -28 4 -35 19 -35 10 0 21 7 25 15 4 12 27 15 116 15 89 0 112 -3 116 -15 4 -8 15 -15 26 -15 16 0 19 6 16 33 l-3 32 -157 3 -158 3 0 -36z"/>
|
||||
<path d="M1243 218 c-42 -43 -43 -45 -43 -111 l0 -67 111 0 c98 0 110 2 99 15 -7 8 -24 15 -39 16 -25 1 -25 1 -3 7 29 9 30 26 2 30 -25 4 -16 22 11 22 10 0 19 5 19 10 0 6 -9 10 -20 10 -11 0 -20 5 -20 10 0 6 9 10 20 10 11 0 20 5 20 10 0 6 -26 10 -59 10 -33 0 -63 -4 -66 -10 -3 -5 1 -10 9 -10 9 0 16 -4 16 -10 0 -5 -11 -10 -25 -10 -30 0 -32 16 -4 46 11 12 19 32 17 44 -3 19 -7 17 -45 -22z m57 -98 c0 -5 -12 -10 -26 -10 -14 0 -23 4 -19 10 3 6 15 10 26 10 10 0 19 -4 19 -10z m-7 -47 c-7 -2 -21 -2 -30 0 -10 3 -4 5 12 5 17 0 24 -2 18 -5z"/>
|
||||
<path d="M1372 248 c-18 -18 -14 -38 8 -38 15 0 20 7 20 25 0 27 -9 32 -28 13z"/>
|
||||
<path d="M853 208 c1 -9 -17 -14 -60 -16 -47 -2 -63 -7 -63 -17 0 -12 17 -15 83 -15 l83 0 59 -60 c58 -58 61 -60 112 -60 l53 0 0 57 0 56 -25 -23 c-14 -13 -25 -29 -25 -37 0 -24 -25 -13 -65 27 l-39 40 42 0 c31 0 42 4 42 15 0 12 -13 15 -60 15 -45 0 -62 4 -66 15 -8 19 -75 21 -71 3z"/>
|
||||
<path d="M1151 186 c-19 -20 -22 -33 -19 -82 3 -52 5 -59 26 -62 21 -3 22 0 22 66 0 39 3 77 6 86 9 24 -9 19 -35 -8z"/>
|
||||
<path d="M506 178 c-20 -17 -18 -17 57 -18 48 0 77 -4 77 -11 0 -7 -29 -10 -85 -8 -81 3 -86 2 -115 -26 -16 -16 -30 -39 -30 -52 0 -23 1 -23 135 -23 131 0 135 1 150 24 11 16 15 41 13 76 l-3 52 -89 1 c-72 2 -94 -1 -110 -15z m134 -88 c0 -6 -32 -10 -75 -10 -43 0 -75 4 -75 10 0 6 32 10 75 10 43 0 75 -4 75 -10z"/>
|
||||
<path d="M745 90 l-49 -50 35 0 c28 0 43 8 74 40 21 22 43 40 47 40 4 0 8 -18 8 -40 0 -38 2 -40 30 -40 28 0 30 2 30 38 0 50 -14 62 -75 62 -48 0 -54 -3 -100 -50z"/>
|
||||
</g>`
|
||||
});
|
||||
|
||||
Iconify.addIcon('logos:tencent-video', {
|
||||
width: 32,
|
||||
height: 32,
|
||||
|
||||
body: `<path d="M0 0 C9.05 0.15 14.79 3.96 21.82 9.41 C23 11 23 11 23.41 13.46 C22.76 17.5 20.35 18.88 17.29 21.35 C12.81 24.58 8.92 26.7 3.44 27.56 C-0.22 26.96 -2.73 26.42 -5.68 24.16 C-8.76 19.11 -7.69 11.69 -7 6 C-6.34 5.01 -5.68 4.02 -5 3 C-3.68 3 -2.36 3 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#46E40D" transform="translate(8,2)"/>
|
||||
<path d="M0 0 C9.05 0.15 14.79 3.96 21.82 9.41 C23 11 23 11 23.41 13.46 C22.76 17.5 20.35 18.88 17.29 21.35 C11.75 25.34 7.04 27.99 0 27 C-0.66 26.01 -1.32 25.02 -2 24 C-1.04 23.6 -0.07 23.2 0.92 22.79 C2.2 22.26 3.47 21.72 4.75 21.19 C5.7 20.8 5.7 20.8 6.67 20.4 C10.13 18.94 13.12 17.42 16 15 C14.58 11.45 13.12 10.18 9.75 8.44 C8.55 7.81 8.55 7.81 7.33 7.17 C6.56 6.79 5.79 6.4 5 6 C3.82 5.4 2.65 4.8 1.44 4.19 C0.63 3.8 -0.17 3.4 -1 3 C-0.67 2.01 -0.34 1.02 0 0 Z " fill="#0199F8" transform="translate(8,2)"/>
|
||||
<path d="M0 0 C0.99 0 1.98 0 3 0 C3.33 6.93 3.66 13.86 4 21 C3.34 21.33 2.68 21.66 2 22 C0.02 21 0.02 21 -2 19 C-2.35 16.18 -2.47 13.8 -2.38 11 C-2.37 10.28 -2.37 9.55 -2.37 8.8 C-2.31 5.03 -2.14 3.2 0 0 Z " fill="#F78B00" transform="translate(3,5)"/>
|
||||
<path d="M0 0 C3.96 0.57 6.67 1.81 10 4 C7.87 6.34 5.94 7.81 3 9 C2.01 9 1.02 9 0 9 C0 6.03 0 3.06 0 0 Z " fill="#F2FDED" transform="translate(9,11)"/>`
|
||||
});
|
||||
|
||||
Iconify.addIcon('logos:iqiyi', {
|
||||
width: 125,
|
||||
height: 45,
|
||||
body: `<g>
|
||||
<path style=" stroke:none;fill-rule:nonzero;fill:rgb(0%,86.274511%,35.294119%);fill-opacity:1;" d="M 70.894531 44.335938 C 70.898438 44.4375 70.941406 44.535156 71.019531 44.605469 C 71.09375 44.671875 71.191406 44.710938 71.292969 44.707031 L 80.816406 44.707031 C 81.03125 44.714844 81.207031 44.550781 81.21875 44.335938 L 81.21875 1.511719 C 81.214844 1.410156 81.167969 1.3125 81.09375 1.242188 C 81.019531 1.171875 80.921875 1.136719 80.816406 1.140625 L 71.292969 1.140625 C 71.082031 1.132812 70.90625 1.296875 70.894531 1.507812 Z M 0.886719 44.335938 C 0.886719 44.441406 0.929688 44.539062 1.007812 44.609375 C 1.082031 44.679688 1.183594 44.71875 1.285156 44.714844 L 10.808594 44.714844 C 11.019531 44.722656 11.199219 44.558594 11.207031 44.34375 L 11.207031 17.375 C 11.199219 17.160156 11.019531 16.996094 10.808594 17.003906 L 1.285156 17.003906 C 1.074219 16.996094 0.894531 17.160156 0.886719 17.375 Z M 64.609375 37.347656 L 59.898438 33.550781 C 59.808594 33.480469 59.742188 33.378906 59.714844 33.265625 C 59.691406 33.148438 59.707031 33.023438 59.761719 32.914062 C 65.042969 22.929688 62.027344 10.558594 52.753906 4.144531 C 43.484375 -2.269531 30.894531 -0.6875 23.476562 7.820312 C 16.0625 16.328125 16.179688 29.0625 23.742188 37.4375 C 31.308594 45.808594 43.925781 47.160156 53.082031 40.578125 C 53.269531 40.4375 53.527344 40.4375 53.714844 40.578125 L 58.769531 44.648438 C 58.851562 44.714844 58.960938 44.746094 59.066406 44.734375 C 59.171875 44.722656 59.269531 44.667969 59.335938 44.582031 L 64.664062 37.917969 C 64.734375 37.835938 64.765625 37.726562 64.753906 37.621094 C 64.746094 37.515625 64.695312 37.414062 64.609375 37.347656 Z M 42.460938 35.316406 C 35.5625 36.566406 28.914062 31.839844 27.640625 24.777344 C 26.367188 17.71875 30.945312 10.949219 37.855469 9.699219 C 44.765625 8.445312 51.402344 13.171875 52.675781 20.234375 C 53.949219 27.300781 49.367188 34.070312 42.460938 35.316406 Z M 142.328125 1.140625 L 132.804688 1.140625 C 132.589844 1.132812 132.410156 1.296875 132.402344 1.507812 L 132.402344 44.335938 C 132.410156 44.550781 132.589844 44.714844 132.804688 44.707031 L 142.328125 44.707031 C 142.429688 44.710938 142.527344 44.671875 142.601562 44.605469 C 142.675781 44.535156 142.722656 44.4375 142.726562 44.335938 L 142.726562 1.511719 C 142.71875 1.300781 142.539062 1.132812 142.328125 1.140625 Z M 9.226562 11.523438 C 12.066406 9.761719 12.945312 6.019531 11.191406 3.167969 C 9.433594 0.316406 5.707031 -0.566406 2.867188 1.195312 C 0.0273438 2.960938 -0.851562 6.699219 0.902344 9.550781 C 2.660156 12.402344 6.386719 13.285156 9.226562 11.523438 Z M 127.363281 1.140625 L 117.214844 1.140625 C 117.042969 1.136719 116.886719 1.226562 116.796875 1.371094 L 107.371094 18.203125 C 107.371094 18.203125 107.300781 18.328125 107.207031 18.332031 C 107.113281 18.332031 107.046875 18.203125 107.046875 18.203125 L 97.617188 1.375 C 97.53125 1.226562 97.371094 1.136719 97.199219 1.140625 L 87.050781 1.140625 C 86.886719 1.140625 86.734375 1.230469 86.660156 1.378906 C 86.582031 1.527344 86.59375 1.703125 86.691406 1.839844 L 101.773438 27.316406 C 101.917969 27.5625 101.992188 27.84375 101.992188 28.128906 L 101.992188 44.261719 C 101.988281 44.378906 102.035156 44.492188 102.117188 44.578125 C 102.199219 44.664062 102.308594 44.714844 102.425781 44.714844 L 111.972656 44.714844 C 112.089844 44.714844 112.199219 44.667969 112.285156 44.585938 C 112.367188 44.5 112.410156 44.386719 112.410156 44.269531 L 112.410156 28.128906 C 112.410156 27.84375 112.488281 27.5625 112.632812 27.316406 L 127.722656 1.84375 C 127.820312 1.707031 127.835938 1.527344 127.757812 1.378906 C 127.683594 1.230469 127.53125 1.140625 127.363281 1.140625 Z M 127.363281 1.140625 "/>
|
||||
</g>`
|
||||
});
|
||||
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
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
|
||||
@@ -0,0 +1,92 @@
|
||||
module("luci.controller.conntrack_icons", package.seeall)
|
||||
|
||||
function index()
|
||||
entry({"admin", "network", "conntrack", "conntrack_icons_config"}, cbi("conntrack_icons"), nil, nil).dependent = true
|
||||
entry({"admin", "network", "conntrack", "conntrack_custom_js"}, call("action_generate_js"), nil, nil)
|
||||
end
|
||||
|
||||
local function base64_decode(data)
|
||||
local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
data = string.gsub(data, '[^'..b..'=]', '')
|
||||
return (data:gsub('.', function(x)
|
||||
if (x == '=') then return '' end
|
||||
local r, f = '', (b:find(x) - 1)
|
||||
for i = 6, 1, -1 do r = r .. (f % 2^i - f % 2^(i-1) >= 2^(i-1) and '1' or '0') end
|
||||
return r;
|
||||
end):gsub('%d%d%d%d%d%d%d%d', function(x)
|
||||
local c = 0
|
||||
for i = 1, 8 do c = c + (x:sub(i, i) == '1' and 2^(8-i) or 0) end
|
||||
return string.char(c)
|
||||
end))
|
||||
end
|
||||
|
||||
function action_generate_js()
|
||||
local uci = require("luci.model.uci").cursor()
|
||||
luci.http.prepare_content("application/javascript")
|
||||
|
||||
local rules = {}
|
||||
local bundles = {}
|
||||
|
||||
uci:foreach("conntrack", "regex_icon", function(s)
|
||||
if s.pattern and s.icon then
|
||||
local raw_pattern, modifier = s.pattern:match("^/(.-)/(%a*)$")
|
||||
if raw_pattern then
|
||||
table.insert(rules, string.format("\t\t{ reg: new RegExp(%q, \"%s\"), icon: %q }", raw_pattern, modifier or "", s.icon))
|
||||
else
|
||||
table.insert(rules, string.format("\t\t{ reg: new RegExp(%q, \"i\"), icon: %q }", s.pattern, s.icon))
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
uci:foreach("conntrack", "my_custom_icons", function(s)
|
||||
if s.name and s.svg_body then
|
||||
-- local safe_svg = nixio.bin.b64decode(s.svg_body):gsub("[\r\n\t]", " ")
|
||||
table.insert(bundles, string.format("\t\t%q: { body: %q, width: %d, height: %d }",
|
||||
s.name, s.svg_body, tonumber(s.width) or 24, tonumber(s.height) or 24))
|
||||
end
|
||||
end)
|
||||
|
||||
luci.http.write("window._customIconRules = [\n" .. table.concat(rules, ",\n") .. "\n];\n\n")
|
||||
luci.http.write("window._customIconBundles = {\n" .. table.concat(bundles, ",\n") .. "\n};\n\n")
|
||||
|
||||
luci.http.write([[
|
||||
(function() {
|
||||
if (window.Iconify && window._customIconBundles) {
|
||||
for (var name in window._customIconBundles) {
|
||||
if (window._customIconBundles.hasOwnProperty(name)) {
|
||||
var b = window._customIconBundles[name];
|
||||
var parts = name.split(':');
|
||||
if (parts.length === 2) {
|
||||
var prefix = parts[0];
|
||||
var localName = parts[1];
|
||||
var iconData = { icons: {} };
|
||||
iconData.icons[localName] = { body: atob(b.body), width: b.width, height: b.height };
|
||||
iconData.prefix = prefix;
|
||||
Iconify.addIcon(name, iconData.icons[localName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
var previews = document.querySelectorAll(".iconify-live-preview");
|
||||
previews.forEach(function(span) {
|
||||
var iconName = span.getAttribute("data-icon");
|
||||
if (iconName && window.Iconify && typeof window.Iconify.getIcon === "function") {
|
||||
var iconObj = window.Iconify.getIcon(iconName);
|
||||
if (iconObj && iconObj.body) {
|
||||
var iw = 48, ih = 48;
|
||||
if (iconObj.width/iconObj.height > 2 ||
|
||||
iconObj.height/iconObj.width > 2) {
|
||||
iw = ""; ih = "";
|
||||
}
|
||||
span.innerHTML = '<svg xmlns="http://w3.org" viewBox="0 0 ' + (iconObj.width || 24) + ' ' + (iconObj.height || 24) + '" width="' + iw + '" height="' + ih + '" style="vertical-align:middle;">' + iconObj.body + '</svg>';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
]])
|
||||
end
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
local i18n = require("luci.i18n")
|
||||
local nixio = require("nixio")
|
||||
|
||||
local m = Map("conntrack", i18n.translate("Conntrack Custom App Icons"), i18n.translate("Manage your custom application regex mappings and SVG icons here. Note that the regex and icon mappings only apply to tracked TCP connections that successfully return a domain name for matching, and will not affect existing hardcoded rules (such as YouTube detection). Custom regex expressions and icon mappings will be matched with the highest priority based on their ranking order."))
|
||||
|
||||
local old_map_render = m.render
|
||||
function m.render(self, ...)
|
||||
luci.http.write([[
|
||||
<script src="https://code.iconify.design/3/3.1.0/iconify.min.js"></script>
|
||||
<script type="text/javascript" src="]] .. luci.dispatcher.build_url('admin/network/conntrack/conntrack_custom_js') .. [["></script>
|
||||
<h2>
|
||||
<ul class="cbi-tabmenu" style="border-bottom:0 !important;">
|
||||
<li class="cbi-tab-disabled"><a href="]] .. luci.dispatcher.build_url('admin/network/conntrack') .. [[">]] .. i18n.translate("ConnTrack") .. [[</a></li>
|
||||
<li class="cbi-tab"><a href="]] .. luci.dispatcher.build_url('admin/network/conntrack/conntrack_icons_config') .. [["><strong>]] .. i18n.translate("Custom App Icons Config") .. [[</strong></a></li>
|
||||
</ul>
|
||||
</h2>
|
||||
]])
|
||||
return old_map_render(self, ...)
|
||||
end
|
||||
|
||||
local s1 = m:section(TypedSection, "regex_icon", i18n.translate("Regex Expression & Icon Mappings"))
|
||||
s1.description = i18n.translate('You can use an <a href="https://icon-sets.iconify.design/" target="_blank">existing Iconify ID</a> (e.g., logos:youtube) or a custom ID defined in the Custom SVG Icons Repository below.')
|
||||
s1.anonymous = true
|
||||
s1.addremove = true
|
||||
s1.sortable = true
|
||||
s1.template = "cbi/tblsection"
|
||||
|
||||
local p1 = s1:option(Value, "pattern", i18n.translate("Regex Pattern"))
|
||||
p1.rmempty = false
|
||||
p1.placeholder = "/\\.google\\./i"
|
||||
|
||||
local i1_hint = i18n.translate("You can use an existing Iconify ID (e.g., logos:youtube) or a custom ID defined in the Custom SVG Icons Repository below.")
|
||||
local i1 = s1:option(Value, "icon", string.format('%s <span title="%s" data-tooltip="%s" style="cursor:help; border-bottom:1px dashed #666;">(?)</span>', i18n.translate("Iconify ID"), i1_hint, i1_hint))
|
||||
i1.rmempty = false
|
||||
i1.placeholder = "conntrack:google"
|
||||
|
||||
local s2 = m:section(TypedSection, "my_custom_icons", i18n.translate("Custom SVG Icons Repository"))
|
||||
s2.anonymous = true
|
||||
s2.addremove = true
|
||||
s2.template = "cbi/tblsection"
|
||||
|
||||
local v2 = s2:option(DummyValue, "_preview", i18n.translate("Icon Preview"))
|
||||
function v2.cfgvalue(self, section)
|
||||
local name = self.map:get(section, "name") or "mdi:server-network"
|
||||
return string.format('<span class="iconify-live-preview" data-icon="%s" style="font-size:24px;vertical-align:middle;"></span>', name)
|
||||
end
|
||||
v2.rawhtml = true
|
||||
|
||||
local n2_hint = i18n.translate("The Iconify ID must start with 'conntrack:' prefix to avoid overriding existing Iconify datasets.")
|
||||
local n2 = s2:option(Value, "name", string.format('%s <span title="%s" data-tooltip="%s" style="cursor:help; color: #e00; border-bottom:1px dashed #e00;">(?)</span>', i18n.translate("Iconify ID"), i18n.translate(n2_hint), i18n.translate(n2_hint)))
|
||||
n2.rmempty = false
|
||||
n2.placeholder = "conntrack:google"
|
||||
function n2.validate(self, value)
|
||||
if value and value ~= "" then
|
||||
if not value:match("^conntrack:") then
|
||||
return nil, i18n.translate("Validation Error: The Iconify ID must begin with 'conntrack:' prefix (e.g., conntrack:custom_name).")
|
||||
end
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
local w2 = s2:option(Value, "width", i18n.translate("Icon Width"))
|
||||
w2.rmempty = false
|
||||
w2.datatype = "uinteger"
|
||||
w2.placeholder = "24"
|
||||
w2.default = "24"
|
||||
|
||||
local h2 = s2:option(Value, "height", i18n.translate("Icon Height"))
|
||||
h2.rmempty = false
|
||||
h2.datatype = "uinteger"
|
||||
h2.placeholder = "24"
|
||||
h2.default = "24"
|
||||
|
||||
local b2 = s2:option(TextValue, "svg_body", i18n.translate("SVG Body (Raw XML)"))
|
||||
b2.rmempty = false
|
||||
b2.rows = 3
|
||||
|
||||
local function base64_encode(data)
|
||||
local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
return ((data:gsub('.', function(x)
|
||||
local r, b_val = '', x:byte()
|
||||
for i = 8, 1, -1 do r = r .. (b_val % 2^i - b_val % 2^(i-1) >= 2^(i-1) and '1' or '0') end
|
||||
return r
|
||||
end) .. '0000'):gsub('%d%d%d%d%d%d', function(x)
|
||||
if (#x < 6) then return '' end
|
||||
local c = 0
|
||||
for i = 1, 6 do c = c + (x:sub(i, i) == '1' and 2^(6-i) or 0) end
|
||||
return b:sub(c + 1, c + 1)
|
||||
end) .. ({ '', '==', '=' })[#data % 3 + 1])
|
||||
end
|
||||
|
||||
local function base64_decode(data)
|
||||
local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
data = string.gsub(data, '[^'..b..'=]', '')
|
||||
return (data:gsub('.', function(x)
|
||||
if (x == '=') then return '' end
|
||||
local r, f = '', (b:find(x) - 1)
|
||||
for i = 6, 1, -1 do r = r .. (f % 2^i - f % 2^(i-1) >= 2^(i-1) and '1' or '0') end
|
||||
return r;
|
||||
end):gsub('%d%d%d%d%d%d%d%d', function(x)
|
||||
local c = 0
|
||||
for i = 1, 8 do c = c + (x:sub(i, i) == '1' and 2^(8-i) or 0) end
|
||||
return string.char(c)
|
||||
end))
|
||||
end
|
||||
|
||||
function b2.cfgvalue(self, section)
|
||||
local value = Value.cfgvalue(self, section)
|
||||
if value and value ~= "" then
|
||||
local pcall_ok, decoded = pcall(nixio.bin.b64decode, value)
|
||||
if pcall_ok then
|
||||
return decoded
|
||||
end
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
function b2.validate(self, value)
|
||||
if value and value ~= "" then
|
||||
if not value:match("^%s*<svg") then
|
||||
return nil, i18n.translate("Invalid SVG content. Must start with <svg> tag.")
|
||||
end
|
||||
return nixio.bin.b64encode(value)
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
return m
|
||||
|
||||
@@ -0,0 +1,933 @@
|
||||
<%+header%>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.3.2/css/flag-icons.min.css" />
|
||||
<script src="https://code.iconify.design/3/3.1.0/iconify.min.js"></script>
|
||||
<script src="/luci-static/conntrack/js/iconify_custom_icons.js"></script>
|
||||
<script type="text/javascript" src="<%=pcdata(luci.dispatcher.build_url('admin', 'network', 'conntrack', 'conntrack_custom_js'))%>"></script>
|
||||
|
||||
<style>
|
||||
span.iconify {
|
||||
display:inline-block;
|
||||
font-size:14px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
.ct-flag-inline {
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.fixed-table {
|
||||
width: 100%;
|
||||
/* table-layout: fixed !important; */
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.text-ellipsis {
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
word-break: break-all;
|
||||
}
|
||||
.unit-toggle {
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
margin-left: 5px;
|
||||
font-size: 11px;
|
||||
color: #333;
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.unit-toggle:hover {
|
||||
background: #e0e0e0;
|
||||
border-color: #999;
|
||||
}
|
||||
span.active, span.inactive { padding: 2px; }
|
||||
span.active { cursor: pointer; color: #000; margin-left: 5px; }
|
||||
span.inactive { cursor: pointer; color: #000; margin-left: 5px; }
|
||||
.table-responsive-wrapper {
|
||||
width: 100%;
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
th { padding-left: 5px !important; padding-right: 5px !important; }
|
||||
.conntrack-table th, .conntrack-table td:not(.ip-col-limit) {
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
@media screen and (min-width: 992px) {
|
||||
.ip-col-limit {
|
||||
max-width: 220px !important;
|
||||
}
|
||||
.icon-col-limit {
|
||||
max-width: 160px !important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 991px) {
|
||||
.ip-col-limit {
|
||||
max-width: 180px !important;
|
||||
}
|
||||
.icon-col-limit {
|
||||
max-width: 120px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="cbi-map">
|
||||
<h2>
|
||||
<ul class="cbi-tabmenu" style="border-bottom:0 !important;">
|
||||
<li class="cbi-tab"><a href="<%=luci.dispatcher.build_url('admin/network/conntrack')%>"><strong><%:ConnTrack%></strong></a></li>
|
||||
<li class="cbi-tab-disabled"><a href="<%=luci.dispatcher.build_url('admin/network/conntrack/conntrack_icons_config')%>"><%:Custom App Icons Config%></a></li>
|
||||
</ul>
|
||||
</h2>
|
||||
<div class="cbi-map-descr"><%:Realtime single-direction traffic speed flow based on Event-Stream (SSE).%><br /><%:To show AppName, you'll need to patch kmod-nf-conntrack, please refers to%><a href="https://github.com/simonchen/luci-app-conntrack" target="_blank"> Github luci-app-conntrack</a></div>
|
||||
|
||||
<fieldset class="cbi-section">
|
||||
<div class="cbi-section-node">
|
||||
<div class="cbi-value">
|
||||
<label class="cbi-value-title" for="interval_select"><%:Interval (Seconds)%></label>
|
||||
<div class="cbi-value-field">
|
||||
<select class="cbi-input-select" id="interval_select">
|
||||
<option value="1000" selected>1.0s</option>
|
||||
<option value="1500">1.5s</option>
|
||||
<option value="2000">2.0s</option>
|
||||
<option value="2500">2.5s</option>
|
||||
<option value="3000">3.0s</option>
|
||||
<option value="3500">3.5s</option>
|
||||
<option value="4000">4.0s</option>
|
||||
<option value="4500">4.5s</option>
|
||||
<option value="5000">5.0s</option>
|
||||
<option value="5500">5.5s</option>
|
||||
<option value="6000">6.0s</option>
|
||||
<option value="6500">6.5s</option>
|
||||
<option value="7000">7.0s</option>
|
||||
<option value="7500">7.5s</option>
|
||||
<option value="8000">8.0s</option>
|
||||
<option value="8500">8.5s</option>
|
||||
<option value="9000">9.0s</option>
|
||||
<option value="9500">9.5s</option>
|
||||
<option value="10000">10.0s</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-value">
|
||||
<label class="cbi-value-title" for="rows_select"><%:Display Rows%></label>
|
||||
<div class="cbi-value-field">
|
||||
<select class="cbi-input-select" id="rows_select">
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="30">30</option>
|
||||
<option value="40">40</option>
|
||||
<option value="50" selected>50</option>
|
||||
<option value="60">60</option>
|
||||
<option value="70">70</option>
|
||||
<option value="80">80</option>
|
||||
<option value="90">90</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-map">
|
||||
<div class="cbi-value" style="display: flex; justify-content: space-between; align-items: center; width: 100%; height: 2.5em; box-sizing: border-box;">
|
||||
<div style="display: flex; align-items: center; height: 100%;">
|
||||
<label class="cbi-value-title" style="margin: 0; padding: 0; display: inline-block; vertical-align: middle;"><%:Actual Delta Time%></label>
|
||||
<div class="cbi-value-field" id="delta_label" style="font-weight: bold; color: #0071c5; padding-left: 10px; display: inline-block; vertical-align: middle;">0.0000s</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; height: 100%; padding-right: 15px;">
|
||||
<label style="font-weight: bold; cursor: pointer; user-select: none; display: flex; align-items: center; margin: 0; height: 100%;">
|
||||
<input type="checkbox" checked id="filter_local_checkbox" style="margin: 0 6px 0 0; padding: 0; width: 14px; height: 14px; display: inline-block; vertical-align: middle;" />
|
||||
<span style="display: inline-block; vertical-align: middle; line-height: 14px;"><%:Filter Local/Loopback%></span>
|
||||
</label>
|
||||
<label style="font-weight: bold; cursor: pointer; user-select: none; display: flex; align-items: center; margin:0 15px; height: 100%;">
|
||||
<input type="checkbox" id="show_appname_checkbox" style="margin: 0 6px 0 0; padding: 0; width: 14px; height: 14px; display:
|
||||
<span style="display: inline-block; vertical-align: middle; line-height: 14px;"><%:Show AppName (experiment)%></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="cbi-section">
|
||||
<table class="table cbi-section-table fixed-table" id="conntrack_table" style="width:100%; text-align:left;">
|
||||
<tr class="cbi-section-table-titles">
|
||||
<th class="cbi-section-table-cell" style="width:8%;"><%:L3%></th>
|
||||
<th class="cbi-section-table-cell" style="width:10%;"><%:Protocol%></th>
|
||||
<th class="cbi-section-table-cell" style="width:17%;"><%:State (Direction)%></th>
|
||||
<th class="cbi-section-table-cell text-ellipsis ip-col-limit" style="width:23%;">
|
||||
<select id="filter_src_select" style="width:120px; padding:2px; font-weight:bold; color:#333; border:1px solid #ccc; border-radius:3px;">
|
||||
<option value="all" selected><%:Source IP:Port%></option>
|
||||
</select>
|
||||
</th>
|
||||
<th class="cbi-section-table-cell text-ellipsis ip-col-limit" style="width:23%;">
|
||||
<select id="filter_dst_select" style="width:120px; padding:2px; font-weight:bold; color:#333; border:1px solid #ccc; border-radius:3px;">
|
||||
<option value="all" selected><%:Destination IP:Port%></option>
|
||||
</select>
|
||||
</th>
|
||||
<th class="cbi-section-table-cell" style="width:11%;"><%:Accumulated%><span id="arrow-flow" class="active">↓</span></th>
|
||||
<th class="cbi-section-table-cell" style="width:11%;">
|
||||
<%:Current Speed%><span id="arrow-rate" class="inactive">⇅</span><span id="toggle_btn" class="unit-toggle">Bit</span>
|
||||
</th>
|
||||
<th id="th_appname" class="cbi-section-table-cell text-ellipsis icon-col-limit" style="width:11%;"><%:AppName%></th>
|
||||
</tr>
|
||||
<tr class="cbi-section-table-row"><td colspan="7" style="text-align:center;" id="loading_msg"><%:Connecting to Event-Stream...%></td></tr>
|
||||
</table>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">//<![CDATA[
|
||||
var eventSource = null;
|
||||
var currentUnit = 'Bit';
|
||||
var lastCacheData = null;
|
||||
var localIsSrcFocus = false;
|
||||
var localIsDstFocus = false;
|
||||
var isSrcOpen = false;
|
||||
var isDstOpen = false;
|
||||
var globalHostMap = {};
|
||||
|
||||
var serverCache = {}; // Temp used to capture server names
|
||||
var geoIpCache = {};
|
||||
var geoIpQueue = [];
|
||||
var isGeoIpProcessing = false;
|
||||
var queuedIpMap = {};
|
||||
|
||||
const appMapper = {
|
||||
"0102b75000000001": "<%:Honor of Kings%>",
|
||||
|
||||
isDomainHonorOfKings: function(d) {
|
||||
if (!d) return -1;
|
||||
if (!d.match(/\.qq\.com/i) && !d.match(/\.tencent\.com/i)) return -1;
|
||||
if (d.match(/smoba\.qq\.com/i) || d.match(/sgame/i) || d.match(/tgpa\.qq\.com/i) || d.match(/kohesport\.qq\.com/i) || d.match(/tga\.qq\.com/i)) return 1;
|
||||
if (d.match(/crashsight\.qq\.com/i) || d.match(/perfsight\.qq\.com/i) || d.match(/gcloud\.qq\.com/i) || d.match(/msdk\.qq\.com/i) || d.match(/pandora\.qq\.com/i) || d.match(/game\.qq\.com/i) || d.match(/dlied\d*\.qq\.com/i)) return 1;
|
||||
return 0;
|
||||
},
|
||||
|
||||
iconifyIcon: function(org) {
|
||||
if (!org) return "";
|
||||
|
||||
if (window._customIconRules && window._customIconRules.length > 0) {
|
||||
for (var i = 0; i < window._customIconRules.length; i++) {
|
||||
var rule = window._customIconRules[i];
|
||||
if (org.match(rule.reg)) {
|
||||
return rule.icon;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (org.match(/<%:PCDN Stream Media%>/i))
|
||||
return 'glyphs-poly:stream';
|
||||
if (org.match(/<%:Honor of Kings%>/i))
|
||||
return 'logos:honorofkings';
|
||||
if (org.match(/telegram/i))
|
||||
return 'logos:telegram';
|
||||
if (org.match(/youtube/i) || org.match(/\.ggpht\./i) || org.match(/<%:YouTube%>/i))
|
||||
return 'logos:youtube';
|
||||
if (org.match(/azure/i))
|
||||
return 'logos:azure';
|
||||
if (org.match(/gmail/i))
|
||||
return 'selfhst:gmail';
|
||||
if (org.match(/google/i) || org.match(/gstatic/i))
|
||||
return 'logos:google';
|
||||
if (org.match(/github/i))
|
||||
return 'logos:github';
|
||||
if (org.match(/douyin/i) || org.match(/\.amemv\.com/i) || org.match(/<%:TikTok%>/i))
|
||||
return 'logos:tiktok';
|
||||
if (org.match(/<%:Instagram%>/i))
|
||||
return 'logos:instagram';
|
||||
if (org.match(/facebook/i))
|
||||
return 'logos:facebook';
|
||||
if (org.match(/<%:X (Twitter)%>/i) || org.match(/^x\.com/i) || org.match(/\.x\./i) || org.match(/\.twimg\./i))
|
||||
return 'logos:twittr';
|
||||
if (org.match(/<%:Netflix%>/i))
|
||||
return 'logos:netflix-icon';
|
||||
if (org.match(/cloudflare/i))
|
||||
return 'logos:cloudflare';
|
||||
if (org.match(/aws/i))
|
||||
return 'logos:aws';
|
||||
if (org.match(/<%:Bilibili%>/i) || org.match(/bili/i))
|
||||
return 'ant-design:bilibili-filled';
|
||||
if (org.match(/<%:Bilibili Video%>/i))
|
||||
return 'thesvg-color:bilibili';
|
||||
if (org.match(/miui/i) || org.match(/xiaomi/i))
|
||||
return 'thesvg-color:xiaomi';
|
||||
if (org.match(/weixin/i))
|
||||
return 'fa7-brands:weixin';
|
||||
if (org.match(/v.*?\.qq\.com/i))
|
||||
return 'logos:tencent-video';
|
||||
if (org.match(/tencent/i) || org.match(/\.qq\.com/i) || org.match(/\.tencent\.com/i))
|
||||
return 'thesvg-color:tencent';
|
||||
if (org.match(/china unicom/i) || org.match(/unicom/i))
|
||||
return 'arcticons:china-unicom';
|
||||
if (org.match(/china telecom/i) || org.match(/telecom/i) || org.match(/chinanet/i))
|
||||
return 'arcticons:china-telecom';
|
||||
if (org.match(/china mobile/i))
|
||||
return 'arcticons:china-mobile';
|
||||
if (org.match(/alibaba/i))
|
||||
return 'selfhst:alibaba';
|
||||
if (org.match(/alipay/i))
|
||||
return 'thesvg-color:alipay';
|
||||
if (org.match(/aliyun/i))
|
||||
return 'thesvg-color:alibaba-cloud';
|
||||
if (org.match(/baidu/i))
|
||||
return 'selfhst:baidu';
|
||||
if (org.match(/\.iqiyi\./i) || org.match(/\.qiyi\./i) || org.match(/\.qy\./i))
|
||||
return 'logos:iqiyi';
|
||||
if (org.match(/Skype/i) || org.match(/Microsoft Teams/i))
|
||||
return 'thesvg-color:microsoft-teams';
|
||||
if (org.match(/outlook/i) || org.match(/live\.com/i))
|
||||
return 'thesvg-color:microsoft-outlook';
|
||||
if (org.match(/microsoft/i) || org.match(/\.windows\./i) || org.match(/\.office\./i) || org.match(/\.msn\./i))
|
||||
return 'arcticons:microsoft';
|
||||
if (org.match(/^bing\./i) || org.match(/\.bing\./i))
|
||||
return 'logos:bing';
|
||||
if (org.match(/\.intel\./i))
|
||||
return 'thesvg-color:intel';
|
||||
if (org.match(/nvidia/i))
|
||||
return 'logos:nvidia';
|
||||
if (org.match(/amd/i))
|
||||
return 'thesvg-color:amd';
|
||||
if (org.match(/amazon/i))
|
||||
return 'selfhst:amazon';
|
||||
if (org.match(/linkedin/i))
|
||||
return 'logos:linkedin-icon';
|
||||
|
||||
return "";
|
||||
},
|
||||
|
||||
renderIcon: function(icon, appName, as) {
|
||||
var as_code = "";
|
||||
if (as) {
|
||||
var m = as.match(/(AS\d+)/ig);
|
||||
if (m) as_code = m[0];
|
||||
}
|
||||
function as_link(as_code, content) {
|
||||
if (!as_code) return content;
|
||||
return `<a href="https://ipinfo.io/${as_code}" target="_blank" style="color:#666;cursor:pointer;">${content}</a>`;
|
||||
}
|
||||
if (!icon) return as_link(as_code, appName);
|
||||
|
||||
if (typeof(Iconify) != 'undefined') {
|
||||
if (!Iconify.iconExists(icon)) {
|
||||
Iconify.loadIcon(icon);
|
||||
}
|
||||
var cp = {width:40, height:40};
|
||||
var icon_data = Iconify.getIcon(icon);
|
||||
if (icon_data) {
|
||||
if (icon_data.width/icon_data.height >= 2 ||
|
||||
icon_data.height/icon_data.width >= 2) {
|
||||
cp = {};
|
||||
}
|
||||
}
|
||||
var html = Iconify.renderHTML(icon, cp) || "";
|
||||
if (html) return as_link(as_code, html);
|
||||
}
|
||||
|
||||
return as_link(as_code, appName);
|
||||
},
|
||||
|
||||
getAppName: function(payload, geoInfo, proto, bytes) {
|
||||
var isPayloadHexStr = isPureHexString(payload);
|
||||
if (!isPayloadHexStr) { // domain - plain text
|
||||
serverCache[payload] = true;
|
||||
|
||||
if (payload.match(/googlevideo/i))
|
||||
return "<%:YouTube%>";
|
||||
if (payload.match(/bilivideo/i))
|
||||
return "<%:Bilibili Video%>";
|
||||
if (payload.match(/gmail/i))
|
||||
return "<%:Gmail%>";
|
||||
if (payload.match(/skype/i))
|
||||
return "<%:Skype%>";
|
||||
if (payload.match(/teams\.microsoft/i) || payload.match(/teams\.live\./i))
|
||||
return "<%:Microsoft Teams%>";
|
||||
if (this.isDomainHonorOfKings(payload) == 1)
|
||||
return "<%:Honor of Kings%>";
|
||||
|
||||
if (payload)
|
||||
return payload; // (i.e, the exact server name)
|
||||
}
|
||||
|
||||
const prefix = payload.substring(0, 6);
|
||||
const org = geoInfo && geoInfo.as ? geoInfo.as.toLowerCase() : "";
|
||||
org_as = geoInfo ? (geoInfo.as || geoInfo.org) : ""; org_as = org_as.replace(/^AS\d+\s+/ig, '');
|
||||
|
||||
// Intercept standard TLS (160301 / 160303) handshakes and match via ASN
|
||||
if (payload.charAt(0) === 'c' && payload.substring(2, 6) === '0000') { // UDP(QUIC) feature with payload
|
||||
if (org.includes("google") || org.includes("youtube")) {
|
||||
return "<%:YouTube%>";
|
||||
}
|
||||
if (org.includes("akamai") || org.includes('fastly') || org.includes("bytedance") || org.includes("tiktok")) {
|
||||
return "<%:TikTok%>";
|
||||
}
|
||||
}
|
||||
|
||||
if (prefix === "160301" || prefix === "160303") { // TCP HTTPS feature (443 port)
|
||||
if (org.includes("google") || org.includes("youtube")) {
|
||||
if (bytes && bytes >= 4194304) { // at least 4MB to ensure..
|
||||
return "<%:YouTube%>";
|
||||
}
|
||||
return "<%:Google Service%>";
|
||||
}
|
||||
if (org.includes("akamai") || org.includes('fastly') || org.includes("bytedance") || org.includes("tiktok")) {
|
||||
if (bytes && bytes >= 2097152) { // at least 2MB to ensure..
|
||||
return "<%:TikTok%>";
|
||||
}
|
||||
}
|
||||
if (org.includes("unicom") || org.includes("telecom") || org.includes("chinanet") || org.includes("cmnet")) {
|
||||
if (bytes && bytes >= 4194304) { // at least 4MB to ensure..
|
||||
return "<%:Bilibili%>";
|
||||
}
|
||||
}
|
||||
if (org.includes("tencent") || org.includes("qq")) return "<%:Tencent%>";
|
||||
if (org.includes("bilibili")) return "<%:Bilibili%>";
|
||||
if (org.includes("netflix")) return "<%:Netflix%>";
|
||||
if (org.includes("twitter") || org.includes("x corp")) return "<%:X (Twitter)%>";
|
||||
if (org.includes("instagram") || org.includes("facebook") || org.includes("meta")) return "<%:Instagram%>";
|
||||
|
||||
return org_as ? org_as : "<%:HTTPS Connection%>";
|
||||
} else { // Non HTTPS connection
|
||||
if (org.includes("google") || org.includes("youtube")) {
|
||||
if (bytes && bytes >= 4194304) { // at least 4MB to ensure..
|
||||
return "<%:YouTube%>";
|
||||
}
|
||||
}
|
||||
// General steram media? (Current payload equals to previous cache's ?)
|
||||
if (payload && (payload == geoInfo.payload) && proto && proto.toLowerCase() == 'udp' && bytes > 1000) {
|
||||
return "<%:PCDN Stream Media%>";
|
||||
}
|
||||
}
|
||||
|
||||
return org_as ? org_as : "";
|
||||
}
|
||||
};
|
||||
|
||||
function isPureHexString(payloadStr) {
|
||||
if (!payloadStr) return false;
|
||||
return /^[0-9a-fA-F]+$/.test(payloadStr);
|
||||
}
|
||||
|
||||
function isLocalIp(ip) {
|
||||
if (!ip) return true;
|
||||
let clean = ip.toLowerCase().replace(/[\s\[\]]/g, '');
|
||||
if (clean === '127.0.0.1' || clean === '0.0.0.0' || clean === '::1' || clean === '::') return true;
|
||||
if (clean.replace(/0/g, '') === ':::::::1' || clean.replace(/0/g, '') === '::::::::') return true;
|
||||
if (/^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/.test(clean)) return true;
|
||||
if (/^f[cd]/.test(clean)) return true;
|
||||
if (/^fe[89ab]/.test(clean)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function fastNormalizeIP(ip) {
|
||||
if (!ip) return '';
|
||||
let clean = ip.toLowerCase().replace(/[\s\[\]]/g, '');
|
||||
if (clean.includes('.') && !clean.includes(':')) {
|
||||
return ip.trim();
|
||||
}
|
||||
if (clean.startsWith('::ffff:') && clean.includes('.')) {
|
||||
return clean.substring(7);
|
||||
}
|
||||
let parts = [];
|
||||
if (clean.includes('::')) {
|
||||
const sides = clean.split('::');
|
||||
const left = sides[0] ? sides[0].split(':') : [];
|
||||
const right = sides[1] ? sides[1].split(':') : [];
|
||||
const missingCount = 8 - (left.length + right.length);
|
||||
parts = [...left, ...Array(missingCount).fill('0000'), ...right];
|
||||
} else {
|
||||
parts = clean.split(':');
|
||||
}
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
let segment = parts[i] || '0000';
|
||||
if (segment.length < 4) {
|
||||
segment = segment.padStart(4, '0');
|
||||
}
|
||||
parts[i] = segment;
|
||||
}
|
||||
while (parts.length < 8) {
|
||||
parts.push('0000');
|
||||
}
|
||||
return parts.slice(0, 8).join(':');
|
||||
}
|
||||
|
||||
var geoIpTimer = null;
|
||||
function fetchGeoIp(ip, srcId, dstId, payload) {
|
||||
if (isLocalIp(ip)) return;
|
||||
if (geoIpCache[ip]) {
|
||||
applyGeoIpToDom(ip, srcId, dstId);
|
||||
return;
|
||||
}
|
||||
if (!queuedIpMap[ip]) queuedIpMap[ip] = [];
|
||||
queuedIpMap[ip].push({ srcId: srcId, dstId: dstId, payload: payload });
|
||||
if (geoIpQueue.indexOf(ip) === -1) geoIpQueue.push(ip);
|
||||
|
||||
if (geoIpTimer) clearTimeout(geoIpTimer);
|
||||
|
||||
geoIpTimer = setTimeout(function() {
|
||||
if (!isGeoIpProcessing) {
|
||||
isGeoIpProcessing = true;
|
||||
processGeoIpBatch();
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function processGeoIpBatch() {
|
||||
if (geoIpQueue.length === 0) {
|
||||
isGeoIpProcessing = false;
|
||||
return;
|
||||
}
|
||||
var batchIps = [];
|
||||
while (geoIpQueue.length > 0 && batchIps.length < 100) {
|
||||
var ip = geoIpQueue.shift();
|
||||
if (geoIpCache[ip]) {
|
||||
var targets = queuedIpMap[ip] || [];
|
||||
targets.forEach(function(t) { applyGeoIpToDom(ip, t.srcId, t.dstId); });
|
||||
delete queuedIpMap[ip];
|
||||
} else {
|
||||
batchIps.push(ip);
|
||||
}
|
||||
}
|
||||
if (batchIps.length === 0) {
|
||||
setTimeout(processGeoIpBatch, 10);
|
||||
return;
|
||||
}
|
||||
fetch('http://ip-api.com/batch?lang=<%= (luci.util.exec("uci -q get luci.main.lang"):gsub("%s+", ""):find("zh") and "zh-CN" or "en") %>', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(batchIps)
|
||||
})
|
||||
.then(function(res) {
|
||||
if (res.status === 429) {
|
||||
batchIps.forEach(function(ip) { geoIpQueue.push(ip); });
|
||||
setTimeout(processGeoIpBatch, 5000);
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(results) {
|
||||
if (!results) return;
|
||||
results.forEach(function(json) {
|
||||
if (json && json.status === 'success') {
|
||||
var queryIp = fastNormalizeIP(json.query);
|
||||
var region = json.country + ' ' + (json.regionName || '') + ' ' + (json.city || '') + ' (' + (json.isp || '') + ')';
|
||||
var flagHtml = '';
|
||||
if (json.countryCode && json.countryCode.length === 2) {
|
||||
var code = json.countryCode.toLowerCase();
|
||||
flagHtml = '<span class="fi fi-' + code + ' ct-flag-inline"></span>';
|
||||
}
|
||||
var appName = "", appIcon = "", payload = "", bytes = "";
|
||||
if (queuedIpMap[queryIp]) {
|
||||
var arr = queuedIpMap[queryIp][0].payload.split('|');
|
||||
payload = arr[0], proto = arr[1], bytes = arr[2];
|
||||
if (payload) {
|
||||
appName = appMapper[payload];
|
||||
if (!appName) appName = appMapper.getAppName(payload, json, proto, bytes);
|
||||
appIcon = appMapper.iconifyIcon(appName);
|
||||
//if (appName && !(appName in appMapper)) appMapper[payload] = appName;
|
||||
var payload_elems = document.querySelectorAll(`[payload="{payload}"]`);
|
||||
payload_elems.forEach(function(elem) { elem.innerHTML = appMapper.renderIcon(appIcon, appName, json.as); elem.title = appName; });
|
||||
}
|
||||
}
|
||||
geoIpCache[queryIp] = { org: json.org, as: json.as, region: region, flagHtml: flagHtml, appName: appName, appIcon: appIcon, payload: payload };
|
||||
var targets = queuedIpMap[queryIp] || [];
|
||||
targets.forEach(function(t) { applyGeoIpToDom(queryIp, t.srcId, t.dstId); });
|
||||
delete queuedIpMap[queryIp];
|
||||
}
|
||||
if (json && json.status == 'fail') {
|
||||
var queryIp = fastNormalizeIP(json.query);
|
||||
delete queuedIpMap[queryIp];
|
||||
}
|
||||
});
|
||||
setTimeout(processGeoIpBatch, 2000);
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error("GeoIP Batch failed:", err);
|
||||
batchIps.forEach(function(ip) { geoIpQueue.push(ip); });
|
||||
setTimeout(processGeoIpBatch, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function applyGeoIpToDom(ip, srcId, dstId) {
|
||||
var cached = geoIpCache[ip];
|
||||
if (!cached) return;
|
||||
var elSrc = document.getElementById(srcId);
|
||||
var elDst = document.getElementById(dstId);
|
||||
if (elSrc && elSrc.getAttribute('data-ip') === ip && !elSrc.getAttribute('data-geo-loaded')) {
|
||||
elSrc.title = elSrc.title + '\nGeo: ' + cached.region;
|
||||
if (cached.flagHtml || cached.region) {
|
||||
var infoStr = cached.flagHtml ? cached.flagHtml + ' ' + cached.region : cached.region;
|
||||
elSrc.innerHTML = elSrc.innerHTML + '<br /><span style="font-size:11px; color:#666; font-weight:normal;">' + infoStr + '</span>';
|
||||
}
|
||||
elSrc.setAttribute('data-geo-loaded', 'true');
|
||||
}
|
||||
if (elDst && elDst.getAttribute('data-ip') === ip && !elDst.getAttribute('data-geo-loaded')) {
|
||||
elDst.title = elDst.title + '\nGeo: ' + cached.region;
|
||||
if (cached.flagHtml || cached.region) {
|
||||
var infoStr = cached.flagHtml ? cached.flagHtml + ' ' + cached.region : cached.region;
|
||||
elDst.innerHTML = elDst.innerHTML + '<br /><span style="font-size:11px; color:#666; font-weight:normal;">' + infoStr + '</span>';
|
||||
}
|
||||
elDst.setAttribute('data-geo-loaded', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSec) {
|
||||
if (currentUnit === 'Bit') {
|
||||
var bitsPerSec = bytesPerSec * 8;
|
||||
if (bitsPerSec >= 1000 * 1000 * 1000) return (bitsPerSec / 1000 / 1000 / 1000).toFixed(2) + ' Gbps';
|
||||
if (bitsPerSec >= 1000 * 1000) return (bitsPerSec / 1000 / 1000).toFixed(2) + ' Mbps';
|
||||
if (bitsPerSec >= 1000) return (bitsPerSec / 1000).toFixed(2) + ' Kbps';
|
||||
return bitsPerSec + ' bps';
|
||||
} else {
|
||||
if (bytesPerSec >= 1024 * 1024) return (bytesPerSec / 1024 / 1024).toFixed(2) + ' MB/s';
|
||||
if (bytesPerSec >= 1024) return (bytesPerSec / 1024).toFixed(2) + ' KB/s';
|
||||
return bytesPerSec + ' B/s';
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes >= 1024 * 1024 * 1024) return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||||
if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(2) + ' MB';
|
||||
return (bytes / 1024).toFixed(2) + ' KB';
|
||||
}
|
||||
|
||||
function updateFilterOptions(selectId, ipList, currentValue) {
|
||||
var select = document.getElementById(selectId);
|
||||
var defaultText = select.options[0] ? select.options[0].text : (selectId === 'filter_src_select' ? '<%:Source IP:Port%>' : '<%:Destination IP:Port%>');
|
||||
select.options.length = 0;
|
||||
var defaultOpt = document.createElement('option');
|
||||
defaultOpt.value = 'all'; defaultOpt.text = defaultText;
|
||||
if (currentValue === 'all') defaultOpt.selected = true;
|
||||
select.appendChild(defaultOpt);
|
||||
ipList.forEach(function(ip) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = ip;
|
||||
var host = globalHostMap[ip];
|
||||
opt.text = host ? ip + ' (' + host + ')' : ip;
|
||||
if (ip === currentValue) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(data, showAppName) {
|
||||
var table = document.getElementById('conntrack_table');
|
||||
while (table.rows.length > 1) {
|
||||
table.deleteRow(1);
|
||||
}
|
||||
|
||||
if (data.connections.length === 0) {
|
||||
var row = table.insertRow(-1);
|
||||
row.className = 'cbi-section-table-row';
|
||||
var cell = row.insertCell(0);
|
||||
cell.colSpan = 7;
|
||||
cell.style.textAlign = 'center';
|
||||
cell.innerHTML = '<%:No active traffic speed detected...%>';
|
||||
return;
|
||||
}
|
||||
|
||||
data.connections.forEach(function(conn) {
|
||||
var row = table.insertRow(-1);
|
||||
row.className = 'cbi-section-table-row';
|
||||
|
||||
if (conn.payload && !isPureHexString(conn.payload)) {
|
||||
if (!(conn.src in globalHostMap)) {
|
||||
globalHostMap[conn.src] = conn.payload;
|
||||
}
|
||||
if (!(conn.dst in globalHostMap)) {
|
||||
globalHostMap[conn.dst] = conn.payload;
|
||||
}
|
||||
}
|
||||
|
||||
var src_host = globalHostMap[conn.src];
|
||||
var dst_host = globalHostMap[conn.dst];
|
||||
var src_display = src_host ? src_host + ':' + conn.sport : conn.src + ':' + conn.sport;
|
||||
var dst_display = dst_host ? dst_host + ':' + conn.dport : conn.dst + ':' + conn.dport;
|
||||
var src_title = conn.src + ':' + conn.sport + (src_host ? ' (' + src_host + ')' : '');
|
||||
var dst_title = conn.dst + ':' + conn.dport + (dst_host ? ' (' + dst_host + ')' : '');
|
||||
|
||||
var speedStr = formatSpeed(conn.speed);
|
||||
|
||||
var c0 = row.insertCell(0); c0.className = 'text-ellipsis'; c0.innerHTML = conn.l3.toUpperCase(); c0.title = conn.l3.toUpperCase();
|
||||
var c1 = row.insertCell(1); c1.className = 'text-ellipsis'; c1.innerHTML = '<strong>' + conn.proto.toUpperCase() + '</strong>'; c1.title = conn.proto.toUpperCase();
|
||||
var c2 = row.insertCell(2); c2.className = 'text-ellipsis'; c2.innerHTML = conn.state; c2.title = conn.state;
|
||||
|
||||
var c3 = row.insertCell(3); c3.className = 'text-ellipsis ip-col-limit'; c3.innerHTML = src_display; c3.title = src_title;
|
||||
var c4 = row.insertCell(4); c4.className = 'text-ellipsis ip-col-limit'; c4.innerHTML = dst_display; c4.title = dst_title;
|
||||
|
||||
var randId = Math.random().toString(36).substr(2, 9);
|
||||
var src_dom_id = 'src_' + randId;
|
||||
var dst_dom_id = 'dst_' + randId;
|
||||
c3.id = src_dom_id; c3.setAttribute('data-ip', conn.src);
|
||||
c4.id = dst_dom_id; c4.setAttribute('data-ip', conn.dst);
|
||||
|
||||
if (geoIpCache[conn.src]) { applyGeoIpToDom(conn.src, src_dom_id, dst_dom_id); }
|
||||
if (geoIpCache[conn.dst]) { applyGeoIpToDom(conn.dst, src_dom_id, dst_dom_id); }
|
||||
|
||||
if (!geoIpCache[conn.src]) { fetchGeoIp(conn.src, src_dom_id, dst_dom_id, conn.payload+'|'+conn.proto+'|'+conn.bytes); }
|
||||
if (!geoIpCache[conn.dst]) { fetchGeoIp(conn.dst, src_dom_id, dst_dom_id, conn.payload+'|'+conn.proto+'|'+conn.bytes); }
|
||||
|
||||
var c5 = row.insertCell(5); c5.className = 'text-ellipsis'; c5.innerHTML = formatBytes(conn.bytes); c5.title = formatBytes(conn.bytes);
|
||||
var c6 = row.insertCell(6); c6.className = 'text-ellipsis'; c6.innerHTML = '<span style="color:#468847; font-weight:bold;">' + speedStr + '</span>'; c6.title = speedStr;
|
||||
|
||||
var c7 = row.insertCell(7); c7.className = 'text-ellipsis icon-col-limit'; c7.style.display = showAppName ? '' : 'none';
|
||||
var payload = conn.payload, appName = "", as = "";
|
||||
if (payload) {
|
||||
c7.setAttribute("payload", payload);
|
||||
if (appMapper[payload]) {
|
||||
appName = appMapper[payload];
|
||||
}
|
||||
}
|
||||
if (!appName) {
|
||||
if (geoIpCache[conn.src]){ // Revise appName by conn.bytes
|
||||
appName = appMapper.getAppName(payload, geoIpCache[conn.src], conn.proto, conn.bytes);
|
||||
if (appName) geoIpCache[conn.src].appName = appName;
|
||||
as = geoIpCache[conn.src].as;
|
||||
} else if (geoIpCache[conn.dst]){ // Revise appName by conn.bytes
|
||||
appName = appMapper.getAppName(payload, geoIpCache[conn.dst], conn.proto, conn.bytes);
|
||||
if (appName) geoIpCache[conn.dst].appName = appName;
|
||||
as = geoIpCache[conn.dst].as;
|
||||
}
|
||||
}
|
||||
c7.innerHTML = appMapper.renderIcon(appMapper.iconifyIcon(appName), appName, as);
|
||||
c7.title = appName;
|
||||
});
|
||||
}
|
||||
|
||||
var streamRestartTimer = null;
|
||||
function startStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
if (streamRestartTimer) { clearTimeout(streamRestartTimer); streamRestartTimer = null; }
|
||||
|
||||
var interval = document.getElementById('interval_select').value;
|
||||
var rows = document.getElementById('rows_select').value;
|
||||
var filterSrc = document.getElementById('filter_src_select').value;
|
||||
var filterDst = document.getElementById('filter_dst_select').value;
|
||||
var filterLocal = document.getElementById('filter_local_checkbox').checked ? "true" : "false";
|
||||
var sort = isRateSorted ? "rate" : (isFlowSorted ? "flow" : "rate");
|
||||
|
||||
var url = '<%=luci.dispatcher.build_url("admin", "network", "conntrack_stream")%>' +
|
||||
'?interval=' + interval + '&rows=' + rows + '&filter_src=' + filterSrc + '&filter_dst=' + filterDst + '&filter_local=' + filterLocal + "&sort=" + sort;
|
||||
|
||||
eventSource = new EventSource(url);
|
||||
|
||||
eventSource.onmessage = function(event) {
|
||||
var showAppName = document.getElementById('show_appname_checkbox').checked ? true : false;
|
||||
document.getElementById('th_appname').style.display = showAppName ? '' : 'none';
|
||||
|
||||
if (!streamRestartTimer) {
|
||||
streamRestartTimer = setTimeout(function() {
|
||||
streamRestartTimer = null;
|
||||
startStream();
|
||||
}, 1800000);
|
||||
}
|
||||
|
||||
var loading = document.getElementById('loading_msg');
|
||||
if (loading) loading.style.display = 'none';
|
||||
|
||||
var data = JSON.parse(event.data);
|
||||
if (data.host_map) { globalHostMap = data.host_map; }
|
||||
document.getElementById('delta_label').innerHTML = data.delta + 's';
|
||||
lastCacheData = data;
|
||||
|
||||
if (!localIsSrcFocus && !localIsDstFocus) {
|
||||
var currentSrc = document.getElementById('filter_src_select').value;
|
||||
var currentDst = document.getElementById('filter_dst_select').value;
|
||||
renderTable(data, showAppName);
|
||||
updateFilterOptions('filter_src_select', data.unique_ips, currentSrc);
|
||||
updateFilterOptions('filter_dst_select', data.unique_ips, currentDst);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = function(err) {
|
||||
console.error("EventSource failed:", err);
|
||||
var loading = document.getElementById('loading_msg');
|
||||
if (loading) {
|
||||
loading.style.display = 'table-cell';
|
||||
loading.innerHTML = '<span style="color:red;"><%:Stream connection lost, retrying...%></span>';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let isFlowSorted = true;
|
||||
let isRateSorted = false;
|
||||
|
||||
function updateArrows() {
|
||||
const f = document.getElementById('arrow-flow');
|
||||
const r = document.getElementById('arrow-rate');
|
||||
|
||||
f.innerHTML = isFlowSorted ? '↓' : '⇅';
|
||||
f.className = isFlowSorted ? 'active' : 'inactive';
|
||||
|
||||
r.innerHTML = isRateSorted ? '↓' : '⇅';
|
||||
r.className = isRateSorted ? 'active' : 'inactive';
|
||||
}
|
||||
|
||||
document.getElementById('arrow-flow').addEventListener('click', function() {
|
||||
isRateSorted = false;
|
||||
isFlowSorted = true;
|
||||
updateArrows();
|
||||
startStream();
|
||||
});
|
||||
document.getElementById('arrow-rate').addEventListener('click', function() {
|
||||
isRateSorted = true;
|
||||
isFlowSorted = false;
|
||||
updateArrows();
|
||||
startStream();
|
||||
});
|
||||
|
||||
document.getElementById('toggle_btn').addEventListener('click', function() {
|
||||
if (currentUnit === 'Bit') {
|
||||
currentUnit = 'B';
|
||||
this.innerHTML = 'B';
|
||||
} else {
|
||||
currentUnit = 'Bit';
|
||||
this.innerHTML = 'Bit';
|
||||
}
|
||||
if (lastCacheData) {
|
||||
renderTable(lastCacheData);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('interval_select').addEventListener('change', startStream);
|
||||
document.getElementById('rows_select').addEventListener('change', startStream);
|
||||
|
||||
document.getElementById('filter_src_select').addEventListener('change', function() {
|
||||
isSrcOpen = false;
|
||||
this.blur();
|
||||
localIsSrcFocus = false;
|
||||
startStream();
|
||||
});
|
||||
|
||||
document.getElementById('filter_dst_select').addEventListener('change', function() {
|
||||
isDstOpen = false;
|
||||
this.blur();
|
||||
localIsDstFocus = false;
|
||||
startStream();
|
||||
});
|
||||
|
||||
document.getElementById('filter_src_select').addEventListener('click', function() {
|
||||
isSrcOpen = !isSrcOpen;
|
||||
if (!isSrcOpen) { this.blur(); }
|
||||
});
|
||||
|
||||
document.getElementById('filter_src_select').addEventListener('blur', function() {
|
||||
isSrcOpen = false;
|
||||
});
|
||||
|
||||
document.getElementById('filter_dst_select').addEventListener('click', function() {
|
||||
isDstOpen = !isDstOpen;
|
||||
if (!isDstOpen) { this.blur(); }
|
||||
});
|
||||
|
||||
document.getElementById('filter_dst_select').addEventListener('blur', function() {
|
||||
isDstOpen = false;
|
||||
});
|
||||
|
||||
document.getElementById('filter_src_select').addEventListener('focus', function() { localIsSrcFocus = true; });
|
||||
document.getElementById('filter_src_select').addEventListener('blur', function() { localIsSrcFocus = false; });
|
||||
document.getElementById('filter_dst_select').addEventListener('focus', function() { localIsDstFocus = true; });
|
||||
document.getElementById('filter_dst_select').addEventListener('blur', function() { localIsDstFocus = false; });
|
||||
|
||||
document.getElementById('filter_local_checkbox').addEventListener('change', startStream);
|
||||
|
||||
window.addEventListener('DOMContentLoaded', startStream);
|
||||
//]]></script>
|
||||
<script type="text/javascript">//<![CDATA[
|
||||
(function() {
|
||||
var getUrl = "<%=luci.dispatcher.build_url('admin/network/conntrack/get_settings')%>";
|
||||
var setUrl = "<%=luci.dispatcher.build_url('admin/network/conntrack/set_settings')%>";
|
||||
|
||||
var activeTimer = null;
|
||||
|
||||
var elements = {
|
||||
interval: document.getElementById("interval_select"),
|
||||
rows: document.getElementById("rows_select"),
|
||||
filter_local: document.getElementById("filter_local_checkbox"),
|
||||
show_appname: document.getElementById("show_appname_checkbox")
|
||||
};
|
||||
|
||||
window.conntrack_settings = {
|
||||
interval: 1.5,
|
||||
rows: 50,
|
||||
filter_local: true,
|
||||
show_appname: false
|
||||
};
|
||||
|
||||
var loadSavedSettings = function() {
|
||||
fetch(getUrl, { method: "GET", cache: "no-cache" })
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(config) {
|
||||
if (config) {
|
||||
window.conntrack_settings.interval = parseFloat(config.interval) || 1.5;
|
||||
window.conntrack_settings.rows = parseInt(config.rows) || 50;
|
||||
window.conntrack_settings.filter_local = (config.filter_local === "true");
|
||||
window.conntrack_settings.show_appname = (config.show_appname === "true");
|
||||
|
||||
if (elements.interval) elements.interval.value = config.interval;
|
||||
if (elements.rows) elements.rows.value = config.rows;
|
||||
if (elements.filter_local) {
|
||||
if (elements.filter_local.type === "checkbox") elements.filter_local.checked = window.conntrack_settings.filter_local;
|
||||
else elements.filter_local.value = config.filter_local;
|
||||
}
|
||||
if (elements.show_appname) {
|
||||
if (elements.show_appname.type === "checkbox") elements.show_appname.checked = window.conntrack_settings.show_appname;
|
||||
else elements.show_appname.value = config.show_appname;
|
||||
}
|
||||
|
||||
if (typeof window.startStream === "function") {
|
||||
window.startStream();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
};
|
||||
|
||||
var bindAutoSyncEvents = function() {
|
||||
var postUpdate = function() {
|
||||
var payload = {
|
||||
interval: elements.interval ? elements.interval.value : "1500",
|
||||
rows: elements.rows ? elements.rows.value : "50",
|
||||
filter_local: elements.filter_local ? (elements.filter_local.type === "checkbox" ? elements.filter_local.checked : elements.filter_local.value === "true") : true,
|
||||
show_appname: elements.show_appname ? (elements.show_appname.type === "checkbox" ? elements.show_appname.checked : elements.show_appname.value === "true") : false
|
||||
};
|
||||
|
||||
window.conntrack_settings.interval = parseFloat(payload.interval);
|
||||
window.conntrack_settings.rows = parseInt(payload.rows);
|
||||
window.conntrack_settings.filter_local = (payload.filter_local === true || payload.filter_local === "true");
|
||||
window.conntrack_settings.show_appname = (payload.show_appname === true || payload.show_appname === "true");
|
||||
|
||||
fetch(setUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(function(res) { return res.json(); })
|
||||
.then(function(data) {
|
||||
if (data && data.status === "ok" && typeof window.startStream === "function") {
|
||||
// window.startStream(); // have been processed (see above individual event handler)
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
};
|
||||
|
||||
for (var key in elements) {
|
||||
if (elements.hasOwnProperty(key) && elements[key]) {
|
||||
var evType = (elements[key].type === "checkbox" || elements[key].tagName === "SELECT") ? "change" : "input";
|
||||
elements[key].addEventListener(evType, postUpdate);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
loadSavedSettings();
|
||||
bindAutoSyncEvents();
|
||||
});
|
||||
})();
|
||||
//]]></script>
|
||||
</script>
|
||||
<%+footer%>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
msgid "ConnTrack"
|
||||
msgstr "连接跟踪"
|
||||
|
||||
msgid "ConnTrack Connection Monitor"
|
||||
msgstr "ConnTrack 连接实时监视器"
|
||||
|
||||
msgid "Realtime single-direction traffic speed flow based on Event-Stream (SSE)."
|
||||
msgstr "基于 Event-Stream (SSE) 长连接架构的单向实时网速/协议审计看板。"
|
||||
|
||||
msgid "To show AppName, you'll need to patch kmod-nf-conntrack, please refers to"
|
||||
msgstr "想查看应用名称, 你需要给内核模块kmod-nf-connntrack打补丁, 详见"
|
||||
|
||||
msgid "AppName"
|
||||
msgstr "应用名称"
|
||||
|
||||
msgid "Show AppName (experiment)"
|
||||
msgstr "显示应用名称 (实验)"
|
||||
|
||||
msgid "Interval (Seconds)"
|
||||
msgstr "采样刷新时间间隔"
|
||||
|
||||
msgid "Display Rows"
|
||||
msgstr "屏幕显示最大行数"
|
||||
|
||||
msgid "Actual Delta Time"
|
||||
msgstr "内核实际采样物理间隔"
|
||||
|
||||
msgid "L3"
|
||||
msgstr "网络层"
|
||||
|
||||
msgid "Protocol"
|
||||
msgstr "传输协议"
|
||||
|
||||
msgid "State (Direction)"
|
||||
msgstr "连接状态 (单向流量方向)"
|
||||
|
||||
msgid "Source IP:Port"
|
||||
msgstr "源 IP 地址 : 端口号"
|
||||
|
||||
msgid "Destination IP:Port"
|
||||
msgstr "目的 IP 地址 : 端口号"
|
||||
|
||||
msgid "Accumulated"
|
||||
msgstr "该方向累计消耗流量"
|
||||
|
||||
msgid "Current Speed"
|
||||
msgstr "当前方向即时网速"
|
||||
|
||||
msgid "Connecting to Event-Stream..."
|
||||
msgstr "正在安全握手并建立 Event-Stream 毫秒级长连接数据通道..."
|
||||
|
||||
msgid "No active traffic speed detected..."
|
||||
msgstr "当前暂无活跃网速的大流量连接,表格已自动过滤零速率空闲链..."
|
||||
|
||||
msgid "Stream connection lost, retrying..."
|
||||
msgstr "Event-Stream 连接意外被系统或网页剥离,正在疯狂尝试自动重新重连..."
|
||||
|
||||
msgid "Filter Local/Loopback"
|
||||
msgstr "过滤本地或回环连接"
|
||||
|
||||
msgid "Honor of Kings"
|
||||
msgstr "王者荣耀"
|
||||
|
||||
msgid "YouTube"
|
||||
msgstr "油管"
|
||||
|
||||
msgid "TikTok"
|
||||
msgstr "抖音"
|
||||
|
||||
msgid "Tencent Video"
|
||||
msgstr "腾讯视频"
|
||||
|
||||
msgid "X (Twitter)"
|
||||
msgstr "X (推特)"
|
||||
|
||||
msgid "Instagram"
|
||||
msgstr "INS 照片墙"
|
||||
|
||||
msgid "HTTPS Connection"
|
||||
msgstr "HTTPS 连接"
|
||||
|
||||
msgid "Custom Icons Config"
|
||||
msgstr "自定义图标配置"
|
||||
|
||||
msgid "Manage your custom application regex mappings and SVG icons here. Note that the regex and icon mappings only apply to tracked TCP connections that successfully return a domain name for matching, and will not affect existing hardcoded rules (such as YouTube detection). Custom regex expressions and icon mappings will be matched with the highest priority based on their ranking order."
|
||||
msgstr "在此管理您的自定义应用正则表达式与 SVG 图标库。请注意,正则表达式与图标映射仅适用于能够成功返回明文域名进行匹配的跟踪 TCP 连接,且绝不会影响系统中已有的固定硬编码规则(例如 YouTube 判定)。自定义的正则表达式与图标映射将严格按照列表中的上下排名次序享有最高优先匹配权。"
|
||||
|
||||
msgid "Connection Tracking List"
|
||||
msgstr "连接跟踪列表"
|
||||
|
||||
msgid "Custom App Icons Config"
|
||||
msgstr "自定义应用图标配置"
|
||||
|
||||
msgid "Conntrack Custom App Icons"
|
||||
msgstr "连接跟踪自定义应用图标"
|
||||
|
||||
msgid "Regex Expression & Icon Mappings"
|
||||
msgstr "正则表达式与图标映射"
|
||||
|
||||
msgid "Regex Pattern"
|
||||
msgstr "正则模式"
|
||||
|
||||
msgid "Iconify ID"
|
||||
msgstr "Iconify ID"
|
||||
|
||||
msgid "Custom SVG Icons Repository"
|
||||
msgstr "自定义 SVG 图标库"
|
||||
|
||||
msgid "Icon Preview"
|
||||
msgstr "图标预览"
|
||||
|
||||
msgid "Icon Width"
|
||||
msgstr "宽度"
|
||||
|
||||
msgid "Icon Height"
|
||||
msgstr "高度"
|
||||
|
||||
msgid "SVG Body (Raw XML)"
|
||||
msgstr "SVG 源码 (原始 XML)"
|
||||
|
||||
msgid "You can use an <a href=\"https://icon-sets.iconify.design/\" target=\"_blank\">existing Iconify ID</a> (e.g., logos:youtube) or a custom ID defined in the Custom SVG Icons Repository below."
|
||||
msgstr "可以使用<a href=\"https://icon-sets.iconify.design/\" target=\"_blank\">已有的公共 Iconify ID</a>(例如 logos:youtube),或者下方【自定义 SVG 图标库】中已注册的专属 ID。"
|
||||
|
||||
msgid "You can use an existing Iconify ID (e.g., logos:youtube) or a custom ID defined in the Custom SVG Icons Repository below."
|
||||
msgstr "可以使用已有的公共 Iconify ID(例如 logos:youtube),或者下方【自定义 SVG 图标库】中已注册的专属 ID。"
|
||||
|
||||
msgid "The Iconify ID must start with 'conntrack:' prefix to avoid overriding existing Iconify datasets."
|
||||
msgstr "Iconify ID 必须以 'conntrack:' 前缀起始,以避免与系统已有的远程通用图标集产生命名空间冲突。"
|
||||
|
||||
msgid "Validation Error: The Iconify ID must begin with 'conntrack:' prefix (e.g., conntrack:custom_name)."
|
||||
msgstr "验证错误:Iconify ID 必须以 'conntrack:' 前缀起始 (例如 conntrack:custom_name)。"
|
||||
|
||||
msgid "Invalid SVG content. Must start with <svg> tag."
|
||||
msgstr "无效的 SVG 内容。必须以 <svg> 标签起始。"
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
zh-cn
|
||||
Reference in new issue
Block a user