From c3e527335a85b3aadf3b893b752f280dfc9a73a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 28 Oct 2026 19:42:05 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=B4=20Sync=202026-10-28=2019:42:05?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- isp-split/Makefile | 130 + isp-split/files/etc/init.d/isp-split | 29 + isp-split/files/etc/isp-split.conf | 171 + isp-split/files/etc/uci-defaults/99-isp-split | 26 + isp-split/files/usr/lib/isp-split/badcidr.awk | 42 + isp-split/files/usr/lib/isp-split/build.sh | 58 + .../files/usr/lib/isp-split/cidr2range.awk | 59 + isp-split/files/usr/lib/isp-split/exclude.awk | 55 + isp-split/files/usr/lib/isp-split/ispdiff.awk | 102 + isp-split/files/usr/lib/isp-split/merge.awk | 45 + .../files/usr/lib/isp-split/range2cidr.awk | 42 + .../files/usr/lib/isp-split/rules-splice.awk | 115 + isp-split/files/usr/sbin/isp-split | 3799 +++++++++++++++++ luci-app-isp-split/Makefile | 58 + .../resources/isp-split/isp-split.css | 723 ++++ .../isp-split/logos/logo-chinatelecom.svg | 1 + .../resources/isp-split/logos/logo-mobile.svg | 1 + .../resources/isp-split/logos/logo-unicom.svg | 1 + .../resources/view/isp-split/config.js | 862 ++++ .../resources/view/isp-split/overview.js | 734 ++++ .../share/luci/menu.d/luci-app-isp-split.json | 29 + .../share/rpcd/acl.d/luci-app-isp-split.json | 23 + 22 files changed, 7105 insertions(+) create mode 100644 isp-split/Makefile create mode 100644 isp-split/files/etc/init.d/isp-split create mode 100644 isp-split/files/etc/isp-split.conf create mode 100644 isp-split/files/etc/uci-defaults/99-isp-split create mode 100644 isp-split/files/usr/lib/isp-split/badcidr.awk create mode 100644 isp-split/files/usr/lib/isp-split/build.sh create mode 100644 isp-split/files/usr/lib/isp-split/cidr2range.awk create mode 100644 isp-split/files/usr/lib/isp-split/exclude.awk create mode 100644 isp-split/files/usr/lib/isp-split/ispdiff.awk create mode 100644 isp-split/files/usr/lib/isp-split/merge.awk create mode 100644 isp-split/files/usr/lib/isp-split/range2cidr.awk create mode 100644 isp-split/files/usr/lib/isp-split/rules-splice.awk create mode 100644 isp-split/files/usr/sbin/isp-split create mode 100644 luci-app-isp-split/Makefile create mode 100644 luci-app-isp-split/htdocs/luci-static/resources/isp-split/isp-split.css create mode 100644 luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-chinatelecom.svg create mode 100644 luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-mobile.svg create mode 100644 luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-unicom.svg create mode 100644 luci-app-isp-split/htdocs/luci-static/resources/view/isp-split/config.js create mode 100644 luci-app-isp-split/htdocs/luci-static/resources/view/isp-split/overview.js create mode 100644 luci-app-isp-split/root/usr/share/luci/menu.d/luci-app-isp-split.json create mode 100644 luci-app-isp-split/root/usr/share/rpcd/acl.d/luci-app-isp-split.json diff --git a/isp-split/Makefile b/isp-split/Makefile new file mode 100644 index 000000000..03320b839 --- /dev/null +++ b/isp-split/Makefile @@ -0,0 +1,130 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=isp-split +PKG_VERSION:=0.2.1 +PKG_RELEASE:=1 +PKG_LICENSE:=MIT +PKG_LICENSE_FILES:=LICENSE +PKG_MAINTAINER:=jihebube + +include $(INCLUDE_DIR)/package.mk + +define Package/isp-split + SECTION:=net + CATEGORY:=Network + SUBMENU:=Routing and Redirection + TITLE:=ISP-aware policy routing for mwan3 (nftables) +# 注意:这里故意不写 +mwan3 依赖。 +# 官方 feed 的 mwan3 是 iptables 版(2.12.5),写 +mwan3 会错误地拉进它; +# Kwrt 源里 nft 版的包名是 mwan3-nft,与其他源命名不一致。 +# nftables 版 mwan3 是硬性前提,由 install.sh 与 router-preflight 检查, +# 并在 description 里说明。 + DEPENDS:=+nftables +curl +mwan3-nft + PKGARCH:=all +endef + +define Package/isp-split/description + Route traffic by the ISP that owns the destination address. + Builds mutually exclusive nftables sets from daily-updated + Chinese ISP CIDR data and wires them into mwan3-nft rules. + + REQUIRES the nftables port of mwan3 (>= 3.6.0): + - Kwrt: apk add mwan3-nft (软件源里自带) + - 上游: 用 https://github.com/dl12345/mwan3 替换官方 feed 的 + iptables 版 mwan3 (2.12.5),或从 Release 下载 +endef + +define Build/Compile +endef + +define Package/isp-split/conffiles +/etc/isp-split.conf +endef + +# opkg 升级会以 prerm upgrade 调用;apk 的 prerm 只映射到 pre-deinstall。 +# 升级时绝不能 uninstall:数据目录、mwan3 规则都必须保留。 +define Package/isp-split/prerm +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] && exit 0 +case "$${1:-}" in + upgrade|pre-upgrade|*upgrade*) exit 0 ;; +esac +if [ -x /usr/sbin/isp-split ]; then + /usr/sbin/isp-split uninstall >/dev/null 2>&1 || true +fi +exit 0 +endef + +# 卸载后的兜底清理:cron、命名计数器、dnsmasq confdir、数据目录。 +# 正常路径由 prerm 的 isp-split uninstall 完成,这里防止二进制先被删掉。 +define Package/isp-split/postrm +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] && exit 0 +case "$${1:-}" in + upgrade|pre-upgrade|*upgrade*) exit 0 ;; +esac +if [ -f /etc/crontabs/root ]; then + # mktemp 而不是 /tmp/.ispcron.$$:PID 可预测,符号链接能抢占(SEC-1) + _cron_tmp=$$(mktemp /etc/.isp-split-cron.XXXXXX) || _cron_tmp="" + if [ -n "$$_cron_tmp" ]; then + grep -v 'isp-split' /etc/crontabs/root > "$$_cron_tmp" 2>/dev/null || true + mv -f "$$_cron_tmp" /etc/crontabs/root 2>/dev/null || rm -f "$$_cron_tmp" + fi + /etc/init.d/cron restart >/dev/null 2>&1 || true +fi +nft delete counter inet mwan3 isp_fallback >/dev/null 2>&1 || true +_state=/etc/isp-split/state/dnsmasq.confdir.orig +if [ -f "$$_state" ]; then + _orig=$$(cat "$$_state" 2>/dev/null) + if [ -n "$$_orig" ]; then + uci -q set "dhcp.@dnsmasq[0].confdir=$$_orig" 2>/dev/null || true + else + uci -q delete dhcp.@dnsmasq[0].confdir 2>/dev/null || true + fi + uci -q commit dhcp 2>/dev/null || true + rm -f "$$_state" +fi +# BUG-6:OpenClash 接管时 dnsmasq 实际加载的是 /var/etc/dnsmasq.conf.* +# 里 conf-dir= 指向的目录,必须按同一来源清理,不能只删 /tmp/dnsmasq.d。 +for _f in /var/etc/dnsmasq.conf.*; do + [ -f "$$_f" ] || continue + sed -n 's/^[[:space:]]*conf-dir=//p' "$$_f" 2>/dev/null | + while IFS= read -r _line; do + for _d in $$_line; do + _d=$${_d%%,*} + while [ "$$_d" != "/" ] && [ "$${_d%/}" != "$$_d" ]; do + _d=$${_d%/} + done + [ -n "$$_d" ] && rm -f "$$_d/isp-split.conf" 2>/dev/null || true + done + done +done +rm -f /tmp/dnsmasq.d/isp-split.conf /etc/isp-split/dnsmasq.d/isp-split.conf 2>/dev/null || true +rm -rf /etc/isp-split 2>/dev/null || true +exit 0 +endef + +# 显式指定权限,不依赖源文件的权限位——固件里脚本不可执行会很难查 +define Package/isp-split/install + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_DIR) $(1)/usr/lib/isp-split + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_DIR) $(1)/etc/uci-defaults + $(INSTALL_DIR) $(1)/etc/isp-split/data + + $(INSTALL_BIN) ./files/usr/sbin/isp-split $(1)/usr/sbin/isp-split + $(INSTALL_BIN) ./files/etc/init.d/isp-split $(1)/etc/init.d/isp-split + $(INSTALL_BIN) ./files/usr/lib/isp-split/build.sh $(1)/usr/lib/isp-split/build.sh + $(INSTALL_BIN) ./files/etc/uci-defaults/99-isp-split $(1)/etc/uci-defaults/99-isp-split + + $(INSTALL_DATA) ./files/usr/lib/isp-split/cidr2range.awk $(1)/usr/lib/isp-split/cidr2range.awk + $(INSTALL_DATA) ./files/usr/lib/isp-split/merge.awk $(1)/usr/lib/isp-split/merge.awk + $(INSTALL_DATA) ./files/usr/lib/isp-split/exclude.awk $(1)/usr/lib/isp-split/exclude.awk + $(INSTALL_DATA) ./files/usr/lib/isp-split/range2cidr.awk $(1)/usr/lib/isp-split/range2cidr.awk + $(INSTALL_DATA) ./files/usr/lib/isp-split/rules-splice.awk $(1)/usr/lib/isp-split/rules-splice.awk + $(INSTALL_DATA) ./files/usr/lib/isp-split/badcidr.awk $(1)/usr/lib/isp-split/badcidr.awk + $(INSTALL_DATA) ./files/usr/lib/isp-split/ispdiff.awk $(1)/usr/lib/isp-split/ispdiff.awk + $(INSTALL_CONF) ./files/etc/isp-split.conf $(1)/etc/isp-split.conf +endef + +$(eval $(call BuildPackage,isp-split)) diff --git a/isp-split/files/etc/init.d/isp-split b/isp-split/files/etc/init.d/isp-split new file mode 100644 index 000000000..6f75e1351 --- /dev/null +++ b/isp-split/files/etc/init.d/isp-split @@ -0,0 +1,29 @@ +#!/bin/sh /etc/rc.common +# 开机时把已构建好的集合灌进 nftables。 +# 注意:开机不下载数据 —— 网络可能还没通,交给 cron 每日更新。 + +START=95 +STOP=10 + +start() { + ( + i=0 + while [ "$i" -lt 60 ]; do + if nft list table inet mwan3 >/dev/null 2>&1; then + /usr/sbin/isp-split apply + + # mwan3_output 是 mwan3 重建的,我们插进去的 DNS 旁路规则 + # 会随 mwan3 重启丢失。dns 子命令是幂等的,直接重放。 + /usr/sbin/isp-split dns >/dev/null 2>&1 + exit 0 + fi + sleep 1 + i=$((i + 1)) + done + logger -t isp-split "开机等待 mwan3 超时,未应用运营商集合" + ) & +} + +stop() { + : +} diff --git a/isp-split/files/etc/isp-split.conf b/isp-split/files/etc/isp-split.conf new file mode 100644 index 000000000..c91aa7563 --- /dev/null +++ b/isp-split/files/etc/isp-split.conf @@ -0,0 +1,171 @@ +# /etc/isp-split.conf +# openwrt-isp-split 配置文件 + +# ---------------------------------------------------------------- 多线路 DNS +# +# 为什么需要:只做"按目标 IP 纠线路"治标不治本。如果 DNS 查询本身走的是 +# 另一条线,CDN 会返回那条线的节点,你拿着电信节点走联通线,速度差 100 倍。 +# 让"从哪条线出去,就用哪条线的 DNS",CDN 才会主动把本运营商节点给你。 +# 这是商业方案(如爱快)分流效果更好的关键,原理见 docs/DESIGN.md 的 +# 「与商业方案(iKuai)的对比」一节。 +# +# 每条线路对应的 DNS:: +DNS_TABLE=" +wan_only:223.5.5.5 +wanb_only:119.29.29.29 +" + +# 多线路 DNS 的「出线方式」 +# bind = server=/域名/IP@接口,用 dnsmasq 的 SO_BINDTODEVICE 绑定出接口 +# mark = server=/域名/IP + nft 给发往该 DNS 的 53 端口打上对应策略的标 +# +# 怎么选: +# · 纯 dnsmasq 环境、没装 OpenClash → bind(更直接) +# · OpenClash 接管了 DNS,或解析器不是 dnsmasq → mark +# (mark 只看目标 IP,不关心是谁发的查询,抗干扰;也不依赖 @接口特性) +DNS_MODE="bind" + +# 统计未命中任何运营商集合的兜底流量(status 里显示"兜底"行) +# 规则插在本工具规则块之后,命中集合的流量不会经过它 +# 口径:ct state new —— 只计每条连接的**首包**。不加这一条时,若某个流 +# 最终没拿到 conntrack 标记(例如 mwan3 的 default_rule_v4 被 enabled=0 +# 关闭),同一连接的每个包都会重复计数,数字会按包放大。 +COUNT_FALLBACK="1" + +# 兜底计数是否排除 DNS(udp/tcp 任一侧端口 53) +# 1 = 排除(默认;出向查询 dport=53 与入向响应 sport=53 都不计) +# 0 = 不排除,保持旧行为 +# +# 为什么默认排除:DNS 查询/响应本身不是业务流量——到网关(如 192.168.10.1:53) +# 的查询在本地就被应答,到运营商 DNS 的也只是解析开销。它们占兜底包数 +# 比例很高,计进去只会稀释"未命中运营商集合的真实业务流量"这个口径。 +# 排除按 UDP/TCP 源或目的端口 53 判断,不写死网关地址,所以无论查的是 +# 本地网关还是运营商 DNS,查询和响应都不会被当成业务流量。 +COUNT_FALLBACK_SKIP_DNS="1" + +# 域名分流:<域名后缀>:<策略> +# 这些域名的解析会被强制从对应线路发出。 +# 例如 B 站:联通自有 CDN(cn-*-cu-*)需要联通 DNS 才会解析出来。 +DOMAIN_SPLIT=" +bilivideo.com:wanb_only +bilibili.com:wanb_only +" + +# 可选:外部域名清单目录,$DOMAINDIR/<策略>.txt,每行一个域名。 +# 也兼容 felixonmars/dnsmasq-china-list 的 server=/domain/1.2.3.4 格式。 +DOMAIN_LIST_DIR="" + +# 运营商 IP 段数据源(单源兼容模式;ISP_SOURCES 为空时才用它) +# +# 这里保留 clang 的基址和 <源名>.txt 命名,是为了让 0.1.x 老配置在 +# ISP_SOURCES 留空时逐字节保持原行为。默认多源配置见下面的 ISP_SOURCES。 +ISP_MIRROR="https://ispip.clang.cn" + +# ---------------------------------------------------------------- 多数据源(0.2.1 默认双源) +# +# 格式:每行一条 <名字>:<角色>:<镜像基址> +# 角色 primary 出数据(必须且只能有一个) +# verify 只用来跑日 diff,不参与合成 +# audit 低频审计源(同样只跑 diff) +# +# 0.2.1 默认:"主源 + 校验源"(方案 c) +# · 主源换成 gaoyifan/china-operator-ip(MIT,BGP/AS_PATH 口径) +# · clang 只做运行时校验源:默认每天下载、跑 Jaccard diff,不参与合成 +# · 校验源连续失败 1-2 天只降级;达到 ISP_SOURCE_MAX_FAIL_DAYS 天则拒绝更新 +# · 主源失败立即冻结(保留旧数据),绝不自动换源——换源=换口径 +# +# 留空 = 单源兼容模式:仍按上面的 ISP_MIRROR/<源名>.txt 拉取, +# 行为与 0.1.x 完全一致。老配置不需要改任何东西。 +# +# 注意:升级时 opkg/apk 会保留用户已有的 /etc/isp-split.conf。这里改的只是 +# 新安装的默认模板;已有配置里显式声明的 ISP_SOURCES 不会被覆盖。 +ISP_SOURCES=" +yfgao:primary:https://china-operator-ip.yfgao.com +clang:verify:https://ispip.clang.cn +" + +# 每个源的"文件名映射":<源名>:=<该源上的文件名>,... +# +# 必须显式声明:clang 的文件名是 chinatelecom.txt,yfgao 的同一个运营 +# 商叫 chinanet.txt。按名字猜一定会错,所以这里只认声明。 +# +# 规则:某个源只要声明了任意一条映射,映射里没列出的运营商默认不下载; +# 若该运营商已启用,主源会按历史命名 <源名>.txt 再试一次(兼容 0.2.0 老配置), +# 文件不存在时 fetch 直接报错。校验源/审计源只认映射,绝不去猜。 +# +# yfgao 只有 chinanet/unicom/cmcc/cernet 四类运营商数据,没有 +# chinabtn/gwbn/othernet/hk/mo/tw。所以 ISP_TABLE 里后六类默认保持 0(关闭); +# 若要启用它们,需要把主源换成提供这些文件的源,或自行补映射。 +ISP_SOURCE_FILES=" +yfgao:chinatelecom=chinanet,unicom_cnc=unicom,cmcc=cmcc,cernet=cernet +clang:chinatelecom=chinatelecom,unicom_cnc=unicom_cnc,cmcc=cmcc,cernet=cernet +" + +# 是否启用校验源(0 = 不下载不校验;0.2.1 起默认 1) +ISP_VERIFY_ENABLE=1 + +# 分歧阈值:某个运营商的 Jaccard 低于这个百分比就拒绝本次更新(退出码 3) +# 实测正常值 95.7%-96.9%,口径切错时 64.6%-87.9%,90 是中间带 +ISP_DIFF_MAX_JACCARD_DROP=90 + +# 单运营商地址数日变化上限(%),只在校验源启用时生效 +ISP_DIFF_MAX_DELTA_PCT=3 + +# 校验源连续失败多少天后,从"降级继续"升级为"拒绝更新" +ISP_SOURCE_MAX_FAIL_DAYS=3 + +# 运营商表,格式:<源文件名>:::<是否启用> +# +# 顺序即优先级:排在前面的运营商优先占有重叠地址。 +# 同一个 IP 只会出现在一个集合里,所以调整这里的顺序会改变归属, +# 但不会出现"规则先后决定归属"的隐性行为。 +# +# 集合名同时也是 mwan3 规则名,mwan3 限制规则名 <=15 字符, +# 且不能以 mwan3_ 开头(该前缀为 mwan3 内部保留)。 +# +# 策略名必须已在 mwan3 中定义,可用 `mwan3 status` 或 +# `uci show mwan3 | grep policy` 查看。 +ISP_TABLE=" +chinatelecom:isp_tel:wan_only:1 +unicom_cnc:isp_uni:wanb_only:1 +cmcc:isp_cmcc:wanb_only:1 +cernet:isp_cernet:wan_only:1 +chinabtn:isp_tietong:wanb_only:0 +gwbn:isp_gwbn:balanced:0 +othernet:isp_other:wanb_only:0 +hk:isp_hk:wanb_only:0 +mo:isp_mo:wanb_only:0 +tw:isp_tw:wanb_only:0 +" + +# 关于 othernet 默认关闭(实测教训): +# othernet 是"三大运营商以外"的集合(华为云、腾讯云、各类 IDC、 +# 以及**公共 DNS** 如 223.5.5.5 / 119.29.29.29 都可能落在这里)。 +# 启用它之后,路由器自己的 DNS 查询会被分到这条线上——如果这条线 +# 恰好不可用,整个域名解析就挂了(实测:启用后所有域名解析失败)。 +# 要启用它,先确认你的 DNS 服务器不属于该集合,或者给策略配好备份成员。 + +# 关于 cmcc(移动)默认走联通的实测依据: +# Steam 移动 CDN(39.134.x)实测 联通 22ms / 电信 72ms,联通明显更好, +# 而 Steam 下载是典型大流量场景,故默认给联通。 +# +# 已知例外:B 站 PCDN(*mcdn.bilivideo.cn)虽然是移动段, +# 实测 电信 12-22 MB/s 而联通只有 0.04-1 MB/s。若你常用 B 站且命中 mcdn, +# 应改用域名/网段级覆盖,而不是把 cmcc 整体改回电信—— +# B 站自有联通 CDN(cn-*-cu-*)恰恰需要走联通才快。 + +# 规则插入位置:在第一个匹配该正则的 mwan3 rule 之前插入运营商规则。 +# 默认插在按端口/兜底规则之前、按设备/IP 的规则之后。 +ISP_ANCHOR='dest_port|src_port|default_rule|https|internet' + +# 每日自动更新(crontab 时间) +ISP_CRON="17 4 * * *" + +# 分线测速用的接口与探测目标 +PROBE_IFACES="pppoe-wan1 pppoe-wan2" +PROBE_BYTES=33554432 +PROBE_TIME=25 +PROBE_URLS=" +https://mirrors.huaweicloud.com/ubuntu-releases/24.04/ubuntu-24.04.3-live-server-amd64.iso +https://mirrors.aliyun.com/ubuntu-releases/24.04/ubuntu-24.04.3-live-server-amd64.iso +" diff --git a/isp-split/files/etc/uci-defaults/99-isp-split b/isp-split/files/etc/uci-defaults/99-isp-split new file mode 100644 index 000000000..70c3426d0 --- /dev/null +++ b/isp-split/files/etc/uci-defaults/99-isp-split @@ -0,0 +1,26 @@ +#!/bin/sh +# 首次启动初始化 —— 用于把本包直接编译进固件的情况 +# +# 为什么需要:打进固件时,opkg/apk 的 postinst 在镜像构建阶段就被跳过 +# (IPKG_INSTROOT 非空),所以 /etc/rc.d 的自启软链不会自动建立。 +# OpenWrt 会在首次启动时执行 /etc/uci-defaults/ 下的脚本,就在这里补上。 + +[ -x /usr/sbin/isp-split ] || exit 0 + +/etc/init.d/isp-split enable >/dev/null 2>&1 + +# 启用每日更新(只改数据,不重写 mwan3 配置) +if [ -x /etc/init.d/cron ]; then + mkdir -p /etc/crontabs + touch /etc/crontabs/root + if ! grep -q 'isp-split' /etc/crontabs/root 2>/dev/null; then + echo "17 4 * * * /usr/sbin/isp-split refresh >/dev/null 2>&1 # isp-split" \ + >> /etc/crontabs/root + fi + /etc/init.d/cron enable >/dev/null 2>&1 + /etc/init.d/cron restart >/dev/null 2>&1 +fi + +logger -t isp-split "固件内置初始化完成:已开自启与每日更新;运行 isp-split plan 进行预演" + +exit 0 diff --git a/isp-split/files/usr/lib/isp-split/badcidr.awk b/isp-split/files/usr/lib/isp-split/badcidr.awk new file mode 100644 index 000000000..8f8b218eb --- /dev/null +++ b/isp-split/files/usr/lib/isp-split/badcidr.awk @@ -0,0 +1,42 @@ +# 找出不合法的 CIDR 行(校验规则与 cidr2range.awk 保持一致) +# 输出格式:<行号>: <原始内容> +{ + line = $0 + sub(/#.*/, "", line) + gsub(/[ \t\r]/, "", line) + if (line == "") + next + + n = split(line, p, "/") + if (n != 2) { + print NR ": " $0 + next + } + + alen = split(p[1], q, ".") + if (alen != 4) { + print NR ": " $0 + next + } + + # 与 cidr2range.awk 保持一致:前缀必须是纯数字,不能靠 +0 转换 + if (p[2] !~ /^[0-9]+$/) { + print NR ": " $0 + next + } + len = p[2] + 0 + if (len < 0 || len > 32) { + print NR ": " $0 + next + } + + bad = 0 + for (i = 1; i <= 4; i++) { + if (q[i] !~ /^[0-9]+$/) + bad = 1 + else if (q[i] + 0 > 255) + bad = 1 + } + if (bad) + print NR ": " $0 +} diff --git a/isp-split/files/usr/lib/isp-split/build.sh b/isp-split/files/usr/lib/isp-split/build.sh new file mode 100644 index 000000000..db94e2836 --- /dev/null +++ b/isp-split/files/usr/lib/isp-split/build.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# 数据管线:原始 CIDR -> 归一化 -> 按优先级互斥化 -> 每运营商一个 CIDR 文件 +# +# 用法: build.sh <优先级列表> +# rawdir : 含 .raw 的目录,每行一个 CIDR +# 优先级 : 空格分隔的运营商名,排在前面的优先占有重叠地址 +# +# 设计要点: +# 1. 每个集合内部先合并去重(RFC1918 无关,只做区间合并) +# 2. 逐级做差集:后一个集合要扣掉所有前面已占用的地址 +# -> 保证同一 IP 只出现在一个集合里,规则顺序不再影响归属 +# 3. 全程只依赖 awk + sort,无 Python、无 ipcalc +set -eu + +RAW=$1 +OUT=$2 +PRIORITY=$3 + +LIB=${ISP_SPLIT_LIB:-$(dirname "$0")} +WORK=$OUT/.work + +mkdir -p "$OUT" "$WORK" +: > "$WORK/claimed" + +printf '%-16s %8s %8s %14s\n' "运营商" "原始" "互斥后" "地址数" > "$WORK/report" + +for isp in $PRIORITY; do + src=$RAW/$isp.raw + [ -f "$src" ] || { + printf '%-16s %8s\n' "$isp" "缺失" >> "$WORK/report" + continue + } + + # 1. CIDR -> 区间,排序,合并 + awk -f "$LIB/cidr2range.awk" "$src" | + sort | + awk -f "$LIB/merge.awk" > "$WORK/$isp.ranges" + + # 2. 扣掉高优先级运营商已占用的地址 + awk -v claimed="$WORK/claimed" -f "$LIB/exclude.awk" "$WORK/$isp.ranges" > "$WORK/$isp.excl" + + # 3. 区间 -> 最小 CIDR 集合 + awk -f "$LIB/range2cidr.awk" "$WORK/$isp.excl" > "$OUT/$isp.txt" + + # 4. 已占用集合累加(需重新合并以保持有序且互不重叠) + cat "$WORK/claimed" "$WORK/$isp.excl" | + sort | + awk -f "$LIB/merge.awk" > "$WORK/claimed.new" + mv "$WORK/claimed.new" "$WORK/claimed" + + # 统计 + n_raw=$(awk -f "$LIB/cidr2range.awk" "$src" | wc -l | tr -d ' ') + n_out=$(wc -l < "$OUT/$isp.txt" | tr -d ' ') + n_addr=$(awk '{ split($0, p, "/"); t += 2 ^ (32 - (p[2] + 0)) } END { printf "%.0f", t }' "$OUT/$isp.txt") + printf '%-16s %8s %8s %14s\n' "$isp" "$n_raw" "$n_out" "$n_addr" >> "$WORK/report" +done + +cat "$WORK/report" >&2 diff --git a/isp-split/files/usr/lib/isp-split/cidr2range.awk b/isp-split/files/usr/lib/isp-split/cidr2range.awk new file mode 100644 index 000000000..40d5f24d8 --- /dev/null +++ b/isp-split/files/usr/lib/isp-split/cidr2range.awk @@ -0,0 +1,59 @@ +# CIDR -> 数值区间 +# 输入:每行一个 CIDR(允许 # 注释、行尾空白) +# 输出:每行 "<起始> <结束>",均为零填充的 10 位定宽数字 +# +# 为什么零填充: +# OpenWrt 的 busybox sort 对大数执行 sort -n 时并不按数值排序 +# (实测 3232235520 会排在 134744064 之前,等于没排序)。 +# 把数值零填充到 10 位后,普通字典序 == 数值序,任何平台都成立。 +# +# 纯算术实现,不使用位运算,兼容 busybox awk / mawk / gawk + +function pad(n, s) { + s = sprintf("%.0f", n) + while (length(s) < 10) + s = "0" s + return s +} + +{ + line = $0 + sub(/#.*/, "", line) + gsub(/[ \t\r]/, "", line) + if (line == "") + next + + n = split(line, p, "/") + if (n != 2) + next + + alen = split(p[1], q, ".") + if (alen != 4) + next + + # 前缀必须是纯数字;否则 "abc"/"" 会被 +0 当成 0,单行就吞掉整个 IPv4 + if (p[2] !~ /^[0-9]+$/) + next + len = p[2] + 0 + if (len < 0 || len > 32) + next + + bad = 0 + for (i = 1; i <= 4; i++) { + if (q[i] !~ /^[0-9]+$/) + bad = 1 + else if (q[i] + 0 > 255) + bad = 1 + } + if (bad) + next + + ip = q[1] * 16777216 + q[2] * 65536 + q[3] * 256 + q[4] + if (ip < 0 || ip > 4294967295) + next + + blk = 2 ^ (32 - len) + s = int(ip / blk) * blk + e = s + blk - 1 + print pad(s), pad(e) +} diff --git a/isp-split/files/usr/lib/isp-split/exclude.awk b/isp-split/files/usr/lib/isp-split/exclude.awk new file mode 100644 index 000000000..816851f9b --- /dev/null +++ b/isp-split/files/usr/lib/isp-split/exclude.awk @@ -0,0 +1,55 @@ +# 区间差集:target 减去 claimed +# 用法:awk -v claimed=<已占用区间文件> -f exclude.awk < target +# claimed:已按字典序排好的互不重叠区间 "<起始> <结束>"(零填充定宽) +# target :同样已排序去重叠的区间(stdin) +# 输出:target 中未被 claimed 覆盖的部分(零填充、升序、互不重叠) +# 目的:让同一个 IP 只归属一个运营商集合,消除归属歧义 + +function pad(n, s) { + s = sprintf("%.0f", n) + while (length(s) < 10) + s = "0" s + return s +} + +BEGIN { + if (claimed == "") { + print "exclude.awk: 缺少参数 -v claimed=<已占用区间文件>" > "/dev/stderr" + exit 2 + } + nc = 0 + while ((getline l < claimed) > 0) { + n = split(l, f, " ") + if (n < 2) + continue + nc++ + cs[nc] = f[1] + 0 + ce[nc] = f[2] + 0 + } + close(claimed) + ci = 1 +} +{ + s = $1 + 0 + e = $2 + 0 + if (s > e) + next + + # 跳过完全位于 s 之前的已占用区间 + while (ci <= nc && ce[ci] < s) + ci++ + + cur = ci + while (cur <= nc && cs[cur] <= e) { + if (cs[cur] > s) + print pad(s), pad(cs[cur] - 1) + if (ce[cur] + 1 > s) + s = ce[cur] + 1 + if (s > e) + break + cur++ + } + + if (s <= e) + print pad(s), pad(e) +} diff --git a/isp-split/files/usr/lib/isp-split/ispdiff.awk b/isp-split/files/usr/lib/isp-split/ispdiff.awk new file mode 100644 index 000000000..6977216fb --- /dev/null +++ b/isp-split/files/usr/lib/isp-split/ispdiff.awk @@ -0,0 +1,102 @@ +# 两个运营商集合的三段差异:仅主源 / 仅校验源 / 共同,并计算 Jaccard +# +# 用法: +# awk -v vf=<校验源区间文件> [-v outp=<仅主源区间输出>] [-v outv=<仅校验源区间输出>] \ +# -f ispdiff.awk <主源区间文件> +# +# 输入格式:两边的区间文件都必须是「每行 <起始> <结束>,升序、互不重叠」, +# 也就是 cidr2range.awk | sort | merge.awk 的输出(零填充十位定宽)。 +# 输出(stdout 一行六列): +# <主源地址数> <校验源地址数> <共同> <仅主> <仅校验> +# +# 为什么用扫描线而不是逐条 CIDR 比对: +# 运营商集合动辄上千段,"每段对每段"是 O(n*m);排序后双指针是 O(n+m), +# 在 4 千行量级一次 diff 只要几十毫秒,路由器上完全无感。 +# +# 注意:awk 函数里除第一个参数外多写的形参才是局部变量,这里用显式形参 +# (i/j/k/s/e 都是全局的,只用一次,不影响正确性)。 + +BEGIN { + nv = 0 + while ((getline line < vf) > 0) { + if (line == "") continue + split(line, a, " ") + if (a[1] == "" || a[2] == "") continue + nv++ + vs[nv] = a[1] + 0 + ve[nv] = a[2] + 0 + } + close(vf) + np = 0 +} + +{ + if ($1 == "" || $2 == "") next + np++ + ps[np] = $1 + 0 + pe[np] = $2 + 0 +} + +END { + ptot = 0 + for (i = 1; i <= np; i++) ptot += pe[i] - ps[i] + 1 + vtot = 0 + for (i = 1; i <= nv; i++) vtot += ve[i] - vs[i] + 1 + + # 交集长度(双指针) + common = 0 + i = 1 + j = 1 + while (i <= np && j <= nv) { + s = (ps[i] > vs[j]) ? ps[i] : vs[j] + e = (pe[i] < ve[j]) ? pe[i] : ve[j] + if (s <= e) common += e - s + 1 + if (pe[i] < ve[j]) i++ + else if (ve[j] < pe[i]) j++ + else { i++; j++ } + } + + # 仅主源区间(主源减去校验源) + if (outp != "") { + j = 1 + for (i = 1; i <= np; i++) { + s = ps[i] + e = pe[i] + while (j <= nv && ve[j] < s) j++ + k = j + while (k <= nv && vs[k] <= e) { + if (vs[k] > s) print s " " (vs[k] - 1) > outp + if (ve[k] + 1 > s) s = ve[k] + 1 + if (s > e) break + k++ + } + if (s <= e) print s " " e > outp + } + close(outp) + } + + # 仅校验源区间(校验源减去主源) + if (outv != "") { + i = 1 + for (j = 1; j <= nv; j++) { + s = vs[j] + e = ve[j] + while (i <= np && pe[i] < s) i++ + k = i + while (k <= np && ps[k] <= e) { + if (ps[k] > s) print s " " (ps[k] - 1) > outv + if (pe[k] + 1 > s) s = pe[k] + 1 + if (s > e) break + k++ + } + if (s <= e) print s " " e > outv + } + close(outv) + } + + if (ptot + vtot - common > 0) + jac = common * 100 / (ptot + vtot - common) + else + jac = 100 + printf "%.0f %.0f %.0f %.0f %.0f %.2f\n", ptot, vtot, common, ptot - common, vtot - common, jac +} diff --git a/isp-split/files/usr/lib/isp-split/merge.awk b/isp-split/files/usr/lib/isp-split/merge.awk new file mode 100644 index 000000000..4ba792f81 --- /dev/null +++ b/isp-split/files/usr/lib/isp-split/merge.awk @@ -0,0 +1,45 @@ +# 合并重叠/相邻区间 +# 输入:已按字典序排好的 "<起始> <结束>"(零填充定宽,字典序 == 数值序) +# 输出:互不重叠、已合并的区间,同样零填充 +# +# 注意:不能依赖 sort -n —— busybox 的 sort -n 对大数不生效。 +# 输入已经是零填充格式,普通 sort 即可得到正确数值序。 + +function pad(n, s) { + s = sprintf("%.0f", n) + while (length(s) < 10) + s = "0" s + return s +} + +BEGIN { + have = 0 +} +{ + s = $1 + 0 + e = $2 + 0 + if (s > e) + next + + if (!have) { + cs = s + ce = e + have = 1 + next + } + + # 与当前合并段重叠或正好相邻 + if (s <= ce + 1) { + if (e > ce) + ce = e + next + } + + print pad(cs), pad(ce) + cs = s + ce = e +} +END { + if (have) + print pad(cs), pad(ce) +} diff --git a/isp-split/files/usr/lib/isp-split/range2cidr.awk b/isp-split/files/usr/lib/isp-split/range2cidr.awk new file mode 100644 index 000000000..3b758515b --- /dev/null +++ b/isp-split/files/usr/lib/isp-split/range2cidr.awk @@ -0,0 +1,42 @@ +# 数值区间 -> 最小 CIDR 集合 +# 输入:每行 "<起始> <结束>"(升序、互不重叠) +# 输出:每行一个 CIDR +# +# 注意:awk 函数里除第一个参数外,多写的形参才是局部变量。 +# int2ip(n, a,b,c,d) 中的 a,b,c,d 若不加在形参列表里就会变成全局变量, +# 直接污染外层的块大小 b,导致死循环。这是本项目踩过的一个坑。 +function int2ip(n, a, b, c, d) { + a = int(n / 16777216) % 256 + b = int(n / 65536) % 256 + c = int(n / 256) % 256 + d = n % 256 + return a "." b "." c "." d +} + +function log2(x, r) { + r = 0 + while (x > 1) { + x = x / 2 + r++ + } + return r +} + +{ + s = $1 + 0 + e = $2 + 0 + if (s > e) + next + + while (s <= e) { + # 找出以 s 为起点、长度不超过 e 的最大 2 的幂块 + blk = 1 + while (s % (blk * 2) == 0 && s + blk * 2 - 1 <= e) + blk = blk * 2 + if (blk < 1) + blk = 1 + + printf "%s/%d\n", int2ip(s), 32 - log2(blk) + s = s + blk + } +} diff --git a/isp-split/files/usr/lib/isp-split/rules-splice.awk b/isp-split/files/usr/lib/isp-split/rules-splice.awk new file mode 100644 index 000000000..e920fa943 --- /dev/null +++ b/isp-split/files/usr/lib/isp-split/rules-splice.awk @@ -0,0 +1,115 @@ +# 把运营商分流规则块插入 /etc/config/mwan3 的正确位置 +# +# 为什么不直接 uci add: +# mwan3 按配置文件中的顺序渲染 nft 规则链(first-match),而 uci add 只能追加到末尾。 +# 追加的规则必然排在 default_rule 之后,永远匹配不到 —— 等于白装。 +# +# 用法: +# awk -v insert=<规则片段文件> -v anchor=<锚点正则> -f rules-splice.awk /etc/config/mwan3 +# +# 行为: +# 1. 只删掉带精确标记 option isp_split '1' 的本工具规则块 +# (不再按 isp_ 前缀或注释里的 isp_split 字符串判定,避免误删用户规则) +# 2. 在第一个匹配 anchor 的 rule 块之前插入 insert 文件内容 +# 3. 找不到锚点且 insert 非空时,输出警告到 stderr 并以 3 退出, +# 绝不静默追加到链尾(追加在 catch-all 之后永远不命中) +# +# 注意:awk 函数除首参外多写的形参才是局部变量,务必写全,否则污染全局。 + +function blocktext(i, e, j, t) { + e = (i < nb) ? bs[i + 1] - 1 : NR + t = "" + # 只拼接块内的 option/list 行。块间注释、空行不能算进前一个块, + # 否则一段含 isp_split 字样的注释就会把上面的用户规则误判成我们的。 + for (j = bs[i]; j <= e; j++) + if (line[j] ~ /^[[:space:]]*(option|list)[[:space:]]/) + t = t " " line[j] + return t +} + +function is_ours(i, t) { + if (bt[i] != "rule") + return 0 + t = blocktext(i) + if (t ~ /option[[:space:]]+isp_split[[:space:]]+['"]1['"]/) + return 1 + return 0 +} + +function warn_no_anchor() { + print "rules-splice.awk: 找不到锚点 '" anchor "',拒绝把规则追加到链尾(inserted=0)" > "/dev/stderr" +} + +BEGIN { + if (insert == "") { + print "rules-splice.awk: 缺少参数 -v insert=<规则片段文件>" > "/dev/stderr" + exit 2 + } + # 先读入待插入片段,顺便判断是否为空(卸载时 insert 为空是合法的) + ni = 0 + while ((getline l < insert) > 0) + ins[++ni] = l + close(insert) +} + +{ + line[NR] = $0 + if ($1 == "config") { + nb++ + bs[nb] = NR + bt[nb] = $2 + bn[nb] = $3 + } +} + +END { + for (i = 1; i <= nb; i++) { + gsub(/['\''"]/, "", bt[i]) + gsub(/['\''"]/, "", bn[i]) + } + + # 找锚点:第一个不是我们的、且匹配 anchor 的 rule 块 + aidx = 0 + for (i = 1; i <= nb && aidx == 0; i++) { + if (bt[i] != "rule") + continue + if (is_ours(i)) + continue + # blocktext 只含 option/list 行;section 名(如 default_rule_v4) + # 也要参与锚点匹配,否则默认锚点里的 default_rule 会失效。 + if (blocktext(i) ~ anchor || bn[i] ~ anchor) + aidx = i + } + + if (nb == 0) { + if (ni > 0) { + warn_no_anchor() + exit 3 + } + exit 0 + } + + # 文件头(第一个 config 之前的内容) + for (j = 1; j < bs[1]; j++) + print line[j] + + inserted = 0 + for (i = 1; i <= nb; i++) { + if (is_ours(i)) + continue + if (!inserted && i == aidx) { + for (k = 1; k <= ni; k++) + print ins[k] + inserted = 1 + } + e = (i < nb) ? bs[i + 1] - 1 : NR + for (j = bs[i]; j <= e; j++) + print line[j] + } + + # 没有锚点:insert 为空(卸载)时原样输出;非空时拒绝追加 + if (!inserted && ni > 0) { + warn_no_anchor() + exit 3 + } +} diff --git a/isp-split/files/usr/sbin/isp-split b/isp-split/files/usr/sbin/isp-split new file mode 100644 index 000000000..1180de794 --- /dev/null +++ b/isp-split/files/usr/sbin/isp-split @@ -0,0 +1,3799 @@ +#!/bin/sh +# openwrt-isp-split - 按目标 IP 归属运营商做 mwan3 分流 +# +# 依赖:mwan3-nft (>=3.6,原生 nftables 版)、nft、awk、sort +# +# 子命令: +# fetch 下载并重建运营商 IP 段(不改变线上流量) +# apply 把已构建的集合原子刷入 nftables(不断流、不重启 mwan3) +# wire 在 mwan3 中创建 nft 集合与分流规则,并插到正确位置 +# sync 以上三步 + 重载 mwan3(首次安装用这个) +# status 查看各集合条目数、命中包数/字节数、数据源健康度 +# diff 重新计算主源与校验源的分歧(超阈值返回 3) +# probe 分线实测各运营商出口速度,用来决定策略映射 +# rollback 回滚到上一份数据并重新 apply +# install 注册开机自启与每日更新 +# uninstall 卸载所有改动 +set -u +ISP_VERSION="0.2.1" + +CONF=${ISP_SPLIT_CONF:-/etc/isp-split.conf} +BASE=${ISP_SPLIT_BASE:-/etc/isp-split} +# BASE 会被 cp/写文件,也会在 uninstall 里被 `rm -rf`。它可以从环境变量 +# 覆盖(离线自检用),所以先挡住危险值:空、根、相对路径、含 ".."。 +case "$BASE" in +"" | "/" | "." | ".." | *..*) + echo "ISP_SPLIT_BASE 非法: '$BASE'" >&2 + exit 1 + ;; +/*) ;; +*) + echo "ISP_SPLIT_BASE 必须是绝对路径: '$BASE'" >&2 + exit 1 + ;; +esac +DATA=$BASE/data +WORK=$BASE/work +LIB=${ISP_SPLIT_LIB:-/usr/lib/isp-split} +CFG_MWAN3=${ISP_SPLIT_MWAN3:-/etc/config/mwan3} +# crontab 文件与 dnsmasq 服务脚本:允许覆盖仅用于离线自检。 +CRONTAB=${ISP_SPLIT_CRONTAB:-/etc/crontabs/root} +DNSMASQ_SERVICE=${DNSMASQ_SERVICE:-/etc/init.d/dnsmasq} + +[ -f "$CONF" ] || { echo "缺少 $CONF" >&2; exit 1; } +. "$CONF" + +# 配置文件常被 Windows 编辑器/网页粘贴保存成 CRLF。sh 的 source 不在乎, +# 但 ISP_TABLE 的最后一列会变成 "1\r",`[ "$4" = "1" ]` 永远不成立: +# plan 只剩一张空表、status 只剩兜底行、apply 报"没有启用的运营商", +# 而真正的原因(行尾多了一个 \r)完全看不出来。这里在入口统一去掉。 +ISP_TABLE=$(printf '%s' "${ISP_TABLE:-}" | tr -d '\r') +DNS_TABLE=$(printf '%s' "${DNS_TABLE:-}" | tr -d '\r') +DOMAIN_SPLIT=$(printf '%s' "${DOMAIN_SPLIT:-}" | tr -d '\r') + +# ---- 多数据源(0.2.1 默认双源)---- +# ISP_SOURCES 为空 = 单源兼容模式:仍按 ISP_MIRROR/<源名>.txt 拉取, +# 与 0.1.x 的行为逐字节一致(老配置升级后不需要改任何东西)。 +ISP_SOURCES=$(printf '%s' "${ISP_SOURCES:-}" | tr -d '\r') +ISP_SOURCE_FILES=$(printf '%s' "${ISP_SOURCE_FILES:-}" | tr -d '\r') +# 0.2.1 起默认启用校验源;用户配置里显式写了 ISP_VERIFY_ENABLE=0 时仍以用户值为准。 +ISP_VERIFY_ENABLE=${ISP_VERIFY_ENABLE:-1} +ISP_DIFF_MAX_JACCARD_DROP=${ISP_DIFF_MAX_JACCARD_DROP:-90} +ISP_DIFF_MAX_DELTA_PCT=${ISP_DIFF_MAX_DELTA_PCT:-3} +ISP_SOURCE_MAX_FAIL_DAYS=${ISP_SOURCE_MAX_FAIL_DAYS:-3} +RAW_ARCHIVE=$BASE/raw +STATE_DIR=$BASE/state +SOURCE_STATE=$STATE_DIR/sources.tsv + +ISP_ANCHOR=${ISP_ANCHOR:-'dest_port|src_port|default_rule|https|internet'} +ISP_CRON=${ISP_CRON:-"17 4 * * *"} +PROBE_BYTES=${PROBE_BYTES:-33554432} +# 单个源文件的体积上限(默认 4 MiB)。正常数据 < 300 KB; +# 镜像被劫持或换源换错时,一个超大响应会直接写满 overlay(/etc 就在 +# overlay 上),路由器连重启都可能起不来。 +MAX_RAW_BYTES=${ISP_SPLIT_MAX_RAW_BYTES:-4194304} +PROBE_TIME=${PROBE_TIME:-25} + +# mwan3 reload 是异步的:命令返回时 nft 链可能还在重建。 +# 轮询间隔与次数可被配置/测试覆盖;默认最多等 15 秒。 +MWAN3_READY_TRIES=${MWAN3_READY_TRIES:-15} +MWAN3_READY_DELAY=${MWAN3_READY_DELAY:-1} +DNS_RETRY_TRIES=${DNS_RETRY_TRIES:-15} +DNS_RETRY_DELAY=${DNS_RETRY_DELAY:-1} + +log() { + logger -t isp-split -- "$*" 2>/dev/null + echo "[isp-split] $*" +} + +die() { + log "错误: $*" + exit 1 +} + +need_nft() { + command -v nft >/dev/null 2>&1 || die "未找到 nft 命令" + nft list table inet mwan3 >/dev/null 2>&1 || + die "table inet mwan3 不存在,请确认 mwan3-nft 已启动" +} + +# ---- OpenClash fake-ip 模式检测(BUG-17)---- +# fake-ip 模式给域名返回 198.18.0.0/15 的假地址,而本工具是按"目标 IP +# 归属运营商"匹配集合的:假 IP 不在任何运营商网段里,规则永远不会命中, +# 所有流量都落到兜底;同时 fake-ip 的 DNS 劫持会盖掉本工具写的分线 DNS +# 片段。结果是"装好了但静默不生效",用户只能看到兜底计数一直涨。 +# 因此 plan/verify/status 以及 wire/apply/sync 启动时都主动告警。 +# 只告警、不阻断:用户可能就是要先装好,再切 redir-host 模式。 +_openclash_fakeip() { + command -v uci >/dev/null 2>&1 || return 1 + _ocf_en=$(uci -q get openclash.config.enable 2>/dev/null) || return 1 + [ "$_ocf_en" = "1" ] || return 1 + _ocf_mode=$(uci -q get openclash.config.en_mode 2>/dev/null) || return 1 + # 兼容 fake-ip / fakeip / fake_ip / Fake-IP 等写法 + _ocf_norm=$(printf '%s' "$_ocf_mode" | tr 'A-Z' 'a-z' | tr -d ' _-') + case "$_ocf_norm" in + fakeip*) return 0 ;; + esac + return 1 +} + +_openclash_fakeip_warn() { + _openclash_fakeip || return 0 + # sync 会依次调用 wire/apply,同一次执行只提示一次,避免刷屏 + [ "${_OCF_WARNED:-0}" = "1" ] && return 0 + _OCF_WARNED=1 + echo "[isp-split] ⚠ 检测到 OpenClash fake-ip 模式:假 IP 无法匹配运营商集合," + echo " IP 分流与多线路 DNS 均不会生效。处理:OpenClash 切 redir-host 模式" + echo " (LuCI → OpenClash → 模式设置),或接受分流不生效。" + return 0 +} + +# work 目录(临时批处理/备份文件的家)是否真的可写。 +# 用 mktemp 探路而不是固定名字 .write-test:固定名字在可写目录里 +# (例如把 BASE 指到 /tmp 下的测试/容器场景)能被低权用户预先做成 +# 符号链接,root 的 `: > 文件` 会顺着链接把目标截断。 +_work_writable() { + mkdir -p "$WORK" || return 1 + _wt=$(mktemp "$WORK/.write-test.XXXXXX" 2>/dev/null) || return 1 + rm -f "$_wt" + return 0 +} + +# 统计一个集合里的元素个数 +# +# 坑:带 flags interval 的集合,nft list set 输出的是区间格式 +# (1.48.0.0-1.50.255.255),不是 CIDR(1.48.0.0/15)。 +# 用 /前缀 正则去数会永远得到 0。这里按元素的逗号分隔来数。 +count_set_elements() { + nft list set inet "$1" "$2" 2>/dev/null | awk ' + { + line = $0 + # 用 index() 而不是正则匹配花括号: + # busybox awk 的 ERE 会拒绝 \{,报 "Invalid contents of {}" 并整段退出。 + if (!inb && index(line, "elements = {")) { + inb = 1 + line = substr(line, index(line, "elements = {") + 12) + } + if (inb) { + q = index(line, "}") + if (q) { + end = 1 + line = substr(line, 1, q - 1) + } + if (line ~ /[0-9]/) + seen = 1 + c += gsub(/,/, ",", line) + if (end) + inb = 0 + } + } + END { printf "%d\n", (seen ? c + 1 : 0) }' +} + +# 按 ISP_TABLE 顺序输出运营商源名 +priority_list() { + for row in $ISP_TABLE; do + case "$row" in \#*) continue ;; esac + _row_check_row "$row" + printf '%s ' "${row%%:*}" + done +} + +# 已启用集合的源名列表(空格分隔)。用来说明"运营商表有没有变化": +# 启用/停用某个运营商会合法地改变地址总量,增幅熔断要跳过这种情况。 +_enabled_src_list() { + _el="" + for row in $ISP_TABLE; do + _old_ifs=$IFS + IFS=':' + set -- $row + IFS=$_old_ifs + [ $# -ge 4 ] || continue + [ "$4" = "1" ] || continue + _el="${_el:+$_el }$1" + done + printf '%s\n' "$_el" +} + +# 遍历启用的运营商,逐行回调:cb <源名> <集合名> <策略> +foreach_enabled() { + _cb=$1 + _old_ifs=$IFS + for row in $ISP_TABLE; do + IFS=':' + set -- $row + IFS=$_old_ifs + case "$row" in \#*) continue ;; esac + [ $# -ge 4 ] || + die "ISP_TABLE 行格式错误(应为 源:集合:策略:0|1): $row" + _row_check "$1" "$2" "$3" "$4" + [ "$4" = "1" ] || continue + $_cb "$1" "$2" "$3" + done +} + +fetch_one() { + _out=$2 + if command -v uclient-fetch >/dev/null 2>&1; then + uclient-fetch -q -T 30 -O "$_out" "$1" + elif command -v curl >/dev/null 2>&1; then + curl -fsS -m 30 -o "$_out" "$1" + elif command -v wget >/dev/null 2>&1; then + wget -q -T 30 -O "$_out" "$1" + else + die "没有可用的下载工具(uclient-fetch / curl / wget)" + fi +} + +# ---------------------------------------------------------------- 多数据源(0.2.1) +# +# 0.2.1 默认双源:yfgao 主源 + clang 校验源(模板见 files/etc/isp-split.conf)。 +# 老配置里显式声明的 ISP_SOURCES 完全按用户声明执行,不会被覆盖。 +# ISP_SOURCES 为空时完全退回 0.1.x 的单源行为(ISP_MIRROR/<源名>.txt)。 + +# 源声明规范化:每行一条 <名字>:<角色>:<基址> +_source_rows() { + if printf '%s' "${ISP_SOURCES:-}" | grep -q '[^[:space:]]'; then + printf '%s\n' "$ISP_SOURCES" | tr -s ' \t' '\n' | sed '/^[[:space:]]*$/d' + else + printf '%s\n' "ispip:primary:${ISP_MIRROR:-}" + fi +} + +# 拆一行源声明到 _s_name / _s_role / _s_base +_source_split() { + _s_name=${1%%:*} + _s_rest=${1#*:} + _s_role=${_s_rest%%:*} + _s_base=${_s_rest#*:} +} + +_source_role_of() { + _source_rows | awk -F: -v n="$1" '$1 == n { print $2; exit }' +} + +_source_base_of() { + _source_rows | awk -v n="$1" ' + { + i = index($0, ":") + name = substr($0, 1, i - 1) + if (name != n) next + rest = substr($0, i + 1) + j = index(rest, ":") + print substr(rest, j + 1) + exit + }' +} + +_primary_source_name() { + _source_rows | awk -F: '$2 == "primary" { print $1; exit }' +} + +# 单个源声明的白名单校验(写入侧与读取侧共用) +_source_check() { + _sc_row=$1 + _source_split "$_sc_row" + case "$_s_name" in + "" | *[!A-Za-z0-9._-]*) + die "ISP_SOURCES 的源名 '$_s_name' 非法(只允许 [A-Za-z0-9._-])" + ;; + esac + case "$_s_role" in + primary | verify | audit) ;; + *) die "ISP_SOURCES 源 '$_s_name' 的角色 '$_s_role' 非法(只能是 primary / verify / audit)" ;; + esac + # ISP_MIRROR 没配(老配置残缺)时 status/plan 仍要能看,只是 fetch 会拒绝 + [ -n "$_s_base" ] || return 0 + case "$_s_base" in + https://*) _sc_host=${_s_base#https://} ;; + http://*) + [ "${ISP_SPLIT_ALLOW_HTTP:-0}" = "1" ] || + die "ISP_SOURCES 源 '$_s_name' 使用明文 HTTP($_s_base):数据可被中间人替换。改用 https://,或确认风险后设 ISP_SPLIT_ALLOW_HTTP=1" + _sc_host=${_s_base#http://} + log "警告:源 '$_s_name' 使用明文 HTTP,数据完整性无保证" + ;; + *) + die "ISP_SOURCES 源 '$_s_name' 的基址必须形如 https://host(当前: $_s_base)" + ;; + esac + # 基址只允许主机/端口/路径:不带查询串、不带空白、不带 shell 元字符。 + case "$_sc_host" in + "" | *[!A-Za-z0-9._:/~-]*) + die "ISP_SOURCES 源 '$_s_name' 的基址不合法(只允许 host[:port][/path]): $_s_base" + ;; + esac +} + +_source_check_all() { + _sa_primary=0 + _sa_seen=" " + for _sa_row in $(_source_rows); do + _source_check "$_sa_row" + case "$_sa_seen" in + *" $_s_name "*) die "ISP_SOURCES 里源名 '$_s_name' 重复" ;; + esac + _sa_seen="$_sa_seen$_s_name " + if [ "$_s_role" = "primary" ]; then + _sa_primary=$((_sa_primary + 1)) + fi + done + [ "$_sa_primary" -eq 1 ] || + die "ISP_SOURCES 必须且只能有一个 primary 源(当前 $_sa_primary 个)" +} + +# ISP_SOURCE_FILES 规范化成 "<源名>:=<远端文件名>" 每行一条。 +# 书写格式(与设计一致):yfgao:chinatelecom=chinanet,unicom_cnc=unicom +_source_map_lines() { + printf '%s\n' "${ISP_SOURCE_FILES:-}" | tr ',' '\n' | awk ' + { + gsub(/[[:space:]]+/, "", $0) + if ($0 == "") next + i = index($0, ":") + if (i > 0) { + cur = substr($0, 1, i - 1) + $0 = substr($0, i + 1) + } + if (cur != "") print cur ":" $0 + }' +} + +# <源名> -> 该源上的远端文件名(空 = 未显式映射) +# 映射值写"不带 .txt 的名字"(与设计文档一致):chinatelecom=chinanet。 +# 值里自带扩展名(如 foo.dat)时原样使用,不再补后缀。 +_source_file_of() { + _sf=$(_source_map_lines | + awk -v n="$1" -v s="$2" -F'[:=]' '$1 == n && $2 == s { print $3; exit }') + case "$_sf" in + "") ;; + *.*) ;; + *) _sf="$_sf.txt" ;; + esac + printf '%s' "$_sf" +} + +# 该源是否声明过任意一条文件名映射。 +# 声明过 = 默认只处理映射里列出的运营商;主源上"已启用但未映射"的运营商 +# 仍会退回 <源名>.txt 兼容路径(见 _fetch_source)。完全没声明 = 老配置。 +_source_has_map() { + _source_map_lines | grep -q "^$1:" +} + +# ---- 源健康状态:$BASE/state/sources.tsv ---- +# 列:name role last_ok fail_days jaccard note +_state_get() { + [ -f "$SOURCE_STATE" ] || return 0 + awk -F "$(printf '\t')" -v n="$1" -v f="$2" ' + $1 == n { + if (f == "role") print $2 + else if (f == "last_ok") print $3 + else if (f == "fail") print $4 + else if (f == "jaccard") print $5 + else if (f == "note") print $6 + exit + }' "$SOURCE_STATE" +} + +# 连续失败计数 +1;返回新的失败天数 +_state_fail_plus() { + _sf_prev=$(_state_get "$1" fail) + case "${_sf_prev:-}" in '' | *[!0-9]*) _sf_prev=0 ;; esac + _sf_new=$((_sf_prev + 1)) + _state_put "$1" "$2" "$(_state_get "$1" last_ok)" "$_sf_new" \ + "$(_state_get "$1" jaccard)" "$3" + printf '%s' "$_sf_new" +} + +_state_put() { + mkdir -p "$STATE_DIR" || return 1 + _sp_tmp=$(mktemp "$STATE_DIR/.sources.XXXXXX" 2>/dev/null) || return 1 + if [ -f "$SOURCE_STATE" ]; then + awk -F "$(printf '\t')" -v OFS="$(printf '\t')" \ + -v n="$1" -v r="$2" -v o="$3" -v f="$4" -v j="$5" -v s="$6" ' + $1 == n { if (!done) { print n, r, o, f, j, s; done = 1 } next } + { print } + END { if (!done) print n, r, o, f, j, s } + ' "$SOURCE_STATE" > "$_sp_tmp" || { rm -f "$_sp_tmp"; return 1; } + else + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" "$5" "$6" > "$_sp_tmp" || + { rm -f "$_sp_tmp"; return 1; } + fi + mv -f "$_sp_tmp" "$SOURCE_STATE" 2>/dev/null || { rm -f "$_sp_tmp"; return 1; } + return 0 +} + +# 清理 state/sources.tsv 里已经不在 ISP_SOURCES 中的残留行。 +# +# 典型场景:0.1.x 单源时代留下 `ispip primary`,用户后来改成双源, +# 旧行会一直躺在文件里(status/health 虽然只遍历声明过的源,但数据本身是脏的, +# 排查时也很容易被误读)。这里在 fetch/status/health 入口做一次幂等清理: +# · 只保留当前 ISP_SOURCES(空则为兼容模式的 ispip)声明过的源; +# · 顺带丢掉字段数不足 6 的坏行; +# · 没有任何变化时不动文件(status 每 10 秒轮询,不必反复写 flash)。 +_state_prune() { + [ -f "$SOURCE_STATE" ] || return 0 + _sp_decl=" " + for _sp_row in $(_source_rows); do + _sp_name=${_sp_row%%:*} + [ -n "$_sp_name" ] && _sp_decl="$_sp_decl$_sp_name " + done + [ "$_sp_decl" != " " ] || return 0 + + mkdir -p "$STATE_DIR" || return 1 + _sp_tmp=$(mktemp "$STATE_DIR/.sources.prune.XXXXXX" 2>/dev/null) || return 1 + _sp_rem=$(mktemp "$STATE_DIR/.sources.removed.XXXXXX" 2>/dev/null) || + { rm -f "$_sp_tmp"; return 1; } + awk -F "$(printf '\t')" -v keep="$_sp_decl" -v rem="$_sp_rem" ' + NF >= 6 { + if (index(keep, " " $1 " ") > 0) print + else print $1 > rem + }' "$SOURCE_STATE" > "$_sp_tmp" || { + rm -f "$_sp_tmp" "$_sp_rem" + return 1 + } + + if cmp -s "$_sp_tmp" "$SOURCE_STATE"; then + rm -f "$_sp_tmp" "$_sp_rem" + return 0 + fi + + _sp_removed=$(tr '\n' ' ' < "$_sp_rem" 2>/dev/null) + mv -f "$_sp_tmp" "$SOURCE_STATE" 2>/dev/null || { + rm -f "$_sp_tmp" "$_sp_rem" + return 1 + } + rm -f "$_sp_rem" + [ -n "${_sp_removed:-}" ] && + log "已清理 state/sources.tsv 中不在 ISP_SOURCES 的残留源: ${_sp_removed% }" >&2 + return 0 +} + +_ts_text() { + case "${1:-}" in + "" | 0 | *[!0-9]*) printf '%s' "-"; return 0 ;; + esac + date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null || + date -r "$1" '+%Y-%m-%d %H:%M' 2>/dev/null || printf '%s' "-" +} + +# 下载一个源的全部文件到 <目录>(文件名保持远端原名)。 +# 只做"单源单文件"的三道闸门(体积/行数/badcidr);总量与增幅闸门在合成后做。 +# 返回 0 成功;1 失败(调用方决定冻结还是降级)。 +_fetch_source() { + _fs_name=$1 + _fs_base=$(printf '%s' "$2" | sed 's:/*$::') + _fs_dir=$3 + _fs_role=$4 + + mkdir -p "$_fs_dir" || { log "源 $_fs_name: 无法创建 $_fs_dir"; return 1; } + + for _fs_row in $ISP_TABLE; do + case "$_fs_row" in \#*) continue ;; esac + _row_check_row "$_fs_row" + _fs_src=${_fs_row%%:*} + _fs_file=$(_source_file_of "$_fs_name" "$_fs_src") + if [ -z "$_fs_file" ]; then + # 该源声明过映射时,映射里没列出的运营商默认不下载: + # yfgao 没有 hk/mo/tw 这些分类,按 <源名>.txt 去猜只会每天 404。 + # 但为了不破坏 0.2.0 老配置(clang 主源 + 用户自己启用 hk 等), + # 主源上"已启用但未映射"的运营商仍退回历史命名试一次; + # 文件不存在时由下载失败明确报错,而不是静默少一个集合。 + if _source_has_map "$_fs_name"; then + case "$_fs_row" in + *:1) ;; + *) continue ;; + esac + [ "$_fs_role" = "primary" ] || continue + _fs_file="$_fs_src.txt" + else + # 完全没声明映射的老配置,沿用 0.1.x 的命名 <源名>.txt; + # 校验源只在显式映射里出现——绝不去猜别人的文件名。 + [ "$_fs_role" = "primary" ] || continue + _fs_file="$_fs_src.txt" + fi + fi + case "$_fs_file" in + "" | *[!A-Za-z0-9._-]*) + log "源 $_fs_name: 映射出的文件名 '$_fs_file' 非法" + return 1 + ;; + esac + + log "源 $_fs_name: 下载 $_fs_src ($_fs_file)" + fetch_one "$_fs_base/$_fs_file" "$_fs_dir/$_fs_file" || { + log "源 $_fs_name: $_fs_src 下载失败" + return 1 + } + _fs_sz=$(wc -c < "$_fs_dir/$_fs_file" 2>/dev/null | tr -d ' \t') + if [ "${_fs_sz:-0}" -gt "$MAX_RAW_BYTES" ]; then + log "源 $_fs_name: $_fs_src 体积 ${_fs_sz} 字节,超过上限 $MAX_RAW_BYTES" + return 1 + fi + _fs_n=$(grep -c '[0-9]' "$_fs_dir/$_fs_file" 2>/dev/null) + _fs_n=${_fs_n:-0} + [ "$_fs_n" -gt 5 ] || { + log "源 $_fs_name: $_fs_src 内容异常(仅 $_fs_n 行)" + return 1 + } + _fs_bad=$(awk -f "$LIB/badcidr.awk" "$_fs_dir/$_fs_file" 2>/dev/null | head -5) + if [ -n "$_fs_bad" ]; then + printf '%s\n' "$_fs_bad" >&2 + log "源 $_fs_name: $_fs_src 含非法 CIDR,拒绝(现有数据未改动)" + return 1 + fi + done + return 0 +} + +# 把主源文件按 ISP_SOURCE_FILES 映射拷成 .raw,交给 build.sh +_compose_raw() { + _cm_name=$1 + _cm_src=$2 + _cm_out=$3 + rm -rf "$_cm_out" + mkdir -p "$_cm_out" || return 1 + for _cm_row in $ISP_TABLE; do + case "$_cm_row" in \#*) continue ;; esac + _row_check_row "$_cm_row" + _cm_isp=${_cm_row%%:*} + _cm_file=$(_source_file_of "$_cm_name" "$_cm_isp") + if [ -z "$_cm_file" ]; then + if _source_has_map "$_cm_name"; then + case "$_cm_row" in + *:1) _cm_file="$_cm_isp.txt" ;; + *) continue ;; + esac + else + _cm_file="$_cm_isp.txt" + fi + fi + [ -f "$_cm_src/$_cm_file" ] || { + log "合成: 缺少 $_cm_isp 的源文件($_cm_file)" + return 1 + } + cp -f "$_cm_src/$_cm_file" "$_cm_out/$_cm_isp.raw" || return 1 + done + return 0 +} + +# 源数据存档:$BASE/raw/<源名>/<远端文件名>。差异/日变化都基于存档重算, +# 所以即使当天拒绝更新,也能事后 `isp-split diff` 复查。 +_archive_source() { + _ar_new=$RAW_ARCHIVE/.$1.new + mkdir -p "$RAW_ARCHIVE" || return 1 + rm -rf "$_ar_new" + mkdir -p "$_ar_new" || return 1 + cp -a "$2/." "$_ar_new/" 2>/dev/null || { rm -rf "$_ar_new"; return 1; } + date +%s > "$_ar_new/.ok" || { rm -rf "$_ar_new"; return 1; } + rm -rf "$RAW_ARCHIVE/.$1.old" + if [ -d "$RAW_ARCHIVE/$1" ]; then + mv "$RAW_ARCHIVE/$1" "$RAW_ARCHIVE/.$1.old" 2>/dev/null || return 1 + fi + if ! mv "$_ar_new" "$RAW_ARCHIVE/$1" 2>/dev/null; then + [ -d "$RAW_ARCHIVE/.$1.old" ] && mv "$RAW_ARCHIVE/.$1.old" "$RAW_ARCHIVE/$1" 2>/dev/null + return 1 + fi + rm -rf "$RAW_ARCHIVE/.$1.old" + return 0 +} + +# 单个运营商两个源的三段差异 + Jaccard +# 输出到 stdout: <共同> <仅主> <仅校验> +_diff_ranges() { + _dr_p=$1 + _dr_v=$2 + _dr_pout=$3 + _dr_vout=$4 + awk -v vf="$_dr_v" -v outp="$_dr_pout" -v outv="$_dr_vout" \ + -f "$LIB/ispdiff.awk" "$_dr_p" +} + +_quarantine_write() { # <校验源名> <运营商源名> <仅主区间> <仅校验区间> + _qw_dir=$BASE/quarantine/$(date +%Y%m%d) + mkdir -p "$_qw_dir" || return 1 + awk -f "$LIB/range2cidr.awk" "$3" > "$_qw_dir/$1.$2.only-primary.txt" || return 1 + awk -f "$LIB/range2cidr.awk" "$4" > "$_qw_dir/$1.$2.only-verify.txt" || return 1 + return 0 +} + +# 计算并打印 主源 vs 校验源 的分歧;超阈值返回 3(拒绝更新) +# <主源目录> <校验源目录> <主源名> <校验源名> +_run_diff() { + _rd_pdir=$1 + _rd_vdir=$2 + _rd_pname=$3 + _rd_vname=$4 + _rd_save=$5 + _rd_rc=0 + _rd_rows=0 + _rd_min="" + _rd_bad="" + DIFF_MIN_JACCARD="" + DIFF_REJECTED="" + DIFF_ROWS=0 + + mkdir -p "$WORK" || return 1 + _rd_tmp=$WORK/.diff.$$ + rm -rf "$_rd_tmp" + mkdir -p "$_rd_tmp" || return 1 + + echo "== 数据源差异(主源 $_rd_pname vs 校验源 $_rd_vname,阈值 ${ISP_DIFF_MAX_JACCARD_DROP}%)" + printf '%-14s %10s %10s %10s %9s %9s %8s\n' \ + "运营商" "主源地址" "校验地址" "共同" "仅主" "仅校验" "Jaccard" + + for _rd_row in $ISP_TABLE; do + case "$_rd_row" in \#*) continue ;; esac + _row_check_row "$_rd_row" + _rd_isp=${_rd_row%%:*} + _rd_pf=$(_source_file_of "$_rd_pname" "$_rd_isp") + if [ -z "$_rd_pf" ]; then + if _source_has_map "$_rd_pname"; then + case "$_rd_row" in + *:1) _rd_pf="$_rd_isp.txt" ;; + *) continue ;; + esac + else + _rd_pf="$_rd_isp.txt" + fi + fi + _rd_vf=$(_source_file_of "$_rd_vname" "$_rd_isp") + [ -n "$_rd_vf" ] || continue + [ -f "$_rd_pdir/$_rd_pf" ] && [ -f "$_rd_vdir/$_rd_vf" ] || continue + + awk -f "$LIB/cidr2range.awk" "$_rd_pdir/$_rd_pf" | sort | + awk -f "$LIB/merge.awk" > "$_rd_tmp/p.ranges" + awk -f "$LIB/cidr2range.awk" "$_rd_vdir/$_rd_vf" | sort | + awk -f "$LIB/merge.awk" > "$_rd_tmp/v.ranges" + # 先建空文件:awk 只在有内容时才创建输出文件, + # "没有差异"时下游 range2cidr 会打不开不存在的路径。 + : > "$_rd_tmp/only-p.ranges" + : > "$_rd_tmp/only-v.ranges" + _rd_res=$(_diff_ranges "$_rd_tmp/p.ranges" "$_rd_tmp/v.ranges" \ + "$_rd_tmp/only-p.ranges" "$_rd_tmp/only-v.ranges") + # shellcheck disable=SC2086 + set -- $_rd_res + [ $# -ge 6 ] || continue + _rd_rows=$((_rd_rows + 1)) + _rd_j=$6 + printf '%-14s %10s %10s %10s %9s %9s %7s%%\n' \ + "$_rd_isp" "$1" "$2" "$3" "$4" "$5" "$_rd_j" + + if awk -v j="$_rd_j" -v m="$_rd_min" 'BEGIN { exit !(m == "" || j + 0 < m + 0) }'; then + _rd_min=$_rd_j + fi + if awk -v j="$_rd_j" -v t="$ISP_DIFF_MAX_JACCARD_DROP" \ + 'BEGIN { exit !(j + 0 < t + 0) }'; then + _rd_rc=3 + _rd_bad="$_rd_bad $_rd_isp($_rd_j%)" + if [ "$_rd_save" != "never" ]; then + _quarantine_write "$_rd_vname" "$_rd_isp" \ + "$_rd_tmp/only-p.ranges" "$_rd_tmp/only-v.ranges" || true + { + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$_rd_isp" "$1" "$2" "$3" "$4" "$5" "$_rd_j" "reject" + } >> "$BASE/quarantine/$(date +%Y%m%d)/summary.tsv" 2>/dev/null || true + fi + fi + done + + rm -rf "$_rd_tmp" + [ "$_rd_rows" -gt 0 ] || + echo "(两源没有共同运营商文件,无法比较——检查 ISP_SOURCE_FILES 映射)" + [ -n "$_rd_min" ] || _rd_min="" + DIFF_MIN_JACCARD=$_rd_min + DIFF_REJECTED=$_rd_bad + DIFF_ROWS=$_rd_rows + return $_rd_rc +} + +# 单运营商地址数日变化(只比主源,同口径:原始文件 vs 上次存档) +_delta_check() { # <源名> <新目录> + _dc_name=$1 + _dc_new=$2 + _dc_bad="" + [ -d "$RAW_ARCHIVE/$_dc_name" ] || return 0 + echo "== 主源日变化(阈值 ${ISP_DIFF_MAX_DELTA_PCT}%)" + printf '%-14s %10s %10s %8s\n' "运营商" "上次" "本次" "变化" + for _dc_row in $ISP_TABLE; do + case "$_dc_row" in \#*) continue ;; esac + _row_check_row "$_dc_row" + _dc_isp=${_dc_row%%:*} + _dc_f=$(_source_file_of "$_dc_name" "$_dc_isp") + if [ -z "$_dc_f" ]; then + if _source_has_map "$_dc_name"; then + case "$_dc_row" in + *:1) _dc_f="$_dc_isp.txt" ;; + *) continue ;; + esac + else + _dc_f="$_dc_isp.txt" + fi + fi + [ -f "$_dc_new/$_dc_f" ] && [ -f "$RAW_ARCHIVE/$_dc_name/$_dc_f" ] || continue + _dc_old_n=$(awk -f "$LIB/cidr2range.awk" "$RAW_ARCHIVE/$_dc_name/$_dc_f" | + awk '{ t += $2 - $1 + 1 } END { printf "%.0f", t }') + _dc_new_n=$(awk -f "$LIB/cidr2range.awk" "$_dc_new/$_dc_f" | + awk '{ t += $2 - $1 + 1 } END { printf "%.0f", t }') + [ "${_dc_old_n:-0}" -gt 0 ] || continue + _dc_pct=$(awk -v a="$_dc_old_n" -v b="$_dc_new_n" \ + 'BEGIN { d = b - a; if (d < 0) d = -d; printf "%.1f", d * 100 / a }') + printf '%-14s %10s %10s %7s%%\n' "$_dc_isp" "$_dc_old_n" "$_dc_new_n" "$_dc_pct" + if awk -v p="$_dc_pct" -v t="$ISP_DIFF_MAX_DELTA_PCT" \ + 'BEGIN { exit !(p + 0 > t + 0) }'; then + _dc_bad="$_dc_bad $_dc_isp($_dc_pct%)" + fi + done + if [ -n "$_dc_bad" ]; then + echo "拒绝:单运营商日变化超过 ${ISP_DIFF_MAX_DELTA_PCT}%$_dc_bad" + return 3 + fi + return 0 +} + +# 源健康度区块(status / plan 共用) +_sources_report() { + _source_check_all + _state_prune + echo "数据源健康度:" + printf ' %-8s %-8s %-16s %6s %9s %s\n' "源" "角色" "最后成功" "失败" "Jaccard" "状态" + for _sr_row in $(_source_rows); do + _source_split "$_sr_row" + _sr_last=$(_state_get "$_s_name" last_ok) + _sr_fail=$(_state_get "$_s_name" fail) + _sr_jac=$(_state_get "$_s_name" jaccard) + [ -n "${_sr_fail:-}" ] || _sr_fail=0 + _sr_state="正常" + if [ "$_s_role" = "primary" ]; then + [ "${_sr_fail:-0}" -gt 0 ] && + _sr_state="主源失败(已冻结,连续 $_sr_fail 天)" + elif [ "$ISP_VERIFY_ENABLE" != "1" ]; then + _sr_state="未启用校验(ISP_VERIFY_ENABLE=0)" + elif [ "${_sr_fail:-0}" -ge "${ISP_SOURCE_MAX_FAIL_DAYS:-3}" ]; then + _sr_state="拒绝更新(连续失败 $_sr_fail/$ISP_SOURCE_MAX_FAIL_DAYS 天)" + elif [ "${_sr_fail:-0}" -gt 0 ]; then + _sr_state="降级(连续失败 $_sr_fail/$ISP_SOURCE_MAX_FAIL_DAYS 天)" + fi + [ -n "${_sr_last:-}" ] || _sr_last="" + if [ -n "${_sr_jac:-}" ]; then + _sr_jac_s="$_sr_jac%" + else + _sr_jac_s="-" + fi + printf ' %-8s %-8s %-16s %6s %9s %s\n' \ + "$_s_name" "$_s_role" "$(_ts_text "$_sr_last")" "$_sr_fail" \ + "$_sr_jac_s" "$_sr_state" + done + if [ -f "$DATA/.stamp" ]; then + echo " 数据更新时间: $(_ts_text "$(cat "$DATA/.stamp" 2>/dev/null)")" + fi +} + +# 下次 cron 触发的绝对时间(空 = 表达式不合法/一年内不触发) +# 纯 awk 实现:路由器上没有 python,逐分钟调 date 又太慢(10 秒轮询受不了)。 +_cron_next_epoch() { + _cne_off=$(date +%z 2>/dev/null) + [ -n "${_cne_off:-}" ] || _cne_off="+0000" + awk -v expr="$1" -v now="$(date +%s)" -v off="$_cne_off" ' + function expand(f, lo, hi, arr, n, p, k, r, h, a, b, st, i) { + if (f == "") return 0 + n = split(f, p, ",") + for (k = 1; k <= n; k++) { + st = 1 + r = p[k] + if (index(r, "/") > 0) { split(r, h, "/"); st = h[2] + 0; r = h[1] } + if (r == "*") { a = lo; b = hi } + else if (index(r, "-") > 0) { split(r, h, "-"); a = h[1] + 0; b = h[2] + 0 } + else { a = r + 0; b = a } + if (st < 1) st = 1 + if (a < lo) a = lo + if (b > hi) b = hi + for (i = a; i <= b; i += st) arr[i] = 1 + } + return 1 + } + function civil(z, era, doe, yoe, y, doy, mp) { + z += 719468 + era = int((z >= 0 ? z : z - 146096) / 146097) + doe = z - era * 146097 + yoe = int((doe - int(doe / 1460) + int(doe / 36524) - int(doe / 146096)) / 365) + y = yoe + era * 400 + doy = doe - (365 * yoe + int(yoe / 4) - int(yoe / 100)) + mp = int((5 * doy + 2) / 153) + CD = doy - int((153 * mp + 2) / 5) + 1 + CM = mp + (mp < 10 ? 3 : -9) + CY = y + (CM <= 2) + } + BEGIN { + if (split(expr, f, " ") != 5) exit 1 + if (!expand(f[1], 0, 59, MIN)) exit 1 + if (!expand(f[2], 0, 23, HOUR)) exit 1 + if (!expand(f[3], 1, 31, DOM)) exit 1 + if (!expand(f[4], 1, 12, MON)) exit 1 + if (!expand(f[5], 0, 7, DOW)) exit 1 + if (DOW[7]) DOW[0] = 1 + dom_star = (f[3] ~ /^\*/) + dow_star = (f[5] ~ /^\*/) + sign = (substr(off, 1, 1) == "-") ? -1 : 1 + offsec = sign * (substr(off, 2, 2) * 3600 + substr(off, 4, 2) * 60) + start = now + offsec + z0 = int(start / 86400) + for (d = 0; d <= 366; d++) { + z = z0 + d + civil(z) + dow = (z + 4) % 7 + dok = DOM[CD] + wok = DOW[dow] + if (dom_star || dow_star) { if (!(dok && wok)) continue } + else if (!(dok || wok)) continue + if (!MON[CM]) continue + for (h = 0; h <= 23; h++) { + if (!HOUR[h]) continue + for (m = 0; m <= 59; m++) { + if (!MIN[m]) continue + t = z * 86400 + h * 3600 + m * 60 + if (t <= start) continue + print t - offsec + exit + } + } + } + exit 1 + }' +} + +# mwan3 运行时状态 -> TSV 行(L=接口 T=track P=策略成员) +_mwan3_lines() { + command -v mwan3 >/dev/null 2>&1 || return 0 + mwan3 status 2>/dev/null | awk ' + $1 == "interface" { cur = $2; printf "L\t%s\t%s\t%s\n", $2, $4, $8; next } + $1 == "track" { + ip = $2; sub(/:$/, "", ip) + printf "T\t%s\t%s\t%s\n", cur, ip, $3 + next + } + /^Current policies:/ { sec = 1; next } + sec && /^[^[:space:]]/ { + if ($0 ~ /^[A-Za-z0-9_.-]+:$/) { pol = $0; sub(/:$/, "", pol); next } + sec = 0 + } + sec && /^[[:space:]]+[A-Za-z0-9_.-]+ \([0-9]+%\)/ { + iface = $1 + pct = $0; sub(/.*\(/, "", pct); sub(/%.*/, "", pct) + printf "P\t%s\t%s\t%s\n", pol, iface, pct + next + } + ' +} + +cmd_fetch() { + mkdir -p "$WORK" || die "无法创建 $WORK" + + # 源声明先校验,再联网:非法 URL/角色不该等到下载一半才发现。 + _source_check_all + _state_prune + case "${ISP_SOURCE_MAX_FAIL_DAYS:-}" in + "" | 0 | *[!0-9]*) + die "ISP_SOURCE_MAX_FAIL_DAYS 必须是正整数(当前: '${ISP_SOURCE_MAX_FAIL_DAYS:-}')" + ;; + esac + + _primary=$(_primary_source_name) + _primary_base=$(_source_base_of "$_primary") + [ -n "$_primary" ] || die "ISP_SOURCES 里没有 primary 源" + [ -n "$_primary_base" ] || + die "主源缺少镜像基址:ISP_SOURCES 为空时必须配置 ISP_MIRROR" + + _force=${ISP_SPLIT_FORCE_FETCH:-0} + + rm -rf "$WORK/raw.new" "$WORK/out.new" "$WORK/compose.new" + mkdir -p "$WORK/raw.new" + + # ---- 1) 主源:失败即冻结(保留旧数据),绝不自动换源 ---- + log "主源: $_primary($_primary_base)" + if ! _fetch_source "$_primary" "$_primary_base" "$WORK/raw.new/$_primary" primary; then + _state_fail_plus "$_primary" primary "主源下载失败" >/dev/null + die "主源 $_primary 下载失败,保留现有数据不动(冻结,cron 会在下次再试)" + fi + # 主源下载成功就先记一笔:后面的 diff/熔断即使拒绝更新,也不该让 + # status 把主源显示成"从未成功"。 + _state_put "$_primary" primary "$(date +%s)" 0 \ + "$(_state_get "$_primary" jaccard)" "主源下载正常(待校验)" + + # ---- 2) 校验源/审计源:默认不启用 ---- + _verify_ok="" + _verify_fail="" + for _row in $(_source_rows); do + _source_split "$_row" + case "$_s_role" in + verify | audit) ;; + *) continue ;; + esac + if [ "$ISP_VERIFY_ENABLE" != "1" ]; then + log "跳过校验源 $_s_name(配置里显式 ISP_VERIFY_ENABLE=0)" + continue + fi + + log "校验源: $_s_name($_s_base)" + if _fetch_source "$_s_name" "$_s_base" "$WORK/raw.new/$_s_name" "$_s_role"; then + _verify_ok="$_verify_ok $_s_name" + _state_put "$_s_name" "$_s_role" "$(date +%s)" 0 \ + "$(_state_get "$_s_name" jaccard)" "校验源下载正常(待 diff)" + else + # 设计 §2.5 的分级降级:校验源挂了继续用主源,但连续失败 + # 达到 ISP_SOURCE_MAX_FAIL_DAYS 天就升级为"拒绝更新",防止 + # 校验长期失效而无人察觉。 + _fail=$(_state_fail_plus "$_s_name" "$_s_role" "校验源不可用(已降级)") + _verify_fail="$_verify_fail $_s_name" + log "警告: 校验源 $_s_name 不可用(连续 $_fail/$ISP_SOURCE_MAX_FAIL_DAYS 天),本次降级为只用主源" + if [ "$_fail" -ge "$ISP_SOURCE_MAX_FAIL_DAYS" ] && [ "$_force" != "1" ]; then + log "错误: 校验源 $_s_name 已连续 $_fail 天不可用(>= $ISP_SOURCE_MAX_FAIL_DAYS),拒绝在无校验的情况下更新" + echo "现有数据未改动。确认可接受无校验更新,可设 ISP_SPLIT_FORCE_FETCH=1 再试。" + exit 3 + fi + fi + done + + # ---- 3) 闸门:日变化 + 与校验源的差异(都在写任何东西之前)---- + # 日变化闸门只跟"校验源已启用且拉到了"一起生效:单源兼容模式 + # (ISP_SOURCES 为空)不能凭空多出一道会拒更新的闸门,0.1.x 行为保持不变。 + if [ -n "$_verify_ok" ] && [ "$_force" != "1" ]; then + if ! _delta_check "$_primary" "$WORK/raw.new/$_primary"; then + echo "已拒绝本次更新(现有数据未改动)。确认数据无误可设 ISP_SPLIT_FORCE_FETCH=1 再试。" + exit 3 + fi + fi + + _diff_min_all="" + for _vname in $_verify_ok; do + _rd_ok=1 + _run_diff "$WORK/raw.new/$_primary" "$WORK/raw.new/$_vname" \ + "$_primary" "$_vname" reject || _rd_ok=0 + + # 先把本次结果写进源健康度,再决定是否拒绝: + # 被拒绝的那次也要能在 status 里看到"最近 Jaccard 掉到多少"。 + if [ "${DIFF_ROWS:-0}" -eq 0 ]; then + _state_put "$_vname" "$(_source_role_of "$_vname")" "$(date +%s)" 0 \ + "$(_state_get "$_vname" jaccard)" "无可比对运营商(检查映射)" + elif [ -n "${DIFF_MIN_JACCARD:-}" ]; then + if [ "$_rd_ok" = "1" ]; then + _jnote="校验源正常" + else + _jnote="分歧超阈值( ${DIFF_REJECTED# })" + fi + _state_put "$_vname" "$(_source_role_of "$_vname")" "$(date +%s)" 0 \ + "$DIFF_MIN_JACCARD" "$_jnote" + if awk -v a="$_diff_min_all" -v b="$DIFF_MIN_JACCARD" \ + 'BEGIN { exit !(a == "" || b + 0 < a + 0) }'; then + _diff_min_all=$DIFF_MIN_JACCARD + fi + fi + + if [ "${DIFF_ROWS:-0}" -eq 0 ]; then + if [ "$_force" = "1" ]; then + echo + log "警告: 校验源 $_vname 没有可比对的运营商,已按 ISP_SPLIT_FORCE_FETCH=1 放行" + else + echo + echo "已拒绝本次更新:校验源 $_vname 没有可比对的运营商(检查 ISP_SOURCE_FILES 的文件名映射)。" + echo "没有可比对项时无法证明主源没有漂移。确认可接受无校验更新,可设 ISP_SPLIT_FORCE_FETCH=1 再试。" + exit 3 + fi + elif [ "$_rd_ok" = "0" ]; then + if [ "$_force" = "1" ]; then + echo + log "警告: 与校验源 $_vname 的分歧超过 ${ISP_DIFF_MAX_JACCARD_DROP}%(${DIFF_REJECTED}),已按 ISP_SPLIT_FORCE_FETCH=1 放行" + else + echo + echo "已拒绝本次更新:与校验源的分歧超过 ${ISP_DIFF_MAX_JACCARD_DROP}%($_vname:${DIFF_REJECTED})。" + echo "差异段已留档: $BASE/quarantine/$(date +%Y%m%d)/" + echo "人工确认数据无误后,可用 ISP_SPLIT_FORCE_FETCH=1 isp-split fetch 放行。" + exit 3 + fi + fi + done + DIFF_MIN_JACCARD=$_diff_min_all + + # ---- 4) 合成(只有 primary 参与合成;校验源只跑 diff)---- + log "构建集合(互斥化)" + _compose_raw "$_primary" "$WORK/raw.new/$_primary" "$WORK/compose.new" || + die "按 ISP_SOURCE_FILES 合成失败,保留现有数据不动" + + ISP_SPLIT_LIB=$LIB sh "$LIB/build.sh" "$WORK/compose.new" "$WORK/out.new" \ + "$(priority_list)" || die "构建失败,保留现有数据不动" + + # 至少要有启用集合的非空产物,否则视为构建异常 + _ok=0 + _chk() { [ -s "$WORK/out.new/$1.txt" ] && _ok=1; } + foreach_enabled _chk + [ "$_ok" -eq 1 ] || die "构建产物为空,拒绝覆盖" + + # 熔断:单个集合地址数超过 IPv4 总量 50% 时拒绝本次更新。 + # 正常运营商数据远低于该比例;出现这种结果通常意味着坏 CIDR 或源被污染。 + _fetch_guard() { + _gf=$WORK/out.new/$1.txt + [ -s "$_gf" ] || return 0 + if ! awk ' + { + split($0, p, "/") + if (p[2] !~ /^[0-9]+$/) next + t += 2 ^ (32 - (p[2] + 0)) + } + END { exit (t > 2147483648 ? 1 : 0) } + ' "$_gf"; then + die "集合 $2 地址数超过 IPv4 总量的 50%,拒绝本次更新" + fi + } + foreach_enabled _fetch_guard + + # 熔断(续):单集合 50% 只挡得住"一步到位"的投毒,两种方式能绕过: + # 1) 把 60% 的地址平分给 3 个集合,每个都不到 50%; + # 2) 先给正常数据,再每天放大一点,永远不触发 50%。 + # 这里补两条上限:总量绝对值(25% IPv4)与相对上一份数据的增幅(2 倍)。 + # 运营商表变化时跳过增幅比较——启用/停用运营商会合法地改变总量。 + # 两条都能用 ISP_SPLIT_FORCE_FETCH=1 显式放行(源站大改版时的逃生口)。 + _addr_total=0 + _addr_add() { + [ -s "$1" ] || return 0 + _at_v=$(awk ' + { + split($0, p, "/") + if (p[2] !~ /^[0-9]+$/) next + t += 2 ^ (32 - (p[2] + 0)) + } + END { printf "%.0f", t } + ' "$1" 2>/dev/null) + _addr_total=$((_addr_total + ${_at_v:-0})) + } + _addr_new() { _addr_add "$WORK/out.new/$1.txt"; } + _addr_old() { _addr_add "$DATA/$1.txt"; } + + _addr_total=0 + foreach_enabled _addr_new + _new_total=$_addr_total + _addr_total=0 + foreach_enabled _addr_old + _old_total=$_addr_total + + if [ "$_new_total" -gt 1073741824 ] && [ "$_force" != "1" ]; then + die "所有集合合计 $_new_total 个地址,超过 IPv4 总量的 25%,拒绝本次更新(疑似源数据被污染;确认数据无误可设 ISP_SPLIT_FORCE_FETCH=1 再试)" + fi + _sets_prev=$(cat "$DATA/.sets" 2>/dev/null || true) + if [ "$_old_total" -gt 0 ] && [ -n "$_sets_prev" ] && + [ "$_sets_prev" = "$(_enabled_src_list)" ] && + [ "$_new_total" -gt $((_old_total * 2)) ] && [ "$_force" != "1" ]; then + die "本次数据地址数比上一份翻倍以上($_old_total -> $_new_total),拒绝本次更新(疑似分步投毒;确认数据无误可设 ISP_SPLIT_FORCE_FETCH=1 再试)" + fi + + mkdir -p "$BASE/.history" + if [ -d "$DATA" ]; then + _ts=$(date +%Y%m%d-%H%M%S) + # 快照失败(磁盘满)时必须中止:否则新数据写坏了也没有东西可回滚。 + cp -a "$DATA" "$BASE/.history/$_ts" || + die "无法创建历史快照(磁盘空间不足?),现有数据未改动" + # 只保留最近 3 份历史快照 + _cnt=$(ls -1d "$BASE/.history"/*/ 2>/dev/null | wc -l | tr -d ' ') + if [ "$_cnt" -gt 3 ]; then + ls -1d "$BASE/.history"/*/ 2>/dev/null | sort | + head -n $((_cnt - 3)) | + while read -r _d; do rm -rf "$_d"; done + fi + fi + + # 先在暂存目录里准备好整套数据并校验,最后整体替换目录。 + # 旧写法是 `rm -f $DATA/*.txt` + `cp`:磁盘满时 cp 写一半失败没人看, + # 现场留下"半套数据 + 旧 .stamp",fetch 照样打印"数据已更新"并返回 0, + # 下一次 apply 就会把没拷全的集合静默清空。 + _new=$WORK/data.new + rm -rf "$_new" + mkdir -p "$_new" || die "无法创建暂存目录 $_new" + cp "$WORK/out.new"/*.txt "$_new/" || + { rm -rf "$_new"; die "写入新数据失败(磁盘空间不足?),现有数据未改动"; } + date +%s > "$_new/.stamp" || + { rm -rf "$_new"; die "无法写入数据时间戳,现有数据未改动"; } + # 记录本次启用的运营商,下次 fetch 用它判断"表变了没有" + _enabled_src_list > "$_new/.sets" || + { rm -rf "$_new"; die "无法写入运营商列表,现有数据未改动"; } + + _chk_new() { + [ -s "$_new/$1.txt" ] || { + rm -rf "$_new" + die "构建产物 $1.txt 为空或缺失,拒绝覆盖现有数据" + } + } + foreach_enabled _chk_new + + _old=$BASE/data.old + rm -rf "$_old" + if [ -d "$DATA" ]; then + mv "$DATA" "$_old" || { rm -rf "$_new"; die "无法替换数据目录,现有数据未改动"; } + fi + if ! mv "$_new" "$DATA"; then + [ -d "$_old" ] && mv "$_old" "$DATA" + die "无法替换数据目录,已恢复原数据" + fi + rm -rf "$_old" + + # 主源存档留到最后:更新成功了才把本次数据当作"上一份", + # 失败/拒绝时存档保持不动,日变化与 diff 才有正确的比较基准。 + _archive_source "$_primary" "$WORK/raw.new/$_primary" || + log "警告: 主源存档失败(不影响本次更新,但下次的日变化比较会退化为无基准)" + for _vname in $_verify_ok; do + _archive_source "$_vname" "$WORK/raw.new/$_vname" || + log "警告: 校验源 $_vname 存档失败(不影响本次更新)" + done + + # 源健康度落盘(主源成功) + _state_put "$_primary" primary "$(date +%s)" 0 "${DIFF_MIN_JACCARD:-}" "正常" + + if [ -n "${DIFF_MIN_JACCARD:-}" ]; then + mkdir -p "$STATE_DIR" 2>/dev/null || true + printf '%s\t%s\n' "$DIFF_MIN_JACCARD" "$(date +%s)" \ + > "$STATE_DIR/last-diff" 2>/dev/null || true + fi + + log "数据已更新:$(wc -l "$DATA"/*.txt | tail -1)" +} + +# ---------------------------------------------------------------- diff + +# 重新计算"主源 vs 校验源"的分歧(基于 $BASE/raw 存档,不联网)。 +# 分歧超过 ISP_DIFF_MAX_JACCARD_DROP 时返回 3;差异段写 quarantine/<日期>/。 +cmd_diff() { + _save=always + case "${1:-}" in + --no-save) _save=never ;; + "") ;; + *) die "用法: isp-split diff [--no-save]" ;; + esac + _source_check_all + _state_prune + _p=$(_primary_source_name) + [ -d "$RAW_ARCHIVE/$_p" ] || + die "还没有主源存档($RAW_ARCHIVE/$_p)。先运行 isp-split fetch 拉一份数据" + + _rc=0 + _any=0 + for _row in $(_source_rows); do + _source_split "$_row" + [ "$_s_role" = "primary" ] && continue + _any=1 + if [ ! -d "$RAW_ARCHIVE/$_s_name" ]; then + echo "== 校验源 $_s_name: 无存档(从未成功拉取);启用后先跑一次 fetch" + continue + fi + _run_diff "$RAW_ARCHIVE/$_p" "$RAW_ARCHIVE/$_s_name" \ + "$_p" "$_s_name" "$_save" || _rc=3 + done + [ "$_any" = "1" ] || + echo "ISP_SOURCES 里没有校验源(只有 primary),没有可比较的对象。" + + if [ "$_rc" -eq 3 ]; then + echo + echo "分歧超过阈值 ${ISP_DIFF_MAX_JACCARD_DROP}%:按设计 fetch 会拒绝本次更新(退出码 3)。" + [ "$_save" = "always" ] && + echo "差异段已留档: $BASE/quarantine/$(date +%Y%m%d)/" + fi + return $_rc +} + +# ---------------------------------------------------------------- dnstest + +# 单条线路的 DNS 可达性探测(LuCI 配置页的"测线"按钮) +cmd_dnstest() { + _pol=${1:-} + _json=0 + [ "${2:-}" = "--json" ] && _json=1 + [ -n "$_pol" ] || die "用法: isp-split dnstest <策略名> [--json]" + _valid_policy "$_pol" || die "策略名 '$_pol' 非法" + _TARGET=$_pol + _dns=$(dns_of_policy "$_pol") + [ -n "$_dns" ] || die "DNS_TABLE 里没有策略 '$_pol' 的 DNS 服务器" + _valid_ipv4 "$_dns" || die "DNS_TABLE 里策略 '$_pol' 的 DNS '$_dns' 不是合法 IPv4" + _dev=$(policy_device "$_pol") + + _ok=false + _ans="" + if command -v nslookup >/dev/null 2>&1; then + if command -v timeout >/dev/null 2>&1; then + _out=$(timeout 6 nslookup www.example.com "$_dns" 2>/dev/null) + else + _out=$(nslookup www.example.com "$_dns" 2>/dev/null) + fi + _ans=$(printf '%s\n' "$_out" | awk '/^Address: / { print $2 }' | grep -v ':' | head -1) + [ -n "$_ans" ] && _ok=true + fi + + if [ "$_json" = "1" ]; then + printf '{"policy":"%s","dns":"%s","device":"%s","ok":%s,"answer":"%s"}\n' \ + "$_pol" "$_dns" "${_dev:-}" "$_ok" "${_ans:-}" + else + printf '策略 %s -> 线路 %s,DNS %s\n' "$_pol" "${_dev:-未知}" "$_dns" + if [ "$_ok" = "true" ]; then + printf ' 结果: 可用(www.example.com -> %s)\n' "$_ans" + else + printf ' 结果: 不可用(DNS 无响应或 nslookup 不可用)\n' + fi + fi + [ "$_ok" = "true" ] || return 1 + return 0 +} + +# ---------------------------------------------------------------- apply + +# 把一个大集合分块写进 nft 批处理文件,避免单行过长 +_emit_elements() { + _set=$1 + _file=$2 + awk -v set="$_set" -v tbl="$TABLE" ' + { + buf = (buf == "") ? $1 : buf ", " $1 + n++ + if (n % 400 == 0) { + printf "add element inet %s %s { %s }\n", tbl, set, buf + buf = "" + } + } + END { + if (buf != "") + printf "add element inet %s %s { %s }\n", tbl, set, buf + } + ' "$_file" +} + +_apply_one() { + _src=$1 + _set=$2 + _file=$DATA/$_src.txt + + if [ "$TABLE" = "mwan3" ] && nft list set inet mwan3 "$_set" >/dev/null 2>&1; then + # 即使数据文件为空也必须 flush:否则上游当天把该运营商完全 + # 覆盖(或源返回空)时,旧网段会永远留在 nft 里继续分流。 + echo "flush set inet $TABLE $_set" >> "$BATCH" + else + [ "$TABLE" = "mwan3" ] && log "集合 $_set 不存在,创建" + # 按 mwan3 的声明方式创建,保证标志位一致, + # 否则下次 mwan3 reload 会判定标志不同而删掉重建。 + echo "add set inet $TABLE $_set { type ipv4_addr; flags interval; auto-merge; counter; }" \ + >> "$BATCH" + fi + + if [ -s "$_file" ]; then + _emit_elements "$_set" "$_file" >> "$BATCH" + else + log "集合 $_set 无数据,已清空" + fi +} + +# 生成 nft 批处理语句 +# $1 输出文件;$2 表名(mwan3 或自检用的独立表);$3=1 时先建表 +_gen_batch() { + _gen_out=$1 + _gen_tbl=${2:-mwan3} + _gen_new=${3:-0} + : > "$_gen_out" + TABLE=$_gen_tbl + BATCH=$_gen_out + [ "$_gen_new" = "1" ] && echo "add table inet $_gen_tbl" >> "$_gen_out" + _apply_one_all() { _apply_one "$@"; } + foreach_enabled _apply_one_all +} + +# 兜底流量计数器:插在本工具规则块之后、既有端口/兜底规则之前。 +# 命中集合的流量在前面的 jump 里已离开本链,能走到这条规则的 +# 必然是未命中任何启用集合的流量,从而可以量化"覆盖率"。 +# +# 是否把 DNS(udp/tcp 任一侧 53)排除在兜底统计之外。默认 1=排除: +# DNS 查询/响应本身不是业务流量(网关本地应答或运营商解析开销),计进 +# 兜底只会稀释"未命中运营商集合的真实业务流量"这个口径。写 0 保持旧行为。 +_fallback_skip_dns() { + [ "${COUNT_FALLBACK_SKIP_DNS:-1}" != "0" ] +} + +# 判断链上的兜底计数规则是否已经是当前配置期望的形状。 +# 传入的必须是已经过滤过 counter name "isp_fallback" 的行。 +# nft list chain 会渲染 counter packets N bytes N / # handle N,比较前先去掉。 +_fallback_rule_ok() { + _fbo_norm=$(printf '%s\n' "$1" | + sed 's/ # handle [0-9][0-9]*$//; s/ packets [0-9][0-9]* bytes [0-9][0-9]*//g') + _fbo_n=$(printf '%s\n' "$_fbo_norm" | + grep -c 'counter name "isp_fallback"' || true) + _fbo_n=${_fbo_n:-0} + if _fallback_skip_dns; then + # 为什么是两条:nft 不接受一条规则里同时出现 udp dport 和 tcp dport + # (conflicting transport layer protocols)。拆成 + # A) 非 tcp/udp:ICMP 等其他协议照常计数 + # B) tcp/udp 且 dport != 53 且 sport != 53 + # B 必须同时排除两侧 53:DNS 查询 dport=53,DNS 响应 sport=53 + # (WAN→LAN)——只排除 dport 会漏掉响应包(BUG-21 残留半修)。 + # nft 1.1.6 接受一条规则里写两个 th 不等式,故仍是两条规则。 + # 两条规则共用同一个命名计数器,计数自动汇总。 + # 两条都必须带 ct state new:否则未打标的连接会按包重复计数。 + [ "$_fbo_n" -eq 2 ] || return 1 + _fbo_other=$(printf '%s\n' "$_fbo_norm" | + grep -F 'meta l4proto != tcp meta l4proto != udp counter name "isp_fallback"' | + grep -F 'ct state new' | head -1) + [ -n "$_fbo_other" ] || return 1 + printf '%s\n' "$_fbo_other" | + grep -qE 'meta nfproto ipv4 .*meta mark (&|and) 0x0*3f00 == 0x0+' || + return 1 + # 不依赖 dport/sport 的渲染顺序:同一行里两个条件都出现即可。 + _fbo_dns=$(printf '%s\n' "$_fbo_norm" | + grep -F 'th dport != 53' | + grep -F 'th sport != 53' | head -1) + [ -n "$_fbo_dns" ] || return 1 + case "$_fbo_dns" in + *'ct state new'*) ;; + *) return 1 ;; + esac + # nft 通常按书写顺序渲染集合;两种顺序都认,避免老版本 + # 归一化顺序后每次 apply 都误判成"形状不对"而重建。 + case "$_fbo_dns" in + *'meta l4proto { tcp, udp }'* | *'meta l4proto { udp, tcp }'*) ;; + *) return 1 ;; + esac + printf '%s\n' "$_fbo_dns" | + grep -qE 'meta nfproto ipv4 .*meta mark (&|and) 0x0*3f00 == 0x0+' || + return 1 + else + # COUNT_FALLBACK_SKIP_DNS=0:单条规则,不排除 DNS,但同样只计首包。 + [ "$_fbo_n" -eq 1 ] || return 1 + printf '%s\n' "$_fbo_norm" | + grep -qE 'meta nfproto ipv4 .*meta mark (&|and) 0x0*3f00 == 0x0+ .*ct state new .*counter name "isp_fallback"' || + return 1 + printf '%s\n' "$_fbo_norm" | grep -q 'meta l4proto' && return 1 + fi + return 0 +} + +# 删除链上所有 isp_fallback 计数规则。删除循环加上限,避免 nft 删除 +# 返回成功但规则实际仍在时无限循环。 +_fallback_drop_rules() { + _fbd_i=0 + while [ "$_fbd_i" -lt 10 ]; do + _fbd_h=$(nft -a list chain inet mwan3 mwan3_rules 2>/dev/null | + grep 'counter name "isp_fallback"' | + grep -oE '# handle [0-9]+' | grep -oE '[0-9]+' | head -1) + [ -n "$_fbd_h" ] || return 0 + nft delete rule inet mwan3 mwan3_rules handle "$_fbd_h" 2>/dev/null || return 1 + _fbd_i=$((_fbd_i + 1)) + done + ! nft -a list chain inet mwan3 mwan3_rules 2>/dev/null | + grep -q 'counter name "isp_fallback"' +} + +_fallback_counter() { + [ "$COUNT_FALLBACK" = "1" ] || return 0 + nft list chain inet mwan3 mwan3_rules >/dev/null 2>&1 || return 0 + + if ! nft list counter inet mwan3 isp_fallback >/dev/null 2>&1; then + nft add counter inet mwan3 isp_fallback 2>/dev/null || return 0 + fi + + # 幂等:规则已存在且形状正确(mark==0、ct state new、IPv4、命名计数器、 + # DNS 条件)就不动它。旧版单条规则(无 mark 条件或未排除 DNS)、只排 + # dport=53 的半修形状(漏 DNS 响应)、以及缺少 ct state new 的按包计数 + # 形状,都需要重建。 + _fb_lines=$(nft -a list chain inet mwan3 mwan3_rules 2>/dev/null | + grep 'counter name "isp_fallback"') + if _fallback_rule_ok "$_fb_lines"; then + return 0 + fi + + # 只在缺失/重复/形状不对时重建。 + _fallback_drop_rules || return 1 + + # 插入点:最后一条 daddr @isp_* 规则的下一条 + _h=$(nft -a list chain inet mwan3 mwan3_rules 2>/dev/null | + sed -n '/chain mwan3_rules/,/^ }/p' | + awk '/daddr @isp_/ { seen = 1; next } + seen && /# handle/ { print $NF; exit }') + + # ct state new 是兜底计数的口径锚点(BUG-21 收尾,FIX23): + # 这个计数器的语义是"未命中任何集合的新连接数",status 也是这么写的。 + # 但 `meta mark & 0x3f00 == 0` 只说明"这个包进来时还没打标",不保证它 + # 接下来一定会被打标:如果整条连接最终没拿到 conntrack 标记(例如 + # mwan3 的 default_rule_v4 被 enabled=0 关闭,或策略链什么都没命中), + # 同一连接的每个包都会重新走一遍本链,兜底计数就从"按连接"退化成 + # "按包",数字被放大任意倍(VM 实测:一条未打标的 ICMP 流 ping 5 次, + # 旧形状计 +5,加 ct state new 后计 +1)。 + # 加 ct state new 后,不论后续是否打标,同一连接只计首包一次。 + _FB_BASE='meta nfproto ipv4 meta mark & 0x00003f00 == 0x00000000 ct state new' + if _fallback_skip_dns; then + _COND_OTHER="$_FB_BASE meta l4proto != tcp meta l4proto != udp counter name \"isp_fallback\"" + _COND_DNS="$_FB_BASE meta l4proto { tcp, udp } th dport != 53 th sport != 53 counter name \"isp_fallback\"" + if [ -n "$_h" ]; then + # 连续两次插到同一个锚点前:先 OTHER 后 DNS,两条保持相邻。 + if nft insert rule inet mwan3 mwan3_rules position "$_h" \ + "$_COND_OTHER" 2>/dev/null && + nft insert rule inet mwan3 mwan3_rules position "$_h" \ + "$_COND_DNS" 2>/dev/null; then + return 0 + fi + _fallback_drop_rules + return 1 + fi + # 没找到位置(本工具规则在链尾或版本差异)就追加到链尾 + if nft add rule inet mwan3 mwan3_rules "$_COND_OTHER" 2>/dev/null && + nft add rule inet mwan3 mwan3_rules "$_COND_DNS" 2>/dev/null; then + return 0 + fi + _fallback_drop_rules + return 1 + fi + + # COUNT_FALLBACK_SKIP_DNS=0:保持 0.1.x/0.2.x 的单条旧形状。 + _COND="$_FB_BASE counter name \"isp_fallback\"" + if [ -n "$_h" ]; then + nft insert rule inet mwan3 mwan3_rules position "$_h" \ + "$_COND" 2>/dev/null && return 0 + fi + # 没找到位置(本工具规则在链尾或版本差异)就追加到链尾 + nft add rule inet mwan3 mwan3_rules "$_COND" 2>/dev/null || true +} + +cmd_apply() { + _openclash_fakeip_warn + need_nft + [ -f "$DATA/.stamp" ] || die "还没有数据,先执行: isp-split fetch" + + # mwan3 reload 返回后 nft 链可能仍在重建:此时 flush/add element 与 + # DNS 旁路都会落到旧链上,随后被 mwan3 的 batch flush 掉。 + # 先等 mwan3_output 真正就绪,再动集合。 + _ap_waited=0 + if ! _mwan3_output_ready; then + _ap_waited=1 + log "等待 mwan3 重建完成…" + _mwan3_wait_ready || + die "mwan3 在 ${MWAN3_READY_TRIES}s 内未就绪(mwan3_output 缺少 hook output 或 jump mwan3_rules),请检查 mwan3 日志后重试 apply" + log "mwan3 已就绪,继续 apply" + fi + + # 集合由 wire 写入的 config ipset 在 mwan3 reload 时建立。 + # apply 的 batch 只 flush/add element,不含 add set——集合缺失时 + # nft 的报错是模糊的"批量加载失败",这里给出明确指引。 + # 取第一个 enabled=1 的行,而不是无条件取第一行:用户禁用第一个 + # 运营商后,wire 不会为它建集合,旧写法会让每日 refresh 永久失败。 + _fs="" + for _row in $(_conf_val ISP_TABLE 2>/dev/null); do + _old_ifs=$IFS + IFS=':' + set -- $_row + IFS=$_old_ifs + case "$_row" in \#*) continue ;; esac + [ $# -ge 4 ] || die "ISP_TABLE 行格式错误(应为 源:集合:策略:0|1): $_row" + _row_check "$1" "$2" "$3" "$4" + [ "$4" = "1" ] || continue + _fs=$2 + break + done + [ -n "$_fs" ] || die "ISP_TABLE 中没有启用的运营商(第 4 列为 1),请先修改 $CONF" + if ! nft list set inet mwan3 "$_fs" >/dev/null 2>&1; then + die "nft 中不存在集合 $_fs —— 集合由 wire 创建。先执行: isp-split wire(或 isp-split sync)" + fi + _work_writable || die "work 目录不可写($WORK),未做任何改动" + _gen_batch "$WORK/batch.nft" mwan3 + [ -s "$WORK/batch.nft" ] || die "没有可应用的集合" + + # nft -f 对整份文件是一个事务:要么全成功,要么全不变。 + nft -f "$WORK/batch.nft" || die "nft 批量加载失败,线上规则未改动" + # 兜底计数规则失败以前是静默的:status 里"兜底"行直接消失,用户只会 + # 看到数字不再增长,很容易误判成"没有兜底流量"。至少留一条可查的日志。 + _fallback_counter || + log "警告:兜底计数规则未安装成功(status 的兜底行会缺失或为旧值),可稍后重跑 isp-split apply" + # 任何一次 mwan3 reload 都会 flush mwan3_output,DNS 旁路规则会丢; + # apply/refresh 是日常最常跑的入口,这里也重放一次。 + # 失败不能再只打警告:apply 必须让调用方看到非 0,否则 cron/LuCI + # 会以为 DNS 旁路已恢复。 + _dns_bypass 1 "$_ap_waited" || + die "DNS 旁路规则恢复失败(mwan3 未就绪或 DNS_TABLE 配置错误,见上方日志)" + log "已生效" +} + +# 干跑校验:让 nft 完整解析并检查,但不提交任何改动。 +# 用来提前发现"某条元素非法导致整批被拒"这类失败模式。 +cmd_check() { + need_nft + [ -f "$DATA/.stamp" ] || die "还没有数据,先执行: isp-split fetch" + _work_writable || die "work 目录不可写($WORK),未做任何改动" + _gen_batch "$WORK/batch.nft" mwan3 + [ -s "$WORK/batch.nft" ] || die "没有可应用的集合" + + if nft -c -f "$WORK/batch.nft" 2>"$WORK/check.err"; then + log "干跑校验通过:$(wc -l < "$WORK/batch.nft") 条语句,未提交任何改动" + else + log "干跑校验失败,未提交任何改动。" + echo "---- nft 报错(原始)----" >&2 + head -6 "$WORK/check.err" >&2 + + # nft 对非法条目只会说 "Could not resolve hostname",对用户毫无指向性。 + # 这里再扫一遍数据文件,把具体哪一行有问题指出来。 + echo "---- 数据文件自检 ----" >&2 + _badfound=0 + _bc() { + _src=$1 + _set=$2 + _f="$DATA/$_src.txt" + [ -s "$_f" ] || return 0 + _o=$(awk -f "$LIB/badcidr.awk" "$_f" 2>/dev/null | head -5) + if [ -n "$_o" ]; then + echo " $_f:" >&2 + printf '%s\n' "$_o" | sed 's/^/ /' >&2 + _badfound=1 + fi + } + foreach_enabled _bc + if [ "$_badfound" = "0" ]; then + echo " 数据文件本身格式都合法,问题可能出在集合名或 nft 版本差异上。" >&2 + fi + return 1 + fi +} + +# 实弹自检:在一张独立的临时表里真跑一遍,测完立刻删除。 +# inet mwan3 全程不受影响,也验证了真实加载耗时与元素数。 +cmd_selftest() { + need_nft + [ -f "$DATA/.stamp" ] || die "还没有数据,先执行: isp-split fetch" + _work_writable || die "work 目录不可写($WORK),未做任何改动" + log "在独立表 inet isptest 中实测加载(inet mwan3 不受影响)" + + nft delete table inet isptest 2>/dev/null + _gen_batch "$WORK/selftest.nft" isptest 1 + _t0=$(date +%s) + if ! nft -f "$WORK/selftest.nft"; then + nft delete table inet isptest 2>/dev/null + die "自检加载失败,临时表已清理" + fi + _t1=$(date +%s) + + _total=0 + _cidr=0 + _st() { + _n=$(count_set_elements isptest "$2") + _c=$(wc -l < "$DATA/$1.txt" 2>/dev/null | tr -d ' ') + printf ' %-14s 输入 %5s 条 CIDR -> 内核 %5s 个元素\n' "$2" "${_c:-0}" "$_n" + _total=$((_total + _n)) + _cidr=$((_cidr + ${_c:-0})) + } + foreach_enabled _st + echo " ----" + echo " 合计:输入 $_cidr 条 CIDR,内核合并为 $_total 个元素,加载耗时 $((_t1 - _t0)) 秒" + echo " (元素数少于 CIDR 数是正常的:auto-merge 会把相邻网段合并成区间)" + + nft delete table inet isptest 2>/dev/null + log "自检完成,临时表已删除" +} + + +# ---------------------------------------------------------------- wire + +_wire_ipset() { + _src=$1 + _set=$2 + _sec="ipset_$_set" + uci -q get "mwan3.$_sec" >/dev/null 2>&1 || uci set "mwan3.$_sec=ipset" + uci set "mwan3.$_sec.name=$_set" + uci set "mwan3.$_sec.family=ipv4" + uci set "mwan3.$_sec.counters=1" + uci set "mwan3.$_sec.loadfile=$DATA/$_src.txt" + uci set "mwan3.$_sec.enabled=1" + # 精确标记:rules-splice/uninstall 只按它识别本工具的段, + # 不再按 isp_ 前缀误伤用户自己的 rule。 + uci set "mwan3.$_sec.isp_split=1" +} + +_write_rule() { + _src=$1 + _set=$2 + _pol=$3 + { + printf "config rule '%s'\n" "$_set" + printf "\toption ipset '%s'\n" "$_set" + printf "\toption use_policy '%s'\n" "$_pol" + printf "\toption family 'ipv4'\n" + printf "\toption enabled '%s'\n" "${RULE_ENABLED:-1}" + printf "\toption isp_split '1'\n" + printf "\n" + } >> "$WORK/rules.txt" +} + +cmd_wire() { + _openclash_fakeip_warn + need_nft + # 先确认 work 目录可写再动 UCI:否则可能 commit 了 ipset 段之后才发现 + # 写不了规则片段,留下半套配置。 + _work_writable || die "work 目录不可写($WORK):无法写入规则片段,配置未改动" + + # 1. 集合声明 + _wire_all() { _wire_ipset "$1" "$2"; } + foreach_enabled _wire_all + uci -q commit mwan3 + + # 2. 规则片段 + : > "$WORK/rules.txt" || die "无法写入 $WORK/rules.txt(work 目录不可写?)" + _write_all() { _write_rule "$1" "$2" "$3"; } + foreach_enabled _write_all + + # 一条规则都没有时,splice 会把配置文件里现有的 isp-split 规则块整段删掉。 + # 这是合法操作(用户可能把运营商全禁用了),但必须说出来,不能只打印 + # "规则已注入" 让人以为一切正常。 + [ -s "$WORK/rules.txt" ] || + log "警告:ISP_TABLE 中没有启用的运营商,本次规则块为空(已有 isp-split 规则将被移除)" + + # 校验策略是否存在,避免生成指向不存在策略的规则 + _pol_exists() { + uci -q get "mwan3.$3" >/dev/null 2>&1 || + die "mwan3 中不存在策略 '$3',请先在 MultiWAN 管理器里创建,或修改 $CONF" + } + foreach_enabled _pol_exists + + # 4. 插入到正确位置 + cp "$CFG_MWAN3" "$WORK/mwan3.bak" || + die "无法备份 $CFG_MWAN3 到 $WORK/mwan3.bak,配置未改动" + awk -v insert="$WORK/rules.txt" -v anchor="$ISP_ANCHOR" \ + -f "$LIB/rules-splice.awk" "$WORK/mwan3.bak" > "$WORK/mwan3.new" || + die "规则注入失败(锚点缺失或配置不可写),配置未改动" + + # 冒烟检查:必须用新生成的 mwan3.new 做 uci 解析,而不是读旧的 + # /etc/config/mwan3(旧写法永远通过,等于没检查)。把新文件放到临时 + # 目录并命名为 mwan3,校验通过后才覆盖正式配置。 + _ucitmp=$WORK/uci-check + rm -rf "$_ucitmp" + mkdir -p "$_ucitmp" || die "无法创建 uci 校验临时目录" + cp "$WORK/mwan3.new" "$_ucitmp/mwan3" || { + rm -rf "$_ucitmp" + die "无法复制待校验配置,已放弃(备份在 $WORK/mwan3.bak)" + } + if ! uci -q -c "$_ucitmp" show mwan3 >/dev/null 2>&1; then + rm -rf "$_ucitmp" + die "注入后的 mwan3 配置无法解析,已放弃(备份在 $WORK/mwan3.bak)" + fi + rm -rf "$_ucitmp" + + cat "$WORK/mwan3.new" > "$CFG_MWAN3" + log "规则已注入(备份: $WORK/mwan3.bak)" + /etc/init.d/mwan3 reload + _fallback_counter || + log "警告:兜底计数规则未安装成功(status 的兜底行会缺失或为旧值),可稍后重跑 isp-split apply" + # reload 刚返回时 mwan3_output 往往还没重建完,这里必须等/重试; + # 恢复失败要让 wire 以非 0 退出,否则 sync/install 会误报成功。 + _dns_bypass 1 1 || + die "DNS 旁路规则恢复失败(mwan3 未就绪或 DNS_TABLE 配置错误,见上方日志)" +} + +# 只刷新数据并原子刷入,不重写 mwan3 配置、不 reload mwan3。 +# 日常定时任务应该用它:规则结构没变,每天重写配置再 reload 是没必要的抖动。 +cmd_refresh() { + cmd_fetch + cmd_apply + log "数据已刷新(mwan3 配置未改动)。要改规则映射请用 wire / sync" +} + +cmd_sync() { + _openclash_fakeip_warn + cmd_fetch + cmd_wire + cmd_apply + log "同步完成,用 isp-split status 验证" +} + +# ---------------------------------------------------------------- stage / enable / verify + +# 两阶段启用(dark launch): +# 第一次注入时把规则写成 enabled=0,mwan3 会完全跳过它们; +# 若我们的配置有问题,也只会影响"这些新规则不生效", +# 不可能波及你原有的分流规则。确认 mwan3 仍健康后再翻正。 +cmd_stage() { + RULE_ENABLED=0 + cmd_wire + echo + log "规则已注入但未启用(enabled=0),现有分流配置不受影响。" + echo "请先确认 mwan3 没被搞坏:" + echo " nft list chain inet mwan3 mwan3_rules" + echo " mwan3 status" + echo "确认无误后执行: isp-split enable" +} + +cmd_enable() { + RULE_ENABLED=1 + cmd_wire + cmd_verify +} + +cmd_verify() { + _openclash_fakeip_warn + need_nft + echo "== mwan3_rules 链中的规则顺序(自上而下,命中即停)" + _body=$(_mwan3_rules_body) + _chain=$(printf '%s\n' "$_body" | sed 's/^[[:space:]]*/ /') + if [ -n "$_chain" ]; then + echo "$_chain" + else + echo " !链为空 —— 这是异常信号,请立刻检查 mwan3 日志" + fi + + # 第一条 catch-all 的行号。只有真正无条件的兜底才算: + # 1) ip daddr 0.0.0.0/0(direct policy jump 与 mwan3 3.6 的 + # jump mwan3_rule_default_rule_v4 间接跳转都覆盖); + # 2) mark==0 的 policy jump,且整行是“纯 mark 条件”(可带 + # meta nfproto ipv4 前缀),没有任何 ip saddr/daddr/tcp/udp 等 + # 前置匹配条件。tcp dport 443 这类用户规则不是无条件兜底。 + # 用户自己的 `ip saddr 192.168.50.9 ... jump mwan3_policy_balanced`(示例地址) + # 只匹配特定源地址,对其他源是 no-op,绝不能当成 catch-all。 + _ca=$(printf '%s\n' "$_body" | awk ' + /ip daddr 0\.0\.0\.0\/0/ { print NR; exit } + /^[[:space:]]*(meta nfproto ipv4[[:space:]]+)?meta mark (&|and) 0x0*3f00 == 0x0+/ && + /(jump|goto) mwan3_policy_/ && + !/ip (saddr|daddr)/ { print NR; exit } + ') + + echo + echo "== 本工具的运营商规则是否已渲染且在 catch-all 之前" + # mwan3-nft 3.6 不再为每条规则建独立子链,规则直接内联在 mwan3_rules 里, + # 形如:ip daddr @isp_tel meta mark & 0x3f00 == 0x0 jump mwan3_policy_wan_only + _vf() { + _ln=$(printf '%s\n' "$_body" | grep -n "@$2 " | head -1 | cut -d: -f1) + if [ -z "$_ln" ]; then + echo " ✗ @$2 未出现在 mwan3_rules(enabled=0 或注入失败)" + elif [ -n "$_ca" ] && [ "$_ln" -ge "$_ca" ]; then + echo " ✗ @$2 位于第一条 catch-all 之后(规则不会命中)" + else + echo " ✓ @$2 -> $3" + fi + } + foreach_enabled _vf + + echo + # 运行时规则自检:mwan3 重启会清掉 apply/dns 运行时插入的规则 + if ! _dns_chain_ok && [ -n "${DNS_TABLE:-}" ]; then + echo "⚠ DNS 旁路规则缺失(mwan3 重启会清掉运行时插入的规则)" + echo " 恢复:isp-split dns (或 isp-split wire)" + fi + if [ "$(nft list chain inet mwan3 mwan3_rules 2>/dev/null | grep -c "counter name")" -eq 0 ] && + [ -n "${DNS_TABLE:-}" ]; then + echo "⚠ 兜底计数规则缺失(同上)" + echo " 恢复:isp-split apply" + fi + echo + echo "== 策略成员状态" + mwan3 status 2>/dev/null | head -24 || echo " (mwan3 status 不可用)" +} + + +# ---------------------------------------------------------------- plan (dry-run) + +# 只读预演:不写任何文件、不发任何 uci/nft 改动。 +cmd_plan() { + _openclash_fakeip_warn + echo "== openwrt-isp-split 预演(不做任何改动)" + echo + printf '%-14s %-14s %-12s %8s %10s\n' "集合" "来源" "策略" "条目" "状态" + _p1() { + _src=$1 + _set=$2 + _pol=$3 + _n="-" + if [ -s "$DATA/$_src.txt" ]; then + _n=$(wc -l < "$DATA/$_src.txt" | tr -d ' ') + else + _n="无数据" + fi + _st="待创建" + nft list set inet mwan3 "$_set" >/dev/null 2>&1 && _st="已存在" + printf '%-14s %-14s %-12s %8s %10s\n' "$_set" "$_src" "$_pol" "$_n" "$_st" + } + foreach_enabled _p1 + + echo + _sources_report + + echo + echo "未启用的运营商(其地址段不会被任何策略接管,落到兜底规则):" + _dp_body=$(_mwan3_rules_body) + _dp_any=0 + for row in $ISP_TABLE; do + _old_ifs=$IFS + IFS=':' + set -- $row + IFS=$_old_ifs + case "$row" in \#*) continue ;; esac + [ $# -ge 4 ] || + die "ISP_TABLE 行格式错误(应为 源:集合:策略:0|1): $row" + _row_check "$1" "$2" "$3" "$4" + [ "$4" = "0" ] || continue + _dp_any=1 + _dp_res="" + nft list set inet mwan3 "$2" >/dev/null 2>&1 && _dp_res="$_dp_res nft集合" + printf '%s\n' "$_dp_body" | grep -q "@$2 " && _dp_res="$_dp_res nft规则" + uci -q get "mwan3.ipset_$2" >/dev/null 2>&1 && _dp_res="$_dp_res mwan3配置段" + if [ -n "$_dp_res" ]; then + printf ' - %s(集合 %s): 检测到残留 ->%s\n' "$1" "$2" "$_dp_res" + else + printf ' - %s(集合 %s): 当前 nft/mwan3 中无残留\n' "$1" "$2" + fi + done + [ "$_dp_any" = "1" ] || echo " (无)" + echo " 说明: 未启用项不会被 apply 写入;残留的 nft 规则会在下次 wire 重写规则块时移除," + echo " mwan3 ipset 配置段与对应 nft 集合会在 uninstall 时清理。" + + echo + echo "dnsmasq confdir(多线路 DNS 片段写入位置):" + # 只读探测:不写 UCI、不写文件。 + _dns_confdir_detect >/dev/null + _cur=$(_dns_confdir_get) + _clean=$(_dns_confdir_clean "$_cur") + _confdir=$(_dns_confdir_first "$DNS_CONFDIR_DIRS") + case "$DNS_CONFDIR_SOURCE" in + runtime) + echo " 将写入 dnsmasq 实际加载目录: $_confdir" + echo " 探测自: $DNS_CONFDIR_DETAIL(运行配置优先于 UCI${_cur:+;UCI 当前值 $_cur}${_cur:-;UCI confdir 未设置})" + if [ -z "$_cur" ]; then + echo " 说明: UCI confdir 未设置,dnsmasq 实际加载目录由运行配置决定" + echo " (OpenClash 接管 DNS 时即为 /tmp/dnsmasq.<实例>.d);不做改动。" + else + echo " 说明: 以运行配置为准,不改写 UCI confdir(避免 OpenClash 关闭后残留)。" + fi + ;; + ps) + echo " 将写入 dnsmasq 实际加载目录: $_confdir" + echo " 探测自: 运行中的 dnsmasq 进程参数(--conf-dir,优先级高于 UCI)" + echo " 说明: 不改写 UCI confdir(UCI 当前值: ${_cur:-未设置})。" + ;; + uci) + echo " 当前值: ${_cur:-(未设置)}" + echo " 探测来源: UCI dhcp.@dnsmasq[0].confdir" + if [ "$_clean" != "$_cur" ]; then + echo " 将修改 confdir: ${_clean:-(删除该项,回到默认 $DNSMASQ_DEFAULT_DIR)}(移除旧版写入的 $DNSDIR)" + else + echo " 将修改 confdir: 否(片段写入 $_confdir,不覆盖原值)" + fi + ;; + *) + echo " 当前值: 未设置;未发现 dnsmasq 实际运行配置" + echo " 将使用默认目录: $DNSMASQ_DEFAULT_DIR(不修改 UCI confdir)" + ;; + esac + + echo + echo "将会写入的 nft 集合声明(每个启用的运营商一个):" + _p3() { + _src=$1 + _set=$2 + _pol=$3 + echo " add set inet mwan3 $_set { type ipv4_addr; flags interval; auto-merge; counter; }" + echo " flush set inet mwan3 $_set # 若已存在" + echo " add element inet mwan3 $_set { ... $(wc -l < "$DATA/$_src.txt" 2>/dev/null || echo 0) 条 ... }" + } + foreach_enabled _p3 + + echo + echo "将会写入的 mwan3 规则(插在第一个匹配 '$ISP_ANCHOR' 的规则之前):" + _tmp=$(mktemp) + : > "$_tmp" + _p4() { + _src=$1 + _set=$2 + _pol=$3 + printf " option ipset '%s' -> %s\n" "$_set" "$_pol" >> "$_tmp" + } + foreach_enabled _p4 + sed 's/^/ /' "$_tmp" + rm -f "$_tmp" + + echo + echo "注入后的规则匹配顺序(预演结果):" + if [ -f "$CFG_MWAN3" ]; then + _rtmp=$(mktemp) + _wtmp=$(mktemp) + : > "$_wtmp" + _p5() { + _src=$1 + _set=$2 + _pol=$3 + printf "config rule '%s'\n\toption ipset '%s'\n\toption use_policy '%s'\n\toption family 'ipv4'\n\toption isp_split '1'\n\n" \ + "$_set" "$_set" "$_pol" >> "$_wtmp" + } + foreach_enabled _p5 + awk -v insert="$_wtmp" -v anchor="$ISP_ANCHOR" \ + -f "$LIB/rules-splice.awk" "$CFG_MWAN3" > "$_rtmp" 2>/dev/null && + grep -oE "^config rule '[^']+'" "$_rtmp" | + sed "s/.*'\(.*\)'/\1/" | awk '{ printf " %d. %s\n", NR, $0 }' + rm -f "$_rtmp" "$_wtmp" + else + echo " (找不到 $CFG_MWAN3)" + fi + + echo + echo "预演结束,未做任何改动。确认无误后执行: isp-split sync" +} + +# ---------------------------------------------------------------- status + +_human() { + awk -v n="$1" 'BEGIN { + split("B KiB MiB GiB TiB", u, " ") + i = 1 + while (n >= 1024 && i < 5) { n /= 1024; i++ } + printf "%.1f%s", n, u[i] + }' +} + +# mwan3_rules 链正文(去表头/结尾括号),供 status/verify 复用 +_mwan3_rules_body() { + nft list chain inet mwan3 mwan3_rules 2>/dev/null | + sed -n '/chain mwan3_rules/,/^[[:space:]]*}/p' | + grep -vE '^[[:space:]]*(table|chain|type|})' +} + +_status_one() { + _src=$1 + _set=$2 + _pol=$3 + if ! nft list set inet mwan3 "$_set" >/dev/null 2>&1; then + printf '%-14s %-10s %10s %12s %10s %s\n' "$_set" "$_src" "缺失" "-" "-" "$_pol" + return 0 + fi + _out=$(nft list set inet mwan3 "$_set" 2>/dev/null) + _n=$(count_set_elements mwan3 "$_set") + _cnt=$(printf '%s\n' "$_out" | awk ' + /counter packets/ { + for (i = 1; i <= NF; i++) { + if ($i == "packets") p += $(i + 1) + if ($i == "bytes") b += $(i + 1) + } + } + END { printf "%.0f %.0f", p, b }') + set -- $_cnt + printf '%-14s %-10s %10s %12s %10s %s\n' \ + "$_set" "$_src" "$_n" "$1" "$(_human "$2")" "$_pol" +} + +# 机器可读输出,供 LuCI 渲染表格 +# 运营商显示名(LuCI 卡片标题);未列出的源名原样显示 +_isp_label() { + case "$1" in + chinatelecom | chinanet | telecom) printf '%s' "电信" ;; + unicom_cnc | unicom) printf '%s' "联通" ;; + cmcc | mobile) printf '%s' "移动" ;; + cernet) printf '%s' "教育网" ;; + chinabtn) printf '%s' "铁通" ;; + gwbn) printf '%s' "长城宽带" ;; + othernet) printf '%s' "其他/IDC" ;; + hk) printf '%s' "香港" ;; + mo) printf '%s' "澳门" ;; + tw) printf '%s' "台湾" ;; + *) printf '%s' "$1" ;; + esac +} + +# 机器可读输出(LuCI 表格/卡片直接用)。形状保持 0.1.x 的数组不变, +# 只给每项加了一个 label 字段(运营商中文名),老消费者不受影响。 +cmd_status_json() { + _first=1 + printf '[' + _sj() { + _src=$1 + _set=$2 + _pol=$3 + _ex=false + nft list set inet mwan3 "$_set" >/dev/null 2>&1 && _ex=true + _n=0 + _p=0 + _b=0 + if [ "$_ex" = "true" ]; then + _n=$(count_set_elements mwan3 "$_set") + _cnt=$(nft list set inet mwan3 "$_set" 2>/dev/null | awk ' + /counter packets/ { + for (i = 1; i <= NF; i++) { + if ($i == "packets") p += $(i + 1) + if ($i == "bytes") b += $(i + 1) + } + } + END { printf "%.0f %.0f", p, b }') + set -- $_cnt + _p=${1:-0} + _b=${2:-0} + fi + [ "$_first" = "1" ] || printf ',' + _first=0 + printf '{"set":"%s","label":"%s","src":"%s","policy":"%s","exists":%s,"elements":%s,"packets":%s,"bytes":%s}' \ + "$_set" "$(_isp_label "$_src")" "$_src" "$_pol" "$_ex" "${_n:-0}" "${_p:-0}" "${_b:-0}" + } + foreach_enabled _sj + if nft list counter inet mwan3 isp_fallback >/dev/null 2>&1; then + _fbp=$(nft list counter inet mwan3 isp_fallback 2>/dev/null | + grep -oE "packets [0-9]+" | grep -oE "[0-9]+") + _fbb=$(nft list counter inet mwan3 isp_fallback 2>/dev/null | + grep -oE "bytes [0-9]+" | grep -oE "[0-9]+") + [ "$_first" = "1" ] || printf ',' + printf '{"set":"兜底","label":"兜底","src":"-","policy":"未命中集合","exists":true,"elements":"","packets":%s,"bytes":%s}' \ + "${_fbp:-0}" "${_fbb:-0}" + fi + printf ']' + echo +} + +# 环境状态:数据源健康度 / 双线健康 / 策略占比 / 数据时间 / 最近一次 diff。 +# 与 status --json(nft 统计)分开放,是为了让后者的数组形状保持不变。 +cmd_health_json() { + _source_check_all + _state_prune + printf '{"sources":[' + _sfj=1 + for _row in $(_source_rows); do + _source_split "$_row" + _last=$(_state_get "$_s_name" last_ok) + _fail=$(_state_get "$_s_name" fail) + case "${_fail:-}" in '' | *[!0-9]*) _fail=0 ;; esac + _jac=$(_state_get "$_s_name" jaccard) + _st="ok" + _en=true + if [ "$_s_role" != "primary" ] && [ "$ISP_VERIFY_ENABLE" != "1" ]; then + _st="disabled" + _en=false + elif [ "$_fail" -gt 0 ]; then + _st="degraded" + fi + [ "$_sfj" = "1" ] || printf ',' + _sfj=0 + printf '{"name":"%s","label":"%s","role":"%s","last_ok":%s,"last_ok_text":"%s","fail_days":%s,"jaccard":%s,"max_fail":%s,"status":"%s","enabled":%s}' \ + "$_s_name" "$(_isp_label "$_s_name")" "$_s_role" "${_last:-0}" \ + "$(_ts_text "$_last")" "$_fail" "${_jac:-null}" \ + "$ISP_SOURCE_MAX_FAIL_DAYS" "$_st" "$_en" + done + printf ']' + + # 线路状态与策略占比(解析 mwan3 status;没装/没跑时返回空数组) + _ml=$(_mwan3_lines 2>/dev/null) + if [ -n "$_ml" ]; then + printf '%s\n' "$_ml" | awk -F'\t' ' + function clean(s) { gsub(/[^A-Za-z0-9_.-]/, "", s); return s } + $1 == "L" { + name = clean($2) + st[name] = clean($3) + tr[name] = clean($4) + if (!(name in seen)) { seen[name] = 1; order[++n] = name } + next + } + $1 == "T" { + k = clean($2) + c[k]++ + tip[k, c[k]] = clean($3) + tst[k, c[k]] = clean($4) + next + } + $1 == "P" { + p = clean($2) + if (!(p in pseen)) { pseen[p] = 1; porder[++np] = p } + m[p]++ + mif[p, m[p]] = clean($3) + mpc[p, m[p]] = $4 + 0 + next + } + END { + printf ",\"lines\":[" + for (i = 1; i <= n; i++) { + if (i > 1) printf "," + printf "{\"iface\":\"%s\",\"state\":\"%s\",\"tracking\":\"%s\",\"tracks\":[", order[i], st[order[i]], tr[order[i]] + for (j = 1; j <= c[order[i]]; j++) { + if (j > 1) printf "," + printf "{\"ip\":\"%s\",\"state\":\"%s\"}", tip[order[i], j], tst[order[i], j] + } + printf "]}" + } + printf "],\"policies\":[" + for (i = 1; i <= np; i++) { + if (i > 1) printf "," + printf "{\"name\":\"%s\",\"members\":[", porder[i] + for (j = 1; j <= m[porder[i]]; j++) { + if (j > 1) printf "," + printf "{\"iface\":\"%s\",\"pct\":%s}", mif[porder[i], j], mpc[porder[i], j] + } + printf "]}" + } + printf "]" + }' + else + printf ',"lines":[],"policies":[]' + fi + if [ "$ISP_VERIFY_ENABLE" = "1" ]; then + printf ',"verify_enabled":true' + else + printf ',"verify_enabled":false' + fi + + _stamp=$(cat "$DATA/.stamp" 2>/dev/null | tr -d ' \t\r\n') + case "${_stamp:-}" in '' | *[!0-9]*) _stamp=0 ;; esac + _cron_safe=$(printf '%s' "$ISP_CRON" | tr -d '"\\') + _next=$(_cron_next_epoch "$ISP_CRON" 2>/dev/null) + printf ',"data":{"stamp":%s,"updated":"%s","cron":"%s","next_cron":"%s","next_epoch":%s}' \ + "$_stamp" "$(_ts_text "$_stamp")" "$_cron_safe" \ + "$(_ts_text "$_next")" "${_next:-0}" + + _dj="" + _da=0 + if [ -f "$STATE_DIR/last-diff" ]; then + _dj=$(awk '{ print $1; exit }' "$STATE_DIR/last-diff" 2>/dev/null) + _da=$(awk '{ print $2; exit }' "$STATE_DIR/last-diff" 2>/dev/null) + fi + case "${_da:-}" in '' | *[!0-9]*) _da=0 ;; esac + printf ',"diff":{"min_jaccard":%s,"at":%s,"at_text":"%s"}' \ + "${_dj:-null}" "$_da" "$(_ts_text "$_da")" + + printf '}\n' +} + +# 环境状态的文字版(命令行排查用) +cmd_health() { + case "${1:-}" in + --json) + _openclash_fakeip_warn >&2 + cmd_health_json + return 0 + ;; + esac + _openclash_fakeip_warn + _sources_report + echo + echo "线路状态:" + _ml=$(_mwan3_lines 2>/dev/null) + if [ -n "$_ml" ]; then + printf '%s\n' "$_ml" | awk -F'\t' ' + $1 == "L" { printf " %-8s %-8s tracking=%s\n", $2, $3, $4 } + $1 == "T" { printf " track %s: %s\n", $3, $4 } + $1 == "P" { printf " 策略 %-12s %s %s%%\n", $2, $3, $4 } + ' + else + echo " (mwan3 未运行,读不到线路状态)" + fi +} + +cmd_status() { + if [ "${1:-}" = "--json" ]; then + need_nft + # stdout 必须是纯 JSON(LuCI 直接解析),告警只走 stderr + _openclash_fakeip_warn >&2 + cmd_status_json + return 0 + fi + _openclash_fakeip_warn + need_nft + printf '%-14s %-10s %10s %12s %10s %s\n' "集合" "来源" "条目" "命中包" "流量" "策略" + _st_all() { _status_one "$1" "$2" "$3"; } + foreach_enabled _st_all + # status 是只读命令:绝不在这里删除/重插 nft 规则。 + # LuCI 每 10 秒轮询一次,自愈式重插会造成统计缺口和规则抖动。 + if [ "$COUNT_FALLBACK" = "1" ]; then + _st_fb=$(_mwan3_rules_body | grep 'counter name "isp_fallback"') + if ! _fallback_rule_ok "$_st_fb"; then + echo "提示: 兜底计数规则缺失或形状不符(status 只读,不自动修复;运行 isp-split apply 恢复)" + fi + fi + if nft list counter inet mwan3 isp_fallback >/dev/null 2>&1; then + _fb=$(nft list counter inet mwan3 isp_fallback 2>/dev/null | + grep -oE "packets [0-9]+ bytes [0-9]+" | head -1) + _fp=$(echo "$_fb" | awk '{print $2}') + _fb_b=$(echo "$_fb" | awk '{print $4}') + printf '%-14s %-10s %10s %12s %10s %s\n' \ + "兜底" "-" "-" "${_fp:-0}" "${_fb_b:-0}" "未命中集合" + fi + echo + echo "注: 命中包/流量为连接首包统计(ct state new;后续包由 conntrack 恢复标记,不再经过规则链)。" + echo " DNS(udp/tcp 任一侧 53)不计入兜底,见 COUNT_FALLBACK_SKIP_DNS。" + echo " 业务吞吐请看接口计数: cat /sys/class/net/pppoe-wan*/statistics/rx_bytes" + echo + _sources_report + _next=$(_cron_next_epoch "$ISP_CRON" 2>/dev/null) + [ -n "${_next:-}" ] && echo " 下次自动更新: $(_ts_text "$_next")(cron: $ISP_CRON)" + # 兜底流量状态:先看 UCI 配置意图,再看链里实际渲染形状。 + # mwan3-nft 3.6 对 rule 有两种渲染形态,必须都认: + # 直接: ip daddr 0.0.0.0/0 ... jump mwan3_policy_ + # 间接: ip daddr 0.0.0.0/0 ... jump mwan3_rule_default_rule_v4 + # (子链里才是 jump mwan3_policy_) + # 旧逻辑只看链尾的 policy jump,间接形态会被误报为“渲染异常”。 + _dr_exists=0 + uci -q get mwan3.default_rule_v4 >/dev/null 2>&1 && _dr_exists=1 + _dr_en=$(uci -q get mwan3.default_rule_v4.enabled 2>/dev/null) + if [ "$_dr_exists" = "0" ] && [ -z "${_dr_en:-}" ]; then + echo "提示: 未配置 mwan3 的 default_rule_v4(未命中集合的流量将走系统默认路由,不参与负载均衡)" + elif [ "${_dr_en:-}" = "0" ]; then + # enabled=0 是合法配置:沿用 BUG-2 的提示,不去查 mwan3 日志。 + echo "提示: mwan3 的 default_rule_v4 已被禁用(enabled=0)" + echo " 未命中集合的流量将走系统默认路由(不参与负载均衡)。" + echo " 如需启用:uci set mwan3.default_rule_v4.enabled=1 && uci commit mwan3 && /etc/init.d/mwan3 reload" + else + _body=$(_mwan3_rules_body) + _dr=0 + _dr_kind="" + _dr_child="" + _dr_direct=$(printf '%s\n' "$_body" | awk ' + /ip daddr 0\.0\.0\.0\/0/ && /(jump|goto) mwan3_policy_/ { print; exit } + /^[[:space:]]*(meta nfproto ipv4[[:space:]]+)?meta mark (&|and) 0x0*3f00 == 0x0+/ && + /(jump|goto) mwan3_policy_/ && + !/ip (saddr|daddr)/ && !/@isp_/ { print; exit } + ') + if [ -n "$_dr_direct" ]; then + _dr=1 + _dr_kind=direct + else + _dr_indirect=$(printf '%s\n' "$_body" | + grep -E 'ip daddr 0\.0\.0\.0/0 .*(jump|goto) mwan3_rule_default_rule_v4' | + head -1) + if [ -n "$_dr_indirect" ]; then + _dr=1 + _dr_kind=indirect + _dr_child=$(printf '%s\n' "$_dr_indirect" | + grep -oE '(jump|goto) mwan3_rule_[A-Za-z0-9_]+' | + awk '{print $2}' | head -1) + fi + fi + if [ "$_dr" -gt 0 ]; then + if [ "$_dr_kind" = "indirect" ]; then + # 存在间接跳转即视为 default 已渲染;能读到子链时再确认 + # 里面确实有 policy jump,读不到只降级为警告,不再误报异常。 + if [ -n "$_dr_child" ] && + nft list chain inet mwan3 "$_dr_child" 2>/dev/null | + grep -qE 'meta mark (&|and) 0x0*3f00 == 0x0+ .*(jump|goto) mwan3_policy_'; then + echo "兜底流量: 已参与负载均衡(default 规则已渲染:$_dr_child 子链内 jump mwan3_policy_*)" + else + echo "兜底流量: default 规则已渲染(间接跳转 ${_dr_child:-mwan3_rule_default_rule_v4})" + echo "警告: 未能确认子链内的 mark==0 policy jump(可能渲染不完整,请检查 mwan3 日志)" + fi + else + echo "兜底流量: 已参与负载均衡(default 规则已渲染,走其 use_policy 指定的策略)" + fi + else + echo + echo "提示: mwan3 的 default 规则渲染异常(链中既没有 0.0.0.0/0 直连 policy jump,也没有 jump mwan3_rule_default_rule_v4)" + echo " 未命中集合的流量将走系统默认路由(不参与负载均衡)。" + echo " 请检查 mwan3.default_rule_v4 是否存在、enabled 是否为 1,并查看 mwan3 日志。" + fi + fi + + echo "规则顺序:" + nft list chain inet mwan3 mwan3_rules 2>/dev/null | + sed -n '/chain mwan3_rules/,/^ }/p' | + grep -vE '^[[:space:]]*(table|chain|type|})' | + sed 's/^[[:space:]]*/ /' +} + +# ---------------------------------------------------------------- probe + +cmd_probe() { + command -v curl >/dev/null 2>&1 || die "probe 需要 curl" + for url in $PROBE_URLS; do + echo "== $url" + printf ' %-16s %12s %12s\n' "接口" "速率" "建连耗时" + for ifc in $PROBE_IFACES; do + res=$(curl -sS -o /dev/null -m "$PROBE_TIME" \ + -r "0-$PROBE_BYTES" --interface "$ifc" \ + -w '%{speed_download} %{time_connect}' "$url" 2>/dev/null) || { + printf ' %-16s %12s\n' "$ifc" "失败" + continue + } + set -- $res + printf ' %-16s %9s MB/s %9s ms\n' "$ifc" \ + "$(awk -v v="$1" 'BEGIN { printf "%.2f", v / 1048576 }')" \ + "$(awk -v v="$2" 'BEGIN { printf "%.0f", v * 1000 }')" + done + done + echo + echo "按实测结果把 ISP_TABLE 里对应运营商的策略改成更快的那条线。" + echo "注意:目标站点的 CDN 分配会变,单次结果仅供参考,必要时用域名级覆盖兜底。" +} + +# ---------------------------------------------------------------- dns + +DNSDIR=${DNSDIR:-/etc/isp-split/dnsmasq.d} +# dnsmasq 未显式配置 confdir 时的默认目录。允许覆盖仅用于离线自检。 +DNSMASQ_DEFAULT_DIR=${DNSMASQ_DEFAULT_DIR:-/tmp/dnsmasq.d} +# dnsmasq 实际运行配置的 glob(OpenWrt 为 /var/etc/dnsmasq.conf.)。 +# 允许覆盖仅用于离线自检;生产环境不要设置。 +DNSMASQ_CONF_GLOB=${DNSMASQ_CONF_GLOB:-/var/etc/dnsmasq.conf.*} +DNS_STATE=$BASE/state +# 多线路 DNS 的出线方式: +# bind = server=/域名/IP@接口(dnsmasq 的 SO_BINDTODEVICE,只有 dnsmasq 支持) +# mark = server=/域名/IP + nft 给发往该 DNS 的 53 端口打对应策略的标 +# 不依赖 @接口,任何能按域名指定上游的解析器都适用;且在 OpenClash +# 接管 DNS 的场景下更抗干扰(我们只看目标 IP,不管是谁发的包) +DNS_MODE=${DNS_MODE:-bind} +# 统计"未命中任何运营商集合"的兜底流量(isp_fallback 计数器,status 里显示) +COUNT_FALLBACK=${COUNT_FALLBACK:-1} +# 兜底计数是否排除 DNS(udp/tcp 任一侧 53)。默认 1=排除:DNS 查询和 +# 响应都不是业务流量,计入兜底只会稀释真实业务流量的口径。0=旧行为。 +COUNT_FALLBACK_SKIP_DNS=${COUNT_FALLBACK_SKIP_DNS:-1} +DOMAINDIR=${DOMAIN_LIST_DIR:-${DOMAINDIR:-/etc/isp-split/domains}} + +# ---- dnsmasq confdir:探测"实际加载目录",绝不整体覆盖 ---- +# 历史版本曾把 dhcp.@dnsmasq[0].confdir 直接改成 $DNSDIR,导致 +# OpenClash/adblock 等放在 /tmp/dnsmasq.d 的片段全部失效。 +# +# BUG-6:OpenClash 接管 DNS 时,UCI 的 confdir 可能为空,但 dnsmasq init +# 生成的 /var/etc/dnsmasq.conf.cfgXXXX 里写着 conf-dir=/tmp/dnsmasq.cfgXXXX.d。 +# 只信 UCI 会把片段写进没人加载的 /tmp/dnsmasq.d,分线 DNS 静默失效。 +# 因此按以下优先级探测: +# a. /var/etc/dnsmasq.conf.* 里的 conf-dir= 行(权威源,可能有多个实例/目录) +# b. 运行中 dnsmasq 进程的 --conf-dir= 参数(busybox ps 取不到时跳过) +# c. UCI dhcp.@dnsmasq[0].confdir(兼容没有 /var/etc 的环境) +# d. 默认 $DNSMASQ_DEFAULT_DIR +# +# _dns_confdir_detect 设置三个全局变量并打印目录(每行一个): +# DNS_CONFDIR_SOURCE = runtime | ps | uci | default +# DNS_CONFDIR_DETAIL = 来源细节(实际运行配置文件 / ps / uci) +# DNS_CONFDIR_DIRS = 空格分隔的目录列表 +# +# 重要:探测到 runtime/ps 目录时,dns 子命令不再改写 UCI confdir。 +# OpenClash 关闭后 UCI 里若残留一个"看起来合理"的 confdir,反而会改变 +# dnsmasq 的加载路径。UCI 只在它本身就是权威源时(有值且无运行配置)才处理。 +_dns_confdir_get() { + uci -q get dhcp.@dnsmasq[0].confdir 2>/dev/null || true +} + +# 去掉旧版本写入的 $DNSDIR,其余目录原样保留。 +_dns_confdir_clean() { + _clean_in=${1:-} + _clean_out="" + for _clean_d in $_clean_in; do + [ "$_clean_d" = "$DNSDIR" ] && continue + _clean_out="${_clean_out:+$_clean_out }$_clean_d" + done + printf '%s\n' "$_clean_out" +} + +# 解析一个 conf-dir= 的值:容忍尾斜杠、空格分隔的多个目录、逗号后的 +# 文件扩展名过滤(dnsmasq 的 conf-dir=[,...] 语法),并去重。 +_dns_confdir_parse() { + _parse_out="" + for _parse_tok in ${1:-}; do + _parse_dir=${_parse_tok%%,*} + case "$_parse_dir" in + \"*\") _parse_dir=${_parse_dir#\"}; _parse_dir=${_parse_dir%\"} ;; + \'*\') _parse_dir=${_parse_dir#\'}; _parse_dir=${_parse_dir%\'} ;; + esac + while [ "$_parse_dir" != "/" ] && [ "${_parse_dir%/}" != "$_parse_dir" ]; do + _parse_dir=${_parse_dir%/} + done + [ -n "$_parse_dir" ] || continue + case " $_parse_out " in + *" $_parse_dir "*) continue ;; + esac + _parse_out="${_parse_out:+$_parse_out }$_parse_dir" + done + printf '%s\n' "$_parse_out" +} + +_dns_confdir_detect_emit() { + for _emit_d in ${DNS_CONFDIR_DIRS:-}; do + printf '%s\n' "$_emit_d" + done +} + +# 目录能不能作为片段写入目标(安全判定,不是功能判定): +# - 必须是绝对路径、已存在的目录 +# - 属主必须是 root +# - 组/其他人不可写 +# 为什么:片段是 root 写的,dnsmasq 也是 root 读的。若目录属于低权用户 +# (例如他在 /tmp 下预先建好 conf-dir 目标),他就能在里面放 +# isp-split.conf -> /etc/... 之类的符号链接,让 root 写穿到任意文件。 +_dns_dir_ok() { + _dk_d=${1:-} + case "$_dk_d" in /*) ;; *) return 1 ;; esac + [ -d "$_dk_d" ] || return 1 + _dk_m=$(ls -ldn "$_dk_d" 2>/dev/null) || return 1 + [ -n "$_dk_m" ] || return 1 + _dk_uid=$(printf '%s\n' "$_dk_m" | awk '{print $3}') + _dk_mode=$(printf '%s\n' "$_dk_m" | awk '{print $1}') + [ "$_dk_uid" = "0" ] || return 1 + case "$_dk_mode" in + ?????w* | ????????w*) return 1 ;; + esac + return 0 +} + +_dns_confdir_detect() { + DNS_CONFDIR_SOURCE="" + DNS_CONFDIR_DETAIL="" + DNS_CONFDIR_DIRS="" + + # a) 权威源:dnsmasq 实际运行配置。glob 可能匹配多个实例,逐个读。 + _dcd_dirs="" + _dcd_files="" + for _dcd_f in $DNSMASQ_CONF_GLOB; do + [ -f "$_dcd_f" ] || continue + _dcd_file_used=0 + while IFS= read -r _dcd_line || [ -n "$_dcd_line" ]; do + _dcd_line=$(printf '%s' "$_dcd_line" | tr -d '\r' | + sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + case "$_dcd_line" in + conf-dir=*) _dcd_val=${_dcd_line#conf-dir=} ;; + *) continue ;; + esac + # 容忍行尾注释(目录名里出现 # 的极端情况不支持)。 + _dcd_val=${_dcd_val%%#*} + _dcd_parsed=$(_dns_confdir_parse "$_dcd_val") + for _dcd_d in $_dcd_parsed; do + case " $_dcd_dirs " in + *" $_dcd_d "*) continue ;; + esac + # 运行配置是 root 生成的,正常都在这里通过; + # 通过不了说明目录本身不可信(被低权用户接管),不往里写。 + _dns_dir_ok "$_dcd_d" || { + log "警告:忽略不可信的 dnsmasq conf-dir(属主非 root 或组/他人可写): $_dcd_d" + continue + } + _dcd_dirs="${_dcd_dirs:+$_dcd_dirs }$_dcd_d" + _dcd_file_used=1 + done + done < "$_dcd_f" + [ "$_dcd_file_used" = "1" ] || continue + _dcd_files="${_dcd_files:+$_dcd_files }$_dcd_f" + done + if [ -n "$_dcd_dirs" ]; then + DNS_CONFDIR_SOURCE=runtime + DNS_CONFDIR_DETAIL=$_dcd_files + DNS_CONFDIR_DIRS=$_dcd_dirs + _dns_confdir_detect_emit + return 0 + fi + + # b) 兜底:运行中的 dnsmasq 进程参数。busybox ps 默认截断命令行, + # "ps w" 在 OpenWrt 上能取到完整参数;取不到就继续往下走。 + # + # 这里只认「命令行里出现了 dnsmasq 的可执行路径」的进程, + # 不再用 /dnsmasq/ 整行匹配:任何进程只要参数里带 + # "dnsmasq --conf-dir=/somewhere"(例如 sh -c '... dnsmasq + # --conf-dir=/tmp/evil')都能骗过整行匹配,把 root 的写目标 + # 指到别处。取到的目录还会再过 _dns_dir_ok + 名字检查。 + _dcd_ps=$(ps w 2>/dev/null || ps 2>/dev/null || true) + if [ -n "$_dcd_ps" ]; then + _dcd_ps_vals=$(printf '%s\n' "$_dcd_ps" | awk ' + { + is_dnsmasq = 0 + for (i = 1; i <= NF; i++) + if ($i ~ /(^|\/)dnsmasq$/) { is_dnsmasq = 1; break } + if (!is_dnsmasq) next + for (i = 1; i <= NF; i++) { + if ($i ~ /^--conf-dir=/) print substr($i, 12) + else if ($i == "--conf-dir" && i < NF) print $(i + 1) + } + }') + _dcd_ps_dirs="" + for _dcd_d in $(_dns_confdir_parse "$_dcd_ps_vals"); do + if ! _dns_dir_ok "$_dcd_d"; then + log "警告:忽略不可信的 dnsmasq conf-dir(属主非 root 或组/他人可写): $_dcd_d" + continue + fi + # 进程参数是弱证据:再要求目录名符合 dnsmasq 的 conf-dir 约定, + # 避免被引导到 /etc、/etc/init.d 这类系统目录。 + case "${_dcd_d##*/}" in + *dnsmasq*) ;; + *) + log "警告:忽略不像 dnsmasq 配置目录的 --conf-dir: $_dcd_d" + continue + ;; + esac + _dcd_ps_dirs="${_dcd_ps_dirs:+$_dcd_ps_dirs }$_dcd_d" + done + if [ -n "$_dcd_ps_dirs" ]; then + DNS_CONFDIR_SOURCE=ps + DNS_CONFDIR_DETAIL="ps dnsmasq --conf-dir" + DNS_CONFDIR_DIRS=$_dcd_ps_dirs + _dns_confdir_detect_emit + return 0 + fi + fi + + # c) UCI。注意:即使值里只有旧版的 $DNSDIR,也要让 source=uci, + # 这样 cmd_dns 能按现状逻辑把旧目录从 UCI 里摘掉(可能摘成空列表)。 + _dcd_uci=$(_dns_confdir_get) + if [ -n "$_dcd_uci" ]; then + DNS_CONFDIR_SOURCE=uci + DNS_CONFDIR_DETAIL="uci dhcp.@dnsmasq[0].confdir" + DNS_CONFDIR_DIRS=$(_dns_confdir_clean "$_dcd_uci") + _dns_confdir_detect_emit + return 0 + fi + + # d) 都没有:dnsmasq 默认目录。 + DNS_CONFDIR_SOURCE=default + DNS_CONFDIR_DETAIL="dnsmasq 默认目录" + DNS_CONFDIR_DIRS=$DNSMASQ_DEFAULT_DIR + _dns_confdir_detect_emit +} + +# 探测结果里的第一个目录就是写入目标;列表为空时回落默认目录。 +_dns_confdir_first() { + _first_list=${1:-${DNS_CONFDIR_DIRS:-}} + for _first_d in $_first_list; do + printf '%s\n' "$_first_d" + return 0 + done + printf '%s\n' "$DNSMASQ_DEFAULT_DIR" +} + +# 兼容旧调用点:参数为目录列表,缺省用最近一次探测结果。 +_dns_confdir_target() { + _dns_confdir_first "${1:-}" +} + +# 所有可能残留 isp-split.conf 的目录:探测结果 + UCI 当前值 + 默认目录 +# + 旧版目录。去重后输出。卸载/重写时用它做彻底清理。 +_dns_confdir_candidates() { + _cand_out="" + for _cand_d in ${DNS_CONFDIR_DIRS:-} $(_dns_confdir_get) \ + "$DNSMASQ_DEFAULT_DIR" "$DNSDIR"; do + [ -n "$_cand_d" ] || continue + case " $_cand_out " in + *" $_cand_d "*) continue ;; + esac + _cand_out="${_cand_out:+$_cand_out }$_cand_d" + done + printf '%s\n' "$_cand_out" +} + +_dns_confdir_set() { + if [ -n "${1:-}" ]; then + uci -q set "dhcp.@dnsmasq[0].confdir=$1" || return 1 + else + uci -q delete dhcp.@dnsmasq[0].confdir 2>/dev/null || true + fi + uci -q commit dhcp +} + +_restore_dns_confdir() { + _state=$DNS_STATE/dnsmasq.confdir.orig + + # 无论有没有状态文件,都按探测结果把所有可能被 dnsmasq 加载的目录 + # 清一遍。探测路径(OpenClash)下 cmd_dns 不再写状态文件,不能因为 + # 状态文件不存在就跳过清理。 + _dns_confdir_detect >/dev/null 2>&1 + for _d in $(_dns_confdir_candidates); do + rm -f "$_d/isp-split.conf" 2>/dev/null || true + done + + # 只有真的改过 UCI 才有状态文件;没改过就不碰 UCI。 + [ -f "$_state" ] || return 0 + _orig=$(cat "$_state" 2>/dev/null || true) + _cur=$(_dns_confdir_get) + if [ -n "$_orig" ]; then + if [ "$_cur" != "$_orig" ]; then + _dns_confdir_set "$_orig" && log "已恢复 dnsmasq confdir=$_orig" + fi + else + if [ -n "$_cur" ]; then + _dns_confdir_set "" && log "已恢复 dnsmasq confdir(默认 $DNSMASQ_DEFAULT_DIR)" + fi + fi + rm -f "$_state" +} + +# 策略 -> 出接口设备名(策略取第一个成员) +policy_device() { + _m=$(uci -q get "mwan3.$1.use_member" 2>/dev/null | awk '{print $1}') + [ -n "$_m" ] || return 0 + _i=$(uci -q get "mwan3.$_m.interface" 2>/dev/null) + [ -n "$_i" ] || return 0 + # pppoe 等虚拟接口的运行时设备取 l3_device(如 pppoe-wan1); + # uci 的 device 字段可能是底层 VLAN(如 eth1.10),DNS 绑定会失败 + _d=$(ifstatus "$_i" 2>/dev/null | jsonfilter -e '@.l3_device' 2>/dev/null) + [ -z "$_d" ] && _d=$(uci -q get "network.$_i.device" 2>/dev/null) + [ -z "$_d" ] && _d=$(uci -q get "network.$_i.device" 2>/dev/null) + echo "$_d" +} + +# 策略 -> 该线路的 DNS +dns_of_policy() { + _old=$IFS + for _r in ${DNS_TABLE:-}; do + IFS=':' + set -- $_r + IFS=$_old + if [ "${1:-}" = "$_TARGET" ]; then echo "${2:-}"; return 0; fi + done + IFS=$_old + return 0 +} + +# 多线路 DNS:让不同线路的 DNS 查询从各自接口发出 +# +# 依据:dnsmasq 的 server=/域名/IP@接口 语法会用 SO_BINDTODEVICE 绑定出接口, +# 从而保证"从哪条线出去,就用哪条线的 DNS",让 CDN 返回本运营商节点。 +# 这是补齐与爱快差距的关键一环(原理见 docs/DESIGN.md 的 +# 「与商业方案(iKuai)的对比」一节)。 +# +# 同时要在 mwan3_output 链顶部给这些 DNS 服务器加 return, +# 否则 mwan3 会按目标 IP 重新打标,把 DNS 查询抢到别的线路上。 +# +# mwan3_output 是否已经重建完成: +# - 链存在 +# - hook output 已挂上 +# - 能看到 jump/goto mwan3_rules(mwan3 先建链、后填规则,只 list 成功不够) +_mwan3_output_ready() { + _mo_out=$(nft list chain inet mwan3 mwan3_output 2>/dev/null) || return 1 + printf '%s\n' "$_mo_out" | grep -q 'hook output' || return 1 + printf '%s\n' "$_mo_out" | + grep -qE '(^|[[:space:]])(jump|goto)[[:space:]]+mwan3_rules([[:space:]]|$)' || + return 1 + return 0 +} + +# 轮询等待 mwan3 重建完成。0=就绪,1=超时。 +_mwan3_wait_ready() { + _mw_tries=${MWAN3_READY_TRIES:-15} + _mw_delay=${MWAN3_READY_DELAY:-1} + _mw_i=0 + while [ "$_mw_i" -lt "$_mw_tries" ]; do + _mwan3_output_ready && return 0 + _mw_i=$((_mw_i + 1)) + [ "$_mw_i" -lt "$_mw_tries" ] && sleep "$_mw_delay" + done + return 1 +} + +# mwan3_output 中本工具 DNS 旁路规则条数(按唯一 comment 统计) +_dns_rule_count() { + nft -a list chain inet mwan3 mwan3_output 2>/dev/null | + grep -c 'comment "isp-split-dns"' || true +} + +# DNS 出线规则重放:mwan3 重建链后运行时插入的规则会丢失, +# wire / apply / dns / init.d(开机) 都会调用本函数恢复。 +# bind 模式:return 旁路,出线由 dnsmasq 的 @接口 绑定决定 +# mark 模式:jump 到该 DNS 所属策略,由 mwan3 打标选路 +# +# 参数: +# $1 = 1 时要求这次必须恢复成功(apply/wire 用);0 时 mwan3 未运行可跳过(dns 用) +# $2 = 1 表示调用前刚经历 reload(成功时打印"已恢复") +# 返回:0 成功;1 mwan3 未就绪/重试耗尽;2 配置错误(重试无意义) +_dns_bypass() { + _db_required=${1:-0} + _db_recovered=${2:-0} + + # 先解析出去重后的条目并计算期望条数(每个条目 udp/tcp 各一条)。 + _db_seen=" " + _db_expected=0 + _db_rows="" + for _db_row in ${DNS_TABLE:-}; do + _db_pol=${_db_row%%:*} + _db_dns=${_db_row#*:} + [ -n "$_db_dns" ] || continue + case "$_db_seen" in + *" $_db_pol:$_db_dns "*) continue ;; + esac + _db_seen="$_db_seen $_db_pol:$_db_dns " + _db_expected=$((_db_expected + 2)) + _db_rows="$_db_rows $_db_pol:$_db_dns" + done + + # 配置错误重试也不会变好,必须在等待/插入前就明确报错。 + case "${DNS_MODE:-bind}" in + bind | mark) ;; + *) + log "错误:DNS_MODE='${DNS_MODE:-}' 无效(只能是 bind 或 mark),未恢复 DNS 旁路规则" + return 2 + ;; + esac + for _db_kv in $_db_rows; do + _db_dns=${_db_kv#*:} + if ! _valid_ipv4 "$_db_dns"; then + log "错误:DNS_TABLE 中的 '$_db_dns' 不是合法 IPv4(配置错误,未重试)" + return 2 + fi + done + + if ! nft list table inet mwan3 >/dev/null 2>&1; then + [ "$_db_required" = "1" ] && + log "错误:table inet mwan3 不存在,无法恢复 DNS 旁路规则" + [ "$_db_required" = "1" ] && return 1 + return 0 + fi + + # 没有 DNS 条目时只需清理旧规则,不必等待 mwan3 重建。 + if [ "$_db_expected" -eq 0 ]; then + if _mwan3_output_ready; then + _db_i=0 + while [ "$_db_i" -lt 256 ]; do + _db_h=$(nft -a list chain inet mwan3 mwan3_output 2>/dev/null | + grep 'comment "isp-split-dns"' | + grep -oE '# handle [0-9]+' | head -1 | grep -oE '[0-9]+') + [ -n "$_db_h" ] || break + nft delete rule inet mwan3 mwan3_output handle "$_db_h" 2>/dev/null || break + _db_i=$((_db_i + 1)) + done + fi + return 0 + fi + + _db_tries=${DNS_RETRY_TRIES:-15} + _db_delay=${DNS_RETRY_DELAY:-1} + _db_attempt=0 + _db_waited=$_db_recovered + _db_lasterr="未知错误" + + while [ "$_db_attempt" -lt "$_db_tries" ]; do + _db_attempt=$((_db_attempt + 1)) + + # 链是否已就绪。未就绪时 list 可能成功但 insert 会 EBUSY, + # 或 insert 成功后被紧接着的 flush 覆盖,所以必须轮询。 + if ! _mwan3_output_ready; then + _db_waited=1 + _db_lasterr="mwan3_output 未就绪(缺少 hook output 或 jump mwan3_rules)" + [ "$_db_attempt" -lt "$_db_tries" ] && sleep "$_db_delay" + continue + fi + + # mark 模式依赖策略子链;链不存在是配置错误,不应重试。 + if [ "${DNS_MODE:-bind}" = "mark" ]; then + _db_badpol=0 + for _db_kv in $_db_rows; do + _db_pol=${_db_kv%%:*} + if ! nft list chain inet mwan3 "mwan3_policy_$_db_pol" >/dev/null 2>&1; then + log "错误:DNS 打标目标 mwan3_policy_$_db_pol 不存在(配置错误,未重试)" + _db_badpol=1 + break + fi + done + [ "$_db_badpol" = "1" ] && return 2 + fi + + # 先按唯一 comment 删除所有旧规则:DNS_TABLE 改了策略/删了条目后, + # 旧规则不会残留;重试时也不会越插越多。 + _db_i=0 + _db_del_fail=0 + while [ "$_db_i" -lt 256 ]; do + _db_h=$(nft -a list chain inet mwan3 mwan3_output 2>/dev/null | + grep 'comment "isp-split-dns"' | + grep -oE '# handle [0-9]+' | head -1 | grep -oE '[0-9]+') + [ -n "$_db_h" ] || break + if ! nft delete rule inet mwan3 mwan3_output handle "$_db_h" 2>/dev/null; then + _db_del_fail=1 + break + fi + _db_i=$((_db_i + 1)) + done + if [ "$_db_del_fail" = "1" ]; then + _db_waited=1 + _db_lasterr="删除旧 DNS 旁路规则失败(链正在重建)" + [ "$_db_attempt" -lt "$_db_tries" ] && sleep "$_db_delay" + continue + fi + + _db_fail=0 + for _db_kv in $_db_rows; do + _db_pol=${_db_kv%%:*} + _db_dns=${_db_kv#*:} + for _db_proto in udp tcp; do + # nft 的 comment 是规则级属性,必须放在规则末尾(verdict 之后)。 + # 写成 comment ... return 会被真 nft 拒绝: + # Error: syntax error, unexpected return, expecting end of file + # 删除循环按 comment 子串匹配,comment 在行尾仍能命中。 + if [ "${DNS_MODE:-bind}" = "mark" ]; then + _db_err=$(nft insert rule inet mwan3 mwan3_output \ + ip daddr "$_db_dns" "$_db_proto" dport 53 counter \ + jump "mwan3_policy_$_db_pol" comment "isp-split-dns" 2>&1) + else + _db_err=$(nft insert rule inet mwan3 mwan3_output \ + ip daddr "$_db_dns" "$_db_proto" dport 53 counter \ + return comment "isp-split-dns" 2>&1) + fi + _db_rc=$? + [ "$_db_rc" -eq 0 ] && continue + _db_lasterr=$_db_err + case "$_db_err" in + "" | *"Device or resource busy"* | *"Resource temporarily unavailable"* | \ + *"No such file or directory"* | *"Interrupted system call"*) + _db_fail=1 + ;; + *) + log "错误:DNS 旁路规则 $_db_dns/$_db_proto 插入失败(配置错误,未重试):$_db_err" + return 2 + ;; + esac + break 2 + done + done + + if [ "$_db_fail" = "1" ]; then + _db_waited=1 + [ "$_db_attempt" -lt "$_db_tries" ] && sleep "$_db_delay" + continue + fi + + # nft insert 返回 0 不代表规则最终还在:mwan3 可能正好在重建链, + # 前几条插进旧链后就随 flush 消失。必须回读校验条数。 + _db_got=$(_dns_rule_count) + if [ "$_db_got" -eq "$_db_expected" ]; then + if [ "$_db_waited" = "1" ] || [ "$_db_attempt" -gt 1 ]; then + log "mwan3 重建后 DNS 旁路规则已恢复($_db_expected 条,第 $_db_attempt 次尝试)" + fi + return 0 + fi + + _db_waited=1 + _db_lasterr="插入后只看到 $_db_got/$_db_expected 条(链可能被 mwan3 重建覆盖)" + [ "$_db_attempt" -lt "$_db_tries" ] && sleep "$_db_delay" + done + + log "错误:DNS 旁路规则未能恢复(已尝试 $_db_attempt 次:$_db_lasterr)" + return 1 +} + +# 检查 mwan3_output 里是否已有 DNS 旁路规则。 +# nft 实际渲染会插入 "counter packets N bytes N",匹配时必须容忍。 +_dns_chain_ok() { + nft list chain inet mwan3 mwan3_output 2>/dev/null | + grep -qE 'dport 53 (counter packets [0-9]+ bytes [0-9]+ )?(return|jump mwan3_policy_)' +} + +cmd_dns() { + need_nft + mkdir -p "$DNS_STATE" "$DOMAINDIR" + + # 先探测 dnsmasq 真正加载的目录。runtime/ps 是权威源;只有落到 UCI + # 时才沿用"首次记录原值、迁移旧版 $DNSDIR"的现状逻辑。 + _dns_confdir_detect >/dev/null + _cur=$(_dns_confdir_get) + _state=$DNS_STATE/dnsmasq.confdir.orig + _uci_note="未改写 UCI confdir" + case "$DNS_CONFDIR_SOURCE" in + uci) + _clean=$(_dns_confdir_clean "$_cur") + if [ "$_clean" != "$_cur" ]; then + # 只有真的要改 UCI 时才写状态文件;没改过就什么都不留, + # 卸载时也不会去碰 UCI。 + _new_state=0 + if [ ! -f "$_state" ]; then + printf '%s\n' "$_clean" > "$_state" || + die "无法保存 dnsmasq confdir 状态" + _new_state=1 + fi + if ! _dns_confdir_set "$_clean"; then + [ "$_new_state" = "0" ] || rm -f "$_state" + die "无法迁移 dnsmasq confdir" + fi + log "已从 confdir 移除旧版写入的 $DNSDIR(其余目录保留)" + _cur=$_clean + DNS_CONFDIR_DIRS=$_clean + _uci_note="已迁移 UCI confdir" + fi + ;; + *) + # runtime/ps/default:OpenClash 等接管时 UCI 是摆设, + # 不改写它,也不为"未发生的修改"留状态文件。 + ;; + esac + + _confdir=$(_dns_confdir_first "$DNS_CONFDIR_DIRS") + mkdir -p "$_confdir" 2>/dev/null || die "无法创建 dnsmasq confdir: $_confdir" + _out=$_confdir/isp-split.conf + + # 先写临时文件、最后 rename 到位。 + # 直接 `> "$_out"` 会跟随目标位置的符号链接:片段目录通常是 /tmp 下的 + # dnsmasq conf-dir,只要有人在里面放一个 isp-split.conf -> /etc/xxx, + # root 就会把 dnsmasq 片段写进 /etc/xxx(截断原文件)。 + # rename 只替换目录项本身,不会跟随链接。 + _out_tmp=$(_tmp_next_to "$_confdir") + [ -n "$_out_tmp" ] || die "无法在 $_confdir 创建临时文件" + + # 旧版本可能把片段留在 /tmp/dnsmasq.d 或旧 $DNSDIR;换目录时清掉 + # 所有候选位置里的旧片段,避免 dnsmasq 同时加载两份规则。 + for _old_dir in $(_dns_confdir_candidates); do + [ "$_old_dir" = "$_confdir" ] && continue + rm -f "$_old_dir/isp-split.conf" 2>/dev/null || true + done + + { + echo "# 本文件由 isp-split 生成,请勿手工修改" + echo "# 重新生成: isp-split dns" + } > "$_out_tmp" || { + rm -f "$_out_tmp" + die "无法写入 $_out_tmp" + } + + _n=0 + _skip=0 + + # 1) 内联域名配置:DOMAIN_SPLIT="域名:策略 ..." + for row in ${DOMAIN_SPLIT:-}; do + _dom=${row%%:*} + _pol=${row#*:} + [ -n "$_dom" ] && [ -n "$_pol" ] || continue + _TARGET=$_pol + _dns=$(dns_of_policy) + _dev=$(policy_device "$_pol") + _bind="" + [ "$DNS_MODE" = "bind" ] && _bind="@$_dev" + if [ -z "$_dns" ] || { [ "$DNS_MODE" = "bind" ] && [ -z "$_dev" ]; }; then + log "跳过 $_dom(策略 $_pol 缺少 DNS_TABLE 或设备名)" + _skip=$((_skip + 1)) + continue + fi + echo "server=/$_dom/$_dns$_bind" >> "$_out_tmp" + _n=$((_n + 1)) + done + + # 2) 外部域名清单:$DOMAINDIR/<策略>.txt + # 每行一个域名;也兼容 felixonmars/dnsmasq-china-list 的 + # server=/domain/1.2.3.4 形式(只取域名,DNS 用本线路的) + for _f in "$DOMAINDIR"/*.txt; do + [ -f "$_f" ] || continue + _pol=${_f##*/} + _pol=${_pol%.txt} + _TARGET=$_pol + _dns=$(dns_of_policy) + _dev=$(policy_device "$_pol") + _bind="" + [ "$DNS_MODE" = "bind" ] && _bind="@$_dev" + if [ -z "$_dns" ] || { [ "$DNS_MODE" = "bind" ] && [ -z "$_dev" ]; }; then + log "跳过 $_f(策略 $_pol 缺少 DNS_TABLE 或设备名)" + _skip=$((_skip + 1)) + continue + fi + _c=0 + while IFS= read -r _line; do + _line=$(printf '%s' "$_line" | tr -d '\r') + case "$_line" in + ""|"#"*) continue ;; + server=/*) + # server=/domain/1.2.3.4 -> domain + _d=${_line#server=/} + _d=${_d%%/*} + ;; + *) _d=$_line ;; + esac + case "$_d" in + ""|.*) continue ;; + esac + echo "server=/$_d/$_dns$_bind" >> "$_out_tmp" + _c=$((_c + 1)) + done < "$_f" + [ "$_c" -gt 0 ] || continue + log "$_f: $_c 条域名 -> $_dns@$_dev" + _n=$((_n + _c)) + done + + # 3) 处理 DNS 查询的出线(幂等,mwan3 重启后重跑 wire/apply/dns 即恢复) + # dns 命令即使 mwan3 暂时未就绪也要把 dnsmasq 片段写出来, + # 所以 required=0;但配置错误必须立即失败,不能静默生成坏片段。 + _dns_bypass 0 + _db_rc=$? + if [ "$_db_rc" -eq 2 ]; then + # 配置错误时不要留下可能含坏 DNS 的 dnsmasq 片段 + rm -f "$_out_tmp" + die "DNS 旁路规则配置错误,请修正 DNS_TABLE / DNS_MODE 后重试" + elif [ "$_db_rc" -ne 0 ]; then + log "警告:mwan3 尚未就绪,DNS 旁路规则未全部恢复;稍后执行 isp-split apply 可重试" + fi + + if [ "$_n" -eq 0 ]; then + rm -f "$_out_tmp" "$_out" + log "没有域名分流规则(DNS 旁路已按 DNS_TABLE 就位)" + log "要启用域名分流请配置 DOMAIN_SPLIT,或在 $DOMAINDIR/<策略>.txt 放入域名清单" + return 0 + fi + + # 4) 片段原子落位(rename 不跟随符号链接),再让 dnsmasq 重新加载。 + # runtime/ps 路径不改 UCI,避免覆盖 OpenClash/adblock 等接管配置。 + # 卸载时按同一探测结果清理。 + # mktemp 建的是 0600;dnsmasq 以 dnsmasq 用户读 conf-dir, + # 片段必须是全局可读的(旧实现直接重定向,权限是 umask 的 0644)。 + chmod 644 "$_out_tmp" 2>/dev/null || true + mv -f "$_out_tmp" "$_out" || { + rm -f "$_out_tmp" + die "无法写入 $_out" + } + "$DNSMASQ_SERVICE" restart >/dev/null 2>&1 || log "警告:dnsmasq 重启失败" + + log "已生成 $_n 条域名 DNS 规则 -> $_out(confdir: $_confdir,来源: $DNS_CONFDIR_SOURCE,$_uci_note)" + [ "$_skip" -gt 0 ] && log "有 $_skip 组因缺配置被跳过" + echo + echo "生成的规则:" + sed 's/^/ /' "$_out" +} + +# ---------------------------------------------------------------- conf + +# 配置文件读写接口,供 LuCI 调用。 +# +# 设计原则:文件解析/改写全部留在 shell 里做(这里已经被充分测试过), +# LuCI 只负责渲染表单和把值传进来。绝不让前端直接拼配置文件。 +# +# 输出用 TSV(制表符分隔)而不是 JSON:值里只有字母数字和 `:._`, +# 不需要转义,前端 split 一下就能用。 + +# 取配置项的原始值(支持 KEY="单行" 与 KEY="\n多行\n") +_conf_val() { + # 第二个参数可指定文件(写入后回读校验用),默认读 $CONF。 + # 逐行去掉 CR:CRLF 保存的配置会让值尾多一个 \r。 + awk -v key="$1" ' + { + sub(/\r$/, "") + if (inb) { + if ($0 ~ /^"[[:space:]]*$/) { inb = 0; next } + # 多行值的收尾引号也可能跟在最后一行末尾:KEY="a\nb" + if ($0 ~ /"[[:space:]]*$/) { + sub(/"[[:space:]]*$/, "", $0) + out = (out == "" ? "" : out "\n") $0 + inb = 0 + next + } + out = (out == "" ? "" : out "\n") $0 + next + } + if (index($0, key "=\"") == 1) { + rest = substr($0, length(key) + 3) + if (rest ~ /"[[:space:]]*$/) { + sub(/"[[:space:]]*$/, "", rest) + out = rest + inb = 0 + } else { + out = rest + inb = 1 + } + } + } + END { print out } + ' "${2:-$CONF}" +} + +conf_get() { + [ -f "$CONF" ] || die "找不到 $CONF" + printf 'dns_mode\t%s\n' "$(_conf_val DNS_MODE)" + printf 'policies\t%s\n' \ + "$(uci -q show mwan3 2>/dev/null | sed -n 's/^mwan3\.\([^=]*\)=policy$/\1/p' | tr '\n' ' ')" + # 值支持两种书写格式:多行(每行一条)或单行空格分隔。 + # 先把所有空白折叠成换行,再按冒号拆字段——两种格式统一处理。 + _conf_val ISP_TABLE | tr -s ' \t' '\n' | + awk -F: 'NF >= 4 { printf "isp\t%s\t%s\t%s\t%s\n", $1, $2, $3, $4 }' + _conf_val DNS_TABLE | tr -s ' \t' '\n' | + awk -F: 'NF >= 2 { printf "dns\t%s\t%s\n", $1, $2 }' + _conf_val DOMAIN_SPLIT | tr -s ' \t' '\n' | + awk -F: 'NF >= 2 { printf "domain\t%s\t%s\n", $1, $2 }' +} + +# ---- 配置字段白名单 ---- +# 配置文件会被 root source,sh -n 只查语法、不执行命令替换,不能当安全校验。 +# 因此每个字段在写入前都必须按白名单解析,绝不允许 $ ` " ' \ 空白 ; | 进入值里。 +_policy_list() { + uci -q show mwan3 2>/dev/null | + sed -n 's/^mwan3\.\([^=]*\)=policy$/\1/p' | tr '\n' ' ' +} + +_valid_src() { + case "${1:-}" in "" | *[!A-Za-z0-9._-]*) return 1 ;; esac + return 0 +} + +_valid_name() { + case "${1:-}" in "" | *[!A-Za-z0-9_]*) return 1 ;; esac + [ "${#1}" -le 15 ] +} + +_valid_domain() { + case "${1:-}" in "" | *[!A-Za-z0-9._-]* | .* | *. | *..*) return 1 ;; esac + return 0 +} + +_valid_policy() { + case "${1:-}" in "" | *[!A-Za-z0-9_-]*) return 1 ;; esac + return 0 +} + +# ---- 读取侧复校验 ---- +# conf set 只保护"写"入口;wire / apply / dns / export / uninstall 都是 +# 直接 source 之后读 $ISP_TABLE 的。这些值最终会进 nft、uci 的命令行参数, +# 而 nft 会把命令行参数重新解析一遍("x;add table inet y" 就是两条语句), +# 所以非法字符必须在读取侧就挡住,不能只在写入侧挡。 +_row_check() { + case "${1:-}" in + "" | *[!A-Za-z0-9._-]*) die "ISP_TABLE 的源名 '${1:-}' 非法(只允许 [A-Za-z0-9._-])" ;; + esac + case "${2:-}" in + "" | *[!A-Za-z0-9_]*) die "ISP_TABLE 的集合名 '${2:-}' 非法(只允许 [A-Za-z0-9_])" ;; + esac + [ "${#2}" -le 15 ] || + die "ISP_TABLE 的集合名 '$2' 超过 15 字符(mwan3 的规则名限制)" + case "${3:-}" in + "" | *[!A-Za-z0-9_-]*) die "ISP_TABLE 的策略名 '${3:-}' 非法(只允许 [A-Za-z0-9_-])" ;; + esac + case "${4:-}" in + 0 | 1) ;; + *) die "ISP_TABLE 第 4 列只能是 0 或 1(当前: '${4:-}')" ;; + esac +} + +# 直接对一整行做校验(不拆字段的调用点用) +_row_check_row() { + _rr_ifs=$IFS + IFS=':' + set -- $1 + IFS=$_rr_ifs + [ $# -ge 4 ] || + die "ISP_TABLE 行格式错误(应为 源:集合:策略:0|1): $1" + _row_check "$1" "$2" "$3" "$4" +} + +_valid_ipv4() { + _ip=${1:-} + case "$_ip" in + "" | *[!0-9.]* | .* | *. | *..*) return 1 ;; + esac + _old_ifs=$IFS + IFS=. + set -- $_ip + IFS=$_old_ifs + [ $# -eq 4 ] || return 1 + for _oct in "$@"; do + case "$_oct" in "" | *[!0-9]*) return 1 ;; esac + [ "${#_oct}" -le 3 ] || return 1 + [ "$_oct" -le 255 ] || return 1 + done + return 0 +} + +# 校验某个 KEY 的完整值;失败直接 die(不写文件) +_conf_validate() { + _key=$1 + _val=$2 + case "$_val" in + *'$'* | *'`'* | *'"'* | *"'"* | *'\'* | *';'* | *'|'*) + die "配置值含不允许的字符(\$ \` \" ' \\ ; |)" + ;; + esac + + _POLS=" $(_policy_list) " + case "$_key" in + ISP_TABLE) + _seen=" " + _rows=0 + _old_ifs=$IFS + # 关掉路径名展开:值里若出现 * ? [,`for _line in $_val` 会拿 + # 当前目录的文件名去替换,等于校验的和写入的不是同一份内容。 + set -f + for _line in $_val; do + _rows=$((_rows + 1)) + IFS=':' + set -- $_line + IFS=$_old_ifs + [ $# -eq 4 ] || die "行格式必须是 运营商:集合名:策略:0或1 —— 出错行: $_line" + _valid_src "$1" || die "运营商源名 '$1' 只能包含 [A-Za-z0-9._-](出错行: $_line)" + _valid_name "$2" || die "集合名 '$2' 只能包含 [A-Za-z0-9_] 且不超过 15 字符(出错行: $_line)" + case " $_seen " in + *" $2 "*) die "集合名 '$2' 重复(出错行: $_line)" ;; + esac + _seen="$_seen $2" + _valid_policy "$3" || die "策略名 '$3' 含非法字符(出错行: $_line)" + case "$_POLS" in + *" $3 "*) ;; + *) die "mwan3 里不存在策略 '$3',请先在 MultiWAN 管理器创建(出错行: $_line)" ;; + esac + case "$4" in + 0 | 1) ;; + *) die "最后一列只能是 0(禁用)或 1(启用)—— 出错行: $_line" ;; + esac + done + IFS=$_old_ifs + # 空表等于把工具废掉:wire 会注入空规则块(连带删掉现有规则), + # apply 之后每次都报"没有启用的运营商"。这种值一律拒绝。 + [ "$_rows" -gt 0 ] || + die "ISP_TABLE 不能为空:至少保留一行 运营商:集合名:策略:0|1" + ;; + DNS_TABLE) + _old_ifs=$IFS + set -f + for _line in $_val; do + IFS=':' + set -- $_line + IFS=$_old_ifs + [ $# -eq 2 ] || die "格式必须是 策略:DNS地址 —— 出错行: $_line" + _valid_policy "$1" || die "策略名 '$1' 含非法字符(出错行: $_line)" + case "$_POLS" in + *" $1 "*) ;; + *) die "mwan3 里不存在策略 '$1'(出错行: $_line)" ;; + esac + _valid_ipv4 "$2" || die "DNS 地址必须是严格 IPv4(出错行: $_line)" + done + IFS=$_old_ifs + ;; + DOMAIN_SPLIT) + _old_ifs=$IFS + set -f + for _line in $_val; do + IFS=':' + set -- $_line + IFS=$_old_ifs + [ $# -eq 2 ] || die "格式必须是 域名:策略 —— 出错行: $_line" + _valid_domain "$1" || die "域名 '$1' 只能包含 [A-Za-z0-9._-](出错行: $_line)" + _valid_policy "$2" || die "策略名 '$2' 含非法字符(出错行: $_line)" + case "$_POLS" in + *" $2 "*) ;; + *) die "mwan3 里不存在策略 '$2'(出错行: $_line)" ;; + esac + done + IFS=$_old_ifs + ;; + DNS_MODE) + case "$_val" in + bind | mark) ;; + *) die "DNS_MODE 只能是 bind 或 mark" ;; + esac + ;; + *) + die "未知配置项 '$_key'" + ;; + esac +} + +# 安全重写某个 KEY 的值:写前校验并备份、写后 sh -n 校验、失败自动回滚 +# 第三个参数是写入目标,默认正式配置;预检用临时副本,绝不碰 /etc/isp-split.conf。 +_conf_write() { + _key=$1 + _val=$2 + _target=${3:-$CONF} + case "$_key" in + isp_table) _key=ISP_TABLE ;; + dns_table) _key=DNS_TABLE ;; + domain_split) _key=DOMAIN_SPLIT ;; + dns_mode) _key=DNS_MODE ;; + *) die "未知配置项 '$_key',可选:isp_table / dns_table / domain_split / dns_mode" ;; + esac + + # 写入前必须通过白名单校验;失败即 die,绝不落盘 + _conf_validate "$_key" "$_val" + + # 写临时文件 + 回读校验 + 原子 rename。 + # 旧实现有两个坑: + # 1) awk 只在遇到 `KEY="` 时才替换,键不存在(用户删掉了某段、 + # 或用了单引号/无引号写法)就原样输出,于是什么都没写, + # 但仍然打印"已更新"并返回 0 —— 静默丢配置; + # 2) 直接重定向覆盖正式文件,写到一半失败(磁盘满)可能留下 + # 一份语法合法但被截断的配置,sh -n 查不出来。 + _backup=$_target.bak.$$ + _tmp=$_target.tmp.$$ + cp -f "$_target" "$_backup" || die "无法备份配置" + # 临时文件先复制原文件再原地截断重写:这样模式/属主跟原文件一致 + # (rename 会把临时文件的权限带过去;OpenWrt 没有 stat,不能靠 chmod 复制) + cp -f "$_target" "$_tmp" || die "无法创建临时配置文件" + + awk -v key="$_key" -v val="$_val" ' + function emit(v, n, i, L) { + n = split(v, L, "\n") + # 单行值保持单行;多行值写成 KEY="\n...\n",与默认配置一致 + if (n <= 1) { print key "=\"" v "\""; return } + print key "=\"" + for (i = 1; i <= n; i++) if (L[i] != "") print L[i] + print "\"" + } + { + sub(/\r$/, "") + if (inb) { + # 旧值的续行:直到收尾引号(可能是独立一行, + # 也可能跟在最后一行内容末尾 KEY="a\nb") + if ($0 ~ /"[[:space:]]*$/) { inb = 0 } + next + } + if (index($0, key "=") == 1) { + found = 1 + rest = substr($0, length(key) + 2) + # 单行写法:KEY="值" / KEY='值' / KEY=值 + if (rest ~ /^".*"[[:space:]]*$/ || rest !~ /^"/) { + emit(val) + next + } + # 其余是以 " 开头但没在同一行收尾的多行写法: + # 输出新值后跳过旧值的续行 + emit(val) + inb = 1 + next + } + print + } + END { if (!found) emit(val) } + ' "$_backup" > "$_tmp" || + { rm -f "$_tmp" "$_backup"; die "写入失败,已回滚"; } + + if ! sh -n "$_tmp" 2>/dev/null; then + rm -f "$_tmp" "$_backup" + die "改动后的配置语法不合法,已回滚(原文件已恢复)" + fi + + # 回读校验:文件里读出来的必须就是刚才写入的值。 + # 这一步是"静默不生效"的最后一道防线。 + # 空行不参与比较:写入时会丢掉空行(word splitting 本来也忽略它们), + # 否则"表里带一个空行"这种正常输入会被误判成写入失败。 + _got=$(_conf_val "$_key" "$_tmp" | awk 'NF') + _want=$(printf '%s\n' "$_val" | awk 'NF') + if [ "$_got" != "$_want" ]; then + rm -f "$_tmp" "$_backup" + die "写入后校验失败($_key 的内容与预期不一致),已回滚,原文件未改动" + fi + + mv -f "$_tmp" "$_target" || { + rm -f "$_tmp" "$_backup" + cp -f "$_backup" "$_target" 2>/dev/null + die "替换配置失败,已回滚" + } + rm -f "$_backup" +} + +conf_set() { + _key=${1:-} + _val=${2:-} + [ -n "$_key" ] || die "用法: isp-split conf set <值>" + [ -f "$CONF" ] || die "找不到 $CONF" + + # 允许用 | 代替换行(前端传参更省事) + _val=$(printf '%s' "$_val" | tr '|' '\n') + + # _conf_write 内部会先调用 _conf_validate 做白名单校验; + # 校验失败直接 die,不会留下半写入的配置。 + # 显式传播非 0:校验函数将来若改成 return 而不是 exit, + # conf_set 也不能继续打印"已更新"并返回 0。 + _conf_write "$_key" "$_val" || return $? + + log "已更新 $_key。要让改动生效:" + echo " 运营/策略映射 -> isp-split wire && isp-split apply" + echo " DNS 相关 -> isp-split dns" +} + +# 只校验不落盘:LuCI 保存前的行内校验与"保存并生效"之间的最后一道闸 +conf_check() { + _ck_key=${1:-} + _ck_val=${2:-} + [ -n "$_ck_key" ] || die "用法: isp-split conf check <项> <值>" + _ck_val=$(printf '%s' "$_ck_val" | tr '|' '\n') + case "$_ck_key" in + isp_table) _ck_key=ISP_TABLE ;; + dns_table) _ck_key=DNS_TABLE ;; + domain_split) _ck_key=DOMAIN_SPLIT ;; + dns_mode) _ck_key=DNS_MODE ;; + *) die "未知配置项 '$_ck_key',可选:isp_table / dns_table / domain_split / dns_mode" ;; + esac + _conf_validate "$_ck_key" "$_ck_val" || return $? + echo "$_ck_key 校验通过" +} + +# 保存前预检:把"还没保存的改动"写进配置的临时副本,再跑 plan 预演。 +# 正式配置一个字节都不动,用户能看到"如果保存会变成什么样"。 +conf_preview() { + [ $# -ge 2 ] || die "用法: isp-split conf preview <项> <值> [<项> <值> ...]" + _pv_tmp=$(mktemp "${TMPDIR:-/tmp}/isp-split-preview.XXXXXX" 2>/dev/null) || + die "无法创建临时配置文件" + cp -f "$CONF" "$_pv_tmp" || { rm -f "$_pv_tmp"; die "无法复制配置"; } + + while [ $# -ge 2 ]; do + _pv_key=$1 + _pv_val=$(printf '%s' "$2" | tr '|' '\n') + shift 2 + if ! _conf_write "$_pv_key" "$_pv_val" "$_pv_tmp"; then + rm -f "$_pv_tmp" + return 1 + fi + done + + echo "== 预检:以下预演基于【尚未保存】的改动($CONF 未被修改)" + ISP_SPLIT_CONF=$_pv_tmp \ + ISP_SPLIT_BASE="$BASE" \ + ISP_SPLIT_LIB="$LIB" \ + ISP_SPLIT_MWAN3="$CFG_MWAN3" \ + ISP_SPLIT_CRONTAB="$CRONTAB" \ + sh "$0" plan + _pv_rc=$? + rm -f "$_pv_tmp" + return $_pv_rc +} + +cmd_conf() { + _op=${1:-get} + case "$_op" in + get) conf_get ;; + set) shift; conf_set "$@" || return $? ;; + check) shift; conf_check "$@" || return $? ;; + preview) shift; conf_preview "$@" || return $? ;; + *) die "用法: isp-split conf [get | set <项> <值> | check <项> <值> | preview <项> <值> ...]" ;; + esac +} + +# ---------------------------------------------------------------- export + +# 把运营商集合导出成代理侧可直接消费的 rule-set 格式。 +# +# 为什么要这个:isp-split 只能管"直连"流量(OpenClash 的 TPROXY 标记在更早的 +# ip rule 上,代理流量根本不经过 mwan3)。若想在代理侧也按运营商分流, +# 需要把同一份数据交给 mihomo / sing-box。 +# +# mihomo / OpenClash : rule-provider,behavior=ipcidr,format=yaml +# sing-box : rule_set,type=local,format=source +# (必须是 {"version":1,"rules":[{"ip_cidr":[...]}]}) +cmd_export() { + _fmt=${1:-} + _dir=${2:-} + [ -n "$_fmt" ] || die "用法: isp-split export <输出目录>" + [ -n "$_dir" ] || die "用法: isp-split export <输出目录>" + # 先校验格式再建目录:旧写法先 mkdir 后报错,一次手误就留下空目录, + # 而且格式非法时报的是"没有可导出的数据",完全指错方向。 + case "$_fmt" in + mihomo | singbox | text) ;; + *) die "未知格式 '$_fmt',可选 mihomo / singbox / text" ;; + esac + # 输出目录由调用方给(LuCI 里是委派用户通过 ubus file exec 调过来的)。 + # export 是以 root 身份 mkdir + 写文件,所以必须挡住系统目录: + # 否则一个只有本应用 ACL 的用户就能让 root 往 /etc/init.d、/etc/crontabs + # 落地文件(文件名固定 isp-<源>.,但目录由他选)。 + case "$_dir" in + /*) ;; + *) die "输出目录必须是绝对路径: $_dir" ;; + esac + case "$_dir" in + *..*) die "输出目录不能包含 '..': $_dir" ;; + /etc/init.d | /etc/init.d/* | /etc/uci-defaults | /etc/uci-defaults/* | \ + /etc/crontabs | /etc/crontabs/* | /etc/rc.d | /etc/rc.d/* | \ + /etc/config | /etc/config/* | /etc/hotplug.d | /etc/hotplug.d/* | \ + /usr/sbin | /usr/sbin/* | /usr/bin | /usr/bin/* | \ + /bin | /bin/* | /sbin | /sbin/* | /lib | /lib/* | \ + /usr/lib | /usr/lib/* | /usr/share/rpcd | /usr/share/rpcd/*) + die "拒绝导出到系统目录: $_dir" ;; + esac + mkdir -p "$_dir" || die "无法创建 $_dir" + + _n=0 + _old_ifs=$IFS + for row in $ISP_TABLE; do + IFS=':' + set -- $row + IFS=$_old_ifs + case "$row" in \#*) continue ;; esac + [ $# -ge 1 ] || continue + # 源名会拼进输出文件名,必须校验(集合/策略列不参与导出,允许缺省) + _valid_src "$1" || + die "ISP_TABLE 的源名 '$1' 非法(只允许 [A-Za-z0-9._-])" + _src=$1 + _f="$DATA/$_src.txt" + [ -s "$_f" ] || continue + + # 目标位置若已是符号链接,直接写会写穿到链接指向的文件; + # 先删掉目录项(rm 删除的是链接本身),再由下面的重定向/ cp 新建。 + case "$_fmt" in + mihomo) _ext=yaml ;; + singbox) _ext=json ;; + *) _ext=txt ;; + esac + _out="$_dir/isp-$_src.$_ext" + rm -f "$_out" + + case "$_fmt" in + mihomo) + { + echo "payload:" + sed 's/^/ - /' "$_f" + } > "$_out" + ;; + singbox) + # sing-box 的 source 格式是对象,不是裸数组: + # {"version":1,"rules":[{"ip_cidr":["10.0.0.0/8", ...]}]} + # 裸 JSON 字符串数组是 mihomo/Clash 的 payload 格式, + # sing-box 读它会直接报 decode rule-set 启动失败。 + { + printf '{\n "version": 1,\n "rules": [\n { "ip_cidr": [\n' + awk 'NR > 1 { printf ",\n" } { printf " \"%s\"", $0 }' "$_f" + printf '\n ] }\n ]\n}\n' + } > "$_out" + ;; + text) + cp "$_f" "$_out" + ;; + *) + die "未知格式 '$_fmt',可选 mihomo / singbox / text" + ;; + esac + printf ' %-34s %6s 条\n' "$_out" "$(wc -l < "$_f" | tr -d ' ')" + _n=$((_n + 1)) + done + + [ "$_n" -gt 0 ] || die "没有可导出的数据,先执行 isp-split fetch" + + # 附一份可直接抄的示例配置 + case "$_fmt" in + mihomo) + cat > "$_dir/README-mihomo.txt" <<'EOF' +mihomo / OpenClash 用法: + +# 1) 放进 rule-provider(OpenClash 可在「规则集」里添加本地文件) +rule-providers: + isp-telecom: + type: file + behavior: ipcidr + format: yaml + path: /etc/openclash/rule_provider/isp-chinatelecom.yaml + isp-unicom: + type: file + behavior: ipcidr + format: yaml + path: /etc/openclash/rule_provider/isp-unicom_cnc.yaml + isp-cmcc: + type: file + behavior: ipcidr + format: yaml + path: /etc/openclash/rule_provider/isp-cmcc.yaml + +# 2) 在 rules 里引用(放在 MATCH 之前) +rules: + - RULE-SET,isp-telecom,电信节点组 + - RULE-SET,isp-unicom,联通节点组 + - RULE-SET,isp-cmcc,联通节点组 + - MATCH,默认组 + +# 3) 定时更新:cron 里跑 isp-split fetch 后再 export 到 OpenClash 目录 +EOF + ;; + singbox) + cat > "$_dir/README-singbox.txt" <<'EOF' +sing-box 用法: + +{ + "rule_set": [ + { "type": "local", "tag": "isp-telecom", + "format": "source", "path": "/etc/sing-box/ruleset/isp-chinatelecom.json" }, + { "type": "local", "tag": "isp-unicom", + "format": "source", "path": "/etc/sing-box/ruleset/isp-unicom_cnc.json" }, + { "type": "local", "tag": "isp-cmcc", + "format": "source", "path": "/etc/sing-box/ruleset/isp-cmcc.json" } + ], + "route": { + "rules": [ + { "rule_set": ["isp-telecom"], "outbound": "电信节点" }, + { "rule_set": ["isp-unicom"], "outbound": "联通节点" }, + { "rule_set": ["isp-cmcc"], "outbound": "联通节点" } + ] + } +} + +注意:sing-box 的 local source 规则集是对象格式 + {"version":1,"rules":[{"ip_cidr":[...]}]},本工具导出的正是该格式。 + 裸 JSON 数组(mihomo 的 payload 格式)sing-box 不接受。 +EOF + ;; + esac + + log "已导出 $_n 个集合到 $_dir(格式: $_fmt)" +} + +# ---------------------------------------------------------------- rollback + +cmd_rollback() { + _last=$(ls -1d "$BASE/.history"/*/ 2>/dev/null | sort | tail -1) + [ -n "$_last" ] || die "没有可回滚的历史数据" + + # 快照可能是空的、或上次 fetch 被磁盘满/断电打断只拷了一半。 + # 旧写法先 rm 掉现有数据再 cp,cp 失败被忽略,接着 apply 会把所有集合 + # flush 成空集,最后打印"已生效"返回 0 —— 一次 rollback 静默清空分流。 + # 因此先把快照拷到暂存目录校验,确认无误后才原子替换。 + mkdir -p "$WORK" 2>/dev/null || true + _stage=$WORK/rollback.new + rm -rf "$_stage" + mkdir -p "$_stage" || die "无法创建回滚暂存目录 $_stage" + if ! cp "$_last"*.txt "$_stage/" 2>/dev/null; then + rm -rf "$_stage" + die "历史快照 $_last 为空或已损坏(没有 .txt),拒绝回滚;现有数据未改动" + fi + _chk_rb() { + [ -s "$_stage/$1.txt" ] || { + rm -rf "$_stage" + die "历史快照缺少 $1.txt(快照不完整),拒绝回滚;现有数据未改动" + } + } + foreach_enabled _chk_rb + + _old=$BASE/data.old + rm -rf "$_old" + if [ -d "$DATA" ]; then + mv "$DATA" "$_old" || { rm -rf "$_stage"; die "无法替换数据目录,现有数据未改动"; } + fi + if ! mv "$_stage" "$DATA"; then + [ -d "$_old" ] && mv "$_old" "$DATA" + die "回滚替换失败,已恢复原数据" + fi + rm -rf "$_old" + # 快照里带 .stamp 就沿用,status 才能显示这份数据的真实时间 + [ -f "$_last/.stamp" ] && cp "$_last/.stamp" "$DATA/.stamp" 2>/dev/null + + log "回滚到 $_last" + cmd_apply +} + +# ---------------------------------------------------------------- install + +# 在指定目录里创建一个不可预测的临时文件(mktemp 用 O_EXCL 创建, +# 低权用户无法用符号链接抢占这个名字)。失败时输出空行。 +_tmp_next_to() { + _td=${1:-/tmp} + mkdir -p "$_td" 2>/dev/null || _td=/tmp + mktemp "$_td/.isp-split.XXXXXX" 2>/dev/null +} + +# 重写 /etc/crontabs/root:保留非本工具的行,按需追加 $1。 +# +# 旧实现用固定的 /tmp/.ispcron 中转: +# grep -v isp-split /etc/crontabs/root > /tmp/.ispcron +# echo ... >> /tmp/.ispcron ; mv /tmp/.ispcron /etc/crontabs/root +# /tmp 是世界可写目录,低权用户可以先建 /tmp/.ispcron -> /tmp/x 的符号链接, +# root 的两次重定向就会写穿到 /tmp/x;随后的 mv 还会把符号链接本身搬到 +# /etc/crontabs/root,于是 crond 改为读 /tmp/x —— 谁都能改 root 的定时任务。 +# 现在临时文件建在 /etc 下(与目标同文件系统,rename 才是原子的)。 +_cron_rewrite() { + _cr_target=$CRONTAB + mkdir -p "${_cr_target%/*}" 2>/dev/null || return 1 + [ -f "$_cr_target" ] || : >> "$_cr_target" 2>/dev/null || return 1 + + _cr_tmp=$(_tmp_next_to "${_cr_target%/*}") + [ -n "$_cr_tmp" ] || return 1 + grep -v 'isp-split' "$_cr_target" > "$_cr_tmp" 2>/dev/null || true + if [ -n "${1:-}" ]; then + printf '%s\n' "$1" >> "$_cr_tmp" || { rm -f "$_cr_tmp"; return 1; } + fi + mv -f "$_cr_tmp" "$_cr_target" || { rm -f "$_cr_tmp"; return 1; } + return 0 +} + +# ISP_CRON 会原样写进 crontab,而 crontab 是 root 的命令执行上下文: +# 一个 "* * * * * root id > /tmp/x" 就能让 root 跑任意命令。 +# 只接受 5 段纯时间字段(数字和 * , - /)。 +_cron_expr_ok() { + # 拆字段时要临时关掉 glob:cron 表达式里合法地包含 *, + # 不关的话 `for f in $ISP_CRON` 会把 * 展开成当前目录的文件名, + # 合法表达式反而被判成非法(实测踩过)。 + _ce_had_f=0 + case $- in *f*) _ce_had_f=1 ;; esac + [ "$_ce_had_f" = "1" ] || set -f + _ce_n=0 + _ce_bad=0 + for _ce_f in ${1:-}; do + _ce_n=$((_ce_n + 1)) + case "$_ce_f" in + "" | *[!0-9*,-/]*) _ce_bad=1 ;; + esac + done + [ "$_ce_had_f" = "1" ] || set +f + [ "$_ce_bad" = "0" ] && [ "$_ce_n" -eq 5 ] +} + +cmd_install() { + _cron_expr_ok "$ISP_CRON" || + die "ISP_CRON 格式非法(必须是 5 段 cron 时间字段): $ISP_CRON" + # crontab + mkdir -p /etc/crontabs + touch /etc/crontabs/root + _cron_rewrite "$ISP_CRON /usr/sbin/isp-split refresh >/dev/null 2>&1 # isp-split" + /etc/init.d/cron restart >/dev/null 2>&1 || true + + /etc/init.d/isp-split enable >/dev/null 2>&1 || true + log "已注册:每日 $ISP_CRON 自动更新,开机自动应用" +} + +cmd_uninstall() { + _cron_rewrite "" 2>/dev/null || true + /etc/init.d/isp-split disable >/dev/null 2>&1 || true + /etc/init.d/isp-split stop >/dev/null 2>&1 || true + + # 删规则:用空片段做一次注入即可移除(rules-splice 会删掉所有 isp_ 规则) + mkdir -p "$WORK" + : > "$WORK/rules.txt" + if [ -f "$CFG_MWAN3" ]; then + cp "$CFG_MWAN3" "$WORK/mwan3.bak" + awk -v insert="$WORK/rules.txt" -v anchor="$ISP_ANCHOR" \ + -f "$LIB/rules-splice.awk" "$WORK/mwan3.bak" > "$WORK/mwan3.new" && + cat "$WORK/mwan3.new" > "$CFG_MWAN3" + fi + + for row in $ISP_TABLE; do + _old_ifs=$IFS + IFS=':' + set -- $row + IFS=$_old_ifs + case "$row" in \#*) continue ;; esac + [ $# -ge 2 ] || continue + _valid_name "$2" || continue + uci -q delete "mwan3.ipset_$2" 2>/dev/null + done + uci -q commit mwan3 + /etc/init.d/mwan3 reload >/dev/null 2>&1 + + # 恢复 dnsmasq confdir 原值(状态文件在 $BASE 里,必须先恢复再删目录) + _restore_dns_confdir + + rm -rf "$BASE" + log "已卸载(nft 集合随 mwan3 reload 一并消失)" +} + +# ---------------------------------------------------------------- main + +# 三个表值都会被 `for row in $ISP_TABLE` 这种方式展开,而展开会做路径名 +# 展开(glob):值里一旦有 * ? [,当前目录里一个叫 +# "x:isp_pwn:wan_only:1" 的文件就能冒充一行运营商配置(实测 plan 里会 +# 直接多出一个 isp_pwn 集合)。脚本里还有好几处必须保留 glob +# (ls .history/*、cp out.new/*.txt),不能全局 set -f,所以在入口 +# 直接拒绝这几个字符 —— 表里本来只该有 字母数字 : . _ - # 和空白。 +_table_no_glob() { + for _tg_v in "$ISP_TABLE" "${DNS_TABLE:-}" "${DOMAIN_SPLIT:-}"; do + case "$_tg_v" in + *'*'* | *'?'* | *'['* | *']'*) + die "配置表里出现通配符字符(* ? [ ]):展开时会被替换成当前目录的文件名。请删除这些字符(注释里也不要写)" + ;; + esac + done +} +_table_no_glob + +case "${1:-}" in +--version | version) + echo "isp-split $ISP_VERSION" + exit 0 + ;; +fetch) cmd_fetch ;; +diff) shift; cmd_diff "$@" ;; +dnstest) shift; cmd_dnstest "$@" ;; +apply) cmd_apply ;; +wire) cmd_wire ;; +sync) cmd_sync ;; +plan) cmd_plan ;; +check) cmd_check ;; +selftest) cmd_selftest ;; +stage) cmd_stage ;; +enable) cmd_enable ;; +verify) cmd_verify ;; +status) shift; cmd_status "$@" ;; +health) shift; cmd_health "$@" ;; +probe) cmd_probe ;; +rollback) cmd_rollback ;; +dns) cmd_dns ;; +conf) shift; cmd_conf "$@" ;; +refresh) cmd_refresh ;; +export) shift; cmd_export "$@" ;; +install) cmd_install ;; +uninstall) cmd_uninstall ;; +*) + cat < + + —— 不改动任何东西 —— + plan 只读预演:打印将要做什么 + check nft 干跑校验(nft -c,不提交) + selftest 在独立临时表里实测加载 1.3 万条,测完删除 + + —— 数据 —— + fetch 下载并重建运营商 IP 段(不动线上流量;多源逐源拉取并过闸门) + diff 重新计算"主源 vs 校验源"的分歧(基于存档,不联网;超阈值返回 3) + rollback 回滚到上一份数据 + + —— 上线(建议分两步)—— + stage 注入规则但保持 enabled=0,验证 mwan3 未被搞坏 + enable 确认无误后真正启用 + verify 检查规则链渲染结果与命中情况 + + —— 其他 —— + apply 原子刷入 nftables 集合(不断流、不重启 mwan3) + wire 建立 mwan3 集合与规则并插到正确位置 + conf 读写配置:conf get | conf set <项> <值> + conf check <项> <值>(只校验)| conf preview <项> <值> ...(预演未保存的改动) + dnstest 测一条线路的 DNS 是否可用:dnstest <策略名> + refresh fetch + apply(只更新数据,不动 mwan3 配置) + sync fetch + wire + apply + status 查看条目数与命中统计 + health 数据源健康度 + 双线状态(--json 给 LuCI 用) + probe 分线实测出口速度 + dns 生成多线路 DNS 配置(域名按线路选 DNS) + export 导出给代理侧用:export mihomo|singbox|text <目录> + install 注册开机自启与每日更新 + uninstall 卸载 + +零风险验证顺序: + isp-split plan && isp-split check && isp-split selftest +上线顺序: + isp-split sync && isp-split stage && isp-split enable +EOF + exit 1 + ;; +esac + +# 显式把最后一个命令的退出码作为进程退出码:conf set 等命令校验失败时, +# 调用方(LuCI fs.exec、cron、shell)必须能拿到非 0,不能依赖隐式行为。 +exit $? diff --git a/luci-app-isp-split/Makefile b/luci-app-isp-split/Makefile new file mode 100644 index 000000000..bcf9254de --- /dev/null +++ b/luci-app-isp-split/Makefile @@ -0,0 +1,58 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-isp-split +PKG_VERSION:=0.2.1 +PKG_RELEASE:=1 +PKG_LICENSE:=MIT +PKG_LICENSE_FILES:=LICENSE +PKG_MAINTAINER:=jihebube + +include $(INCLUDE_DIR)/package.mk + +define Package/luci-app-isp-split + SECTION:=luci + CATEGORY:=LuCI + SUBMENU:=3. Applications + TITLE:=LuCI support for ISP-aware policy routing + DEPENDS:=+isp-split +luci-base + PKGARCH:=all +endef + +define Package/luci-app-isp-split/description + LuCI page for isp-split: shows per-ISP element counts and hit + counters, and offers one-click sync, data refresh, and config + editing (wire/apply/dns). +endef + +define Build/Compile +endef + +define Package/luci-app-isp-split/install + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/isp-split + $(INSTALL_DIR) $(1)/www/luci-static/resources/isp-split + $(INSTALL_DATA) ./htdocs/luci-static/resources/isp-split/isp-split.css \ + $(1)/www/luci-static/resources/isp-split/isp-split.css + $(INSTALL_DIR) $(1)/www/luci-static/resources/isp-split/logos + $(INSTALL_DATA) ./htdocs/luci-static/resources/isp-split/logos/*.svg \ + $(1)/www/luci-static/resources/isp-split/logos/ + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/isp-split/overview.js \ + $(1)/www/luci-static/resources/view/isp-split/overview.js + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/isp-split/config.js \ + $(1)/www/luci-static/resources/view/isp-split/config.js + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-isp-split.json \ + $(1)/usr/share/luci/menu.d/luci-app-isp-split.json + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-isp-split.json \ + $(1)/usr/share/rpcd/acl.d/luci-app-isp-split.json +endef + +define Package/luci-app-isp-split/postinst +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] && exit 0 +rm -f /tmp/luci-indexcache* 2>/dev/null +/etc/init.d/rpcd restart >/dev/null 2>&1 +exit 0 +endef + +$(eval $(call BuildPackage,luci-app-isp-split)) diff --git a/luci-app-isp-split/htdocs/luci-static/resources/isp-split/isp-split.css b/luci-app-isp-split/htdocs/luci-static/resources/isp-split/isp-split.css new file mode 100644 index 000000000..bf73c9bd6 --- /dev/null +++ b/luci-app-isp-split/htdocs/luci-static/resources/isp-split/isp-split.css @@ -0,0 +1,723 @@ +/* + * openwrt-isp-split — LuCI 页面样式(原生 CSS,无框架、无外链) + * + * 配色原则: + * · 背景/边框/文字全部走 LuCI 主题变量(--background-color-*、--border-color-*、 + * --text-color-*、--primary-color-*),深浅色主题自动跟随; + * · 只有"运营商身份色"(电信蓝/联通红/移动绿/教育网紫)是固定色相, + * 统一收敛成 --isp-accent-* 变量,主题作者可以在 theme 里覆盖; + * · 阴影只用中性黑(透明度很低),两种主题下都不会突兀。 + */ + +.isp-app { + --isp-card-bg: var(--background-color-high, rgba(128, 128, 128, 0.07)); + --isp-card-bg-2: var(--background-color-medium, rgba(128, 128, 128, 0.12)); + --isp-border: var(--border-color-low, rgba(128, 128, 128, 0.28)); + --isp-border-strong: var(--border-color-medium, rgba(128, 128, 128, 0.45)); + /* 次要文字用 --text-color-medium:原来的 --text-color-low 是主题里的边框/禁用色 + (浅色 #bfbfbf、深色 #404040),深色主题下正文对比度只有 1.5:1,几乎看不见。 */ + --isp-muted: var(--text-color-medium, rgba(128, 128, 128, 0.95)); + --isp-text: var(--text-color-high, inherit); + --isp-primary: var(--primary-color-high, currentColor); + --isp-ok: var(--success-color-high, currentColor); + --isp-warn: var(--warn-color-high, currentColor); + --isp-bad: var(--error-color-high, currentColor); + + --isp-accent-tel: #2f6fd0; + --isp-accent-uni: #d0362f; + --isp-accent-cmcc: #2e9e5b; + --isp-accent-cernet: #8a5cd6; + --isp-accent-other: #8a8a8a; + + --isp-radius: 14px; + --isp-shadow: 0 1px 2px rgba(0, 0, 0, 0.10), 0 4px 12px rgba(0, 0, 0, 0.06); +} + +.isp-app * { + box-sizing: border-box; +} + +/* ---------------------------------------------------------------- 头部 */ + +.isp-hero { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin: 0 0 14px 0; +} + +.isp-hero h2 { + margin: 0; + font-size: 1.35rem; + font-weight: 600; + color: var(--isp-text); +} + +.isp-hero .isp-sub { + flex: 1 1 260px; + min-width: 200px; + color: var(--isp-muted); + font-size: 0.86rem; + line-height: 1.45; +} + +.isp-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.isp-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 999px; + background: var(--isp-card-bg-2); + border: 1px solid var(--isp-border); + font-size: 0.78rem; + color: var(--isp-muted); + white-space: nowrap; +} + +.isp-pill .isp-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--isp-ok); +} + +.isp-pill.is-paused .isp-dot { + background: var(--isp-warn); +} + +/* 刷新中:徽标转中文「刷新中…」并让图标转圈,替代主题自带的英文 Refreshing 提示 */ +.isp-pill.is-busy { + color: var(--isp-text); + border-color: var(--isp-border-strong); +} + +.isp-pill .isp-ico { + display: inline-flex; + align-items: center; +} + +.isp-spin { + display: inline-flex; + animation: isp-rotate 0.9s linear infinite; + transform-origin: 50% 50%; +} + +@keyframes isp-rotate { + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .isp-spin { + animation: none; + } +} + +/* ---------------------------------------------------------------- 卡片网格 */ + +.isp-cards { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.isp-grid-2 { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + gap: 12px; + margin-bottom: 12px; +} + +.isp-grid-3 { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.isp-card { + position: relative; + background: var(--isp-card-bg); + border: 1px solid var(--isp-border); + border-radius: var(--isp-radius); + box-shadow: var(--isp-shadow); + padding: 14px 16px; + min-width: 0; + overflow: hidden; +} + +.isp-card > h3 { + margin: 0 0 10px 0; + font-size: 0.92rem; + font-weight: 600; + color: var(--isp-muted); + display: flex; + align-items: center; + gap: 8px; +} + +/* 运营商卡片:顶部身份色条 */ +.isp-card-op { + padding-top: 18px; +} + +.isp-card-op::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 4px; + background: var(--isp-accent, var(--isp-accent-other)); +} + +.isp-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.isp-op-name { + font-size: 0.98rem; + font-weight: 600; + color: var(--isp-text); + letter-spacing: 0.02em; +} + +.isp-op-ico { + display: inline-flex; + color: var(--isp-accent, var(--isp-accent-other)); + opacity: 0.9; +} + +.isp-big { + display: inline-block; + font-size: 1.9rem; + line-height: 1.15; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--isp-text); +} + +.isp-unit { + margin-left: 6px; + font-size: 0.76rem; + color: var(--isp-muted); +} + +.isp-op-sub { + margin-top: 4px; + font-size: 0.8rem; + color: var(--isp-muted); + font-variant-numeric: tabular-nums; +} + +.isp-route { + display: flex; + align-items: center; + gap: 6px; + margin-top: 10px; + font-size: 0.8rem; + color: var(--isp-muted); +} + +.isp-route .isp-arrows { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 1rem; + line-height: 1; + color: var(--isp-accent, var(--isp-accent-other)); +} + +.isp-meter { + margin-top: 10px; + height: 6px; + border-radius: 999px; + background: var(--isp-card-bg-2); + overflow: hidden; +} + +.isp-meter > i { + display: block; + height: 100%; + border-radius: 999px; + background: var(--isp-accent, var(--isp-accent-other)); + opacity: 0.85; + transition: width 0.4s ease; +} + +/* 零流量卡片:不画进度条(否则 0% 只剩一个孤点),改成一行占位「—」 */ +.isp-meter-empty { + margin-top: 10px; + height: 6px; + display: flex; + align-items: center; + font-size: 0.8rem; + line-height: 1; + color: var(--isp-muted); + font-variant-numeric: tabular-nums; +} + +/* ---------------------------------------------------------------- 列表 / 表格 */ + +.isp-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.isp-line { + display: grid; + grid-template-columns: 10px minmax(64px, auto) 1fr auto; + gap: 10px; + align-items: center; + padding: 8px 10px; + border-radius: 10px; + background: var(--isp-card-bg-2); + border: 1px solid transparent; + min-width: 0; +} + +.isp-line .isp-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--isp-muted); +} + +.isp-line.is-online .isp-dot { + background: var(--isp-ok); + box-shadow: 0 0 0 3px rgba(46, 158, 91, 0.18); +} + +.isp-line.is-offline .isp-dot { + background: var(--isp-bad); +} + +.isp-line .isp-line-name { + font-weight: 600; + color: var(--isp-text); + font-size: 0.88rem; + white-space: nowrap; +} + +.isp-line .isp-line-track { + font-size: 0.78rem; + color: var(--isp-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.isp-line .isp-line-pct { + font-size: 0.78rem; + color: var(--isp-muted); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.isp-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +.isp-table th { + text-align: left; + font-weight: 500; + color: var(--isp-muted); + padding: 4px 8px 6px 0; + border-bottom: 1px solid var(--isp-border); + white-space: nowrap; +} + +.isp-table td { + padding: 6px 8px 6px 0; + border-bottom: 1px solid var(--isp-border); + color: var(--isp-text); + vertical-align: middle; +} + +.isp-table tr:last-child td { + border-bottom: none; +} + +.isp-table .num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.isp-tag { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + font-size: 0.72rem; + background: var(--isp-card-bg-2); + color: var(--isp-muted); + white-space: nowrap; +} + +/* 长的说明性标签(如出线方式解释)允许换行,避免窄屏把页面撑宽。 + 圆角从 999px 收敛成 8px:胶囊形的半圆端在换行后会把首尾字符压在弧线上, + 看起来像被截断;直角一点的说明条才容得下两行完整文字。 */ +.isp-btn-row .isp-tag { + white-space: normal; + word-break: break-word; + max-width: 100%; + padding: 4px 10px; + border-radius: 8px; + line-height: 1.5; +} + +.isp-tag.is-ok { + color: var(--isp-ok); +} + +.isp-tag.is-warn { + color: var(--isp-warn); +} + +.isp-tag.is-bad { + color: var(--isp-bad); +} + +.isp-empty { + color: var(--isp-muted); + font-size: 0.84rem; + padding: 8px 0; +} + +.isp-time { + display: flex; + flex-wrap: wrap; + gap: 6px 18px; + font-size: 0.82rem; + color: var(--isp-muted); +} + +.isp-time .isp-tag { + margin-left: 6px; +} + +.isp-time b { + color: var(--isp-text); + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +/* ---------------------------------------------------------------- 操作区 */ + +.isp-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 4px 0 10px 0; +} + +.isp-actions .cbi-button { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.isp-ico { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.isp-out { + max-height: 22em; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; + font-family: var(--font-mono, monospace); + font-size: 12px; + line-height: 1.5; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--isp-border); + background: var(--isp-card-bg); + color: var(--isp-text); + margin: 0; +} + +/* 没有输出时不留空盒子:hidden 属性必须真的生效(主题里有 pre{display:block}, + 单靠 hidden 会被作者样式盖掉,只剩一个 22px 高的空边框)。 */ +.isp-out[hidden], +.isp-out.is-empty { + display: none !important; +} + +/* ---------------------------------------------------------------- 表单(配置页) */ + +.isp-section { + background: var(--isp-card-bg); + border: 1px solid var(--isp-border); + border-radius: var(--isp-radius); + box-shadow: var(--isp-shadow); + padding: 14px 16px 16px; + margin-bottom: 12px; +} + +.isp-section > h3 { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 4px 0; + font-size: 1rem; + font-weight: 600; + color: var(--isp-text); +} + +.isp-section > .isp-desc { + margin: 0 0 12px 0; + font-size: 0.82rem; + line-height: 1.5; + color: var(--isp-muted); +} + +.isp-form-table { + width: 100%; + border-collapse: collapse; + font-size: 0.84rem; +} + +.isp-form-table th { + text-align: left; + font-weight: 500; + color: var(--isp-muted); + padding: 0 8px 6px 0; + border-bottom: 1px solid var(--isp-border); + white-space: nowrap; +} + +.isp-form-table td { + padding: 6px 8px 6px 0; + border-bottom: 1px solid var(--isp-border); + vertical-align: top; +} + +.isp-form-table tr:last-child td { + border-bottom: none; +} + +.isp-form-table input[type="text"], +.isp-form-table select { + width: 100%; + min-width: 90px; +} + +.isp-err { + display: none; + margin-top: 3px; + font-size: 0.76rem; + line-height: 1.35; + color: var(--isp-bad); +} + +.isp-err.is-on { + display: block; +} + +.isp-row-actions { + display: inline-flex; + gap: 4px; + align-items: center; + white-space: nowrap; +} + +.isp-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border-radius: 8px; + border: 1px solid var(--isp-border); + background: var(--isp-card-bg-2); + color: var(--isp-text); + cursor: pointer; +} + +.isp-icon-btn:hover:not(:disabled) { + border-color: var(--isp-border-strong); +} + +.isp-icon-btn:disabled { + opacity: 0.4; + cursor: default; +} + +.isp-icon-btn.is-danger { + color: var(--isp-bad); +} + +.isp-switch { + position: relative; + display: inline-block; + width: 40px; + height: 22px; + vertical-align: middle; +} + +.isp-switch input { + position: absolute; + opacity: 0; + width: 100%; + height: 100%; + margin: 0; + cursor: pointer; +} + +.isp-switch span { + position: absolute; + inset: 0; + border-radius: 999px; + background: var(--isp-card-bg-2); + border: 1px solid var(--isp-border); + transition: background 0.2s ease; + pointer-events: none; +} + +.isp-switch span::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--isp-muted); + transition: transform 0.2s ease, background 0.2s ease; +} + +.isp-switch input:checked + span { + background: var(--isp-primary); + border-color: transparent; +} + +.isp-switch input:checked + span::after { + transform: translateX(18px); + background: var(--background-color-high, #fff); +} + +.isp-btn-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 10px; +} + +/* 表单下方的整段说明(如「出线方式」的解释):允许换行,不加胶囊边框 */ +.isp-note { + margin-top: 8px; + font-size: 0.78rem; + line-height: 1.55; + color: var(--isp-muted); + overflow-wrap: anywhere; +} + +.isp-test-result { + font-size: 0.78rem; + line-height: 1.35; + color: var(--isp-muted); + min-height: 1em; +} + +.isp-test-result.is-ok { + color: var(--isp-ok); +} + +.isp-test-result.is-bad { + color: var(--isp-bad); +} + +.isp-footer-actions { + /* 曾经是 position:sticky; bottom:0 —— 会浮在视口底部盖住 DNS 表最后两行。 + 保存按钮放在正常文档流里即可,页脚用一条上边框跟内容分开。 */ + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 4px 0 8px 0; + padding: 12px 0 0 0; + border-top: 1px solid var(--isp-border); + background: transparent; +} + +/* ---------------------------------------------------------------- 响应式 */ + +@media (max-width: 1100px) { + .isp-cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .isp-grid-3 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 820px) { + .isp-grid-2, + .isp-grid-3 { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 620px) { + .isp-cards { + grid-template-columns: minmax(0, 1fr); + } + + .isp-big { + font-size: 1.7rem; + } + + .isp-line { + grid-template-columns: 10px 1fr; + row-gap: 2px; + } + + .isp-line .isp-line-pct { + grid-column: 2; + } + + /* 表单表格在窄屏改成两块:上半行是标签,下半行是输入 */ + .isp-form-table thead { + display: none; + } + + .isp-form-table tr { + display: block; + padding: 8px 0; + border-bottom: 1px solid var(--isp-border); + } + + .isp-form-table td { + display: block; + border: none; + padding: 2px 0; + } + + .isp-form-table td::before { + content: attr(data-th); + display: block; + font-size: 0.72rem; + color: var(--isp-muted); + margin-bottom: 2px; + } +} diff --git a/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-chinatelecom.svg b/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-chinatelecom.svg new file mode 100644 index 000000000..c96ac931b --- /dev/null +++ b/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-chinatelecom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-mobile.svg b/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-mobile.svg new file mode 100644 index 000000000..620031522 --- /dev/null +++ b/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-mobile.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-unicom.svg b/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-unicom.svg new file mode 100644 index 000000000..0bd7e0c82 --- /dev/null +++ b/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-unicom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/luci-app-isp-split/htdocs/luci-static/resources/view/isp-split/config.js b/luci-app-isp-split/htdocs/luci-static/resources/view/isp-split/config.js new file mode 100644 index 000000000..01dc057cd --- /dev/null +++ b/luci-app-isp-split/htdocs/luci-static/resources/view/isp-split/config.js @@ -0,0 +1,862 @@ +'use strict'; +'require view'; +'require fs'; +'require ui'; + +/* + * LuCI 配置页:策略映射 / 多线路 DNS / 域名分流 + * + * 约定: + * · 所有文件解析与改写仍由 `isp-split conf` 在 shell 侧完成(带白名单校验、 + * 回读校验、失败回滚),前端只负责收集值; + * · 前端做一遍同样的校验,错误直接标在出错的输入框下面(红字), + * 不必等后端返回; + * · `conf preview` 只把改动写进临时副本再跑 plan,"保存前预检"不会碰 + * /etc/isp-split.conf。 + */ + +var CMD = '/usr/sbin/isp-split'; + +/* ---------------------------------------------------------------- 图标(内联 SVG) */ + +var ICONS = { + refresh: '', + check: '', + plus: '', + up: '', + down: '', + trash: '', + plug: '', + save: '' +}; + +function svgIcon(name, size) { + var span = document.createElement('span'); + span.className = 'isp-ico'; + span.innerHTML = ''; + return span; +} + +/* ---------------------------------------------------------------- 工具 */ + +function run(args) { + return fs.exec(CMD, args).then(function(res) { + // fs.exec 不管退出码都会 resolve;非 0 必须变成 reject, + // 否则校验失败也会被 UI 当成"已保存"。 + if (res && res.code) { + var out = ((res.stdout || '') + (res.stderr || '')).trim(); + throw new Error('isp-split ' + args.join(' ') + + ' 失败(退出码 ' + res.code + ')' + (out ? ':' + out : '')); + } + return res; + }); +} + +/* 行内错误提示:同一个 td 里紧跟输入框的一行红字 */ +function errBox() { + return E('div', { 'class': 'isp-err' }); +} + +function setErr(box, msg) { + if (!box) + return; + if (msg) { + box.textContent = msg; + box.classList.add('is-on'); + } else { + box.textContent = ''; + box.classList.remove('is-on'); + } +} + +function isIPv4(s) { + if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) + return false; + var p = s.split('.'); + for (var i = 0; i < p.length; i++) { + if (+p[i] > 255) + return false; + } + return true; +} + +function isDomain(s) { + if (!/^[A-Za-z0-9._-]+$/.test(s)) + return false; + if (s.charAt(0) === '.' || s.charAt(s.length - 1) === '.') + return false; + return s.indexOf('..') < 0; +} + +function isSrc(s) { + return /^[A-Za-z0-9._-]+$/.test(s); +} + +function isSetName(s) { + return /^[A-Za-z0-9_]{1,15}$/.test(s); +} + +/* ---------------------------------------------------------------- 视图 */ + +return view.extend({ + load: function() { + /* 配置字段走 conf get;数据源健康度走 health --json。 + health 失败不能拖垮整个配置页:捕获后渲染成“读取失败”。 */ + return Promise.all([ + run(['conf', 'get']), + fs.exec(CMD, ['health', '--json']).catch(function() { return null; }) + ]); + }, + + render: function(res) { + var self = this; + var out = (res && res[0] && res[0].stdout) || ''; + var health = null; + try { + health = JSON.parse(((res[1] && res[1].stdout) || '').trim() || 'null'); + } catch (e) { + health = null; + } + + var model = { + mode: 'bind', + policies: [], + isp: [], + dns: [], + domains: [] + }; + + out.split('\n').forEach(function(line) { + if (!line) + return; + var f = line.split('\t'); + if (f[0] === 'dns_mode') + model.mode = (f[1] || 'bind').trim(); + else if (f[0] === 'policies') + model.policies = (f[1] || '').trim().split(/\s+/).filter(Boolean); + else if (f[0] === 'isp' && f.length >= 5) + model.isp.push({ src: f[1], set: f[2], policy: f[3], enabled: f[4] === '1' }); + else if (f[0] === 'dns' && f.length >= 3) + model.dns.push({ policy: f[1], dns: f[2], test: null }); + else if (f[0] === 'domain' && f.length >= 3) + model.domains.push({ domain: f[1], policy: f[2] }); + }); + + if (!model.isp.length) { + return E('div', { 'class': 'isp-app cbi-map' }, [ + E('link', { 'rel': 'stylesheet', 'href': L.resource('isp-split/isp-split.css') }), + E('h2', {}, [ _('运营商分流 · 配置') ]), + E('div', { 'class': 'cbi-map-descr' }, [ + _('读不到 /etc/isp-split.conf 的 ISP_TABLE,请先确认已安装并执行过 isp-split fetch。') + ]) + ]); + } + + var result = E('pre', { 'id': 'cfg-out', 'class': 'isp-out is-empty', 'hidden': '' }, ['']); + + /* 没有输出时保持 hidden:主题的 pre{display:block} 会盖掉 hidden 属性, + CSS 里配了 .isp-out[hidden]{display:none!important},这里只管切换属性。 */ + function showResult(text) { + if (text) { + result.textContent = text; + result.removeAttribute('hidden'); + result.classList.remove('is-empty'); + } + else { + result.textContent = ''; + result.setAttribute('hidden', ''); + result.classList.add('is-empty'); + } + result.scrollTop = 0; + } + + /* ============================================================ 一、策略映射 */ + + var ispTbody = E('tbody', {}); + + function renderIsp() { + while (ispTbody.firstChild) + ispTbody.removeChild(ispTbody.firstChild); + + model.isp.forEach(function(row, idx) { + row._err = row._err || {}; + + var srcInp = E('input', { + 'type': 'text', 'class': 'cbi-input-text', 'value': row.src, + 'placeholder': '源名,如 chinatelecom' + }); + srcInp.addEventListener('input', function() { + row.src = srcInp.value.trim(); + validateIspRow(row); + }); + + var setInp = E('input', { + 'type': 'text', 'class': 'cbi-input-text', 'value': row.set, + 'placeholder': '集合名,<=15 字符' + }); + setInp.addEventListener('input', function() { + row.set = setInp.value.trim(); + validateIspRow(row); + }); + + var polSel = E('select', { 'class': 'cbi-input-select' }, + model.policies.map(function(p) { + return E('option', { 'value': p, 'selected': p === row.policy ? '' : null }, [ p ]); + })); + if (!model.policies.length) + polSel.appendChild(E('option', { 'value': '' }, [ _('(mwan3 里没有策略)') ])); + polSel.addEventListener('change', function() { + row.policy = polSel.value; + validateIspRow(row); + }); + + var chk = E('input', { 'type': 'checkbox', 'checked': row.enabled ? '' : null }); + chk.addEventListener('change', function() { row.enabled = chk.checked; }); + + var up = E('button', { + 'class': 'isp-icon-btn', 'title': _('上移(提高优先级)'), + 'disabled': idx === 0 ? '' : null, + 'click': function() { moveIsp(idx, -1); } + }, [ svgIcon('up', 15) ]); + + var down = E('button', { + 'class': 'isp-icon-btn', 'title': _('下移(降低优先级)'), + 'disabled': idx === model.isp.length - 1 ? '' : null, + 'click': function() { moveIsp(idx, 1); } + }, [ svgIcon('down', 15) ]); + + var tr = E('tr', {}, [ + E('td', { 'data-th': _('运营商') }, [ srcInp, row._err.src = errBox() ]), + E('td', { 'data-th': _('集合') }, [ setInp, row._err.set = errBox() ]), + E('td', { 'data-th': _('策略') }, [ polSel, row._err.policy = errBox() ]), + E('td', { 'data-th': _('启用') }, [ + E('label', { 'class': 'isp-switch' }, [ + chk, + E('span') + ]) + ]), + E('td', { 'data-th': _('顺序') }, [ + E('div', { 'class': 'isp-row-actions' }, [ up, down ]) + ]) + ]); + ispTbody.appendChild(tr); + validateIspRow(row); + }); + } + + function moveIsp(idx, delta) { + var to = idx + delta; + if (to < 0 || to >= model.isp.length) + return; + var row = model.isp.splice(idx, 1)[0]; + model.isp.splice(to, 0, row); + renderIsp(); + } + + function validateIspRow(row) { + var err = row._err || {}; + var ok = true; + + if (!isSrc(row.src || '')) { + setErr(err.src, _('只能包含字母、数字、点、下划线、连字符')); + ok = false; + } else { + setErr(err.src, ''); + } + + if (!isSetName(row.set || '')) { + setErr(err.set, _('1-15 个字符,只能是字母/数字/下划线')); + ok = false; + } else { + var dup = model.isp.filter(function(r) { return r !== row && r.set === row.set; }).length; + if (dup) { + setErr(err.set, _('集合名重复')); + ok = false; + } else { + setErr(err.set, ''); + } + } + + if (!row.policy || (model.policies.length && model.policies.indexOf(row.policy) < 0)) { + setErr(err.policy, _('mwan3 里没有这个策略')); + ok = false; + } else { + setErr(err.policy, ''); + } + + row._valid = ok; + return ok; + } + + renderIsp(); + + var addIspBtn = E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'click': function() { + model.isp.push({ + src: '', set: '', policy: model.policies[0] || '', enabled: true + }); + renderIsp(); + } + }, [ svgIcon('plus'), E('span', {}, [_('添加运营商')]) ]); + + /* ============================================================ 二、多线路 DNS */ + + var dnsTbody = E('tbody', {}); + + /* DNS 行 = mwan3 策略 ∪ DNS_TABLE 里已有但 mwan3 没列出的策略 */ + var dnsPolicies = model.policies.slice(); + model.dns.forEach(function(r) { + if (dnsPolicies.indexOf(r.policy) < 0) + dnsPolicies.push(r.policy); + }); + dnsPolicies.forEach(function(p) { + var exists = null; + model.dns.forEach(function(r) { if (r.policy === p) exists = r; }); + if (!exists) + model.dns.push({ policy: p, dns: '', test: null }); + }); + + function renderDns() { + while (dnsTbody.firstChild) + dnsTbody.removeChild(dnsTbody.firstChild); + + model.dns.forEach(function(row) { + row._err = errBox(); + + var inp = E('input', { + 'type': 'text', 'class': 'cbi-input-text', + 'placeholder': _('该线路使用的 DNS,如 223.5.5.5'), + 'value': row.dns + }); + inp.addEventListener('input', function() { + row.dns = inp.value.trim(); + validateDnsRow(row); + }); + + var resSpan = E('div', { 'class': 'isp-test-result' }, [ + row.test ? row.test.text : '' + ]); + if (row.test) + resSpan.className = 'isp-test-result ' + (row.test.ok ? 'is-ok' : 'is-bad'); + + var testBtn = E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'title': _('向该线路的 DNS 发一次查询'), + 'click': function() { + resSpan.className = 'isp-test-result'; + resSpan.textContent = _('测试中…'); + /* 这里不能用 run():DNS 不可用时命令返回 1,但 JSON 仍然有效 */ + return fs.exec(CMD, ['dnstest', row.policy, '--json']).then(function(r) { + var d = null; + try { + d = JSON.parse((r.stdout || '').trim() || 'null'); + } catch (e) { + d = null; + } + if (d && d.ok) { + resSpan.className = 'isp-test-result is-ok'; + resSpan.textContent = _('可用') + '(' + (d.device || '?') + ' → ' + + (d.answer || '?') + ')'; + row.test = { ok: true, text: resSpan.textContent }; + } else { + resSpan.className = 'isp-test-result is-bad'; + resSpan.textContent = _('不可用(DNS 无响应)'); + row.test = { ok: false, text: resSpan.textContent }; + } + }).catch(function(e) { + resSpan.className = 'isp-test-result is-bad'; + resSpan.textContent = _('测线失败:') + String(e).replace(/^.*失败([^)]*):/, ''); + }); + } + }, [ svgIcon('plug'), E('span', {}, [_('测线')]) ]); + + dnsTbody.appendChild(E('tr', {}, [ + E('td', { 'data-th': _('线路(策略)') }, [ + E('span', { 'class': 'isp-line-name' }, [ row.policy ]), + row._err + ]), + E('td', { 'data-th': _('DNS') }, [ inp ]), + E('td', { 'data-th': _('测线') }, [ + E('div', { 'class': 'isp-row-actions' }, [ testBtn ]), + resSpan + ]) + ])); + validateDnsRow(row); + }); + } + + function validateDnsRow(row) { + if (!row.dns) { + setErr(row._err, ''); + row._valid = true; + return true; + } + if (!isIPv4(row.dns)) { + setErr(row._err, _('必须是 IPv4 地址,如 223.5.5.5')); + row._valid = false; + return false; + } + setErr(row._err, ''); + row._valid = true; + return true; + } + + renderDns(); + + var modeSel = E('select', { 'class': 'cbi-input-select' }, [ + E('option', { 'value': 'bind', 'selected': model.mode === 'bind' ? '' : null }, + [_('bind · dnsmasq 绑定出接口')]), + E('option', { 'value': 'mark', 'selected': model.mode === 'mark' ? '' : null }, + [_('mark · nft 按目标 DNS 打标')]) + ]); + modeSel.addEventListener('change', function() { model.mode = modeSel.value; }); + + /* ============================================================ 三、域名分流 */ + + var domTbody = E('tbody', {}); + + function renderDomains() { + while (domTbody.firstChild) + domTbody.removeChild(domTbody.firstChild); + + if (!model.domains.length) { + domTbody.appendChild(E('tr', {}, [ + E('td', { 'colspan': 3, 'data-th': '' }, [ + E('div', { 'class': 'isp-empty' }, [ + _('尚未配置。大多数域名不需要在这里出现——只有 CDN 调度不准的站点才需要钉线。') + ]) + ]) + ])); + return; + } + + model.domains.forEach(function(row, idx) { + var inp = E('input', { + 'type': 'text', 'class': 'cbi-input-text', + 'placeholder': _('域名后缀,如 bilivideo.com'), + 'value': row.domain + }); + inp.addEventListener('input', function() { + row.domain = inp.value.trim(); + validateDomainRow(row); + }); + + var sel = E('select', { 'class': 'cbi-input-select' }, + model.policies.map(function(p) { + return E('option', { 'value': p, 'selected': p === row.policy ? '' : null }, [ p ]); + })); + sel.addEventListener('change', function() { + row.policy = sel.value; + validateDomainRow(row); + }); + + var del = E('button', { + 'class': 'isp-icon-btn is-danger', 'title': _('删除'), + 'click': function() { + ui.showModal(_('确认删除'), [ + E('p', {}, [ + _('把 ') + (row.domain || _('这一行')) + _(' 从域名分流里删掉?') + ]), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'btn', 'click': ui.hideModal }, [_('取消')]), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-negative', + 'click': function() { + ui.hideModal(); + model.domains.splice(idx, 1); + renderDomains(); + } + }, [_('删除')]) + ]) + ]); + } + }, [ svgIcon('trash', 15) ]); + + var err = errBox(); + row._err = err; + + domTbody.appendChild(E('tr', {}, [ + E('td', { 'data-th': _('域名后缀') }, [ inp, err ]), + E('td', { 'data-th': _('钉到线路') }, [ sel ]), + E('td', { 'data-th': _('操作') }, [ + E('div', { 'class': 'isp-row-actions' }, [ del ]) + ]) + ])); + validateDomainRow(row); + }); + } + + function validateDomainRow(row) { + if (!row.domain) { + setErr(row._err, ''); + row._valid = true; + return true; + } + if (!isDomain(row.domain)) { + setErr(row._err, _('只能包含字母/数字/点/下划线/连字符,且不能以点开头或结尾')); + row._valid = false; + return false; + } + var dup = model.domains.filter(function(r) { + return r !== row && r.domain === row.domain; + }).length; + if (dup) { + setErr(row._err, _('域名重复')); + row._valid = false; + return false; + } + setErr(row._err, ''); + row._valid = true; + return true; + } + + renderDomains(); + + var addDomBtn = E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { + model.domains.push({ domain: '', policy: model.policies[0] || '' }); + renderDomains(); + } + }, [ svgIcon('plus'), E('span', {}, [_('添加域名')]) ]); + + /* ============================================================ 四、数据源(只读) */ + + /* 数据源地址是 root 路径的下载入口,配置页不开放编辑: + 一个非法 URL 就能让 root 去拉任意内容。这里只展示现状, + 修改方式指向 CONFIGURATION.md 的「多数据源」一节。 */ + var srcTbody = E('tbody', {}); + var srcRows = (health && health.sources) || []; + var verifyEnabled = !!(health && health.verify_enabled); + + function srcRoleText(role) { + if (role === 'primary') + return _('主源'); + if (role === 'verify') + return _('校验源'); + if (role === 'audit') + return _('审计源'); + return role || '—'; + } + + function renderSources() { + while (srcTbody.firstChild) + srcTbody.removeChild(srcTbody.firstChild); + + if (!srcRows.length) { + srcTbody.appendChild(E('tr', {}, [ + E('td', { 'colspan': 6, 'data-th': '' }, [ + E('div', { 'class': 'isp-empty' }, [ + health + ? _('没有数据源信息。') + : _('读取数据源状态失败,请检查 isp-split health --json。') + ]) + ]) + ])); + return; + } + + srcRows.forEach(function(s) { + var tag = 'isp-tag is-ok'; + var text = _('正常'); + if (s.status === 'disabled') { + tag = 'isp-tag'; + text = _('未启用校验'); + } else if (s.max_fail && s.fail_days >= s.max_fail) { + tag = 'isp-tag is-bad'; + text = _('拒绝更新') + ' ' + s.fail_days + '/' + s.max_fail; + } else if (s.fail_days > 0) { + tag = 'isp-tag is-warn'; + text = _('降级') + ' ' + s.fail_days + '/' + s.max_fail; + } + + srcTbody.appendChild(E('tr', {}, [ + E('td', { 'data-th': _('源') }, [ s.label || s.name ]), + E('td', { 'data-th': _('角色') }, [ + E('span', { 'class': 'isp-tag' }, [ srcRoleText(s.role) ]) + ]), + E('td', { 'data-th': _('状态') }, [ + E('span', { 'class': tag }, [ text ]) + ]), + E('td', { 'data-th': _('最近 Jaccard') }, [ + s.jaccard != null ? (s.jaccard + '%') : '—' + ]), + E('td', { 'data-th': _('最后成功') }, [ s.last_ok_text || '—' ]), + E('td', { 'data-th': _('连续失败') }, [ + String(s.fail_days || 0) + ]) + ])); + }); + } + + renderSources(); + + /* ============================================================ 保存 / 预检 */ + + function values() { + var ispVal = model.isp.map(function(r) { + return r.src + ':' + r.set + ':' + r.policy + ':' + (r.enabled ? '1' : '0'); + }).join('|'); + + var dnsVal = model.dns.filter(function(r) { return r.dns; }) + .map(function(r) { return r.policy + ':' + r.dns; }).join('|'); + + var domVal = model.domains.filter(function(r) { return r.domain; }) + .map(function(r) { return r.domain + ':' + r.policy; }).join('|'); + + return { + isp: ispVal, + dns: dnsVal, + dom: domVal, + mode: modeSel.value + }; + } + + /* 返回 true = 全部合法;出错的行已经标红 */ + function validateAll() { + var ok = true; + model.isp.forEach(function(r) { ok = validateIspRow(r) && ok; }); + model.dns.forEach(function(r) { ok = validateDnsRow(r) && ok; }); + model.domains.forEach(function(r) { ok = validateDomainRow(r) && ok; }); + + if (!model.isp.length) { + ui.addNotification(null, E('p', {}, [ + _('至少保留一行运营商映射') + ]), 'danger'); + ok = false; + } + return ok; + } + + function listErrors() { + var msgs = []; + model.isp.forEach(function(r) { + if (r._valid === false) + msgs.push(_('运营商行 ') + (r.set || r.src || '?') + _(' 有错误')); + }); + model.dns.forEach(function(r) { + if (r._valid === false) + msgs.push(_('DNS 行 ') + r.policy + _(' 有错误')); + }); + model.domains.forEach(function(r) { + if (r._valid === false) + msgs.push(_('域名行 ') + (r.domain || '?') + _(' 有错误')); + }); + return msgs; + } + + function step(args, log) { + return run(args).then(function(r) { + var t = ((r.stdout || '') + (r.stderr || '')).trim(); + log.push('$ isp-split ' + args.join(' ') + '\n' + (t || ' (无输出)')); + return r; + }); + } + + function preflight() { + if (!validateAll()) { + showResult(_('预检未通过:') + '\n - ' + listErrors().join('\n - ')); + ui.addNotification(null, E('p', {}, [_('表格里还有非法输入,先修正标红的行')]), 'danger'); + return; + } + + var v = values(); + showResult(_('正在预检(只写临时副本,不会改动配置)…')); + run(['conf', 'preview', + 'isp_table', v.isp, + 'dns_table', v.dns, + 'domain_split', v.dom, + 'dns_mode', v.mode + ]).then(function(r) { + showResult(((r.stdout || '') + (r.stderr || '')) || '(无输出)'); + ui.addNotification(null, E('p', {}, [_('预检完成,配置未被修改')]), 'info'); + }).catch(function(e) { + showResult(_('预检失败:') + String(e)); + ui.addNotification(null, E('p', {}, [_('预检失败:') + String(e)]), 'danger'); + }); + } + + function save(apply) { + if (!validateAll()) { + showResult(_('保存已取消:') + '\n - ' + listErrors().join('\n - ')); + ui.addNotification(null, E('p', {}, [_('表格里还有非法输入,先修正标红的行')]), 'danger'); + return; + } + + var v = values(); + var log = []; + + step(['conf', 'set', 'isp_table', v.isp], log) + .then(function() { return step(['conf', 'set', 'dns_table', v.dns], log); }) + .then(function() { return step(['conf', 'set', 'domain_split', v.dom], log); }) + .then(function() { return step(['conf', 'set', 'dns_mode', v.mode], log); }) + .then(function() { + if (!apply) + return null; + return step(['wire'], log) + .then(function() { return step(['apply'], log); }) + .then(function() { return step(['dns'], log); }); + }) + .then(function() { + showResult(log.join('\n\n')); + ui.addNotification(null, E('p', {}, [ + apply + ? _('已保存并生效') + : _('已保存。改动需执行「保存并生效」或手动 wire/apply 后才生效') + ]), 'info'); + }) + .catch(function(e) { + showResult(log.join('\n\n') + '\n\n错误: ' + String(e)); + ui.addNotification(null, E('p', {}, [ _('操作失败:') + String(e) ]), 'danger'); + }); + } + + /* ============================================================ 页面 */ + + return E('div', { 'class': 'isp-app cbi-map' }, [ + E('link', { 'rel': 'stylesheet', 'href': L.resource('isp-split/isp-split.css') }), + + E('div', { 'class': 'isp-hero' }, [ + E('h2', {}, [ _('运营商分流 · 配置') ]), + E('div', { 'class': 'isp-sub' }, [ + _('改动写入 /etc/isp-split.conf:写入前校验、写入后回读,语法不合法自动回滚。') + ]) + ]), + + /* ---- 策略映射 ---- */ + E('section', { 'class': 'isp-section' }, [ + E('h3', {}, [ _('策略映射') ]), + E('p', { 'class': 'isp-desc' }, [ + _('顺序即优先级:排在前面的运营商优先占有重叠地址。集合名同时是 mwan3 规则名(<=15 字符)。') + ]), + E('table', { 'class': 'isp-form-table' }, [ + E('thead', {}, [ + E('tr', {}, [ + E('th', {}, [_('运营商(源名)')]), + E('th', {}, [_('集合')]), + E('th', {}, [_('策略')]), + E('th', {}, [_('启用')]), + E('th', {}, [_('顺序')]) + ]) + ]), + ispTbody + ]), + E('div', { 'class': 'isp-btn-row' }, [ addIspBtn ]) + ]), + + /* ---- 多线路 DNS ---- */ + E('section', { 'class': 'isp-section' }, [ + E('h3', {}, [ _('多线路 DNS') ]), + E('p', { 'class': 'isp-desc' }, [ + _('让每条线用自己运营商的 DNS,CDN 才会把本运营商节点返回给你。留空表示不配置。') + ]), + E('table', { 'class': 'isp-form-table' }, [ + E('thead', {}, [ + E('tr', {}, [ + E('th', {}, [_('线路(策略)')]), + E('th', {}, [_('DNS 服务器')]), + E('th', {}, [_('测线')]) + ]) + ]), + dnsTbody + ]), + E('div', { 'class': 'isp-btn-row' }, [ + E('span', { 'class': 'isp-line-name' }, [_('出线方式')]), + modeSel + ]), + /* 这行解释原来挤在 .isp-tag 胶囊里,窄屏换成两行后首尾字符被圆角压掉, + 看着像被截断。改成普通说明段落:整段换行显示,title 再兜一次全文。 */ + E('div', { + 'class': 'isp-note', + 'title': _('bind 依赖 dnsmasq @接口;mark 用 nft 打标,OpenClash 接管 DNS 时更稳') + }, [ + _('bind 依赖 dnsmasq @接口;mark 用 nft 打标,OpenClash 接管 DNS 时更稳') + ]) + ]), + + /* ---- 域名分流 ---- */ + E('section', { 'class': 'isp-section' }, [ + E('h3', {}, [ _('域名分流') ]), + E('p', { 'class': 'isp-desc' }, [ + _('未列出的域名走系统默认上游(通常就是 PPPoE 下发的运营商 DNS,无需配置)。'), + _('只有个别站点 CDN 调度不准时,才需要把它的域名钉到指定线路。') + ]), + E('table', { 'class': 'isp-form-table' }, [ + E('thead', {}, [ + E('tr', {}, [ + E('th', {}, [_('域名后缀')]), + E('th', {}, [_('钉到线路')]), + E('th', {}, [_('操作')]) + ]) + ]), + domTbody + ]), + E('div', { 'class': 'isp-btn-row' }, [ addDomBtn ]) + ]), + + /* ---- 数据源(只读) ---- */ + E('section', { 'class': 'isp-section' }, [ + E('h3', {}, [ _('数据源'), E('span', { 'class': 'isp-tag' }, [ _('只读') ]) ]), + E('p', { 'class': 'isp-desc' }, [ + _('主源出数据,校验源只跑日 diff;分歧超阈值会拒绝更新并保留旧数据。'), + _('为避免把非法 URL 写进 root 路径,本页只展示、不开放编辑。') + ]), + E('div', { 'class': 'isp-btn-row' }, [ + E('span', { + 'class': 'isp-tag' + (verifyEnabled ? ' is-ok' : '') + }, [ verifyEnabled ? _('校验已启用') : _('校验未启用') ]), + E('span', { 'class': 'isp-tag' }, [ + _('源声明来自 ISP_SOURCES') + ]) + ]), + E('table', { 'class': 'isp-form-table' }, [ + E('thead', {}, [ + E('tr', {}, [ + E('th', {}, [_('源')]), + E('th', {}, [_('角色')]), + E('th', {}, [_('状态')]), + E('th', {}, [_('最近 Jaccard')]), + E('th', {}, [_('最后成功')]), + E('th', {}, [_('连续失败')]) + ]) + ]), + srcTbody + ]), + E('div', { 'class': 'isp-note' }, [ + _('修改数据源请编辑 /etc/isp-split.conf 的 ISP_SOURCES / ISP_SOURCE_FILES,'), + E('a', { + 'href': 'https://github.com/jihebube/openwrt-isp-split/blob/main/docs/CONFIGURATION.md', + 'target': '_blank', + 'rel': 'noopener' + }, [ _('配置文档') ]), + _(' 的「多数据源」一节有完整字段说明与降级行为表。') + ]) + ]), + + E('div', { 'class': 'isp-footer-actions' }, [ + E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'click': preflight + }, [ svgIcon('refresh'), E('span', {}, [_('保存前预检')]) ]), + E('button', { + 'class': 'cbi-button', + 'click': function() { save(false); } + }, [ svgIcon('save'), E('span', {}, [_('仅保存')]) ]), + E('button', { + 'class': 'cbi-button cbi-button-positive', + 'click': function() { save(true); } + }, [ svgIcon('check'), E('span', {}, [_('保存并生效')]) ]) + ]), + + result + ]); + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null +}); diff --git a/luci-app-isp-split/htdocs/luci-static/resources/view/isp-split/overview.js b/luci-app-isp-split/htdocs/luci-static/resources/view/isp-split/overview.js new file mode 100644 index 000000000..1bd956f89 --- /dev/null +++ b/luci-app-isp-split/htdocs/luci-static/resources/view/isp-split/overview.js @@ -0,0 +1,734 @@ +'use strict'; +'require view'; +'require fs'; +'require ui'; + +/* + * LuCI 概览页:运营商分流状态 + * + * 数据来源(两次只读调用,均由 rpcd file.exec 执行): + * isp-split status --json → 每个集合的条目数/命中包/流量 + 兜底计数 + * isp-split health --json → 源健康度、双线状态、策略占比、数据时间、最近 diff + * + * 页面只用原生 DOM(LuCI 的 E())+ 一份静态 CSS,没有构建工具、没有外链资源: + * 路由器离线时图标与样式照常显示。 + * + * 为什么不用 LuCI 的 poll 模块:`poll.add()` 会触发 document 级的 poll-start 事件, + * 主题随之在顶栏弹出英文 "Refreshing" 徽标(luci.js: showIndicator(_('Refreshing'))), + * 中文界面里很突兀,而且位置离数据很远。这里改成自己的定时器 + 页面内中文徽标 + * 「刷新中…」,用户看到的是同一处状态点。 + */ + +var CMD = '/usr/sbin/isp-split'; +var REFRESH_SEC = 10; + +/* ---------------------------------------------------------------- 图标(内联 SVG,无外链) */ + +var ICONS = { + refresh: '', + pause: '', + play: '', + sync: '', + download: '', + check: '', + tel: '', + uni: '', + cmcc: '', + cernet: '', + other: '' +}; + +var GLYPH_FOR = { + chinatelecom: 'tel', chinanet: 'tel', telecom: 'tel', + unicom_cnc: 'uni', unicom: 'uni', + cmcc: 'cmcc', mobile: 'cmcc', + cernet: 'cernet' +}; + +function svgIcon(name, size) { + var span = document.createElement('span'); + span.className = 'isp-ico'; + span.innerHTML = ''; + return span; +} + +/* ---------------------------------------------------------------- 格式化 */ + +function fmtNum(n) { + n = +n || 0; + return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ','); +} + +function fmtBytes(n) { + n = +n || 0; + var u = ['B', 'KiB', 'MiB', 'GiB', 'TiB'], i = 0; + while (n >= 1024 && i < u.length - 1) { + n /= 1024; + i++; + } + return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + u[i]; +} + +function accentOf(src) { + return 'var(--isp-accent-' + (GLYPH_FOR[src] || 'other') + ')'; +} + +/* + * cron 表达式 → 人话(分 时 日 月 周)。 + * 只翻译最常见、能一眼看懂的形状;认不出来的原样返回,宁可显示原文也不猜错。 + * 17 4 * * * → 每天 04:17 + * 30 3 * * 1 → 每周一 03:30 + * 5 2 1 * * → 每月 1 日 02:05 + */ +var DOW_NAMES = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + +function humanCron(expr) { + var f = String(expr || '').trim().split(/\s+/); + + if (f.length !== 5) + return ''; + + function num(s) { + return /^\d+$/.test(s) ? +s : null; + } + + var min = num(f[0]); + var hour = num(f[1]); + if (min === null || hour === null || min > 59 || hour > 23) + return ''; + + var hhmm = (hour < 10 ? '0' : '') + hour + ':' + (min < 10 ? '0' : '') + min; + var dom = num(f[2]); + var dow = num(f[4]); + + if (f[2] === '*' && f[3] === '*' && f[4] === '*') + return _('每天 ') + hhmm; + + if (f[2] === '*' && f[3] === '*' && dow !== null && dow <= 7) + return _('每') + DOW_NAMES[dow % 7] + ' ' + hhmm; + + if (f[4] === '*' && f[3] === '*' && f[2] !== '*' && dom !== null) + return _('每月 ') + dom + _(' 日 ') + hhmm; + + return ''; +} + +/* mwan3 的线路状态是英文枚举,界面上统一显示中文;原文留在 title 里备查 */ +function stateText(state) { + if (state === 'online') + return _('在线'); + if (state === 'offline') + return _('离线'); + return _('未知'); +} + +/* ---------------------------------------------------------------- 数据解析 */ + +function parseJson(res) { + try { + return JSON.parse(((res && res.stdout) || '').trim() || 'null'); + } catch (e) { + return null; + } +} + +function policyMembers(health, policy) { + var list = (health && health.policies) || []; + for (var i = 0; i < list.length; i++) + if (list[i].name === policy) + return list[i].members || []; + return []; +} + +/* 策略里出现过的线路,按出现顺序编号:第一条 = 左,第二条 = 右 */ +function lineOrder(health) { + var order = []; + ((health && health.policies) || []).forEach(function(p) { + (p.members || []).forEach(function(m) { + if (order.indexOf(m.iface) < 0) + order.push(m.iface); + }); + }); + return order; +} + +function activeLines(health) { + var order = lineOrder(health); + var used = {}; + ((health && health.policies) || []).forEach(function(p) { + (p.members || []).forEach(function(m) { + if (+m.pct > 0) + used[m.iface] = true; + }); + }); + var out = order.filter(function(iface) { return used[iface]; }); + return out.length ? out : order; +} + +function lineInfo(health, iface) { + var lines = (health && health.lines) || []; + for (var i = 0; i < lines.length; i++) + if (lines[i].iface === iface) + return lines[i]; + return null; +} + +function lineShares(health, iface) { + var out = []; + ((health && health.policies) || []).forEach(function(p) { + (p.members || []).forEach(function(m) { + if (m.iface === iface && +m.pct > 0) + out.push({ policy: p.name, pct: +m.pct }); + }); + }); + return out; +} + +/* ---------------------------------------------------------------- 视图 */ + +return view.extend({ + exec: function(args) { + return fs.exec(CMD, args).then(function(res) { + if (res && res.code) { + var out = ((res.stdout || '') + (res.stderr || '')).trim(); + throw new Error('isp-split ' + args.join(' ') + + ' 失败(退出码 ' + res.code + ')' + (out ? ':' + out : '')); + } + return res; + }); + }, + + /* 只读 JSON 调用:失败不抛出,交给页面显示一行告警 */ + readJson: function(args) { + return fs.exec(CMD, args).then(function(res) { + var data = parseJson(res); + if (res && res.code && !data) + throw new Error((((res.stdout || '') + (res.stderr || '')).trim()) || + ('退出码 ' + res.code)); + return data; + }); + }, + + setOut: function(text) { + var out = document.getElementById('isp-out'); + if (out) { + out.textContent = text; + out.scrollTop = 0; + } + }, + + run: function(args, label, confirmText) { + var self = this; + + var doRun = function() { + self.setOut('正在执行:isp-split ' + args.join(' ') + ' …'); + return self.exec(args).then(function(res) { + self.setOut(((res.stdout || '') + (res.stderr || '')) || '(无输出)'); + ui.addNotification(null, E('p', {}, [label + ' 已完成']), 'info'); + return self.refresh(); + }).catch(function(err) { + self.setOut('执行失败:' + String(err)); + ui.addNotification(null, E('p', {}, ['执行失败:' + String(err)]), 'danger'); + }); + }; + + if (!confirmText) + return doRun(); + + return ui.showModal(_('确认'), [ + E('p', {}, [confirmText]), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'btn', 'click': ui.hideModal }, [_('取消')]), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-negative', + 'click': function() { ui.hideModal(); doRun(); } + }, [_('确认执行')]) + ]) + ]); + }, + + refresh: function() { + var self = this; + + /* 上一轮还没回来就不再叠一次取数(10s 一跳,网络慢时很容易叠) */ + if (this._inflight) + return this._inflight; + + this.busy = true; + this.paintPill(); + + var done = function() { + self._inflight = null; + self.busy = false; + self.paintPill(); + }; + + this._inflight = Promise.all([ + this.readJson(['status', '--json']).catch(function(e) { + return { __error: String(e) }; + }), + this.readJson(['health', '--json']).catch(function(e) { + return { __error: String(e) }; + }) + ]).then(function(res) { + self.status = Array.isArray(res[0]) ? res[0] : []; + self.statusError = res[0] && res[0].__error; + self.health = (res[1] && !res[1].__error) ? res[1] : {}; + self.healthError = res[1] && res[1].__error; + self.paint(); + }).then(done, done); + + return this._inflight; + }, + + /* 状态徽标:正常「自动刷新 10s」/ 取数中「刷新中…」+ 转圈 / 暂停「已暂停」 */ + paintPill: function() { + var pill = this._pill; + if (!pill || !pill.parentNode) + return; + + var icon = this._pillIcon; + var text = this._pillText; + + while (icon.firstChild) + icon.removeChild(icon.firstChild); + + var cls = 'isp-pill'; + + if (this.paused) { + cls += ' is-paused'; + icon.appendChild(E('span', { 'class': 'isp-dot' })); + text.textContent = _('已暂停'); + } + else if (this.busy) { + cls += ' is-busy'; + var spin = svgIcon('refresh', 13); + spin.className = 'isp-ico isp-spin'; + icon.appendChild(spin); + text.textContent = _('刷新中…'); + } + else { + icon.appendChild(E('span', { 'class': 'isp-dot' })); + text.textContent = _('自动刷新 ' + REFRESH_SEC + 's'); + } + + pill.className = cls; + }, + + /* ---------------- 运营商卡片 ---------------- */ + + paintOps: function() { + var self = this; + var box = document.getElementById('isp-cards'); + if (!box) + return; + + while (box.firstChild) + box.removeChild(box.firstChild); + + var rows = (this.status || []).filter(function(r) { + return r && r.set !== '兜底'; + }); + + if (!rows.length) { + box.appendChild(E('div', { 'class': 'isp-empty' }, [ + this.statusError + ? _('读取状态失败:') + this.statusError + : _('没有启用的运营商。检查 /etc/isp-split.conf 里的 ISP_TABLE。') + ])); + return; + } + + var maxPackets = 0; + rows.forEach(function(r) { maxPackets = Math.max(maxPackets, +r.packets || 0); }); + + rows.forEach(function(r) { + var accent = accentOf(r.src); + var members = policyMembers(self.health, r.policy); + var order = lineOrder(self.health); + var active = members.filter(function(m) { return +m.pct > 0; }); + var arrows = '·'; + var routeText = _('策略无在线成员'); + + if (active.length > 1) { + arrows = '◀▶'; + routeText = active.map(function(m) { return m.iface; }).join(' + '); + } else if (active.length === 1) { + arrows = (order.indexOf(active[0].iface) <= 0) ? '◀' : '▶'; + routeText = active[0].iface + ' ' + active[0].pct + '%'; + } + + var packets = +r.packets || 0; + var pct = maxPackets > 0 + ? Math.max(2, Math.round(packets * 100 / maxPackets)) + : 0; + + box.appendChild(E('article', { + 'class': 'isp-card isp-card-op', + 'style': '--isp-accent: ' + accent + }, [ + E('div', { 'class': 'isp-card-head' }, [ + E('span', { 'class': 'isp-op-name' }, [ r.label || r.src ]), + E('span', { 'class': 'isp-op-ico' }, [ svgIcon(GLYPH_FOR[r.src] || 'other', 20) ]) + ]), + E('div', {}, [ + E('span', { 'class': 'isp-big' }, [ r.exists ? fmtNum(packets) : '—' ]), + E('span', { 'class': 'isp-unit' }, [ _('命中包') ]) + ]), + E('div', { 'class': 'isp-op-sub' }, [ + r.exists + ? _('流量 ') + fmtBytes(r.bytes) + ' · ' + _('条目 ') + fmtNum(r.elements) + : _('集合不存在(等待 wire/apply)') + ]), + E('div', { 'class': 'isp-route' }, [ + E('span', { 'class': 'isp-arrows' }, [ arrows ]), + E('span', {}, [ routeText ]), + E('span', { 'class': 'isp-tag' }, [ r.policy ]) + ]), + /* 零流量时不画进度条:2% 的最小宽度会缩成一个孤零零的色点, + 看起来像渲染坏了。改成一个「—」,标题里说明原因。 */ + packets > 0 + ? E('div', { 'class': 'isp-meter' }, [ + E('i', { 'style': 'width:' + pct + '%' }) + ]) + : E('div', { + 'class': 'isp-meter-empty', + 'title': r.exists ? _('暂无命中流量') : _('集合尚未生效') + }, [ '—' ]) + ])); + }); + }, + + /* ---------------- 双线健康 ---------------- */ + + paintLines: function() { + var box = document.getElementById('isp-lines'); + if (!box) + return; + + while (box.firstChild) + box.removeChild(box.firstChild); + + var health = this.health || {}; + var ifaces = activeLines(health); + + if (!ifaces.length) { + box.appendChild(E('div', { 'class': 'isp-empty' }, [ + _('读不到 mwan3 的线路状态(mwan3 未运行,或策略里没有成员)。') + ])); + return; + } + + var list = E('div', { 'class': 'isp-list' }); + ifaces.forEach(function(iface) { + var info = lineInfo(health, iface) || {}; + var state = info.state || 'unknown'; + var cls = 'isp-line' + (state === 'online' ? ' is-online' + : (state === 'offline' ? ' is-offline' : '')); + var tracks = (info.tracks || []).map(function(t) { + return t.ip + ':' + t.state; + }).join(' '); + + var shares = lineShares(health, iface).map(function(s) { + return s.policy + ' ' + s.pct + '%'; + }).join(' · '); + + /* 这一列只剩 1fr 的窄位,塞「online · tracking active · IP:up」必然被 + ellipsis 截成 "tracking activ…"。正文只留两个字,全文进 title。 */ + var detail = state + + (info.tracking ? ' · tracking ' + info.tracking : '') + + (tracks ? ' · ' + tracks : ''); + + list.appendChild(E('div', { 'class': cls }, [ + E('span', { 'class': 'isp-dot' }), + E('span', { 'class': 'isp-line-name' }, [ iface ]), + E('span', { + 'class': 'isp-line-track', + 'title': iface + ' · ' + detail + }, [ stateText(state) ]), + E('span', { 'class': 'isp-line-pct' }, [ shares || _('未参与策略') ]) + ])); + }); + box.appendChild(list); + }, + + /* ---------------- 兜底流量 ---------------- */ + + paintFallback: function() { + var box = document.getElementById('isp-fallback'); + if (!box) + return; + + while (box.firstChild) + box.removeChild(box.firstChild); + + var fb = null; + (this.status || []).forEach(function(r) { + if (r && r.set === '兜底') + fb = r; + }); + + if (!fb || !fb.exists) { + box.appendChild(E('div', { 'class': 'isp-empty' }, [ + _('兜底计数器不存在。执行「同步生效」或确认 COUNT_FALLBACK=1。') + ])); + return; + } + + box.appendChild(E('div', {}, [ + E('span', { 'class': 'isp-big' }, [ fmtNum(fb.packets) ]), + E('span', { 'class': 'isp-unit' }, [ _('连接(首包)') ]) + ])); + box.appendChild(E('div', { 'class': 'isp-op-sub' }, [ + _('流量 ') + fmtBytes(fb.bytes) + ' · ' + _('未命中任何运营商集合') + ])); + }, + + /* ---------------- 数据源健康度 ---------------- */ + + paintSources: function() { + var box = document.getElementById('isp-sources'); + if (!box) + return; + + while (box.firstChild) + box.removeChild(box.firstChild); + + var health = this.health || {}; + var srcs = health.sources || []; + + if (!srcs.length) { + box.appendChild(E('div', { 'class': 'isp-empty' }, [ + this.healthError + ? _('读取数据源状态失败:') + this.healthError + : _('没有数据源信息。') + ])); + return; + } + + var table = E('table', { 'class': 'isp-table' }, [ + E('thead', {}, [ + E('tr', {}, [ + E('th', {}, [_('源')]), + E('th', {}, [_('角色')]), + E('th', {}, [_('最后成功')]), + E('th', { 'class': 'num' }, [_('连续失败')]), + E('th', { 'class': 'num' }, [_('Jaccard')]), + E('th', {}, [_('状态')]) + ]) + ]) + ]); + var body = E('tbody', {}); + + srcs.forEach(function(s) { + var tag = 'isp-tag is-ok'; + var text = _('正常'); + if (s.status === 'disabled') { + tag = 'isp-tag'; + text = _('未启用校验'); + } else if (s.max_fail && s.fail_days >= s.max_fail) { + tag = 'isp-tag is-bad'; + text = _('拒绝更新') + ' ' + s.fail_days + '/' + s.max_fail; + } else if (s.fail_days > 0) { + tag = 'isp-tag is-warn'; + text = _('降级') + ' ' + s.fail_days + '/' + s.max_fail; + } + + body.appendChild(E('tr', {}, [ + E('td', {}, [ s.label || s.name ]), + E('td', {}, [ E('span', { 'class': 'isp-tag' }, [ s.role ]) ]), + E('td', {}, [ s.last_ok_text || '—' ]), + E('td', { 'class': 'num' }, [ String(s.fail_days || 0) ]), + E('td', { 'class': 'num' }, [ s.jaccard != null ? (s.jaccard + '%') : '—' ]), + E('td', {}, [ E('span', { 'class': tag }, [ text ]) ]) + ])); + }); + + table.appendChild(body); + box.appendChild(table); + }, + + /* ---------------- 数据时间 / cron / diff ---------------- */ + + paintTime: function() { + var box = document.getElementById('isp-time'); + if (!box) + return; + + while (box.firstChild) + box.removeChild(box.firstChild); + + var d = (this.health && this.health.data) || {}; + var diff = (this.health && this.health.diff) || {}; + + box.appendChild(E('div', { 'class': 'isp-time' }, [ + E('span', {}, [ + _('数据更新:'), + E('b', {}, [ d.updated && d.updated !== '-' ? d.updated : _('未知') ]) + ]), + E('span', {}, [ + _('下次更新:'), + E('b', {}, [ d.next_cron && d.next_cron !== '-' ? d.next_cron : _('未知') ]), + /* 裸 cron 表达式(17 4 * * *)对普通用户等于没写;翻成「每天 04:17」, + 原始表达式退到 title 里,排障时鼠标一悬停就能看到。 */ + d.cron + ? E('span', { + 'class': 'isp-tag', + 'title': _('cron 表达式:') + d.cron + }, [ humanCron(d.cron) || d.cron ]) + : '' + ]), + E('span', {}, [ + _('最近校验:'), + E('b', {}, [ + diff.min_jaccard != null + ? diff.min_jaccard + '%' + : ((this.health && this.health.verify_enabled) ? _('尚未运行') : _('未启用校验')) + ]) + ]) + ])); + }, + + paint: function() { + this.paintOps(); + this.paintLines(); + this.paintFallback(); + this.paintSources(); + this.paintTime(); + + var err = document.getElementById('isp-load-error'); + if (err) { + var msg = this.statusError + ? ('status: ' + this.statusError) + : (this.healthError ? ('health: ' + this.healthError) : ''); + err.textContent = msg; + err.style.display = msg ? '' : 'none'; + } + }, + + /* ---------------- 页面骨架 ---------------- */ + + render: function() { + var self = this; + this.status = []; + this.health = {}; + this.paused = false; + this.busy = false; + this._inflight = null; + + var pauseLabel = E('span', {}, [_('暂停刷新')]); + var pauseBtn = E('button', { + 'class': 'btn', + 'title': _('暂停后不再每 10 秒取值,可省一点 CPU'), + 'click': function() { + self.paused = !self.paused; + while (pauseBtn.firstChild) + pauseBtn.removeChild(pauseBtn.firstChild); + pauseBtn.appendChild(svgIcon(self.paused ? 'play' : 'pause', 16)); + pauseBtn.appendChild(E('span', {}, [ self.paused ? _('继续刷新') : _('暂停刷新') ])); + + self.paintPill(); + + if (!self.paused) + self.refresh(); + } + }, [ svgIcon('pause', 16), pauseLabel ]); + + this._pillIcon = E('span', { 'class': 'isp-pill-ico' }); + this._pillText = E('span', {}); + var statePill = this._pill = E('span', { 'class': 'isp-pill' }, [ + this._pillIcon, + this._pillText + ]); + this.paintPill(); + + var actions = E('div', { 'class': 'isp-actions' }, [ + E('button', { + 'class': 'cbi-button cbi-button-action', + 'click': function() { + self.run(['sync'], _('同步'), + _('将下载最新运营商数据、重写 mwan3 规则并刷入 nftables。这会改动分流配置,确认继续?')); + } + }, [ svgIcon('sync', 16), E('span', {}, [_('同步生效')]) ]), + E('button', { + 'class': 'cbi-button', + 'click': function() { self.run(['refresh'], _('更新数据')); } + }, [ svgIcon('download', 16), E('span', {}, [_('更新数据')]) ]), + E('button', { + 'class': 'cbi-button cbi-button-neutral', + 'click': function() { self.run(['verify'], _('核对')); } + }, [ svgIcon('check', 16), E('span', {}, [_('核对规则')]) ]), + E('button', { + 'class': 'cbi-button', + 'click': function() { self.refresh(); } + }, [ svgIcon('refresh', 16), E('span', {}, [_('刷新')]) ]) + ]); + + var container = E('div', { 'class': 'isp-app' }, [ + E('link', { + 'rel': 'stylesheet', + 'href': L.resource('isp-split/isp-split.css') + }), + + E('div', { 'class': 'isp-hero' }, [ + E('h2', {}, [ _('运营商分流') ]), + E('div', { 'class': 'isp-sub' }, [ + _('按目标 IP 归属选择出口线路;只影响直连流量,代理流量由 OpenClash 自行接管。') + ]), + E('div', { 'class': 'isp-toolbar' }, [ statePill, pauseBtn ]) + ]), + + E('div', { 'id': 'isp-load-error', 'class': 'isp-err', 'style': 'display:none' }), + E('div', { 'id': 'isp-cards', 'class': 'isp-cards' }), + + E('div', { 'class': 'isp-grid-2' }, [ + E('section', { 'class': 'isp-card' }, [ + E('h3', {}, [ _('双线健康') ]), + E('div', { 'id': 'isp-lines' }) + ]), + E('section', { 'class': 'isp-card' }, [ + E('h3', {}, [ _('兜底流量') ]), + E('div', { 'id': 'isp-fallback' }) + ]) + ]), + + E('div', { 'class': 'isp-grid-2' }, [ + E('section', { 'class': 'isp-card' }, [ + E('h3', {}, [ _('数据源健康度') ]), + E('div', { 'id': 'isp-sources' }) + ]), + E('section', { 'class': 'isp-card' }, [ + E('h3', {}, [ _('数据时间') ]), + E('div', { 'id': 'isp-time' }) + ]) + ]), + + actions, + + E('pre', { 'id': 'isp-out', 'class': 'isp-out' }, [ _('命令输出会显示在这里') ]) + ]); + + this.refresh(); + + /* 自带定时器替代 LuCI 的 poll(见文件头注释):暂停即停,页面被换走即收摊。 */ + this.timer = window.setInterval(function() { + if (!document.getElementById('isp-time')) { + window.clearInterval(self.timer); + self.timer = null; + return; + } + if (self.paused || self.busy) + return; + self.refresh(); + }, REFRESH_SEC * 1000); + + return container; + }, + + handleSave: null, + handleSaveApply: null, + handleReset: null +}); diff --git a/luci-app-isp-split/root/usr/share/luci/menu.d/luci-app-isp-split.json b/luci-app-isp-split/root/usr/share/luci/menu.d/luci-app-isp-split.json new file mode 100644 index 000000000..aa84d4037 --- /dev/null +++ b/luci-app-isp-split/root/usr/share/luci/menu.d/luci-app-isp-split.json @@ -0,0 +1,29 @@ +{ + "admin/network/isp-split": { + "title": "运营商分流", + "order": 62, + "action": { + "type": "firstchild" + }, + "depends": { + "acl": [ "luci-app-isp-split" ], + "uci": { "mwan3": true } + } + }, + "admin/network/isp-split/overview": { + "title": "状态", + "order": 1, + "action": { + "type": "view", + "path": "isp-split/overview" + } + }, + "admin/network/isp-split/config": { + "title": "配置", + "order": 2, + "action": { + "type": "view", + "path": "isp-split/config" + } + } +} diff --git a/luci-app-isp-split/root/usr/share/rpcd/acl.d/luci-app-isp-split.json b/luci-app-isp-split/root/usr/share/rpcd/acl.d/luci-app-isp-split.json new file mode 100644 index 000000000..6c298b412 --- /dev/null +++ b/luci-app-isp-split/root/usr/share/rpcd/acl.d/luci-app-isp-split.json @@ -0,0 +1,23 @@ +{ + "luci-app-isp-split": { + "description": "Grant access to ISP split status and control", + "read": { + "ubus": { + "file": [ "read", "stat" ] + }, + "file": { + "/etc/isp-split.conf": [ "read" ], + "/etc/isp-split": [ "read" ], + "/etc/isp-split/*": [ "read" ] + } + }, + "write": { + "ubus": { + "file": [ "exec" ] + }, + "file": { + "/usr/sbin/isp-split": [ "exec" ] + } + } + } +}