diff --git a/isp-split/Makefile b/isp-split/Makefile index a3c052e24..67b94f467 100644 --- a/isp-split/Makefile +++ b/isp-split/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=isp-split -PKG_VERSION:=0.3.0 -PKG_RELEASE:=4 +PKG_VERSION:=0.3.2 +PKG_RELEASE:=5 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE PKG_MAINTAINER:=jihebube diff --git a/isp-split/files/etc/init.d/isp-split b/isp-split/files/etc/init.d/isp-split index 6f75e1351..ed12f625e 100644 --- a/isp-split/files/etc/init.d/isp-split +++ b/isp-split/files/etc/init.d/isp-split @@ -7,6 +7,11 @@ STOP=10 start() { ( + # A-14:体检/状态缓存属主为 root,目录 700、文件 600。开机路径 + # 只做幂等收紧,不主动创建敏感文件。 + umask 077 + mkdir -p /etc/isp-split/state 2>/dev/null || true + chmod 700 /etc/isp-split/state 2>/dev/null || true i=0 while [ "$i" -lt 60 ]; do if nft list table inet mwan3 >/dev/null 2>&1; then diff --git a/isp-split/files/etc/isp-split.conf b/isp-split/files/etc/isp-split.conf index e43c1d044..bc2e1b22d 100644 --- a/isp-split/files/etc/isp-split.conf +++ b/isp-split/files/etc/isp-split.conf @@ -187,9 +187,9 @@ LINE_CHECK_ENABLE=1 # 归属地隐私开关:1=允许向第三方查询出口 IP 归属地;0=只关闭归属地查询 # # 开启后,体检会把**每条 WAN 的出口公网 IP**发送给第三方(默认百度, -# 失败时 ip-api.com)查询归属地。只发送出口 IP,不发送内网地址、设备名、 -# MAC、流量内容。ip-api 使用 HTTP,传输不加密;介意时请设为 0,或把 -# LINE_CHECK_GEO_PROVIDER 固定为 baidu。 +# 默认只走百度 HTTPS)查询归属地。只发送出口 IP,不发送内网地址、设备名、 +# MAC、流量内容。ip-api 是显式选项且使用 HTTP 明文,不是默认兜底;介意时 +# 请设为 0,或把 LINE_CHECK_GEO_PROVIDER 固定为 baidu。 # 关闭后不发起任何归属地请求,卡片显示「归属地:未查询(隐私开关关闭)」。 # 省/市不可知,但仍可用本地 @isp_* 集合判断运营商大类,不需要外发。 # 注意:此开关只关归属地;出口 IP 回显、STUN 与延迟探测仍会外发。 @@ -197,7 +197,7 @@ LINE_CHECK_ENABLE=1 LINE_CHECK_GEO=1 # 归属地服务商:auto | baidu | ip-api | off -# auto 先百度(HTTPS、国内可达),失败转 ip-api(HTTP) +# auto 只走百度(HTTPS、国内可达);ip-api 不再自动兜底 # baidu 只用百度;失败显示「归属地未知」,不会自动转 HTTP 源 # ip-api 只用 ip-api(HTTP,明文) # off 等同 LINE_CHECK_GEO=0 @@ -244,7 +244,10 @@ stun.cloudflare.com:3478 " # 出口 IP 回显服务(逗号分隔,按顺序尝试)。只用于拿公网出口 IP。 -LINE_CHECK_ECHO_URLS="https://api.ipify.org,https://ipv4.icanhazip.com" +# F-23:默认回显源改为国内可达的 HTTPS 服务,按顺序尝试。允许返回裸 IP +# 或含 IP 的文本(如 ipip.net 的“当前 IP:x.x.x.x”),取第一个合法 IPv4。 +# 不要放境外 api.ipify.org / icanhazip.com:国内运营商网络经常 RST/超时。 +LINE_CHECK_ECHO_URLS="https://ip.3322.net,https://myip.ipip.net,https://ddns.oray.com/checkip" # 体检请求的 User-Agent。部分运营商站点对默认 curl UA 返回 412/418, # 必须带浏览器 UA;不要改成空值。 diff --git a/isp-split/files/etc/uci-defaults/99-isp-split b/isp-split/files/etc/uci-defaults/99-isp-split index 70c3426d0..ea2185ebf 100644 --- a/isp-split/files/etc/uci-defaults/99-isp-split +++ b/isp-split/files/etc/uci-defaults/99-isp-split @@ -9,6 +9,10 @@ /etc/init.d/isp-split enable >/dev/null 2>&1 +# A-14:state 首次创建就 700;旧版本留下的 755 也在这里纠正。 +mkdir -p /etc/isp-split/state 2>/dev/null || true +chmod 700 /etc/isp-split/state 2>/dev/null || true + # 启用每日更新(只改数据,不重写 mwan3 配置) if [ -x /etc/init.d/cron ]; then mkdir -p /etc/crontabs diff --git a/isp-split/files/usr/lib/isp-split/teardown.sh b/isp-split/files/usr/lib/isp-split/teardown.sh index d3a807948..118e3ec63 100644 --- a/isp-split/files/usr/lib/isp-split/teardown.sh +++ b/isp-split/files/usr/lib/isp-split/teardown.sh @@ -24,6 +24,9 @@ # sh teardown.sh --list # 打印清单(selftest 与 postrm 漂移断言用),不动作 # 选项: # --keep-conf-bak # 保留 /etc/isp-split.conf.bak.*(默认删除并逐条打印) +# --i-know-what-im-doing # 显式确认非默认 BASE(默认只允许 /etc/isp-split +# # 或 ISP_SPLIT_DESTDIR 下的沙箱路径)。系统目录 +# # (/、/etc、/usr、/var、/bin、/sbin、/lib…)永远拒绝 # # 为什么默认删 .conf.bak.*:它们是安装脚本每次覆盖安装都会新生成的副本, # 不是用户手写的备份。留着等于"卸载"留下一批与工具同名同前缀的孤儿文件, @@ -54,6 +57,12 @@ case "$DESTDIR" in esac BASE=${ISP_SPLIT_BASE:-$DESTDIR/etc/isp-split} +# 归一化尾部斜杠(含多个):`/etc//` 与 `/etc` 必须落到同一条拒绝分支; +# 根路径保持 `/`,否则空串会绕过下面的空值检查。 +if [ "$BASE" != "/" ]; then + BASE=$(printf '%s' "$BASE" | sed 's#/*$##') + [ -n "$BASE" ] || BASE=/ +fi CONF=${ISP_SPLIT_CONF:-$DESTDIR/etc/isp-split.conf} CRONTAB=${ISP_SPLIT_CRONTAB:-$DESTDIR/etc/crontabs/root} LIB=${ISP_SPLIT_LIB:-$DESTDIR/usr/lib/isp-split} @@ -69,9 +78,56 @@ CRON_SERVICE=${CRON_SERVICE:-$DESTDIR/etc/init.d/cron} RPCD_SERVICE=${RPCD_SERVICE:-$DESTDIR/etc/init.d/rpcd} KEEP_CONF_BAK=0 +IWKD=0 SCOPE="" say() { printf '[teardown] %s\n' "$*"; } + +# A-10:BASE 是 `rm -rf "$BASE"` 的唯一根,必须在任何删除之前过白名单。 +# 真实事故:ISP_SPLIT_BASE=/etc 时,旧 cmd_files 直接 rm -rf /etc, +# 而固定清单还会按 DESTDIR 删真实安装文件(附录 C)。 +# 规则: +# 1) 永远拒绝空、/、相对路径、含 ..、以及系统目录本身; +# 2) 真实根(DESTDIR 为空)只接受默认 /etc/isp-split,其它路径必须显式 +# --i-know-what-im-doing; +# 3) 沙箱(DESTDIR 非空)只接受 DESTDIR 子树(防止演练误删宿主 /etc)。 +validate_base() { + case "$BASE" in + "" | "/" | "." | ".." | *..*) + echo "ISP_SPLIT_BASE 非法: '$BASE'(拒绝空、/、相对路径与 ..)" >&2 + return 1 + ;; + /*) ;; + *) + echo "ISP_SPLIT_BASE 必须是绝对路径: '$BASE'" >&2 + return 1 + ;; + esac + case "$BASE" in + /etc | /usr | /var | /bin | /sbin | /lib | /lib64 | /boot | /dev | /proc | /sys | /www | /root | /home | /tmp | /mnt | /opt) + echo "ISP_SPLIT_BASE 指向系统目录,拒绝执行: '$BASE'" >&2 + return 1 + ;; + esac + + # 沙箱:BASE 必须在 DESTDIR 里面;否则演练会删到宿主路径。 + if [ -n "$DESTDIR" ]; then + case "$BASE" in + "$DESTDIR"/*) return 0 ;; + *) + echo "ISP_SPLIT_BASE 必须在 ISP_SPLIT_DESTDIR ($DESTDIR) 子树内: '$BASE'" >&2 + return 1 + ;; + esac + fi + + # 真实根:默认路径直接放行;其它路径必须显式确认。 + [ "$BASE" = "/etc/isp-split" ] && return 0 + [ "$IWKD" = "1" ] && return 0 + echo "ISP_SPLIT_BASE 不是默认的 /etc/isp-split: '$BASE'" >&2 + echo " 如确需操作该目录,请加 --i-know-what-im-doing;系统目录永远不会被接受。" >&2 + return 1 +} have() { command -v "$1" >/dev/null 2>&1; } # 只有真实根(DESTDIR 为空)才动服务:沙箱演练里重启服务既没意义,也会误伤宿主。 @@ -86,13 +142,13 @@ svc_restart() { # 文件/目录清单(相对 DESTDIR)。--list 与 files 段共用,杜绝两处漂移。 files_list() { - cat <<'EOF' + cat </dev/null done printf '%s\n' "${_frag_uci:-}" "${_frag_state:-}" \ - "$DNSMASQ_DEFAULT_DIR" "$DNSDIR" + "$DNSMASQ_DEFAULT_DIR" + _dnsdir_ok "$DNSDIR" && printf '%s\n' "$DNSDIR" } | tr ' ' '\n' | sed -e 's/,.*$//' -e 's/^"//' -e 's/"$//' -e 's:/*$::' | awk 'NF && !seen[$0]++' @@ -272,6 +355,7 @@ cmd_runtime() { # 2) 清片段。探测来源优先,但不依赖探测是否成功——目录列表是并集。 _n=0 for _d in $(_frag_dirs); do + _frag_delete_ok "$_d" || continue [ -f "$_d/isp-split.conf" ] || continue rm -f "$_d/isp-split.conf" 2>/dev/null && _n=$((_n + 1)) done @@ -313,6 +397,19 @@ cmd_files() { _err=0 while read -r _k _p; do case "$_k" in + base) + # A-10:数据目录不再写死 etc/isp-split,而是跟随 BASE。 + # 默认路径与自定义路径走同一条删除分支,避免“清单删默认、 + # BASE 再删一次”的双路径。 + [ -n "$BASE" ] || continue + [ -e "$BASE" ] || [ -L "$BASE" ] || continue + if rm -rf "$BASE"; then + _del=$((_del + 1)) + else + say "错误:删不掉 $BASE" + _err=1 + fi + ;; path) [ -e "$DESTDIR/$_p" ] || [ -L "$DESTDIR/$_p" ] || continue if rm -f "$DESTDIR/$_p"; then @@ -358,17 +455,9 @@ cmd_files() { $(files_list) EOF - # BASE 可被 ISP_SPLIT_BASE 改名(自检沙箱),清单里的是默认路径,这里补上 - # 真实值。ISP_SPLIT_LIB 故意不参与删除:它只用于离线自检,可能指到源码树, - # 删它会把仓库自己的 usr/lib/isp-split 抹掉。 - if [ "$BASE" != "$DESTDIR/etc/isp-split" ] && [ -d "$BASE" ]; then - if rm -rf "$BASE"; then - _del=$((_del + 1)) - else - say "错误:删不掉 $BASE" - _err=1 - fi - fi + # ISP_SPLIT_LIB 故意不参与删除:它只用于离线自检,可能指到源码树, + # 删它会把仓库自己的 usr/lib/isp-split 抹掉。数据目录已在上面的 + # base 分支随 BASE 推导删除(A-10)。 if [ "$_del" -gt 0 ]; then say "已删除 $_del 项文件/目录" @@ -387,6 +476,7 @@ EOF while [ $# -gt 0 ]; do case "$1" in --keep-conf-bak) KEEP_CONF_BAK=1 ;; + --i-know-what-im-doing) IWKD=1 ;; --list) SCOPE=list ;; all | mwan3 | runtime | files) SCOPE=$1 ;; -h | --help) @@ -402,10 +492,13 @@ while [ $# -gt 0 ]; do done if [ -z "$SCOPE" ]; then - echo "用法: sh teardown.sh all|mwan3|runtime|files|--list [--keep-conf-bak]" >&2 + echo "用法: sh teardown.sh all|mwan3|runtime|files|--list [--keep-conf-bak] [--i-know-what-im-doing]" >&2 exit 2 fi +# A-10:任何实际动作(含 --list 之外的删除)之前先验证 BASE。 +validate_base || exit 1 + case "$SCOPE" in list) print_list ;; mwan3) cmd_mwan3 ;; diff --git a/isp-split/files/usr/sbin/isp-split b/isp-split/files/usr/sbin/isp-split index 0b22b44f4..8293222c8 100644 --- a/isp-split/files/usr/sbin/isp-split +++ b/isp-split/files/usr/sbin/isp-split @@ -15,7 +15,7 @@ # install 注册开机自启与每日更新 # uninstall 卸载所有改动 set -u -ISP_VERSION="0.3.0" +ISP_VERSION="0.3.2" # 配置表里的注释行(# 开头,允许前导空白)必须在按空白展开之前整行删掉 # (B-1)。否则 `for row in $ISP_TABLE` 会把 "#" 与后面的文字拆成两个 @@ -26,6 +26,12 @@ _strip_comments() { CONF=${ISP_SPLIT_CONF:-/etc/isp-split.conf} BASE=${ISP_SPLIT_BASE:-/etc/isp-split} +# 归一化尾部斜杠(含多个):`/etc//` 与 `/etc` 必须落到同一条拒绝分支; +# 根路径保持 `/`,否则会变成空串绕过下面的空值检查。 +if [ "$BASE" != "/" ]; then + BASE=$(printf '%s' "$BASE" | sed 's#/*$##') + [ -n "$BASE" ] || BASE=/ +fi # BASE 会被 cp/写文件,也会在 uninstall 里被 `rm -rf`。它可以从环境变量 # 覆盖(离线自检用),所以先挡住危险值:空、根、相对路径、含 ".."。 case "$BASE" in @@ -39,6 +45,16 @@ case "$BASE" in exit 1 ;; esac +# A-10:BASE 的下一站是 `rm -rf "$BASE"`(uninstall)。只挡空/根/相对/.. +# 还不够——/etc、/usr、/var 都是合法绝对路径,却能直接抹掉系统目录。 +# 这里与 teardown.sh 的 validate_base 同一口径,先在最外层拒绝;uninstall +# 不会再有机会往 /etc/work 里写临时文件。 +case "$BASE" in +/ | /etc | /usr | /var | /bin | /sbin | /lib | /lib64 | /boot | /dev | /proc | /sys | /www | /root | /home | /tmp | /mnt | /opt) + echo "ISP_SPLIT_BASE 指向系统目录,拒绝执行: '$BASE'" >&2 + exit 1 + ;; +esac DATA=$BASE/data WORK=$BASE/work LIB=${ISP_SPLIT_LIB:-/usr/lib/isp-split} @@ -82,6 +98,9 @@ PROBE_BYTES=${PROBE_BYTES:-33554432} # 镜像被劫持或换源换错时,一个超大响应会直接写满 overlay(/etc 就在 # overlay 上),路由器连重启都可能起不来。 MAX_RAW_BYTES=${ISP_SPLIT_MAX_RAW_BYTES:-4194304} +# 下载暂存目录(A-01):先落 tmpfs,逐文件校验通过后再 mv 回 overlay 的 +# $WORK/raw.new。允许覆盖仅用于离线自检;生产固定 /tmp。 +FETCH_STAGE_ROOT=${ISP_SPLIT_FETCH_TMP:-/tmp} PROBE_TIME=${PROBE_TIME:-25} # mwan3 reload 是异步的:命令返回时 nft 链可能还在重建。 @@ -367,14 +386,47 @@ foreach_enabled() { done } +# A-01:流式下载,最多只让 MAX_RAW_BYTES+1 字节落盘。 +# 不能用 `cmd > file` 再 wc:那样大响应会先写满 overlay/tmpfs。 +# 子 shell 里把下载器自己的 rc 写进 sidecar,再让 head 提前关管道; +# 体积超限优先按“超限”处理(此时下载器通常因 SIGPIPE 非 0,属预期)。 +_fetch_stream() { + _fs_out=$1 + shift + _fs_rcf=$_fs_out.rc.$$ + rm -f "$_fs_rcf" + { "$@"; echo $? >"$_fs_rcf"; } | + head -c $((MAX_RAW_BYTES + 1)) >"$_fs_out" + _fs_rc=$(cat "$_fs_rcf" 2>/dev/null) + rm -f "$_fs_rcf" + case "${_fs_rc:-}" in '' | *[!0-9]*) _fs_rc=1 ;; esac + _fs_sz=$(wc -c <"$_fs_out" 2>/dev/null | tr -d ' \t') + case "${_fs_sz:-}" in '' | *[!0-9]*) _fs_sz=0 ;; esac + if [ "$_fs_sz" -gt "$MAX_RAW_BYTES" ]; then + log "源下载超过上限 $MAX_RAW_BYTES 字节(已流式截断到 $((MAX_RAW_BYTES + 1)) 字节)" + return 1 + fi + [ "$_fs_rc" -eq 0 ] || return 1 + return 0 +} + fetch_one() { _out=$2 + case "$MAX_RAW_BYTES" in + '' | *[!0-9]* | 0) + log "错误:MAX_RAW_BYTES 必须是正整数(当前: '$MAX_RAW_BYTES')" + return 1 + ;; + esac if command -v uclient-fetch >/dev/null 2>&1; then - uclient-fetch -q -T 30 -O "$_out" "$1" + # uclient-fetch 没有 --max-filesize,统一走 head 截断。 + _fetch_stream "$_out" uclient-fetch -q -T 30 -O - "$1" elif command -v curl >/dev/null 2>&1; then - curl -fsS -m 30 -o "$_out" "$1" + # --max-filesize 让有 Content-Length 的响应在下载前就被拒; + # 无长度/分块响应仍由 _fetch_stream 的 head 兜底。 + _fetch_stream "$_out" curl -fsS -m 30 --max-filesize "$MAX_RAW_BYTES" -o - "$1" elif command -v wget >/dev/null 2>&1; then - wget -q -T 30 -O "$_out" "$1" + _fetch_stream "$_out" wget -q -T 30 -O - "$1" else die "没有可用的下载工具(uclient-fetch / curl / wget)" fi @@ -553,6 +605,10 @@ _state_put() { { rm -f "$_sp_tmp"; return 1; } fi mv -f "$_sp_tmp" "$SOURCE_STATE" 2>/dev/null || { rm -f "$_sp_tmp"; return 1; } + # A-14:state 里是数据源健康度/下载时间,不含口令但也没必要让本机低权 + # 用户读;统一 700/600(升级前已存在的旧权限也在这里被纠正)。 + chmod 700 "$STATE_DIR" 2>/dev/null || true + chmod 600 "$SOURCE_STATE" 2>/dev/null || true return 0 } @@ -1019,6 +1075,37 @@ _mwan3_lines() { ' } +# A-01:下载前空间预检。overlay($BASE)与 tmpfs 暂存各留 2×MAX_RAW_BYTES, +# 避免"下载到一半才发现写不下"。df 解析失败时只告警不阻断(老 busybox +# 的 df 输出列可能不同),真正的体积上限仍由 _fetch_stream 保证。 +_fetch_avail_kb() { + _fak=$(df -k -P "$1" 2>/dev/null | awk 'NR == 2 { print $4; exit }') + case "${_fak:-}" in '' | *[!0-9]*) _fak="" ;; esac + printf '%s' "$_fak" +} + +_fetch_space_precheck() { + _fsp_need=$(( (MAX_RAW_BYTES * 2 + 1023) / 1024 )) + for _fsp_pair in "overlay:$BASE" "tmp:$FETCH_STAGE_ROOT"; do + _fsp_label=${_fsp_pair%%:*} + _fsp_dir=${_fsp_pair#*:} + mkdir -p "$_fsp_dir" 2>/dev/null || { + log "错误:无法创建 $_fsp_label 目录 $_fsp_dir" + return 1 + } + _fsp_kb=$(_fetch_avail_kb "$_fsp_dir") + if [ -z "$_fsp_kb" ]; then + log "警告:无法读取 $_fsp_label($_fsp_dir)剩余空间,跳过空间预检" + continue + fi + if [ "$_fsp_kb" -lt "$_fsp_need" ]; then + log "错误:$_fsp_label($_fsp_dir)剩余 ${_fsp_kb}KB,小于 2×源上限 ${_fsp_need}KB,拒绝下载" + return 1 + fi + done + return 0 +} + _cmd_fetch_body() { mkdir -p "$WORK" || die "无法创建 $WORK" @@ -1039,15 +1126,31 @@ _cmd_fetch_body() { _force=${ISP_SPLIT_FORCE_FETCH:-0} + case "$MAX_RAW_BYTES" in + '' | *[!0-9]* | 0) + die "ISP_SPLIT_MAX_RAW_BYTES 必须是正整数(当前: '$MAX_RAW_BYTES')" + ;; + esac + # A-01:先查空间,再动任何暂存目录。 + _fetch_space_precheck || + die "磁盘剩余空间不足(overlay/tmpfs 需各 ≥ 2×$MAX_RAW_BYTES 字节),本次未下载" + rm -rf "$WORK/raw.new" "$WORK/out.new" "$WORK/compose.new" mkdir -p "$WORK/raw.new" + # A-01:下载先落 tmpfs。每个源的全部文件通过体积/行数/badcidr 校验后, + # 才把该源目录 mv 进 $WORK/raw.new,overlay 不会在闸门前被灌满。 + FETCH_STAGE=$(mktemp -d "$FETCH_STAGE_ROOT/isp-split-fetch.XXXXXX" 2>/dev/null) || + die "无法创建下载暂存目录($FETCH_STAGE_ROOT)" + # ---- 1) 主源:失败即冻结(保留旧数据),绝不自动换源 ---- log "主源: $_primary($_primary_base)" - if ! _fetch_source "$_primary" "$_primary_base" "$WORK/raw.new/$_primary" primary; then + if ! _fetch_source "$_primary" "$_primary_base" "$FETCH_STAGE/$_primary" primary; then _state_fail_plus "$_primary" primary "主源下载失败" >/dev/null die "主源 $_primary 下载失败,保留现有数据不动(冻结,cron 会在下次再试)" fi + mv "$FETCH_STAGE/$_primary" "$WORK/raw.new/$_primary" || + die "无法把主源暂存目录移入 $WORK/raw.new,现有数据未改动" # 主源下载成功就先记一笔:后面的 diff/熔断即使拒绝更新,也不该让 # status 把主源显示成"从未成功"。 _state_put "$_primary" primary "$(date +%s)" 0 \ @@ -1068,7 +1171,9 @@ _cmd_fetch_body() { fi log "校验源: $_s_name($_s_base)" - if _fetch_source "$_s_name" "$_s_base" "$WORK/raw.new/$_s_name" "$_s_role"; then + if _fetch_source "$_s_name" "$_s_base" "$FETCH_STAGE/$_s_name" "$_s_role"; then + mv "$FETCH_STAGE/$_s_name" "$WORK/raw.new/$_s_name" || + die "无法把校验源暂存目录移入 $WORK/raw.new,现有数据未改动" _verify_ok="$_verify_ok $_s_name" _state_put "$_s_name" "$_s_role" "$(date +%s)" 0 \ "$(_state_get "$_s_name" jaccard)" "校验源下载正常(待 diff)" @@ -1293,13 +1398,26 @@ _cmd_fetch_body() { log "数据已更新:$(wc -l "$DATA"/*.txt | tail -1)" } +# A-01:暂存目录由 EXIT/INT/TERM 兜底清理(die 也会走到 EXIT trap)。 +FETCH_STAGE="" +_fetch_stage_cleanup() { + [ -n "${FETCH_STAGE:-}" ] || return 0 + rm -rf "$FETCH_STAGE" 2>/dev/null || true + FETCH_STAGE="" +} + # F-28:fetch 的固定 work 路径(raw.new/out.new/compose.new/data.new) # 必须串行使用。锁在整段 fetch 期间持有;失败路径由 die/EXIT trap 释放。 cmd_fetch() { _op_lock_acquire || die "另一个 isp-split 操作正在运行(锁: $OP_LOCK),本次未做任何改动;稍后重试" + trap '_fetch_stage_cleanup; _op_lock_cleanup' EXIT + trap '_fetch_stage_cleanup; _op_lock_cleanup; exit 130' INT TERM _cmd_fetch_body _rc=$? + _fetch_stage_cleanup _op_lock_release + trap '_op_lock_cleanup' EXIT + trap '_op_lock_cleanup; exit 130' INT TERM return $_rc } @@ -2322,7 +2440,17 @@ _prune_orphan_policies() { # F-14:卸载时的 policy/member 清理同样在 teardown.sh,见上。 # init:发现接口 -> 为 ISP_TABLE 的每个策略绑定成员 -> 跑一次 plan 摘要 +# A-02:与 fetch/wire/apply/sync 共用同一把操作锁,避免与 cmd_wire 的 +# uci -q revert mwan3 互踩全局 /tmp/.uci 暂存区。 cmd_init() { + _op_lock_acquire || die "另一个 isp-split 操作正在运行(锁: $OP_LOCK),本次未做任何改动;稍后重试" + _cmd_init_body "$@" + _rc=$? + _op_lock_release + return $_rc +} + +_cmd_init_body() { INIT_ASSUME=0 INIT_BINDS="" while [ $# -gt 0 ]; do @@ -3093,14 +3221,20 @@ cmd_status_json() { cmd_health_json() { _source_check_all _state_prune + # A-08:数字位只接受纯数字,非法一律 0/null。此前 + # ISP_SOURCE_MAX_FAIL_DAYS='3,"injected":"yes"' 会直接拼进 JSON。 + _max_fail=${ISP_SOURCE_MAX_FAIL_DAYS:-0} + case "$_max_fail" in '' | *[!0-9]*) _max_fail=0 ;; esac printf '{"sources":[' _sfj=1 for _row in $(_source_rows); do _source_split "$_row" _last=$(_state_get "$_s_name" last_ok) + case "${_last:-}" in '' | *[!0-9]*) _last=0 ;; esac _fail=$(_state_get "$_s_name" fail) case "${_fail:-}" in '' | *[!0-9]*) _fail=0 ;; esac _jac=$(_state_get "$_s_name" jaccard) + case "${_jac:-}" in '' | *[!0-9]*) _jac_json=null ;; *) _jac_json=$_jac ;; esac _st="ok" _en=true if [ "$_s_role" != "primary" ] && [ "$ISP_VERIFY_ENABLE" != "1" ]; then @@ -3112,9 +3246,10 @@ cmd_health_json() { [ "$_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" + "$(_lc_json_esc "$_s_name")" "$(_lc_json_esc "$(_isp_label "$_s_name")")" \ + "$(_lc_json_esc "$_s_role")" "$_last" \ + "$(_lc_json_esc "$(_ts_text "$_last")")" "$_fail" "$_jac_json" \ + "$_max_fail" "$(_lc_json_esc "$_st")" "$_en" done printf ']' @@ -3179,11 +3314,13 @@ cmd_health_json() { _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 '"\\') + # A-08:cron 是字符串位,必须完整 JSON 转义(含 LF/CR/引号/反斜杠)。 + _cron_safe=$(_lc_json_esc "$ISP_CRON") _next=$(_cron_next_epoch "$ISP_CRON" 2>/dev/null) + case "${_next:-}" in '' | *[!0-9]*) _next=0 ;; esac 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}" + "$_stamp" "$(_lc_json_esc "$(_ts_text "$_stamp")")" "$_cron_safe" \ + "$(_lc_json_esc "$(_ts_text "$_next")")" "$_next" _dj="" _da=0 @@ -3191,9 +3328,10 @@ cmd_health_json() { _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 "${_dj:-}" in '' | *[!0-9]*) _dj=null ;; esac case "${_da:-}" in '' | *[!0-9]*) _da=0 ;; esac printf ',"diff":{"min_jaccard":%s,"at":%s,"at_text":"%s"}' \ - "${_dj:-null}" "$_da" "$(_ts_text "$_da")" + "$_dj" "$_da" "$(_lc_json_esc "$(_ts_text "$_da")")" printf '}\n' } @@ -3338,13 +3476,19 @@ cmd_status() { cmd_probe() { command -v curl >/dev/null 2>&1 || die "probe 需要 curl" + # A-04:PROBE_URLS 来自被 source 的配置,读取侧先逐个校验;curl 再一律 + # 用 --url 传参,确保以 - 开头的 token 不会被解释成选项(-K/--config)。 + for url in $PROBE_URLS; do + _url_read_ok "$url" || + die "PROBE_URLS 含非法项(必须是 http(s):// URL,且不能是 curl 选项): $url" + done 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) || { + -w '%{speed_download} %{time_connect}' --url "$url" 2>/dev/null) || { printf ' %-16s %12s\n' "$ifc" "失败" continue } @@ -3381,6 +3525,28 @@ COUNT_FALLBACK=${COUNT_FALLBACK:-1} COUNT_FALLBACK_SKIP_DNS=${COUNT_FALLBACK_SKIP_DNS:-1} DOMAINDIR=${DOMAIN_LIST_DIR:-${DOMAINDIR:-/etc/isp-split/domains}} +# A-05:DNSDIR 是旧版片段目录,既会从 UCI confdir 里被删,也会作为 +# `rm -f "$dir/isp-split.conf"` 的删除根。只允许工具自己的 $BASE 子树, +# 或 dnsmasq/OpenClash 的 /tmp/dnsmasq*.d 形态;/etc、/、.. 一律拒绝。 +_dnsdir_ok() { + _ddo=${1:-} + case "$_ddo" in "" | "/" | "." | ".." | *..*) return 1 ;; esac + case "$_ddo" in /*) ;; *) return 1 ;; esac + while [ "$_ddo" != "/" ] && [ "${_ddo%/}" != "$_ddo" ]; do + _ddo=${_ddo%/} + done + case "$_ddo" in + "$BASE"/*) return 0 ;; + /tmp/dnsmasq*.d | /tmp/*/dnsmasq*.d) return 0 ;; + esac + return 1 +} + +_dnsdir_check() { + _dnsdir_ok "$DNSDIR" || + die "DNSDIR 非法:'$DNSDIR'(只允许 $BASE 子树或 /tmp/dnsmasq*.d)" +} + # ---- dnsmasq confdir:探测"实际加载目录",绝不整体覆盖 ---- # 历史版本曾把 dhcp.@dnsmasq[0].confdir 直接改成 $DNSDIR,导致 # OpenClash/adblock 等放在 /tmp/dnsmasq.d 的片段全部失效。 @@ -3407,11 +3573,14 @@ _dns_confdir_get() { } # 去掉旧版本写入的 $DNSDIR,其余目录原样保留。 +# A-05:DNSDIR 未通过白名单时绝不从 UCI 列表里摘目录(防止 /etc 被抹)。 _dns_confdir_clean() { _clean_in=${1:-} _clean_out="" for _clean_d in $_clean_in; do - [ "$_clean_d" = "$DNSDIR" ] && continue + if [ "$_clean_d" = "$DNSDIR" ] && _dnsdir_ok "$DNSDIR"; then + continue + fi _clean_out="${_clean_out:+$_clean_out }$_clean_d" done printf '%s\n' "$_clean_out" @@ -3595,19 +3764,38 @@ _dns_confdir_target() { # 所有可能残留 isp-split.conf 的目录:探测结果 + UCI 当前值 + 默认目录 # + 旧版目录。去重后输出。卸载/重写时用它做彻底清理。 +# A-05:DNSDIR 只有在白名单内才参与删除;/etc、/、.. 穿越路径直接忽略。 _dns_confdir_candidates() { _cand_out="" for _cand_d in ${DNS_CONFDIR_DIRS:-} $(_dns_confdir_get) \ - "$DNSMASQ_DEFAULT_DIR" "$DNSDIR"; do + "$DNSMASQ_DEFAULT_DIR"; do [ -n "$_cand_d" ] || continue case " $_cand_out " in *" $_cand_d "*) continue ;; esac _cand_out="${_cand_out:+$_cand_out }$_cand_d" done + if _dnsdir_ok "$DNSDIR"; then + case " $_cand_out " in + *" $DNSDIR "*) ;; + *) _cand_out="${_cand_out:+$_cand_out }$DNSDIR" ;; + esac + else + log "警告:忽略白名单外的 DNSDIR($DNSDIR),不据此删除任何片段" >&2 + fi printf '%s\n' "$_cand_out" } +# A-05:删除片段的最后一道根目录限制。即便候选目录来自 UCI/runtime, +# 也绝不把 /、/etc 或含 .. 的路径当作删除根。 +_dns_frag_delete_ok() { + _fdo=${1:-} + case "$_fdo" in "" | "/" | "/etc" | "." | ".." | *..*) return 1 ;; esac + case "$_fdo" in /*) ;; *) return 1 ;; esac + [ -d "$_fdo" ] || return 1 + return 0 +} + _dns_confdir_set() { if [ -n "${1:-}" ]; then uci -q set "dhcp.@dnsmasq[0].confdir=$1" || return 1 @@ -3779,6 +3967,12 @@ _dns_bypass() { for _db_row in ${DNS_TABLE:-}; do _db_pol=${_db_row%%:*} _db_dns=${_db_row#*:} + # A-07:策略名直接拼进 mwan3_policy_* argv,必须在任何 nft 调用 + # 之前过 _valid_policy(; 空白等都进不了 argv)。 + if ! _valid_policy "$_db_pol"; then + log "错误:DNS_TABLE 策略名 '$_db_pol' 非法(只允许 [A-Za-z0-9_-]),未恢复 DNS 旁路规则" + return 2 + fi [ -n "$_db_dns" ] || continue case "$_db_seen" in *" $_db_pol:$_db_dns "*) continue ;; @@ -4100,7 +4294,17 @@ _dns_print_preview() { return 0 } +# A-02:dns 也写 UCI confdir / mwan3 output 链,必须与其它写操作串行。 cmd_dns() { + _op_lock_acquire || die "另一个 isp-split 操作正在运行(锁: $OP_LOCK),本次未做任何改动;稍后重试" + _cmd_dns_body "$@" + _rc=$? + _op_lock_release + return $_rc +} + +_cmd_dns_body() { + _dnsdir_check need_nft mkdir -p "$DNS_STATE" "$DOMAINDIR" @@ -4158,6 +4362,7 @@ cmd_dns() { # 所有候选位置里的旧片段,避免 dnsmasq 同时加载两份规则。 for _old_dir in $(_dns_confdir_candidates); do [ "$_old_dir" = "$_confdir" ] && continue + _dns_frag_delete_ok "$_old_dir" || continue rm -f "$_old_dir/isp-split.conf" 2>/dev/null || true done @@ -4395,7 +4600,20 @@ _valid_ipv4() { # 收口,凡是能变成第二个 argv / shell 元字符的输入一律拒绝。 _conf_lc_ctrl() { # 含 CR/LF/TAB 等控制字符 -> 0 - printf '%s' "${1:-}" | grep -q '[[:cntrl:]]' + # A-03:grep 按行匹配,LF 是行分隔符,永远不会出现在被匹配的“行”里。 + # 先剥 CR/LF/TAB 比较长度(任何 LF 都会让长度变短),再用 grep 兜住 + # 其余控制字符(VT/FF/ESC 等)。 + _clc_in=${1:-} + _clc_stripped=$(printf '%s' "$_clc_in" | tr -d '\r\n\t') + [ "${#_clc_stripped}" -eq "${#_clc_in}" ] || return 0 + printf '%s' "$_clc_in" | grep -q '[[:cntrl:]]' +} + +# 只允许可见 ASCII(0x20-0x7e)。UA/Accept 是 HTTP 头值,出现非 ASCII +# 字节或控制字符都应拒绝,而不是静默清洗。 +_conf_lc_visible() { + _clv_bad=$(printf '%s' "${1:-}" | LC_ALL=C tr -d ' -~') + [ -z "$_clv_bad" ] } _conf_lc_url_ok() { @@ -4417,6 +4635,21 @@ _conf_lc_url_ok() { return 0 } +# A-04:读取侧 URL 校验(比 conf 写入侧略宽,允许 ?/# 查询串与片段, +# 但显式拒绝空白、引号、反斜杠、换行、; & | $ ` < > 等会变成 shell/curl +# 额外 argv 的字符)。配合 curl --url,避免 -K 被当成配置文件选项。 +_url_read_ok() { + _uro=${1:-} + case "$_uro" in + http://?* | https://?*) ;; + *) return 1 ;; + esac + case "$_uro" in + *[!A-Za-z0-9:/?#._~%+=,@!-]*) return 1 ;; + esac + return 0 +} + _conf_lc_urls_ok() { _conf_lc_urls_n=0 _conf_lc_urls_old=$IFS @@ -4665,6 +4898,8 @@ _conf_validate() { if _conf_lc_ctrl "$_val"; then die "$_key 不能包含 CR/LF/TAB 等控制字符(会注入 HTTP 头)" fi + _conf_lc_visible "$_val" || + die "$_key 只能包含可见 ASCII 字符(0x20-0x7e,禁止换行/控制字符/非 ASCII)" ;; LINE_CHECK_ECHO_URLS) _conf_lc_urls_ok "$_val" || @@ -4840,7 +5075,9 @@ conf_set() { [ -n "$_key" ] || die "用法: isp-split conf set <项> <值>(可用项: $_CONF_KEYS_HINT)" [ -f "$CONF" ] || die "找不到 $CONF" - # 允许用 | 代替换行(前端传参更省事) + # 允许用 | 代替换行(前端传参更省事)。 + # A-03:转换后必须复验——UA/ACCEPT 的“可见 ASCII”规则会把 | 变成 + # 的 LF 抓住;表类键仍允许真正的多行。 _val=$(printf '%s' "$_val" | tr '|' '\n') # _conf_write 内部会先调用 _conf_validate 做白名单校验; @@ -4943,12 +5180,34 @@ cmd_export() { /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/* | \ + /etc/profile.d | /etc/profile.d/* | /etc/rc.local.d | /etc/rc.local.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/*) + /usr/lib | /usr/lib/* | /usr/share/rpcd | /usr/share/rpcd/* | \ + /usr/share/luci | /usr/share/luci/* | /www | /www/*) die "拒绝导出到系统目录: $_dir" ;; esac mkdir -p "$_dir" || die "无法创建 $_dir" + # A-09:上面的黑名单按字面匹配,/tmp/link-to-etc 这类目录可以绕过。 + # 建目录后再解析一次真实路径(pwd -P 是 busybox 也有的最小原语), + # 对真实路径重复同一张黑名单;不解析到真实路径就不写任何文件。 + _dir_real=$(cd "$_dir" 2>/dev/null && pwd -P) || _dir_real="" + [ -n "$_dir_real" ] || die "无法解析输出目录的真实路径: $_dir" + for _dchk in "$_dir" "$_dir_real"; do + case "$_dchk" in + / | /etc | /usr | /var | /bin | /sbin | /lib | /boot | /dev | /proc | /sys | /www | /root) + die "拒绝导出到系统目录: $_dchk($_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/* | \ + /etc/profile.d | /etc/profile.d/* | /etc/rc.local.d | /etc/rc.local.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/* | \ + /usr/share/luci | /usr/share/luci/* | /www | /www/*) + die "拒绝导出到系统目录: $_dchk($_dir 的真实路径)" ;; + esac + done _n=0 _old_ifs=$IFS @@ -4973,14 +5232,18 @@ cmd_export() { *) _ext=txt ;; esac _out="$_dir/isp-$_src.$_ext" - rm -f "$_out" + # A-09:不再 rm -f 后直接重定向(rm 与重定向之间有 TOCTOU, + # 且 README 以前根本没删链接)。统一用 _tmp_next_to 建同目录 + # 随机临时文件,写完 rename 落位;rename 不跟随符号链接。 + _out_tmp=$(_tmp_next_to "$_dir") + [ -n "$_out_tmp" ] || die "无法在 $_dir 创建临时文件(权限/空间?)" case "$_fmt" in mihomo) { echo "payload:" sed 's/^/ - /' "$_f" - } > "$_out" + } > "$_out_tmp" || { rm -f "$_out_tmp"; die "写入 $_out 失败"; } ;; singbox) # sing-box 的 source 格式是对象,不是裸数组: @@ -4991,15 +5254,18 @@ cmd_export() { 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" + } > "$_out_tmp" || { rm -f "$_out_tmp"; die "写入 $_out 失败"; } ;; text) - cp "$_f" "$_out" + cp -f "$_f" "$_out_tmp" || { rm -f "$_out_tmp"; die "写入 $_out 失败"; } ;; *) + rm -f "$_out_tmp" die "未知格式 '$_fmt',可选 mihomo / singbox / text" ;; esac + chmod 644 "$_out_tmp" 2>/dev/null || true + mv -f "$_out_tmp" "$_out" || { rm -f "$_out_tmp"; die "落位 $_out 失败"; } printf ' %-34s %6s 条\n' "$_out" "$(wc -l < "$_f" | tr -d ' ')" _n=$((_n + 1)) done @@ -5007,9 +5273,14 @@ cmd_export() { [ "$_n" -gt 0 ] || die "没有可导出的数据,先执行 isp-split fetch" # 附一份可直接抄的示例配置 + # A-09:README 也必须走“同目录临时文件 + rename”。旧实现直接 + # `cat > "$_dir/README-*.txt"`,若目标已存在且是指向 /etc/shadow 之类 + # 的符号链接,root 的重定向会写穿链接(数据文件已先 rm,README 漏了)。 case "$_fmt" in mihomo) - cat > "$_dir/README-mihomo.txt" <<'EOF' + _readme_tmp=$(_tmp_next_to "$_dir") + [ -n "$_readme_tmp" ] || die "无法在 $_dir 创建 README 临时文件" + cat > "$_readme_tmp" <<'EOF' mihomo / OpenClash 用法: # 1) 放进 rule-provider(OpenClash 可在「规则集」里添加本地文件) @@ -5039,9 +5310,15 @@ rules: # 3) 定时更新:cron 里跑 isp-split fetch 后再 export 到 OpenClash 目录 EOF + [ -s "$_readme_tmp" ] || { rm -f "$_readme_tmp"; die "写入 README-mihomo.txt 失败"; } + chmod 644 "$_readme_tmp" 2>/dev/null || true + mv -f "$_readme_tmp" "$_dir/README-mihomo.txt" || + { rm -f "$_readme_tmp"; die "落位 README-mihomo.txt 失败"; } ;; singbox) - cat > "$_dir/README-singbox.txt" <<'EOF' + _readme_tmp=$(_tmp_next_to "$_dir") + [ -n "$_readme_tmp" ] || die "无法在 $_dir 创建 README 临时文件" + cat > "$_readme_tmp" <<'EOF' sing-box 用法: { @@ -5066,6 +5343,10 @@ sing-box 用法: {"version":1,"rules":[{"ip_cidr":[...]}]},本工具导出的正是该格式。 裸 JSON 数组(mihomo 的 payload 格式)sing-box 不接受。 EOF + [ -s "$_readme_tmp" ] || { rm -f "$_readme_tmp"; die "写入 README-singbox.txt 失败"; } + chmod 644 "$_readme_tmp" 2>/dev/null || true + mv -f "$_readme_tmp" "$_dir/README-singbox.txt" || + { rm -f "$_readme_tmp"; die "落位 README-singbox.txt 失败"; } ;; esac @@ -5180,7 +5461,16 @@ _cron_expr_ok() { [ "$_ce_bad" = "0" ] && [ "$_ce_n" -eq 5 ] } +# A-02:install 写 crontab/开机自启,与 uninstall 及 refresh 互斥。 cmd_install() { + _op_lock_acquire || die "另一个 isp-split 操作正在运行(锁: $OP_LOCK),本次未做任何改动;稍后重试" + _cmd_install_body "$@" + _rc=$? + _op_lock_release + return $_rc +} + +_cmd_install_body() { _cron_expr_ok "$ISP_CRON" || die "ISP_CRON 格式非法(必须是 5 段 cron 时间字段): $ISP_CRON" # crontab @@ -5193,7 +5483,16 @@ cmd_install() { log "已注册:每日 $ISP_CRON 自动更新,开机自动应用" } +# A-02:uninstall 会删 $BASE,必须与 fetch/wire/refresh 互斥。 cmd_uninstall() { + _op_lock_acquire || die "另一个 isp-split 操作正在运行(锁: $OP_LOCK),本次未做任何改动;稍后重试" + _cmd_uninstall_body "$@" + _rc=$? + _op_lock_release + return $_rc +} + +_cmd_uninstall_body() { # F-14:卸载清理只有一份实现——usr/lib/isp-split/teardown.sh。 # 这里只做"必须由本脚本完成"的部分:mwan3 规则块要用 rules-splice.awk # 原地摘除(teardown 不带 awk 依赖),其余(策略/成员/计数器/confdir/ @@ -5276,7 +5575,11 @@ stun.douyucdn.cn:18000 stun.freeswitch.org:3478 stun.cloudflare.com:3478 ' -LC_DEFAULT_ECHO='https://api.ipify.org,https://ipv4.icanhazip.com' +# F-23:默认回显源必须国内可达。api.ipify.org / icanhazip.com 在境内运营商 +# 网络经常被 RST/超时,造成“HTTP 200 但 exit_ip 空”。下面三个是 2026-10 实测 +# 可达的 HTTPS 端点(ip.3322.net 返回裸 IP;myip.ipip.net 返回 +# “当前 IP:x.x.x.x”;ddns.oray.com 返回 “Current IP Address: x.x.x.x”)。 +LC_DEFAULT_ECHO='https://ip.3322.net,https://myip.ipip.net,https://ddns.oray.com/checkip' # 部分站点对默认 curl UA 直接 412/418(实测 www.chinatelecom.com.cn), # 必须带浏览器 UA;Range 只取 1 字节,避免 CDN 把体检当成下载。 LC_DEFAULT_UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' @@ -5308,6 +5611,17 @@ _lc_ip_valid() { printf '%s' "$1" | awk -F. 'NF == 4 && $1 <= 255 && $2 <= 255 && $3 <= 255 && $4 <= 255 { ok = 1 } END { exit(ok ? 0 : 1) }' } +# F-23:回显源不保证只返回裸 IP。ipip.net 返回“当前 IP:x.x.x.x 来自于:…”, +# oray 返回“Current IP Address: x.x.x.x”。从响应里抓第一个**通过严格 IPv4 +# 校验**的候选;纯文本/JSON/HTML 都走同一条路径,不额外信任任何格式。 +_lc_extract_ip() { + printf '%s' "${1:-}" | + grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' 2>/dev/null | + while IFS= read -r _eip; do + _lc_ip_valid "$_eip" && { printf '%s' "$_eip"; break; } + done +} + _lc_in_net() { # # 用 awk 做 32 位比较:32 位 ARM/MIPS 上 busybox ash 的算术是 long, @@ -5363,15 +5677,50 @@ _lc_jitter() { # max-min } _lc_fmt_ms() { - case "${1:-}" in '' | *[!0-9]*) printf '—' ;; *) printf '%sms' "$1" ;; esac + case "${1:-}" in + '') printf '—' ;; + '<1') printf '<1ms' ;; + *[!0-9]*) printf '—' ;; + *) printf '%sms' "$1" ;; + esac } # ---- 配置/表 ---- _lc_targets_raw() { _lc_opt LINE_CHECK_TARGETS "$LC_DEFAULT_TARGETS"; } _lc_stun_raw() { _lc_opt LINE_CHECK_STUN_SERVERS "$LC_DEFAULT_STUN"; } -_lc_echo_raw() { _lc_opt LINE_CHECK_ECHO_URLS "$LC_DEFAULT_ECHO"; } -_lc_ua() { _lc_opt LINE_CHECK_UA "$LC_DEFAULT_UA"; } +# A-04:读取侧复校验。直接改 /etc/isp-split.conf 或环境变量可绕过 conf set; +# ECHO_URLS 的每个 token 都必须是 http(s)://,不能是 -K 这类 curl 选项。 +_lc_echo_raw() { + _ler=$(_lc_opt LINE_CHECK_ECHO_URLS "$LC_DEFAULT_ECHO") + _conf_lc_urls_ok "$_ler" || + die "LINE_CHECK_ECHO_URLS 含非法项(必须是 http(s):// URL,且不能是 curl 选项): $_ler" + printf '%s' "$_ler" +} +# A-03/A-04:UA 读取侧二次校验,非法直接报错而不是静默清洗。 +_lc_ua() { + _lua=$(_lc_opt LINE_CHECK_UA "$LC_DEFAULT_UA") + if _conf_lc_ctrl "$_lua"; then + die "LINE_CHECK_UA 含控制字符(CR/LF/TAB),拒绝用于 curl -A" + fi + _conf_lc_visible "$_lua" || + die "LINE_CHECK_UA 只能包含可见 ASCII(0x20-0x7e),拒绝用于 curl -A" + printf '%s' "$_lua" +} + +# linecheck 启动时统一把读取侧校验跑一遍。放在这里而不是每个调用点, +# 是因为 _lc_ua/_lc_echo_raw 常在 $(...) 子 shell 里调用:子 shell 里 die +# 只会结束子 shell,父进程会继续用空值跑下去。启动时直接调用才能硬失败。 +_lc_read_validate() { + _lc_ua >/dev/null + _lrv_acc=$(_lc_opt LINE_CHECK_ACCEPT "$LC_DEFAULT_ACCEPT") + if _conf_lc_ctrl "$_lrv_acc"; then + die "LINE_CHECK_ACCEPT 含控制字符(CR/LF/TAB),拒绝用于 curl -H" + fi + _conf_lc_visible "$_lrv_acc" || + die "LINE_CHECK_ACCEPT 只能包含可见 ASCII(0x20-0x7e),拒绝用于 curl -H" + _lc_echo_raw >/dev/null +} _lc_timeout() { _t=$(_lc_int "$(_lc_opt LINE_CHECK_TIMEOUT 3)") [ "$_t" -ge 1 ] 2>/dev/null || _t=3 @@ -5390,6 +5739,9 @@ _lc_set_for() { # <源名> _old_ifs=$IFS _want=$1 for _row in $ISP_TABLE; do + # A-06:入口复用 _row_check_row,坏集合名/策略名在任何 nft argv + # 之前 die,而不是等 nft 解析失败。 + _row_check_row "$_row" IFS=: set -- $_row IFS=$_old_ifs @@ -5403,10 +5755,16 @@ _lc_set_for() { # <源名> _lc_ip_isp() { # _ip=${1:-} _lc_ip_valid "$_ip" || return 0 + # A-06:先校验整张表,再碰 nft;坏集合名连 `nft list table` 都不会发生。 + for _row in $ISP_TABLE; do + _row_check_row "$_row" + done command -v nft >/dev/null 2>&1 || return 0 nft list table inet mwan3 >/dev/null 2>&1 || return 0 _old_ifs=$IFS for _row in $ISP_TABLE; do + # A-06:同 _lc_set_for,整行先过读取侧校验。 + _row_check_row "$_row" IFS=: set -- $_row IFS=$_old_ifs @@ -5553,24 +5911,65 @@ _lc_wan_one() { # ---- 出口 IP ---- -_lc_exit_ip() { # -> ip 或空 +# F-23:逐 URL 探测出口 IP,并把每个 URL 的 http_code/err/耗时写进 +# LC_EXIT_PROBE(JSON 数组),供页面区分“源不可达”与“真的没出网”。 +# 注意:本函数直接设置全局 LC_EXIT_IP / LC_EXIT_PROBE,不能用 $(...) 调用—— +# 命令替换会丢掉探测明细。 +_lc_exit_ip() { # ,设置 LC_EXIT_IP 与 LC_EXIT_PROBE _dev=$1 + LC_EXIT_IP="" + LC_EXIT_PROBE_ITEMS="" # 夹具模式:ISP_SPLIT_LC_EXIT_IPS="1.2.3.4,5.6.7.8",none 表示失败 if [ -n "${ISP_SPLIT_LC_EXIT_IPS:-}" ]; then - printf '%s' "$ISP_SPLIT_LC_EXIT_IPS" | awk -v idx="${LC_IDX:-1}" -F, ' - $idx != "none" && $idx ~ /^[0-9.]+$/ { print $idx }' + LC_EXIT_IP=$(printf '%s' "$ISP_SPLIT_LC_EXIT_IPS" | awk -v idx="${LC_IDX:-1}" -F, ' + $idx != "none" && $idx ~ /^[0-9.]+$/ { print $idx }') + LC_EXIT_PROBE=${ISP_SPLIT_LC_EXIT_PROBE:-[]} return 0 fi - command -v curl >/dev/null 2>&1 || return 0 + command -v curl >/dev/null 2>&1 || { LC_EXIT_PROBE="[]"; return 0; } _old_ifs=$IFS IFS=, for _u in $(_lc_echo_raw); do IFS=$_old_ifs - _r=$(curl -sS --interface "$_dev" --connect-timeout 3 -m 5 \ - -A "$(_lc_ua)" "$_u" 2>/dev/null | tr -d '[:space:]' | head -c 64) - _lc_ip_valid "$_r" && { printf '%s' "$_r"; return 0; } + # TMPDIR 可能指向不可写的沙箱目录;/tmp 作为兜底(mktemp 仍是 + # O_EXCL 随机名,不会被预占)。 + _eb=$(mktemp "${TMPDIR:-/tmp}/.isp-split-echo.XXXXXX" 2>/dev/null) || + _eb=$(mktemp "/tmp/.isp-split-echo.XXXXXX" 2>/dev/null) || _eb="" + _ee=$(mktemp "${TMPDIR:-/tmp}/.isp-split-echo2.XXXXXX" 2>/dev/null) || + _ee=$(mktemp "/tmp/.isp-split-echo2.XXXXXX" 2>/dev/null) || _ee="" + if [ -z "$_eb" ] || [ -z "$_ee" ]; then + rm -f "$_eb" "$_ee" + continue + fi + _meta=$(curl -sS --interface "$_dev" --connect-timeout 3 -m 5 \ + --max-filesize 65536 -A "$(_lc_ua)" -o "$_eb" \ + -w '%{http_code} %{time_total}' --url "$_u" 2>"$_ee") + _rc=$? + _code=$(printf '%s' "$_meta" | awk '{ print $1; exit }') + _tt=$(printf '%s' "$_meta" | awk '{ print $2; exit }') + case "$_code" in '' | *[!0-9]*) _code=0 ;; *) _code=$((_code + 0)) ;; esac + _err=$(head -1 "$_ee" 2>/dev/null | tr '\t\r\n' ' ' | tr -s ' ' | cut -c1-160) + _body=$(head -c 512 "$_eb" 2>/dev/null) + _ip="" + case "$_code" in + 2* | 3*) _ip=$(_lc_extract_ip "$_body") ;; + esac + _ok=false + [ -n "$_ip" ] && _ok=true + if [ "$_ok" = "true" ]; then + LC_EXIT_IP=$_ip + _err="" + else + [ -n "$_err" ] || _err="no-ip" + fi + _ms=$(_lc_ms "${_tt:-0}") + LC_EXIT_PROBE_ITEMS="${LC_EXIT_PROBE_ITEMS}${LC_EXIT_PROBE_ITEMS:+,}" + LC_EXIT_PROBE_ITEMS="${LC_EXIT_PROBE_ITEMS}{\"url\":$(_lc_json_str "$_u"),\"http_code\":$(_lc_int "$_code"),\"err\":$(_lc_json_str "$_err"),\"ms\":$(_lc_int "$_ms"),\"ok\":$_ok}" + rm -f "$_eb" "$_ee" + [ "$_ok" = "true" ] && break done IFS=$_old_ifs + LC_EXIT_PROBE="[${LC_EXIT_PROBE_ITEMS}]" return 0 } @@ -6025,20 +6424,176 @@ _lc_stun_probe() { # # ---- 延迟 / 可用性 ---- -# DNS 段:curl 的 time_namelookup 只有在不钉 IP 时才有意义。 -# 这里单独发一个 http:// 请求(只取 DNS 计时,最多 2s),真正的四段测量 -# 用 --resolve 钉到已校验 IP,避免 GSLB 二次解析换 IP。 -_lc_dns_ms() { # - _dv=$1 - _dm=$2 - if [ -n "${ISP_SPLIT_LC_LAT:-}" ]; then - printf '%s' "$(_lc_lat_get dns 0)" +# F-24b:DNS 段必须测“这条 WAN 的 DNS”,不能测系统 resolver(127.0.0.1 +# 的 dnsmasq 热缓存)。从 DNS_TABLE 的 <策略>: 里找策略出接口等于本 +# WAN 设备的条目;取不到时回退系统 resolver,并在 JSON 标 dns_scope=local。 +# 设置 LC_DNS_SERVER / LC_DNS_SCOPE。 +_lc_dns_for_dev() { # + _lcdd_dev=$1 + LC_DNS_SERVER="" + LC_DNS_SCOPE="local" + # 夹具:ISP_SPLIT_LC_DNS_IPS="223.5.5.5,none"(按 LC_IDX 取),或单个 + # ISP_SPLIT_LC_DNS_IP。夹具也走“按线”语义,scope=wan。 + if [ -n "${ISP_SPLIT_LC_DNS_IPS:-}" ]; then + _lcdd_ip=$(printf '%s' "$ISP_SPLIT_LC_DNS_IPS" | awk -v idx="${LC_IDX:-1}" -F, ' + $idx != "none" { print $idx }') + case "$_lcdd_ip" in + '' | none) ;; + *) + if _valid_ipv4 "$_lcdd_ip"; then + LC_DNS_SERVER=$_lcdd_ip + LC_DNS_SCOPE=wan + fi + ;; + esac return 0 fi - command -v curl >/dev/null 2>&1 || { printf '0'; return 0; } - _v=$(curl -sS --interface "$_dv" --connect-timeout 1 -m 2 -o /dev/null \ - -w '%{time_namelookup}' "http://$_dm/" 2>/dev/null) - _lc_ms "${_v:-0}" + if [ -n "${ISP_SPLIT_LC_DNS_IP:-}" ]; then + if _valid_ipv4 "$ISP_SPLIT_LC_DNS_IP"; then + LC_DNS_SERVER=$ISP_SPLIT_LC_DNS_IP + LC_DNS_SCOPE=wan + fi + return 0 + fi + for _lcdd_row in ${DNS_TABLE:-}; do + _lcdd_pol=${_lcdd_row%%:*} + _lcdd_ip=${_lcdd_row#*:} + [ -n "$_lcdd_ip" ] || continue + _valid_policy "$_lcdd_pol" || continue + _valid_ipv4 "$_lcdd_ip" || continue + _lcdd_pdev=$(policy_device "$_lcdd_pol") + if [ -n "$_lcdd_pdev" ] && [ "$_lcdd_pdev" = "$_lcdd_dev" ]; then + LC_DNS_SERVER=$_lcdd_ip + LC_DNS_SCOPE=wan + break + fi + done + return 0 +} + +# curl 是否带 c-ares(--dns-servers 只在 c-ares 构建里可用)。探测一次缓存。 +LC_CURL_CARES="" +_lc_curl_cares() { + if [ -z "$LC_CURL_CARES" ]; then + case "$(curl --version 2>/dev/null | head -1)" in + *c-ares*) LC_CURL_CARES=1 ;; + *) LC_CURL_CARES=0 ;; + esac + fi + [ "$LC_CURL_CARES" = "1" ] +} + +# curl 秒 -> 整数毫秒 / "<1" / 空(失败)。0 不再作为“未测”哨兵。 +_lc_sec_to_dns_ms() { + case "${1:-}" in '' | *[!0-9.]*) return 1 ;; esac + awk -v v="$1" 'BEGIN { + if (v <= 0) exit 1 + ms = v * 1000 + if (ms < 1) { print "<1"; exit 0 } + printf "%d\n", int(ms + 0.5) + }' +} + +# 纳秒时钟:busybox date 可能不支持 %N(会原样输出秒),用长度判定, +# 不可用就退回 /proc/uptime 的 10ms 精度。返回值写在 stdout。 +_lc_clock_ns() { + _cn=$(date +%s%N 2>/dev/null) || return 1 + case "$_cn" in '' | *[!0-9]*) return 1 ;; esac + [ "${#_cn}" -ge 16 ] || return 1 + printf '%s' "$_cn" +} + +# nslookup 按指定 server 测一次;成功输出毫秒(或 "<1"),失败输出空。 +# busybox nslookup 支持 `nslookup `;没有 timeout 命令时 +# 仍受 resolver 自身超时约束,但不会无限阻塞(OpenWrt 默认 5s × 2)。 +_lc_dns_nslookup() { # [server] + _ldn_dom=$1 + _ldn_srv=${2:-} + _ldn_t0ns=$(_lc_clock_ns 2>/dev/null) || _ldn_t0ns="" + _ldn_u0=$(_lc_uptime_ms) + if [ -n "$_ldn_srv" ]; then + if command -v timeout >/dev/null 2>&1; then + _ldn_out=$(timeout 3 nslookup "$_ldn_dom" "$_ldn_srv" 2>&1) + else + _ldn_out=$(nslookup "$_ldn_dom" "$_ldn_srv" 2>&1) + fi + else + if command -v timeout >/dev/null 2>&1; then + _ldn_out=$(timeout 3 nslookup "$_ldn_dom" 2>&1) + else + _ldn_out=$(nslookup "$_ldn_dom" 2>&1) + fi + fi + _ldn_rc=$? + _ldn_u1=$(_lc_uptime_ms) + _ldn_t1ns=$(_lc_clock_ns 2>/dev/null) || _ldn_t1ns="" + # 失败特征先排除;再要求至少有一行 Name:(NXDOMAIN/SERVFAIL 没有) + case "$_ldn_out" in + *"can't resolve"* | *"timed out"* | *"no servers could be reached"* | *"Connection refused"*) + return 1 ;; + esac + [ "$_ldn_rc" -eq 0 ] || return 1 + printf '%s' "$_ldn_out" | grep -q 'Name:' || return 1 + + if [ -n "$_ldn_t0ns" ] && [ -n "$_ldn_t1ns" ]; then + _ldn_diff=$((_ldn_t1ns - _ldn_t0ns)) + [ "$_ldn_diff" -ge 0 ] 2>/dev/null || return 1 + case "$_ldn_diff" in + 0) printf '<1' ;; + *) + _ldn_ms=$((_ldn_diff / 1000000)) + [ "$_ldn_ms" -gt 0 ] 2>/dev/null || _ldn_ms="<1" + printf '%s' "$_ldn_ms" + ;; + esac + return 0 + fi + # 没有纳秒时钟(busybox date 常见)时,用执行前后 /proc/uptime 的 + # 10ms 精度差。0 表示低于分辨率,输出 "<1",绝不再回 0。 + _ldn_ms=$((_ldn_u1 - _ldn_u0)) + [ "$_ldn_ms" -gt 0 ] 2>/dev/null || _ldn_ms="<1" + printf '%s' "$_ldn_ms" +} + +# DNS 段:优先 c-ares 的 curl --dns-servers(直接问该 WAN 的 DNS); +# 没有 c-ares 时用 busybox nslookup 。两者都取不到时, +# 回退系统 resolver 并标 dns_scope=local。设置 LC_DNS_MS / LC_DNS_SCOPE。 +_lc_dns_ms() { # ,设置 LC_DNS_MS / LC_DNS_SCOPE + _dv=$1 + _dm=$2 + LC_DNS_MS="" + LC_DNS_SCOPE="local" + LC_DNS_SERVER="" + if [ -n "${ISP_SPLIT_LC_LAT:-}" ]; then + # 夹具值按“秒”解释,与 curl -w 的 time_namelookup 同口径: + # dns=0.4 -> 400ms,dns=0.0004 -> "<1",缺失 -> null。 + _raw=$(_lc_lat_get dns "") + LC_DNS_MS=$(_lc_sec_to_dns_ms "$_raw" 2>/dev/null) || LC_DNS_MS="" + return 0 + fi + _lc_dns_for_dev "$_dv" + + # c-ares 路径:--dns-servers 只影响 DNS,--interface 仍负责选路。 + if [ -n "$LC_DNS_SERVER" ] && command -v curl >/dev/null 2>&1 && _lc_curl_cares; then + _meta=$(curl -sS --interface "$_dv" --dns-servers "$LC_DNS_SERVER" \ + --connect-timeout 2 -m 3 -o /dev/null \ + -w '%{time_namelookup} %{http_code}' "https://$_dm/" 2>/dev/null) + _sec=$(printf '%s' "$_meta" | awk '{ print $1; exit }') + LC_DNS_MS=$(_lc_sec_to_dns_ms "$_sec" 2>/dev/null) || LC_DNS_MS="" + [ -n "$LC_DNS_MS" ] && return 0 + fi + + command -v nslookup >/dev/null 2>&1 || return 0 + if [ -n "$LC_DNS_SERVER" ]; then + LC_DNS_MS=$(_lc_dns_nslookup "$_dm" "$LC_DNS_SERVER") || LC_DNS_MS="" + [ -n "$LC_DNS_MS" ] && return 0 + # 指定 server 失败时仍回退系统 resolver,但 scope 保持 wan 会误导; + # 标 local 且不再把失败的 server 写进 JSON。 + LC_DNS_SCOPE=local + LC_DNS_SERVER="" + fi + LC_DNS_MS=$(_lc_dns_nslookup "$_dm") || LC_DNS_MS="" + return 0 } _lc_lat_get() { # 从 ISP_SPLIT_LC_LAT="dns=2,tcp=30,..." 取 @@ -6063,7 +6618,10 @@ _lc_measure() { # [fast] # 而且失败目标的早停能把整次体检压进 rpcd 预算。 _ct=$_mto [ "$_ct" -gt 2 ] && _ct=2 - LC_DNS_MS=$(_lc_dns_ms "$_mdev" "$_mdom") + LC_DNS_MS="" + LC_DNS_SCOPE="local" + LC_DNS_SERVER="" + _lc_dns_ms "$_mdev" "$_mdom" LC_TCP_MS="" LC_TLS_MS="" LC_TTFB_MS="" @@ -6093,8 +6651,21 @@ _lc_measure() { # [fast] fi command -v curl >/dev/null 2>&1 || return 0 - _ua=$(_lc_ua) + # A-03:这里直接取值并校验,不再用 $(_lc_ua):命令替换里的 die 只会 + # 结束子 shell,父进程会带着空 UA 继续跑。 + _ua=$(_lc_opt LINE_CHECK_UA "$LC_DEFAULT_UA") + if _conf_lc_ctrl "$_ua"; then + die "LINE_CHECK_UA 含控制字符(CR/LF/TAB),拒绝用于 curl -A" + fi + _conf_lc_visible "$_ua" || + die "LINE_CHECK_UA 只能包含可见 ASCII(0x20-0x7e),拒绝用于 curl -A" _acc=$(_lc_opt LINE_CHECK_ACCEPT "$LC_DEFAULT_ACCEPT") + # A-03:读取侧二次校验(_lc_read_validate 已挡一次,这里防直接调函数)。 + if _conf_lc_ctrl "$_acc"; then + die "LINE_CHECK_ACCEPT 含控制字符(CR/LF/TAB),拒绝用于 curl -H" + fi + _conf_lc_visible "$_acc" || + die "LINE_CHECK_ACCEPT 只能包含可见 ASCII(0x20-0x7e),拒绝用于 curl -H" _best_code=0 _any_tls=0 _any_http=0 @@ -6156,7 +6727,8 @@ _lc_measure() { # [fast] LC_AVAIL=full fi else - if [ "$LC_DNS_MS" -gt 0 ] 2>/dev/null; then + # F-24b:空 = 未测/失败;"<1" 也是已测到。 + if [ -n "$LC_DNS_MS" ]; then LC_AVAIL=tcpfail else LC_AVAIL=dnsfail @@ -6183,8 +6755,17 @@ _lc_avail_from() { # _lc_geo_cache_get() { # [ -f "$LC_GEO" ] || return 1 + # A-13:只接受四列完整的行。旧缓存若被外部文本写进 TAB/换行,行列会错位; + # 这里让坏行进不了展示链路,下一次写入会用清洗后的文本覆盖。 awk -F'\t' -v ip="$1" -v p="$2" -v now="$(_lc_now)" -v ttl="$(_lc_geo_ttl)" ' - $1 == ip && $2 == p && (now - $4) < ttl { print $3; exit }' "$LC_GEO" 2>/dev/null + NF >= 4 && $1 == ip && $2 == p && (now - $4) < ttl { print $3; exit }' "$LC_GEO" 2>/dev/null +} + +# A-13:缓存是 TAB 分隔的 4 列文本,外部返回的 TAB/CR/LF 会破坏行列结构。 +# 写前统一清洗(不是 JSON 转义:TSV 里要的是“没有分隔符”,不是反斜杠), +# 并截断到 200 字符,避免单条归属地把 state 撑大。 +_lc_geo_cache_clean() { + printf '%s' "${1:-}" | tr '\t\r\n' ' ' | tr -s ' ' | sed 's/[[:cntrl:]]//g' | cut -c1-200 } _lc_geo_ttl() { @@ -6198,7 +6779,12 @@ _lc_geo_cache_put() { # # 写在缓存目录之外:缓存目录整体替换时不能把归属地缓存一起带走(旧实现 # 既丢缓存,又会在探测中途凭空造出一个没有 meta 的半截缓存目录)。 mkdir -p "$STATE_DIR" 2>/dev/null || return 0 - printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$(_lc_now)" >>"$LC_GEO" 2>/dev/null || true + _lg_ip=$(_lc_geo_cache_clean "$1") + _lg_prov=$(_lc_geo_cache_clean "$2") + _lg_text=$(_lc_geo_cache_clean "$3") + [ -n "$_lg_ip" ] && [ -n "$_lg_prov" ] || return 0 + printf '%s\t%s\t%s\t%s\n' "$_lg_ip" "$_lg_prov" "$_lg_text" "$(_lc_now)" >>"$LC_GEO" 2>/dev/null || true + chmod 600 "$LC_GEO" 2>/dev/null || true # 只保留最近 200 条,防止长期运行把 state 撑大 if [ -f "$LC_GEO" ]; then tail -n 200 "$LC_GEO" >"$LC_GEO.$$" 2>/dev/null && @@ -6209,7 +6795,9 @@ _lc_geo_cache_put() { # _lc_geo_query() { # case "$2" in baidu) - _r=$(curl -sS -m 2 --connect-timeout 2 \ + # A-13:主源只走 HTTPS;--max-filesize 防止被控/异常端点用超大 body + # 在 2s 内灌满 shell 变量(curl -m 只限时不限体积)。 + _r=$(curl -sS -m 2 --connect-timeout 2 --max-filesize 131072 --proto '=https' \ "https://opendata.baidu.com/api.php?query=$1&resource_id=6006&oe=utf8" 2>/dev/null) [ -n "$_r" ] || return 1 _loc=$(printf '%s' "$_r" | jsonfilter -e '@.data[0].location' 2>/dev/null) @@ -6217,7 +6805,9 @@ _lc_geo_query() { # printf '%s' "$_loc" ;; ip-api) - _r=$(curl -sS -m 2 --connect-timeout 2 \ + # 显式选择才走这里:ip-api 免费版只有 HTTP,明文。默认 auto 不再 + # 自动降级到它(见 docs/SECURITY.md);仍保留给内网/可信链路用户。 + _r=$(curl -sS -m 2 --connect-timeout 2 --max-filesize 131072 \ "http://ip-api.com/json/$1?lang=zh-CN&fields=status,country,regionName,city,isp" 2>/dev/null) [ -n "$_r" ] || return 1 _st=$(printf '%s' "$_r" | jsonfilter -e '@.status' 2>/dev/null) @@ -6256,7 +6846,9 @@ _lc_geo() { # return 0 fi - _provs="baidu ip-api" + # A-13:auto 只走 HTTPS 的百度。ip-api 是明文 HTTP,降级为“显式选择” + # 才启用;不再自动兜底,避免用户在不知情时把出口 IP 暴露给明文链路。 + _provs="baidu" [ "$_geo_prov" = "auto" ] || _provs=$_geo_prov for _p in $_provs; do _c=$(_lc_geo_cache_get "$_ip" "$_p") @@ -6511,7 +7103,16 @@ _lc_wan_json() { _httpj=false; [ "${LC_HTTP_CODE:-0}" -ge 200 ] 2>/dev/null && [ "${LC_HTTP_CODE:-0}" -lt 500 ] 2>/dev/null && _httpj=true _tcpj=null; [ -n "${LC_TCP_MS:-}" ] && _tcpj=$LC_TCP_MS - _dnsj=null; [ -n "${LC_DNS_MS:-}" ] && _dnsj=$LC_DNS_MS + # F-24b:dns_ms 只允许 null / 整数 / "<1";绝不再输出 0。 + _dnsj=null + case "${LC_DNS_MS:-}" in + '') ;; + '<1') _dnsj='"<1"' ;; + *[!0-9]*) ;; + *) _dnsj=$LC_DNS_MS ;; + esac + _dnssj=null; [ -n "${LC_DNS_SCOPE:-}" ] && _dnssj=$(_lc_json_str "$LC_DNS_SCOPE") + _dnssrvj=null; [ -n "${LC_DNS_SERVER:-}" ] && _dnssrvj=$(_lc_json_str "$LC_DNS_SERVER") _tlsj=null; [ -n "${LC_TLS_MS:-}" ] && _tlsj=$LC_TLS_MS _ttfbj=null; [ -n "${LC_TTFB_MS:-}" ] && _ttfbj=$LC_TTFB_MS _totalj=null; [ -n "${LC_TOTAL_MS:-}" ] && _totalj=$LC_TOTAL_MS @@ -6523,12 +7124,12 @@ _lc_wan_json() { printf '"iface":%s,"device":%s,"up":%s,"state":%s,"wan_ip":%s,"ip_class":%s,' \ "$(_lc_json_str "$LC_IFACE")" "$(_lc_json_str "$LC_DEV")" "$_upj" \ "$(_lc_json_str "$LC_STATE")" "$_wanipj" "$(_lc_json_str "$LC_IP_CLASS")" - printf '"exit_ip":%s,"exit_ok":%s,"multi_nat":%s,"isp":%s,"isp_label":%s,' \ - "$_exipj" "$_exj" "$_mj" "$(_lc_json_str "${LC_ISP:-}")" \ + printf '"exit_ip":%s,"exit_ok":%s,"exit_probe":%s,"multi_nat":%s,"isp":%s,"isp_label":%s,' \ + "$_exipj" "$_exj" "${LC_EXIT_PROBE:-[]}" "$_mj" "$(_lc_json_str "${LC_ISP:-}")" \ "$(_lc_json_str "$(_lc_isp_label "${LC_ISP:-}")")" printf '"target":%s,' "$(_lc_target_json)" - printf '"latency":{"dns_ms":%s,"tcp_ms":%s,"tls_ms":%s,"ttfb_ms":%s,"total_ms":%s,"jitter_ms":%s,"loss_pct":%s,"attempts":%s,"ok_count":%s,"samples":[%s]},' \ - "$_dnsj" "$_tcpj" "$_tlsj" "$_ttfbj" "$_totalj" \ + printf '"latency":{"dns_ms":%s,"dns_scope":%s,"dns_server":%s,"tcp_ms":%s,"tls_ms":%s,"ttfb_ms":%s,"total_ms":%s,"jitter_ms":%s,"loss_pct":%s,"attempts":%s,"ok_count":%s,"samples":[%s]},' \ + "$_dnsj" "$_dnssj" "$_dnssrvj" "$_tcpj" "$_tlsj" "$_ttfbj" "$_totalj" \ "$(_lc_int "${LC_JITTER:-0}")" "$(_lc_int "${LC_LOSS:-100}")" \ "$(_lc_int "${LC_ATTEMPTS:-0}")" "$(_lc_int "${LC_OK_COUNT:-0}")" \ "$(printf '%s' "${LC_SAMPLES:-}" | tr ' ' ',' | sed 's/^,//; s/,$//')" @@ -6692,6 +7293,7 @@ _lc_run_wan() { # LC_WAN_IP=$5 LC_IP_CLASS=$(_lc_ip_class "$LC_WAN_IP") LC_EXIT_IP="" + LC_EXIT_PROBE="[]" LC_EXIT_OK=0 LC_MULTI=0 LC_ISP="" @@ -6701,7 +7303,9 @@ _lc_run_wan() { # LC_T_MODE=none LC_T_VALID=0 LC_T_SAME=0 - LC_DNS_MS=0 + LC_DNS_MS="" + LC_DNS_SCOPE="local" + LC_DNS_SERVER="" LC_TCP_MS="" LC_TLS_MS="" LC_TTFB_MS="" @@ -6725,7 +7329,9 @@ _lc_run_wan() { # LC_STUN_LPORT=0 # 出口 IP:WAN down 时也试一次(可能只是 mwan3 track 失败,链路还在) - LC_EXIT_IP=$(_lc_exit_ip "$LC_DEV") + LC_EXIT_IP="" + LC_EXIT_PROBE="[]" + _lc_exit_ip "$LC_DEV" if _lc_ip_valid "$LC_EXIT_IP"; then LC_EXIT_OK=1 else @@ -7035,6 +7641,13 @@ _lc_publish() { # [ -d "$LC_CACHE" ] && mv "$LC_CACHE" "$LC_CACHE_PREV" 2>/dev/null if mv "$1" "$LC_CACHE" 2>/dev/null; then rm -rf "$LC_CACHE_PREV" 2>/dev/null + # A-14:缓存目录 700、其中每个文件 600。mv 保留临时目录的权限, + # 但升级前留下的旧 cache 可能还是 755/644,这里统一纠正。 + chmod 700 "$LC_CACHE" 2>/dev/null || true + for _pf in "$LC_CACHE"/*; do + [ -f "$_pf" ] || continue + chmod 600 "$_pf" 2>/dev/null || true + done return 0 fi [ -d "$LC_CACHE_PREV" ] && [ ! -d "$LC_CACHE" ] && mv "$LC_CACHE_PREV" "$LC_CACHE" 2>/dev/null @@ -7048,7 +7661,7 @@ _lc_dt() { _lc_privacy_line() { if [ "$(_lc_opt LINE_CHECK_GEO 1)" = "1" ]; then _lc_gp=$(_lc_opt LINE_CHECK_GEO_PROVIDER auto) - [ "$_lc_gp" = "auto" ] && _lc_gp="百度优先,失败转 ip-api" + [ "$_lc_gp" = "auto" ] && _lc_gp="仅百度 HTTPS(ip-api 需显式选择)" [ "$_lc_gp" = "off" ] && _lc_gp="关闭" printf '隐私:归属地查询已开启,仅把每条线的出口公网 IP 发给 %s;不发送内网地址/设备名/MAC/流量内容。\n' "$_lc_gp" else @@ -7105,6 +7718,7 @@ _lc_background() { # [ -n "$_b_only" ] && set -- "$@" --wan "$_b_only" : >"$LC_LOG" 2>/dev/null || true + chmod 600 "$LC_LOG" 2>/dev/null || true # setsid:脱离 rpcd 的会话/进程组,exec 返回后不会被一起收走。 # 三个 fd 全部重定向:父进程退出时 rpcd 的管道不能被子进程挂着,否则 # exec 仍然要等到探测结束(那正是 BUG-23 的另一半)。 @@ -7178,6 +7792,12 @@ cmd_linecheck() { [ "$(_lc_opt LINE_CHECK_ENABLE 1)" = "1" ] || die "线路体检已关闭(LINE_CHECK_ENABLE=0)" [ "$_lc_worker" = "1" ] && _lc_json=1 + # A-14:体检缓存含公网 IP/归属地/STUN 映射。整个 linecheck 进程内 + # 统一 umask 077,新建目录 700、文件 600;已存在的旧目录/文件在下面 + # 再显式 chmod 一次(umask 只影响新建)。 + umask 077 + # A-03/A-04:读取侧校验必须在任何 curl 之前硬失败(含后台/worker)。 + _lc_read_validate LC_START=$(_lc_now) LC_BUDGET=$(_lc_budget) @@ -7189,6 +7809,7 @@ cmd_linecheck() { [ "${ISP_SPLIT_LC_NO_SOCAT:-0}" = "1" ] && LC_SOCAT=0 mkdir -p "$STATE_DIR" 2>/dev/null || true + chmod 700 "$STATE_DIR" 2>/dev/null || true # 每次调用都先扫掉 pid 已死的探测目录(SIGKILL 走不到 trap)。命中缓存时 # 也扫,否则一个被 rpcd 超时杀掉的前台探测要等到缓存过期才有人收尸。 _lc_sweep_tmp @@ -7230,6 +7851,7 @@ cmd_linecheck() { [ "$_lc_force" = "1" ] && set -- "$@" --force [ -n "$_lc_only" ] && set -- "$@" --wan "$_lc_only" : >"$LC_LOG" 2>/dev/null || true + chmod 600 "$LC_LOG" 2>/dev/null || true if command -v setsid >/dev/null 2>&1; then ISP_SPLIT_LC_SELF=$_lc_self setsid sh "$LC_RUN" "$@" >"$LC_LOG" 2>&1 & else diff --git a/luci-app-honk/Makefile b/luci-app-honk/Makefile index 6397431b4..7343f5297 100644 --- a/luci-app-honk/Makefile +++ b/luci-app-honk/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-honk PKG_VERSION:=1.1.4 -PKG_RELEASE:=29 +PKG_RELEASE:=30 LUCI_TITLE:=LuCI support for honk LUCI_PKGARCH:=all diff --git a/luci-app-honk/root/www/luci-static/resources/honk/common.js b/luci-app-honk/root/www/luci-static/resources/honk/common.js index 75a33dbce..4f931e932 100644 --- a/luci-app-honk/root/www/luci-static/resources/honk/common.js +++ b/luci-app-honk/root/www/luci-static/resources/honk/common.js @@ -334,7 +334,8 @@ function ensureEditorStyles() { '.cm-format-btn { margin: 0 !important; cursor: pointer !important; }', '.cbi-value:has(.CodeMirror) { align-items: flex-start !important; }', '.cbi-value:has(.CodeMirror) > .cbi-value-title { padding-top: 5px !important; flex-grow: 0 !important; }', - '.cbi-value:has(.CodeMirror) .cbi-value-field { flex: 1 1 0% !important; min-width: 0 !important; width: auto !important; }', + // 26rem basis: when the row is narrower, the editor wraps under its title instead of being squeezed beside it + '.cbi-value:has(.CodeMirror) .cbi-value-field { flex: 1 1 26rem !important; min-width: 0 !important; width: auto !important; }', '.CodeMirror {', ' border: 1px solid var(--border, var(--hairline, var(--border-color-medium, #ccc))) !important;', ' border-radius: var(--radius-base, 4px);', diff --git a/luci-app-isp-split/Makefile b/luci-app-isp-split/Makefile index 044066aa5..e04f83740 100644 --- a/luci-app-isp-split/Makefile +++ b/luci-app-isp-split/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-isp-split -PKG_VERSION:=0.3.0 -PKG_RELEASE:=4 +PKG_VERSION:=0.3.2 +PKG_RELEASE:=5 PKG_LICENSE:=MIT PKG_LICENSE_FILES:=LICENSE @@ -49,7 +49,7 @@ define Package/luci-app-isp-split/install $(INSTALL_DATA) $(PKG_BUILD_DIR)/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) $(PKG_BUILD_DIR)/htdocs/luci-static/resources/isp-split/logos/*.svg \ + $(INSTALL_DATA) $(PKG_BUILD_DIR)/htdocs/luci-static/resources/isp-split/logos/*.png \ $(1)/www/luci-static/resources/isp-split/logos/ $(INSTALL_DATA) $(PKG_BUILD_DIR)/htdocs/luci-static/resources/view/isp-split/overview.js \ $(1)/www/luci-static/resources/view/isp-split/overview.js 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 index 79dc7d4ee..a14c6a1e2 100644 --- 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 @@ -254,25 +254,74 @@ background: var(--isp-accent, var(--isp-accent-other)); } +/* 卡头(方案 B,0.3.1):44px 图标左置 + 名称右排。 + 0.3.0 是「名称左、20px 图标右」,运营商多起来时右角的小图标很难认, + 且各家 SVG 的视觉重心不一致;改成左置大图后,卡片读起来像一张名片: + 先认标志,再看名字,最后看数字。 */ .isp-card-head { display: flex; align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 6px; + justify-content: flex-start; + gap: 10px; + margin-bottom: 10px; } .isp-op-name { - font-size: 0.98rem; + font-size: 1rem; font-weight: 600; color: var(--isp-text); letter-spacing: 0.02em; + /* 四列布局下内容宽 ~218px:44px 图标 + 10px 间距后, + 长运营商名(ISP_TABLE 自定义标签)不要顶破卡片 */ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } +/* 图标位:logo 与「无 logo 的 glyph 占位」共用 44×44 的盒子, + 两者视觉同构——卡片并排时卡头高度、名称基线完全对齐。 */ .isp-op-ico { display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 44px; + width: 44px; + height: 44px; color: var(--isp-accent, var(--isp-accent-other)); - opacity: 0.9; + opacity: 0.95; +} + +/* 品牌 PNG(192×192 透明底):按盒子缩放,不拉伸、不裁切 */ +.isp-brand-logo { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +/* 深色主题的 logo 提亮(实测,不是"看着差不多"): + 电信官方蓝 #004080 的 luminance 太低,直接贴到深色卡面 (#222) 上对比度只有 + 1.55:1——44px 下像蒙了一层灰。只提亮不改色相(brightness/saturate): + 电信 1.55:1 → 3.31:1 联通 2.47:1 → 3.84:1 移动 2.46:1 → 5.31:1 + (前一列按 PNG 原色对 #222 算,后一列从深色截图里采 logo 像素实测。) + 浅色主题不动:白底上三家分别是 10.27 / 6.44 / 6.48:1,提亮反而失真。 + LuCI 的 bootstrap 主题用 :root[data-darkmode="true"] 切整套变量,这里跟随 + 同一个属性——用户点顶栏开关或跟系统深色,都会命中。 */ +[data-darkmode="true"] .isp-brand-logo { + filter: brightness(1.55) saturate(1.05); +} + +/* 兜底卡:与运营商卡同构(顶部色条 + 44px 卡头 + 大数字)。 + 它是「未归属任何运营商」的那一格,所以身份色用中性灰而不是某家的品牌色。 */ +.isp-card-fb { + --isp-accent: var(--isp-accent-other); +} + +/* 网格里只有一条「没有启用的运营商」提示时,让它横跨整行, + 否则会被 repeat(4) 挤成 1/4 宽的一条细缝 */ +.isp-cards > .isp-empty { + grid-column: 1 / -1; } .isp-big { @@ -819,17 +868,30 @@ .lc-head { display: flex; - align-items: flex-start; - justify-content: space-between; + align-items: center; + justify-content: flex-start; gap: 10px; } +/* 与状态页同一个卡头语言(0.3.1):44px logo/glyph 左置。 + 映射不到运营商的 wan(isp 为空、自定义源)用线稿 glyph 占位,盒子同尺寸。 */ +.lc-head-ico { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 44px; + width: 44px; + height: 44px; +} + .lc-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; min-width: 0; + /* 加了 44px 图标后,标题占中间的剩余宽度,评分仍然贴右 */ + flex: 1 1 auto; } .lc-name { diff --git a/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-chinatelecom.png b/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-chinatelecom.png new file mode 100644 index 000000000..9c6442f89 Binary files /dev/null and b/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-chinatelecom.png differ 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 deleted file mode 100644 index c96ac931b..000000000 --- a/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-chinatelecom.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-mobile.png b/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-mobile.png new file mode 100644 index 000000000..2ff68820e Binary files /dev/null and b/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-mobile.png differ 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 deleted file mode 100644 index 620031522..000000000 --- a/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-mobile.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-unicom.png b/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-unicom.png new file mode 100644 index 000000000..6019e04f5 Binary files /dev/null and b/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-unicom.png differ 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 deleted file mode 100644 index 0bd7e0c82..000000000 --- a/luci-app-isp-split/htdocs/luci-static/resources/isp-split/logos/logo-unicom.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/luci-app-isp-split/htdocs/luci-static/resources/view/isp-split/linecheck.js b/luci-app-isp-split/htdocs/luci-static/resources/view/isp-split/linecheck.js index 73073812c..6f943fe7c 100644 --- a/luci-app-isp-split/htdocs/luci-static/resources/view/isp-split/linecheck.js +++ b/luci-app-isp-split/htdocs/luci-static/resources/view/isp-split/linecheck.js @@ -33,9 +33,51 @@ var ICONS = { refresh: '', pulse: '', chevron: '', - shield: '' + shield: '', + /* 运营商兜底 glyph:wan 卡映射不到品牌 logo 时用它们占位 + (与状态页同一套 24 格线稿;视图之间不共享模块,故此处自带一份) */ + tel: '', + uni: '', + cmcc: '', + cernet: '', + other: '' }; +/* wan 卡的 isp 源码 → glyph(与状态页 GLYPH_FOR 同源) */ +var GLYPH_FOR = { + chinatelecom: 'tel', chinanet: 'tel', telecom: 'tel', + unicom_cnc: 'uni', unicom: 'uni', + cmcc: 'cmcc', mobile: 'cmcc', + cernet: 'cernet' +}; + +/* 品牌 logo:与状态页用的是同一组 PNG(同一路径、同一文件名)。 + wan 卡按 linecheck JSON 里的 isp 字段归属映射;映射不到就走 GLYPH_FOR/占位。 */ +var BRAND_LOGO = { + chinatelecom: 'logo-chinatelecom.png', + chinanet: 'logo-chinatelecom.png', + telecom: 'logo-chinatelecom.png', + unicom_cnc: 'logo-unicom.png', + unicom: 'logo-unicom.png', + cmcc: 'logo-mobile.png', + mobile: 'logo-mobile.png' +}; + +var HEAD_ICO = 44; + +function brandLogo(src, size) { + var file = BRAND_LOGO[src]; + if (!file) + return null; + var img = document.createElement('img'); + img.src = L.resource('isp-split/logos/' + file); + img.alt = src; + img.width = size || HEAD_ICO; + img.height = size || HEAD_ICO; + img.className = 'isp-brand-logo'; + return img; +} + function svgIcon(name, size) { var span = document.createElement('span'); span.className = 'isp-ico'; @@ -68,8 +110,12 @@ function fmtAge(sec) { } function fmtMs(v) { - if (v === null || v === undefined || v === '' || v === 0) + /* F-24b:null = 未测/失败,显示“—”;"<1" 与历史 0 都显示 "<1ms"。 + 0 不再作为“没测到”的哨兵(亚毫秒 DNS 曾经被四舍五入成 0)。 */ + if (v === null || v === undefined || v === '') return '—'; + if (v === 0 || v === '<1') + return '<1ms'; return v + 'ms'; } @@ -427,6 +473,14 @@ return view.extend({ var lat = w.latency || {}; var geo = w.geo || {}; var http = w.http || {}; + /* F-23:exit_probe 是 shell 侧逐 URL 记录的探测明细。有明细但 + exit_ip 为空 => 回显源不可达/返回格式不符;连明细都没有 => + 没有发起探测(curl 缺失/未配置),文案保持“可能未真正出网”。 + 所有 JSON 字段只作为 E() 的文本子项,绝不拼进 HTML 解析路径。 */ + var ep = Array.isArray(w.exit_probe) ? w.exit_probe : []; + var exitText = w.exit_ip || (ep.length + ? _('回显源不可达(建议更换 LINE_CHECK_ECHO_URLS)') + : _('未取到(可能未真正出网)')); var detail = E('div', { 'class': 'lc-detail', 'hidden': '' }); var toggle = E('button', { @@ -457,6 +511,12 @@ return view.extend({ sc.grade === 'C' ? 'var(--isp-warn)' : 'var(--isp-bad)') }, [ E('div', { 'class': 'lc-head' }, [ + /* D2:与状态页同一套卡头——44px 左置。 + wan 卡按 isp 归属取品牌 logo;自定义 ISP / 未识别(isp 为空) + 走 GLYPH_FOR 的线稿 glyph,同一 44px 盒子,卡头高度不变。 */ + E('span', { 'class': 'lc-head-ico' }, [ + brandLogo(w.isp) || svgIcon(GLYPH_FOR[w.isp] || 'pulse', HEAD_ICO) + ]), E('div', { 'class': 'lc-title' }, [ E('span', { 'class': 'lc-name' }, [ w.iface || '?' ]), E('span', { 'class': 'lc-dev' }, [ w.device || '' ]), @@ -477,7 +537,7 @@ return view.extend({ E('span', { 'class': 'lc-tag is-muted' }, [ classText(w.ip_class) ]) ]), row(_('出口 IP'), [ - E('span', {}, [ w.exit_ip || _('未取到(可能未真正出网)') ]), + E('span', {}, [ exitText ]), multi ]), row(_('运营商'), [ w.isp_label || _('未知') ]), 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 index 0f79ee57b..1d0659c89 100644 --- 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 @@ -35,7 +35,13 @@ var ICONS = { uni: '', cmcc: '', cernet: '', - other: '' + other: '', + /* 兜底流量卡的身份 glyph:地球 + 经纬线("未归属任何运营商"的那部分流量)。 + 与上面一族同为 24 格 stroke 线稿(stroke-width 1.8、round 收口), + 44px 下线条不会糊;颜色走 currentColor,深浅主题都不用另做一套。 */ + net: '' + + '' + + '' }; var GLYPH_FOR = { @@ -45,17 +51,22 @@ var GLYPH_FOR = { cernet: 'cernet' }; -/* 品牌 logo(运营商官方标志,fill 型多色 SVG,静态文件按请求加载) */ +/* 品牌 logo(运营商官方风格标志,192×192 透明底 PNG,静态文件按请求加载)。 + 0.3.1 起换图不换名:文件名仍是 logo-<运营商>,只把扩展名从 .svg 改成 .png。 + PNG 是位图,深浅两个主题共用一份即可(透明底天然跟随背景)。 */ var BRAND_LOGO = { - chinatelecom: 'logo-chinatelecom.svg', - chinanet: 'logo-chinatelecom.svg', - telecom: 'logo-chinatelecom.svg', - unicom_cnc: 'logo-unicom.svg', - unicom: 'logo-unicom.svg', - cmcc: 'logo-mobile.svg', - mobile: 'logo-mobile.svg' + chinatelecom: 'logo-chinatelecom.png', + chinanet: 'logo-chinatelecom.png', + telecom: 'logo-chinatelecom.png', + unicom_cnc: 'logo-unicom.png', + unicom: 'logo-unicom.png', + cmcc: 'logo-mobile.png', + mobile: 'logo-mobile.png' }; +/* 卡头图标统一 44px:logo 与「无 logo 的 glyph 占位」同尺寸,视觉同构 */ +var HEAD_ICO = 44; + function brandLogo(src, size) { var file = BRAND_LOGO[src]; if (!file) @@ -63,8 +74,8 @@ function brandLogo(src, size) { var img = document.createElement('img'); img.src = L.resource('isp-split/logos/' + file); img.alt = src; - img.width = size || 20; - img.height = size || 20; + img.width = size || HEAD_ICO; + img.height = size || HEAD_ICO; img.className = 'isp-brand-logo'; return img; } @@ -360,6 +371,14 @@ return view.extend({ return false; if (/^\s*#/.test(String(r.set || '')) || /^\s*#/.test(String(r.src || ''))) return false; + /* D1-3:教育网(cernet)退出状态页,纯 UI 退场。 + 后端数据行照旧保留(status --json / 诊断 / 导出都不受影响), + 只是这一版不把它算进运营商网格:cernet 用户量小,指标常年为空, + 混在电信/联通/移动里只会稀释「一眼看三家」的信息密度。 + set 名与 src 一起判:ISP_TABLE 里 cernet 的集合名可以改, + src 改不了。 */ + if (r.src === 'cernet' || r.set === 'isp_cernet') + return false; return true; }); @@ -369,7 +388,6 @@ return view.extend({ ? _('读取状态失败:') + this.statusError : _('没有启用的运营商。检查 /etc/isp-split.conf 里的 ISP_TABLE。') ])); - return; } var maxPackets = 0; @@ -401,8 +419,13 @@ return view.extend({ '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' }, [ brandLogo(r.src, 20) || svgIcon(GLYPH_FOR[r.src] || 'other', 20) ]) + /* 方案 B:logo 44px 左置 + 运营商名。 + 没有品牌 logo 的来源(自定义源 / other)走同一尺寸的 glyph 占位, + 两张卡并排时卡头高度与基线一致,不会一张高一卡矮。 */ + E('span', { 'class': 'isp-op-ico' }, [ + brandLogo(r.src) || svgIcon(GLYPH_FOR[r.src] || 'other', HEAD_ICO) + ]), + E('span', { 'class': 'isp-op-name' }, [ r.label || r.src ]) ]), E('div', {}, [ E('span', { 'class': 'isp-big' }, [ r.exists ? fmtNum(packets) : '—' ]), @@ -430,6 +453,22 @@ return view.extend({ }, [ '—' ]) ])); }); + + /* D1-4:兜底流量并入同一张运营商网格,成为「第 4 张卡」。 + 桌面四列(.isp-cards 是 repeat(4)):三家运营商 + 兜底正好一行四格; + ISP_TABLE 多开运营商时按四列继续换行,兜底始终排在最后,不会被挤到 + 另一行只剩一半宽。卡头与运营商卡同构:44px glyph + 名称。 + 内容由 paintFallback() 写进 #isp-fallback(paintOps 之后调用)。 */ + box.appendChild(E('article', { + 'class': 'isp-card isp-card-op isp-card-fb', + 'style': '--isp-accent: var(--isp-accent-other)' + }, [ + E('div', { 'class': 'isp-card-head' }, [ + E('span', { 'class': 'isp-op-ico' }, [ svgIcon('net', HEAD_ICO) ]), + E('span', { 'class': 'isp-op-name' }, [ _('兜底流量') ]) + ]), + E('div', { 'id': 'isp-fallback' }) + ])); }, /* ---------------- 双线健康 ---------------- */ @@ -485,7 +524,11 @@ return view.extend({ box.appendChild(list); }, - /* ---------------- 兜底流量 ---------------- */ + /* ---------------- 兜底流量(第 4 张运营商卡的主体) ---------------- + 口径与 0.3.0 完全一致:未命中任何运营商集合的连接数(首包)与流量。 + 卡头在 paintOps() 里画(44px glyph + 名称),这里只负责主体, + 字号/间距与运营商卡的 isp-big / isp-op-sub 同一套类名,因此 + 两张卡的数字基线和单位位置自然对齐。 */ paintFallback: function() { var box = document.getElementById('isp-fallback'); @@ -714,18 +757,15 @@ return view.extend({ 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' }, [ + /* 兜底流量不再是独立大卡:它已经并入顶部运营商网格的第 4 格 + (见 paintOps)。这里腾出来的位置让双线健康 / 数据源健康度 / + 数据时间排成一行三格(isp-grid-3,断点 1100→2 列、820→1 列), + 页面从「2+2+2」变成「4 张运营商 + 3 张明细」,视线更顺。 */ + E('div', { 'class': 'isp-grid-3' }, [ 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' }) 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 index 5029ee811..a6059fc05 100644 --- 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 @@ -1,6 +1,6 @@ { "luci-app-isp-split": { - "description": "Grant access to ISP split status and control", + "description": "Grant access to ISP split status and control. WARNING: write = execute ANY isp-split subcommand as root (wire/apply/conf set/uninstall); grant only to fully trusted admins. read = conf + whole state tree (public IP/geo/STUN).", "read": { "ubus": { "file": [ "read", "stat" ] diff --git a/luci-app-openvpn-plus/Makefile b/luci-app-openvpn-plus/Makefile index cdbb39c60..925b7a04d 100755 --- a/luci-app-openvpn-plus/Makefile +++ b/luci-app-openvpn-plus/Makefile @@ -10,7 +10,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-openvpn-plus PKG_VERSION:=1.0.0 -PKG_RELEASE:=8 +PKG_RELEASE:=9 PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=Manfred Jaider diff --git a/luci-app-openvpn-plus/README.md b/luci-app-openvpn-plus/README.md index af72d3f06..2abfb7011 100755 --- a/luci-app-openvpn-plus/README.md +++ b/luci-app-openvpn-plus/README.md @@ -49,6 +49,18 @@ Even though the traffic has to pass through **Double-NAT** and simple port forwa - **Simple Profile Import & Export:** Upload existing `.ovpn` profiles or multi-key configuration files to start new client connections instantly. - **Clear Status Table:** Shows a small overview of running tunnels, active process IDs (PIDs), encryption types, and live data transfer. +## Screenshots + +| 01 - Server/Client View | 02 - Status View | +| :---: | :---: | +| 01 | 02 +| 03 | 15 +| 09 | 10 +| 13 | 11 +| 12 | 04 +| 05 | 06 + + ## Configuration Layout The application manages settings inside `/etc/config/openvpn`: diff --git a/luci-app-openvpn-plus/po/de/luci-app-openvpn-plus.po b/luci-app-openvpn-plus/po/de/luci-app-openvpn-plus.po index 814b1765e..68477e167 100755 --- a/luci-app-openvpn-plus/po/de/luci-app-openvpn-plus.po +++ b/luci-app-openvpn-plus/po/de/luci-app-openvpn-plus.po @@ -46,6 +46,10 @@ msgstr "Client-Instanz hinzufügen" msgid "Add Office" msgstr "Büro hinzufügen" +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:94 +msgid "Add OpenVPN Server Instance" +msgstr "OpenVPN-Server-Instanz hinzufügen" + #: root/www/luci-static/resources/view/vpn/openvpn.js:64 msgid "Add Server Instance" msgstr "Server-Instanz hinzufügen" @@ -1527,4 +1531,3 @@ msgstr "ist aktuell" #: root/www/luci-static/resources/view/vpn/openvpn.js:136 msgid "packets" msgstr "Pakete" - diff --git a/luci-app-openvpn-plus/po/templates/luci-app-openvpn-plus.pot b/luci-app-openvpn-plus/po/templates/luci-app-openvpn-plus.pot index 5d03ac3c1..de1a5c19d 100755 --- a/luci-app-openvpn-plus/po/templates/luci-app-openvpn-plus.pot +++ b/luci-app-openvpn-plus/po/templates/luci-app-openvpn-plus.pot @@ -46,6 +46,10 @@ msgstr "" msgid "Add Office" msgstr "" +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:94 +msgid "Add OpenVPN Server Instance" +msgstr "" + #: root/www/luci-static/resources/view/vpn/openvpn.js:64 msgid "Add Server Instance" msgstr "" diff --git a/luci-app-openvpn-plus/po/zh-cn/luci-app-openvpn-plus.po b/luci-app-openvpn-plus/po/zh-cn/luci-app-openvpn-plus.po index 83829d4bb..7631b4070 100755 --- a/luci-app-openvpn-plus/po/zh-cn/luci-app-openvpn-plus.po +++ b/luci-app-openvpn-plus/po/zh-cn/luci-app-openvpn-plus.po @@ -46,6 +46,10 @@ msgstr "添加客户端实例" msgid "Add Office" msgstr "添加办公室" +#: root/www/luci-static/resources/view/vpn/openvpn-keygen.js:94 +msgid "Add OpenVPN Server Instance" +msgstr "添加 OpenVPN 服务器实例" + #: root/www/luci-static/resources/view/vpn/openvpn.js:64 msgid "Add Server Instance" msgstr "添加服务器实例" @@ -1529,4 +1533,3 @@ msgstr "当前正在" #: root/www/luci-static/resources/view/vpn/openvpn.js:136 msgid "packets" msgstr "数据包" - diff --git a/luci-app-openvpn-plus/root/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup b/luci-app-openvpn-plus/root/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup index fbea6d5ed..12ca331f8 100755 --- a/luci-app-openvpn-plus/root/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup +++ b/luci-app-openvpn-plus/root/etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup @@ -14,7 +14,7 @@ if [ "$ACTION" != "ifup" ]; then exit 0 fi -# STRIP LOOPBACK: Ignore the internal system loopback to prevent a premature trigger +# Ignore the internal system loopback to prevent a premature trigger if [ "$INTERFACE" = "loopback" ]; then exit 0 fi @@ -23,51 +23,65 @@ LOCK_FILE="/var/run/luci-app-openvpn-plus-bootup.lock" # Mutex implementation preventing parallel execution threads from racing each other if [ -f "$LOCK_FILE" ]; then - # Clean verification via signal 0: check if the holding PID is still alive - if kill -0 $(cat "$LOCK_FILE" 2>/dev/null) 2>/dev/null; then + # check if the holding PID is still alive + lock_pid=$(cat "$LOCK_FILE" 2>/dev/null) + if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then exit 0 fi fi # Register active PID into the lock file to serialize the bootup checks -echo "$$" > "$LOCK_FILE" +printf "%s\n" "$$" > "$LOCK_FILE" # Set traps to delete the lock file reliably on unexpected script exits -trap "rm -f $LOCK_FILE" EXIT INT TERM +trap 'rm -f "$LOCK_FILE"' EXIT INT TERM logger -t openvpn-luci "Bootup: Interface '${INTERFACE}' is up. Checking active OpenVPN profile running state in 10 seconds..." # Give the system firewall and routing tables enough time to init sleep 10 -# Loop through all configured openvpn sections inside the UCI configuration -uci -q show openvpn | grep "=openvpn" | while read -r line; do - # Extract the pure instance identity string from the current UCI line - inst="${line##*.}" - inst="${inst%%=*}" +openvpn_sections=$(uci -q show openvpn | awk -F'[.=]' '/=openvpn$/ {print $2}') - # Process the instance only if it is explicitly enabled by the user - if [ "$(uci -q get openvpn.${inst}.enabled)" = "1" ]; then - # Bulletproof process check utilizing the immune syslog tag pattern - if ! ps | grep -v "grep" | grep -q "openvpn(${inst})"; then - logger -t openvpn-luci "Bootup: Detected unstarted instance '${inst}'. Initializing process." +# Exit if no OpenVPN sections exist +if [ -z "$openvpn_sections" ]; then + rm -f "$LOCK_FILE" + trap - EXIT INT TERM + exit 0 +fi - # Restart the service cleanly to clear out any dead zombie states or locked ports - /etc/init.d/openvpn stop "$inst" >/dev/null 2>&1 - sleep 1 >/dev/null 2>&1 - /etc/init.d/openvpn restart "$inst" >/dev/null 2>&1 - sleep 1 >/dev/null 2>&1 - /etc/init.d/openvpn reload "$inst" >/dev/null 2>&1 - else - logger -t openvpn-luci "Bootup: Instance '${inst}' is already running safely." - fi - fi +# Get the current process list +proc_dump=$(ps) + +# Loop through all configured openvpn sections +for inst in $openvpn_sections; do + [ -z "$inst" ] && continue + + # Process the instance only if it is explicitly enabled by the user + if [ "$(uci -q get openvpn."$inst".enabled)" = "1" ]; then + case "$proc_dump" in + *"openvpn(${inst})"* ) + logger -t openvpn-luci "Bootup: Instance '${inst}' is already running safely." + ;; + *) + logger -t openvpn-luci "Bootup: Detected unstarted instance '${inst}'. Initializing process." + + # Restart the service cleanly to clear out any dead zombie states or locked ports + /etc/init.d/openvpn stop "$inst" >/dev/null 2>&1 + sleep 1 + /etc/init.d/openvpn restart "$inst" >/dev/null 2>&1 + sleep 1 + /etc/init.d/openvpn reload "$inst" >/dev/null 2>&1 + ;; + esac + fi done -# Delete the lock file manually after successful execution +# Delete the lock file and reset all traps to system default values before exiting rm -f "$LOCK_FILE" - -# Unregister and reset all traps to system default values before exiting trap - EXIT INT TERM exit 0 + +# Debug: Manual hotplug simulation (e.g. wan up): +# ACTION="ifup" INTERFACE="wan" ash -x /etc/hotplug.d/iface/35-luci-app-openvpn-plus-bootup diff --git a/luci-app-openvpn-plus/root/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle b/luci-app-openvpn-plus/root/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle index a06aa189c..538ae5c9b 100755 --- a/luci-app-openvpn-plus/root/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle +++ b/luci-app-openvpn-plus/root/etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle @@ -6,7 +6,7 @@ # This is free software, licensed under the Apache License, Version 2.0. # See /LICENSE for more information. # -# luci-app-openvpn-plus : OpenWrt hotplug lifecycle manager for ddns server and client common name +# luci-app-openvpn-plus : hotplug lifecycle manager for ddns server and client common name # /etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle # Ensure we catch the active OpenVPN name context across all OpenWrt variants @@ -25,12 +25,12 @@ PID_FILE="/var/run/openvpn-ddns-${ovpn_instance}.pid" case "$ACTION" in up) - inst_role=$(uci -q get openvpn."$ovpn_instance".role) # Save the active interface name into the OpenWrt UCI configuration if [ "$inst_role" = "server" ] || [ "$inst_role" = "client" ]; then if [ -n "$ovpn_dev" ]; then + # Only write and commit if the devname actually changed current_devname=$(uci -q get openvpn."$ovpn_instance".devname) if [ "$current_devname" != "$ovpn_dev" ]; then uci -q set openvpn."$ovpn_instance".devname="$ovpn_dev" @@ -60,40 +60,52 @@ case "$ACTION" in start-stop-daemon -S -b -m -p "$PID_FILE" -x /usr/libexec/luci-app-openvpn-plus -- ddnsservice "$domain" "$url" "$ovpn_instance" fi fi + # CLIENT: EXTRACT COMMON NAME elif [ "$inst_role" = "client" ]; then cn_update="0" - # Extract the last connection initiation line from logread for this specific instance - log_line=$(logread | grep "openvpn(${ovpn_instance})" | grep "Peer Connection Initiated" | tail -n 1) - if [ -n "$log_line" ]; then - # Parse the pure CN string from inside the brackets (Example: [OfficeServer] -> OfficeServer) - server_cn=$(echo "$log_line" | sed -n 's/.*: \[\([^]]*\)\].*Peer Connection Initiated.*/\1/p') + # Let logread stream only the lines belonging to this specific instance. + log_line=$(logread -e "openvpn(${ovpn_instance})" | grep "Peer Connection Initiated" | tail -n 1) - if [ -n "$server_cn" ]; then - # Write the resolved Common Name safely into the OpenWrt UCI configuration - uci -q set openvpn."$ovpn_instance".server_common_name="$server_cn" - uci commit openvpn + if [ -n "$log_line" ]; then + # Parse the pure CN string from inside the brackets (Example: [OfficeServer] -> OfficeServer) + server_cn=$(echo "$log_line" | sed -n 's/.*\[\([^]]*\)\].*Peer Connection Initiated.*/\1/p') + + if [ -n "$server_cn" ]; then + # Only write and commit if the Common Name actually changed + current_cn=$(uci -q get openvpn."$ovpn_instance".server_common_name) + if [ "$current_cn" != "$server_cn" ]; then + uci -q set openvpn."$ovpn_instance".server_common_name="$server_cn" + uci commit openvpn + logger -t openvpn-luci "Hotplug (${ovpn_instance}): set server_common_name ${server_cn}" + fi cn_update="1" - fi - fi - if [ "$cn_update" = "1" ]; then - logger -t openvpn-luci "Hotplug (${ovpn_instance}): Server Common Name '${server_cn}' resolved" - else - logger -t openvpn-luci "Hotplug (${ovpn_instance}): Server Common Name not found in Peer Connection log entry" - fi - fi + fi + fi + + if [ "$cn_update" = "1" ]; then + logger -t openvpn-luci "Hotplug (${ovpn_instance}): Server Common Name '${server_cn}' resolved" + else + logger -t openvpn-luci "Hotplug (${ovpn_instance}): Server Common Name not found in Peer Connection log entry" + fi + fi ;; route-pre-down|down) - # Clean application stop via standard system manager signals if start-stop-daemon -K -q -p "$PID_FILE" -t 2>/dev/null; then logger -t openvpn-luci "Hotplug: Stopping DDNS service for ${ovpn_instance} because tunnel is down." start-stop-daemon -K -q -p "$PID_FILE" 2>/dev/null fi rm -f "$PID_FILE" - + ;; esac exit 0 + +# Debug: Manual hotplug simulation (e.g. instance1 up): +# ACTION="up" INSTANCE="instance1" dev="tun0" ash -x /etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle + +# Debug: Manual hotplug simulation (e.g. instance1 down): +# ACTION="down" INSTANCE="instance1" dev="tun0" ash -x /etc/hotplug.d/openvpn/35-luci-app-openvpn-plus-lifecycle diff --git a/luci-app-openvpn-plus/root/usr/libexec/luci-app-openvpn-plus b/luci-app-openvpn-plus/root/usr/libexec/luci-app-openvpn-plus index 6dc87834e..0792b6bb0 100755 --- a/luci-app-openvpn-plus/root/usr/libexec/luci-app-openvpn-plus +++ b/luci-app-openvpn-plus/root/usr/libexec/luci-app-openvpn-plus @@ -196,7 +196,7 @@ internal_checkpath() { # Characters are safely escaped with a backslash to protect the shell syntax. return 1 ;; - *$'\t'*|$'\n'*|$'\r'*) + *$'\t'*|*$'\n'*|*$'\r'*) # Block control characters like tabs, newlines, and carriage returns natively return 1 ;; @@ -738,36 +738,36 @@ symlink() { return 1 fi - local ovpn_instance="$1" - local action="$2" + local ovpn_instance="${1}" + local action="${2}" local raw_display_name="${3:-}" # Check if instance name is valid - if ! internal_checkname "$ovpn_instance"; then + if ! internal_checkname "${ovpn_instance}"; then printf "%s\n" "ERROR: Invalid character format in instance name." >&2 return 1 fi local export_static_path="/etc/openvpn/luci/${ovpn_instance}.ovpn" local lan_ip="" - local link_filename="" + local download_filename="" local ram_link_dir="/tmp/luci-ovpn-plus" local www_link_dir="/www/ovpn-downloads" # Check if display name is valid - if [ -n "$raw_display_name" ]; then - if ! internal_checkname "$raw_display_name"; then + if [ -n "${raw_display_name}" ]; then + if ! internal_checkname "${raw_display_name}"; then printf "%s\n" "ERROR: Invalid character format in display name." >&2 return 1 fi - link_filename="$raw_display_name" + download_filename="${raw_display_name}" else - link_filename="$ovpn_instance" + download_filename="${ovpn_instance}" fi - case "$action" in + case "${action}" in create) - if [ ! -f "$export_static_path" ]; then + if [ ! -f "${export_static_path}" ]; then printf "%s\n" "ERROR: Profile file missing." >&2 return 1 fi @@ -775,36 +775,47 @@ symlink() { # Create a 9-byte random secret token as hex safely local secret_token secret_token=$(dd if=/dev/urandom bs=9 count=1 2>/dev/null | hexdump -e '"%02x"') - if [ -z "$secret_token" ]; then + if [ -z "${secret_token}" ]; then printf "%s\n" "ERROR: Failed to generate secure token." >&2 return 1 fi - local session_dir="${ram_link_dir}/ovpn-tmp-${secret_token}-${link_filename}" - mkdir -p "$session_dir" + local session_dir="${ram_link_dir}/ovpn-tmp-${secret_token}-${ovpn_instance}" + mkdir -p "${session_dir}" # Define file path and copy profile with safe webserver permissions - local token_protected_file="${session_dir}/${link_filename}.ovpn" - cp -f "$export_static_path" "$token_protected_file" 2>/dev/null - chmod 644 "$token_protected_file" + local token_protected_file="${session_dir}/${download_filename}.ovpn" + cp -f "${export_static_path}" "${token_protected_file}" 2>/dev/null + chmod 644 "${token_protected_file}" - # Create webserver shortcut to the RAM folder securely - if [ ! -L "$www_link_dir" ] && [ ! -d "$www_link_dir" ]; then - ln -s "$ram_link_dir" "$www_link_dir" + # Create directory with restricted access and block directory listing with index.html redirect + if [ ! -d "${www_link_dir}" ]; then + mkdir -p "${www_link_dir}" + # Enforce ultra-strict permissions so only root/webserver can see the folder structure + chmod 700 "${www_link_dir}" + # create index.html with redirect users back to the server root (/) + printf '%s\n' '' > "${www_link_dir}/index.html" fi - if [ -f "$token_protected_file" ]; then + local symlink_dir="${www_link_dir}/ovpn-tmp-${secret_token}-${ovpn_instance}" + + # Link the specific session directory + if [ ! -L "${symlink_dir}" ]; then + ln -s "${session_dir}" "${symlink_dir}" + fi + + if [ -f "${token_protected_file}" ]; then # Get lan ip with internal lanip function lan_ip=$(lanip "lanip") - [ -z "$lan_ip" ] && lan_ip="localhost" + [ -z "${lan_ip}" ] && lan_ip="localhost" # If the IP contains a colon (:), it is an IPv6 address and needs brackets [] - case "$lan_ip" in + case "${lan_ip}" in *:*) lan_ip="[${lan_ip}]" ;; esac - printf "SUCCESS: http://%s/ovpn-downloads/ovpn-tmp-%s-%s/%s.ovpn\n" "$lan_ip" "$secret_token" "$link_filename" "$link_filename" + printf "SUCCESS: http://%s/ovpn-downloads/ovpn-tmp-%s-%s/%s.ovpn\n" "${lan_ip}" "${secret_token}" "${ovpn_instance}" "${download_filename}" else printf "%s\n" "ERROR: Failed to create web export file." >&2 return 1 @@ -813,14 +824,35 @@ symlink() { delete) # Safe deletion inside RAM directory only. Absolute path structure guaranteed. - if [ -d "$ram_link_dir" ]; then - rm -rf "${ram_link_dir}/ovpn-tmp-*-${link_filename}" + if [ -d "${ram_link_dir}" ]; then + # Find and remove the specific symlink from the webroot before cleaning up RAM + local target_link + for target_link in "${www_link_dir}"/ovpn-tmp-*-"${ovpn_instance}"; do + [ -L "${target_link}" ] && rm -f "${target_link}" + done - # Remove RAM base directory and the web symlink if it was the last file - rmdir "$ram_link_dir" 2>/dev/null - if [ ! -d "$ram_link_dir" ] && [ -L "$www_link_dir" ]; then - rm -f "$www_link_dir" - fi + # Remove the temporary session files for this instance from RAM + rm -rf "${ram_link_dir}"/ovpn-tmp-*-"${ovpn_instance}" + + # Remove RAM base directory if it has become completely empty + rmdir "${ram_link_dir}" 2>/dev/null + + # Smart Web-Folder Cleanup: Check if files other than index.html exist + if [ -d "${www_link_dir}" ]; then + local has_profiles="0" + local check_link + # If a real symlink or folder matching the pattern exists, has_profiles becomes 1 + for check_link in "${www_link_dir}"/ovpn-tmp-*-*; do + if [ -e "${check_link}" ] || [ -L "${check_link}" ]; then + has_profiles="1" + break + fi + done + # Remove the web folder if no active profile links are left + if [ "${has_profiles}" = "0" ]; then + rm -rf "${www_link_dir}" + fi + fi printf "%s\n" "SUCCESS: Link directory and static profile deleted from RAM." else @@ -837,6 +869,7 @@ symlink() { return 0 } + # Helper to prevent routing loops for IPv4 and IPv6 subnets internal_checkloopback() { # internal (no shift) @@ -1878,69 +1911,108 @@ ucisortovpn() { return 0 } -# Delete configuration and key files of deleted VPN instances +# Universal function: deletes all files, folders or symlinks if the instance is not active +internal_cleaninst() { + # internal (no shift) + local active_instances="${1}" + local file_pattern="${2}" + local f + local fname + local instname + + # Loop through all files, folders or links that match the pattern + for f in $file_pattern; do + # Skip if it does not exist AND is not a symlink (handles dead links safely) + if [ ! -e "${f}" ] && [ ! -L "${f}" ]; then + continue + fi + + # a) Get the pure filename or folder name without the path + fname="${f##*/}" + # b) Keep only the word "instance" and everything after it + instname="instance${fname#*instance}" + # c) Remove the extension starting from the first dot (.key, .nft, .status) + instname="${instname%%.*}" + + # Compare the extracted name with our active list in RAM using strict space boundaries + case " ${active_instances} " in + *" ${instname} "*) + # Instance is active, keep the file + ;; + *) + # SECURITY GUARD: To avoid rm manipulation, check the name before the deletion + if internal_checkname "${instname}"; then + # Safely remove files, dead symlinks or directories + rm -rf "${f}" + logger -t openvpn-luci "Cleanup: Removed orphaned item '${f}'." + fi + ;; + esac + done +} + cleanup() { # no argument (no shift) local conf_file local instname + local active_list="" local conf_dir="/etc/openvpn/luci" local key_dir="/etc/openvpn/keys" + local run_dir="/var/run" local ram_link_dir="/tmp/luci-ovpn-plus" local www_link_dir="/www/ovpn-downloads" - # Step 1: Stop here if there are no instance config files on the router - ls /etc/openvpn/luci/instance*.conf >/dev/null 2>&1 || return 0 - - # Step 2: Loop through all existing instance configuration files - for conf_file in "$conf_dir"/instance*.conf; do - if [ -f "$conf_file" ]; then + # Step 1: Build the list of ACTIVE instances from the instance*.conf files + for conf_file in "${conf_dir}"/instance*.conf; do + if [ -f "${conf_file}" ]; then instname="${conf_file##*/}" instname="${instname%.conf}" - # CRITICAL SECURITY GUARD: Validate the instance name before any file operation. - # This completely neutralizes dangerous path traversal attacks via rm -rf! - if ! internal_checkname "$instname"; then - continue - fi - - # Step 3: If the UCI section does not exist, delete files and crypto keys - if ! uci -q get openvpn."$instname" >/dev/null; then - rm -f "$conf_file" - rm -f "$conf_dir/${instname}.nft" - rm -f "$conf_dir/${instname}.jmp.nft" - rm -f "$conf_dir/${instname}.s2s.nft" - rm -f "$key_dir/ca_${instname}.crt" - rm -f "$key_dir/dh_${instname}.pem" - rm -f "$key_dir/server_${instname}.crt" - rm -f "$key_dir/client_${instname}.crt" - rm -f "$key_dir/server_${instname}.key" - rm -f "$key_dir/client_${instname}.key" - rm -f "$key_dir/server_${instname}.tls2.key" - rm -f "$key_dir/client_${instname}.tls2.key" - - # Delete the static .ovpn file for this specific deleted instance - rm -f "$conf_dir/${instname}.ovpn" - - # remove ddns status file for this instance - rm -f "/var/run/openvpn-ddns-${instname}.status" - - # Clean up the specific RAM files for this deleted instance - if [ -d "$ram_link_dir" ]; then - rm -rf "${ram_link_dir}"/ovpn-tmp-*-*"${instname}" - fi + if internal_checkname "${instname}"; then + # Enforce space separation to prevent partial substring matches later + active_list="${active_list} ${instname}" fi fi done - # Step 4: Safely clean up the empty base directories and the web symlink - if [ -d "$ram_link_dir" ]; then - # Removes the RAM base folder ONLY if it is completely empty - rmdir "$ram_link_dir" 2>/dev/null - fi - # Clean up the web shortcut if the RAM directory does not exist anymore - if [ ! -d "$ram_link_dir" ] && [ -L "$www_link_dir" ]; then - rm -f "$www_link_dir" - fi + # Step 2: Remove orphan instance files + internal_cleaninst "${active_list}" "${conf_dir}/instance*.conf" + internal_cleaninst "${active_list}" "${conf_dir}/instance*.nft" + internal_cleaninst "${active_list}" "${conf_dir}/instance*.jmp.nft" + internal_cleaninst "${active_list}" "${conf_dir}/instance*.s2s.nft" + internal_cleaninst "${active_list}" "${conf_dir}/instance*.ovpn" + internal_cleaninst "${active_list}" "${key_dir}/ca_instance*.crt" + internal_cleaninst "${active_list}" "${key_dir}/dh_instance*.pem" + internal_cleaninst "${active_list}" "${key_dir}/server_instance*.crt" + internal_cleaninst "${active_list}" "${key_dir}/client_instance*.crt" + internal_cleaninst "${active_list}" "${key_dir}/server_instance*.key" + internal_cleaninst "${active_list}" "${key_dir}/client_instance*.key" + internal_cleaninst "${active_list}" "${key_dir}/server_instance*.tls2.key" + internal_cleaninst "${active_list}" "${key_dir}/client_instance*.tls2.key" + internal_cleaninst "${active_list}" "${run_dir}/openvpn-ddns-instance*.status" + + # Step 3: Remove orphan symlink files + internal_cleaninst "${active_list}" "${www_link_dir}/ovpn-tmp-*-*" + internal_cleaninst "${active_list}" "${ram_link_dir}/ovpn-tmp-*-*" + + # Remove the temporary RAM directory ONLY if it is completely empty + [ -d "${ram_link_dir}" ] && rmdir "${ram_link_dir}" 2>/dev/null + # Smart Web-Folder Cleanup: Check if files other than index.html exist + if [ -d "${www_link_dir}" ]; then + local has_profiles="0" + local check_link + # If a real symlink or folder matching the pattern exists, has_profiles becomes 1 + for check_link in "${www_link_dir}"/ovpn-tmp-*-*; do + if [ -e "${check_link}" ] || [ -L "${check_link}" ]; then + has_profiles="1" + break + fi + done + # Remove the web folder if no active profile links are left + if [ "${has_profiles}" = "0" ]; then + rm -rf "${www_link_dir}" + fi + fi return 0 } diff --git a/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn-keygen.js b/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn-keygen.js index 58387eba5..c2065abe5 100755 --- a/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn-keygen.js +++ b/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn-keygen.js @@ -91,7 +91,7 @@ const TXT = { tls_key_server: _('Master Server Secret Key'), tls_key_client: _('Client Key'), title_main: _('OpenVPN Instance Key Generator'), - add_server_instance: ('Add OpenVPN Server Instance'), + add_server_instance: _('Add OpenVPN Server Instance'), unknown_asset: _('Unknown Asset'), unknown_structure: _('Unknown Structure'), use_unique_name_for_each_office: _('Important: Use a unique name for each office to configure the network routing correctly.'), @@ -231,8 +231,8 @@ const getKeyMeta = async function (keyfile, luci_app_openvpn_plus_callback) { if (!res || res.length === 0) { return result; } - res = res.toUpperCase(); - if (res.startsWith('ERROR') || res.indexOf('ERROR') !== -1 || res.startsWith('INVALID')) { + const resUpper = res.toUpperCase(); + if (resUpper.startsWith('ERROR') || resUpper.indexOf('ERROR') !== -1 || resUpper.startsWith('INVALID')) { return result; } diff --git a/luci-app-partexp/Makefile b/luci-app-partexp/Makefile index 905c9db5d..f52f83821 100644 --- a/luci-app-partexp/Makefile +++ b/luci-app-partexp/Makefile @@ -11,7 +11,7 @@ LUCI_TITLE:=LuCI Support for Automatic Partition Mount LUCI_PKGARCH:=all LUCI_DEPENDS:=+fdisk +block-mount +bc +blkid +parted +btrfs-progs +losetup +resize2fs +e2fsprogs +f2fs-tools +kmod-loop PKG_VERSION:=2.0.5 -PKG_RELEASE:=4 +PKG_RELEASE:=5 PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=Sirpdboy diff --git a/luci-app-partexp/root/usr/bin/partexp b/luci-app-partexp/root/usr/bin/partexp index 0ded8656d..c3ae2ffc6 100755 --- a/luci-app-partexp/root/usr/bin/partexp +++ b/luci-app-partexp/root/usr/bin/partexp @@ -30,6 +30,26 @@ logn(){ echo -e " $*" | tee -a $LOG } +# 获取当前 overlay 的数据目录。 +# 不要写死 /overlay:部分固件(如 Kwrt/ImmortalWrt 的部分机型)把 rootfs_data +# 挂在 /rom/overlay,而 /overlay 只存在于 preinit 的挂载命名空间里。 +get_overlay_src() { + local src upper + upper=$(sed -n 's/.*upperdir=\([^,]*\).*/\1/p' /proc/self/mountinfo 2>/dev/null | head -1) + src="${upper%/upper}" + if [ -n "$src" ] && [ -d "$src" ]; then + echo "$src" + return 0 + fi + for src in /overlay /rom/overlay; do + if [ -d "$src" ]; then + echo "$src" + return 0 + fi + done + return 1 +} + load_uci_config() { if [ -f "/etc/config/$CONFIG" ]; then target_function=$(uci -q get $CONFIG.global.target_function) @@ -214,10 +234,10 @@ format_disk() { # 获取系统盘 get_system_disk() { - rom_dev=$(df -h | grep boot | awk '{print $1}' | head -1 ) - [ -z ${rom_dev} ] && rom_dev=`df -P /boot 2>/dev/null | awk 'NR==2 {print $1}'` - [ -z ${rom_dev} ] && rom_dev=`blkid -t TYPE="squashfs" |awk -F : '{print $1}'` - [ -z ${rom_dev} ] && TDISK=`lsblk -l -o NAME,MOUNTPOINTS | grep -E '/rom'| grep -v '/rom/' | head -1 | awk '{print $1}'` && rom_dev="/dev/$TDISK" + rom_dev=$(df -h 2>/dev/null | grep boot | awk '{print $1}' | head -1 ) + [ -z "${rom_dev}" ] && rom_dev=$(df -P /boot 2>/dev/null | awk 'NR==2 {print $1}') + [ -z "${rom_dev}" ] && rom_dev=$(blkid -t TYPE="squashfs" 2>/dev/null | awk -F : 'NR==1 {print $1}') + [ -z "${rom_dev}" ] && TDISK=`lsblk -l -o NAME,MOUNTPOINTS | grep -E '/rom'| grep -v '/rom/' | head -1 | awk '{print $1}'` && rom_dev="/dev/$TDISK" if [ -n "$rom_dev" ]; then case "$rom_dev" in /dev/sd[a-z][0-9]*) @@ -429,7 +449,10 @@ fdiskB() { block detect > /etc/config/fstab uci -q set fstab.@global[0].anon_mount='0' uci -q set fstab.@global[0].auto_mount='0' - uci commit fstab + # 注意:'uci commit fstab' 会让 procd 重载 /etc/init.d/fstab,而它的 + # stop() 是 '/sbin/block umount',会把 /rom 和当前 /overlay 一起卸载。 + # 扩展 overlay 时当前 overlay 正是配置拷贝源,所以此时不能提交。 + [ "$target_function" = "/overlay" ] || uci commit fstab if [ "$target_function" = '/opt' ]; then /etc/init.d/dockerd stop >/dev/null 2> /dev/null @@ -483,6 +506,14 @@ fdiskB() { fi sleep 5 if [[ "$target_function" = "/" || "$target_function" = "/overlay" ]]; then + # 整盘被自动挂载(如 /dev/sda -> /mnt/sda)时 mkfs 会拒绝执行 + # ("apparently in use by the system"),所以先卸载整盘。 + if mount | grep -q "^/dev/$a "; then + log "取消整盘挂载 /dev/$a ..." + block umount /dev/$a >/dev/null 2> /dev/null + umount /dev/$a >/dev/null 2> /dev/null + sleep 1 + fi format_disk "/dev/$b" $format_type elif [[ "$format_type" != "0" || "$isfdisk" = "1" ]]; then format_disk "/dev/$b" $format_type @@ -523,7 +554,22 @@ fdiskB() { case "$target_function" in "/overlay") if [ "$keep_config" = "1" ]; then - tar -C /overlay -cvf - . | tar -C /mnt/$b/ -xf - || tar -C /rom/overlay -cvf - . | tar -C /mnt/$b/ -xf - + SRC=$(get_overlay_src) + if [ -z "$SRC" ]; then + log "错误:找不到当前 overlay 数据目录,已中止(未修改 fstab)" + expquit 1 + fi + log "正在从 $SRC 拷贝配置到 /mnt/$b ..." + if ! tar -C "$SRC" -cf - . | tar -C /mnt/$b/ -xf -; then + log "错误:配置拷贝失败,已中止(未修改 fstab,系统未受影响)" + umount /dev/$b >/dev/null 2> /dev/null + expquit 1 + fi + if [ ! -f "/mnt/$b/upper/etc/config/network" ] && [ ! -f "/mnt/$b/etc/config/network" ]; then + log "错误:拷贝校验失败(未找到 etc/config/network),已中止(未修改 fstab)" + umount /dev/$b >/dev/null 2> /dev/null + expquit 1 + fi umount /dev/$b >/dev/null 2> /dev/null [ $? -ne 0 ] && umount /mnt/$b >/dev/null 2> /dev/null [ $? -ne 0 ] && block umount /dev/$b >/dev/null 2> /dev/null @@ -781,9 +827,13 @@ stop() { expquit() { rm -f $LOCK - uci -q set fstab.@global[0].anon_mount='1' - uci -q set fstab.@global[0].auto_mount='1' - uci commit fstab + # 只有非 overlay 模式才在开头改过 anon_mount/auto_mount,需要恢复; + # overlay 模式为避免 block umount 没有提交,这里也就不能再 commit。 + if [ "$target_function" != "/overlay" ]; then + uci -q set fstab.@global[0].anon_mount='1' + uci -q set fstab.@global[0].auto_mount='1' + uci commit fstab + fi [ -e "/etc/config/dockerd" ] && /etc/init.d/dockerd restart >/dev/null 2> /dev/null [ "$1" = "2" ] && log "如果没生效,请重启设备" sleep 1 diff --git a/luci-app-wwand/htdocs/luci-static/resources/view/wwand/status.js b/luci-app-wwand/htdocs/luci-static/resources/view/wwand/status.js index fda0da6be..395a77922 100644 --- a/luci-app-wwand/htdocs/luci-static/resources/view/wwand/status.js +++ b/luci-app-wwand/htdocs/luci-static/resources/view/wwand/status.js @@ -983,7 +983,7 @@ function renderLive(name, modem, graphs, board) { '%d / %d'.format(lc.tac, lc.global_cell_id) ]); var enb = fmt.fmtEnodeb(lc.global_cell_id); if (enb) - srvRows.push([ term(_('eNodeB / Sector'), _('The Cell ID split into the base station (eNodeB ID = Cell ID / 256) and its cell, often one sector of the site (Cell ID % 256). Cells with the same eNodeB ID are on the same site')), + srvRows.push([ term(_('eNB / Sector'), _('The Cell ID split into the base station (eNB = eNodeB, ID = Cell ID / 256) and its cell, often one sector of the site (Cell ID % 256). Cells with the same eNB ID belong to the same base station, which may drive radios at more than one site')), enb ]); } var nc = cells.nr5g_cell, sn = (cells.serving||{}).nr; diff --git a/luci-app-wwand/po/templates/wwand.pot b/luci-app-wwand/po/templates/wwand.pot index e53a23c88..16d70cc4b 100644 --- a/luci-app-wwand/po/templates/wwand.pot +++ b/luci-app-wwand/po/templates/wwand.pot @@ -2852,9 +2852,10 @@ msgstr "" #: htdocs/luci-static/resources/view/wwand/status.js:986 msgid "" -"The Cell ID split into the base station (eNodeB ID = Cell ID / 256) and its " -"cell, often one sector of the site (Cell ID % 256). Cells with the same " -"eNodeB ID are on the same site" +"The Cell ID split into the base station (eNB = eNodeB, ID = Cell ID / 256) " +"and its cell, often one sector of the site (Cell ID % 256). Cells with the " +"same eNB ID belong to the same base station, which may drive radios at more " +"than one site" msgstr "" #: htdocs/luci-static/resources/view/wwand/status.js:936 @@ -3573,7 +3574,7 @@ msgid "disabled (SIM boots without PIN)" msgstr "" #: htdocs/luci-static/resources/view/wwand/status.js:986 -msgid "eNodeB / Sector" +msgid "eNB / Sector" msgstr "" #: htdocs/luci-static/resources/view/wwand/sims.js:94 diff --git a/luci-theme-vantage/Makefile b/luci-theme-vantage/Makefile index 8325e7d9a..0a67a99e6 100644 --- a/luci-theme-vantage/Makefile +++ b/luci-theme-vantage/Makefile @@ -11,7 +11,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-theme-vantage PKG_VERSION:=1.0.10 -PKG_RELEASE:=24 +PKG_RELEASE:=25 PKG_LICENSE:=GPL-3.0-or-later LUCI_TITLE:=Vantage theme diff --git a/luci-theme-vantage/htdocs/luci-static/vantage/vantage.css b/luci-theme-vantage/htdocs/luci-static/vantage/vantage.css index 1ce6f2cd2..3d7d6e174 100644 --- a/luci-theme-vantage/htdocs/luci-static/vantage/vantage.css +++ b/luci-theme-vantage/htdocs/luci-static/vantage/vantage.css @@ -979,12 +979,11 @@ input, select, textarea, button { font: inherit; color: inherit; } line-height: 20px; transition: border-color .12s, box-shadow .12s; } -.vt-main textarea, .cbi-input-textarea, .modal textarea { max-width: calc(2 * var(--field-max)); min-height: 88px; font-family: var(--mono); font-size: var(--fs-sm); resize: vertical; } +.vt-main textarea, .cbi-input-textarea, .modal textarea { max-width: 100%; min-height: 88px; font-family: var(--mono); font-size: var(--fs-sm); resize: vertical; } /* read-only text (System and Kernel log) is a viewer, not a field: full width */ .vt-main textarea[readonly] { max-width: none; } -/* a short free-text option (System -> Notes: no rows set) is as wide as the - fields beside it; editors that set their rows (Scheduled Tasks) stay wide */ -.cbi-value-field textarea.cbi-input-textarea:not([rows]) { max-width: var(--field-max); } +/* System notes follow the neighbouring single-line fields. */ +body[data-page="admin-system-system"] .cbi-value[data-name="notes"] textarea { max-width: var(--field-max); } .vt-main select, .cbi-input-select, .modal select { appearance: none; -webkit-appearance: none; padding-right: 30px; @@ -1209,7 +1208,8 @@ input[type="button"], input[type="reset"], input[type="submit"]) { border-radius: var(--r); box-shadow: var(--shadow-pop); overflow-x: hidden; overflow-y: auto; - transition: max-height .12s ease-out; + /* LuCI focuses the selected item on transitionend; avoid animating its scroll range. */ + transition: max-height .001s; } .cbi-dropdown[open] > ul.dropdown[style*="bottom"] { top: auto; } .cbi-dropdown[open] > ul.dropdown > li { @@ -1952,6 +1952,8 @@ body.modal-overlay-active #modal_overlay { display: block; } font-size: var(--fs-lg); } #modal_overlay > .modal.cbi-modal { width: min(960px, 100%); } +#modal_overlay > .modal:has(> textarea[readonly]) { width: min(1080px, 100%); } +#modal_overlay > .modal > textarea[readonly] { max-width: 100%; } #modal_overlay > .modal .cbi-section { background: none; border: 0; padding: 0; margin: 0; box-shadow: none; } /* a modal's sections have no card padding, so nothing in them bleeds out by it: tables keep the modal's width (a bleed scrolled the section @@ -2067,7 +2069,7 @@ body.modal-overlay-active #modal_overlay { display: block; } .assoclist .td { vertical-align: middle; } /* free-standing tables (no section around them) become a card; clip, not hidden: a scroll container would capture the sticky header */ -#view > .table, #view > div > .table, .cbi-map > .table, #view > div > div > [data-tab-title]:not(.cbi-section) > .table { +#view > .table, #view > div:not(.cbi-section) > .table, .cbi-map > .table, #view > div > div > [data-tab-title]:not(.cbi-section) > .table { margin: 0 0 var(--sp-4); background: var(--s1); border: 1px solid var(--line); @@ -2075,8 +2077,8 @@ body.modal-overlay-active #modal_overlay { display: block; } box-shadow: var(--v-shadow); overflow: clip; } -:is(#view, #view > div, .cbi-map, [data-tab-title]:not(.cbi-section)) > .table .tr > :is(.th, .td):first-child { padding-left: var(--card-px); } -:is(#view, #view > div, .cbi-map, [data-tab-title]:not(.cbi-section)) > .table .tr > :is(.th, .td):last-child { padding-right: var(--card-px); } +:is(#view, #view > div:not(.cbi-section), .cbi-map, [data-tab-title]:not(.cbi-section)) > .table .tr > :is(.th, .td):first-child { padding-left: var(--card-px); } +:is(#view, #view > div:not(.cbi-section), .cbi-map, [data-tab-title]:not(.cbi-section)) > .table .tr > :is(.th, .td):last-child { padding-right: var(--card-px); } #view > h3, #view > div > h3 { margin: var(--sp-6) 0 var(--sp-3); font-size: var(--fs-md); } /* LuCI 24.10: Status -> Overview's sections are #view's children with the heading first (25.12 wraps it in .cbi-title): it keeps the header row's @@ -2459,19 +2461,27 @@ html:not([data-darkmode="true"]) .vt-login-page::before { .cbi-section-table-row:has(> .td[data-name="_stat"] big):first-child > .td { border-top: 0; } .cbi-section-table-row:not(:has(big)) > .td[data-name="_badge"] { padding-left: calc(var(--card-px) + 16px); } -/* progress bars: thin track, value text outside the fill */ +/* progress bars: thin track, value text above it and in flow, so larger + browser fonts cannot run into the heading */ .cbi-progressbar { - height: 6px; margin: 20px 0 2px; - background: var(--s3); + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto 6px; + gap: 5px; + height: auto; margin: 0 0 2px; + background: none; border: 0; border-radius: 3px; overflow: visible; } -.cbi-progressbar > div { border-radius: 3px; background: var(--accent-text); } +.cbi-progressbar > div { grid-area: 2 / 1; height: 6px; z-index: 1; border-radius: 3px; background: var(--accent-text); } .cbi-progressbar::before { - inset: auto 0 calc(100% + 5px) 0; padding: 0; + position: static; inset: auto; padding: 0; + display: block; color: var(--text-2); font: 500 var(--fs-micro)/1.2 var(--mono); + white-space: normal; overflow-wrap: anywhere; } +.cbi-progressbar::after { content: ""; grid-area: 2 / 1; height: 6px; border-radius: 3px; background: var(--s3); } /* fused memory / storage meters (built by menu-vantage.js) */ .v-src-hidden { display: none !important; } @@ -2517,15 +2527,23 @@ html:not([data-darkmode="true"]) .vt-login-page::before { .v-meter-row > .v-meter-val b { color: var(--text); font-weight: 600; } .v-sr { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } -/* wide tables scroll inside their card, never the page. Sticky headers need - a non-scrolling ancestor, so status tables only scroll when narrow. The - card is as tall as its content, so it has nothing to scroll down to; a - few pixels of overflow (Firefox on a scaled display) made it scroll */ -.cbi-tblsection, .cbi-section:has(> .cbi-tblsection) { overflow-x: auto; overflow-y: hidden; } +/* Share the widest table's intrinsic width with its headings and dividers. */ +@media (width > 720px) { + .cbi-tblsection { display: grid; grid-template-columns: minmax(min-content, 1fr); } + .cbi-section.cbi-tblsection > .table { width: auto !important; } + .cbi-section:has(.cbi-tblsection) > .cbi-section-node { display: table; width: 100%; } + .cbi-value:has(> .table) { display: block; } +} + +/* Table sections can sit below form wrappers; their outer card scrolls too. + Other status cards only scroll when narrow so their headers can stick. + Vertical overflow is clipped to prevent spurious scrolling on scaled + Firefox displays. */ +.cbi-tblsection, .cbi-section:has(.cbi-tblsection), .cbi-section:has(> :where(#packages)) { overflow-x: auto; overflow-y: hidden; } /* overlay scrollbars show nothing until touched: a shade on the side with more table says it scrolls. The covers move with the content and hide the shade at either end, so a card that fits looks as before. */ -.vt-main .cbi-tblsection, .vt-main .cbi-section:has(> .cbi-tblsection) { +.vt-main .cbi-tblsection, .vt-main .cbi-section:has(.cbi-tblsection), .vt-main .cbi-section:has(> :where(#packages)) { background-image: linear-gradient(to right, var(--s1) 50%, transparent), linear-gradient(to left, var(--s1) 50%, transparent), linear-gradient(to right, var(--line-2), transparent), linear-gradient(to left, var(--line-2), transparent); @@ -2547,11 +2565,11 @@ html:not([data-darkmode="true"]) .vt-login-page::before { section to scroll in: the card itself scrolls, its rows held in one table box (LuCI puts every row, titles too, in the tbody, so the columns stay aligned), its header static as in sections */ - :is(#view, #view > div, .cbi-map, #view > div > div > [data-tab-title]:not(.cbi-section)) > .table:not(:has(> thead)) { + :is(#view, #view > div:not(.cbi-section), .cbi-map, #view > div > div > [data-tab-title]:not(.cbi-section)) > .table:not(:has(> thead)) { display: block; overflow-x: auto; } /* and says so with the same shade as the table cards (Processes) */ - .vt-main :is(#view, #view > div, .cbi-map, #view > div > div > [data-tab-title]:not(.cbi-section)) > .table:not(:has(> thead)) { + .vt-main :is(#view, #view > div:not(.cbi-section), .cbi-map, #view > div > div > [data-tab-title]:not(.cbi-section)) > .table:not(:has(> thead)) { background-image: linear-gradient(to right, var(--s1) 50%, transparent), linear-gradient(to left, var(--s1) 50%, transparent), linear-gradient(to right, var(--line-2), transparent), linear-gradient(to left, var(--line-2), transparent); @@ -2560,15 +2578,15 @@ html:not([data-darkmode="true"]) .vt-login-page::before { background-repeat: no-repeat; background-attachment: local, local, scroll, scroll; } - :is(#view, #view > div, .cbi-map, #view > div > div > [data-tab-title]:not(.cbi-section)) > .table:not(:has(> thead)) > tbody { display: table; width: 100%; } - :is(#view, #view > div, .cbi-map, #view > div > div > [data-tab-title]:not(.cbi-section)) > .table:has(.cbi-dropdown[open]) { overflow: visible; } - :is(#view, #view > div, .cbi-map, #view > div > div > [data-tab-title]:not(.cbi-section)) > .table .tr > .th.th { position: static; } + :is(#view, #view > div:not(.cbi-section), .cbi-map, #view > div > div > [data-tab-title]:not(.cbi-section)) > .table:not(:has(> thead)) > tbody { display: table; width: 100%; } + :is(#view, #view > div:not(.cbi-section), .cbi-map, #view > div > div > [data-tab-title]:not(.cbi-section)) > .table:has(.cbi-dropdown[open]) { overflow: visible; } + :is(#view, #view > div:not(.cbi-section), .cbi-map, #view > div > div > [data-tab-title]:not(.cbi-section)) > .table .tr > .th.th { position: static; } } /* a sticky header inside a horizontal scroller would stick relative to that scroller (pushed down by --stick-top over the rows): keep those static */ -.cbi-tblsection .table .tr > .th.th, .cbi-section:has(> .cbi-tblsection) .table .tr > .th.th { position: static; } +.cbi-tblsection .table .tr > .th.th, .cbi-section:has(.cbi-tblsection) .table .tr > .th.th, #packages .tr > .th.th { position: static; } @media (max-width: 1180px) { .cbi-section .table .tr > .th.th { position: static; } } /* add-ons: adblock and banIP inject status palettes keyed on the OS @@ -2887,6 +2905,8 @@ select[id^="widget.cbid.firewall."] > option { color: var(--text); font-weight: /* Network -> Diagnostics: the three tools in equal columns, top aligned */ .table:has(.diag-action) { table-layout: fixed; } +/* LuCI measures the scroll parent before opening a diagnostic menu. */ +#view .cbi-map > .table:has(.diag-action .cbi-dropdown) { overflow: visible; } .table:has(.diag-action) .tr > .td { width: calc(100% / 3); vertical-align: top; } .table:has(.diag-action) .td > input { max-width: none; } /* fields fill them: even gaps */ @@ -3042,7 +3062,7 @@ select[id^="widget.cbid.firewall."] > option { color: var(--text); font-weight: white-space: normal; text-align: center; line-height: 1.15; } .cbi-section { overflow-x: visible; } - .cbi-tblsection, .cbi-section:has(> .cbi-tblsection) { overflow-x: visible; } + .cbi-tblsection, .cbi-section:has(.cbi-tblsection), .cbi-section:has(> :where(#packages)) { overflow: visible; } .network-status-table .ifacebox-head, .network-status-table .ifacebox-body { padding-left: 12px; padding-right: 12px; } :is(.ifacebox-body, .ifacebadge) > span:has(> .nowrap) { column-gap: 10px; } .v-meter-sub { margin-left: 0; flex-basis: 100%; } @@ -3114,6 +3134,7 @@ select[id^="widget.cbid.firewall."] > option { color: var(--text); font-weight: :is(.vt-main, .modal) .table .tr > .td[data-title]:not(:only-child) { flex-basis: 50%; } :is(.vt-main, .modal) .table .tr > .td[data-title]:last-child:nth-child(odd) { flex-basis: 100%; } :is(.vt-main, .modal) .table .tr > .td.cbi-section-actions { flex-basis: 100%; text-align: left !important; } + :is(.vt-main, .modal) .table .tr > .td:not(.cbi-value-field) { white-space: normal; overflow-wrap: anywhere; } /* a named grid section's name cell heads its card */ :is(.vt-main, .modal) .table .tr > .td.cbi-section-table-titles { flex-basis: 100%; color: var(--text); font-weight: 600; } :is(.vt-main, .modal) .table .tr > .td:is([data-name="_ifacebox"], [data-name="_ifacestat"]) { flex-basis: 100%; } @@ -3226,7 +3247,7 @@ select[id^="widget.cbid.firewall."] > option { color: var(--text); font-weight: .spinning::before { animation-duration: 1.6s !important; animation-iteration-count: infinite !important; } .alert-message { transition-duration: .01s; } /* ui.Dropdown moves focus on transitionend: keep a (near-instant) transition */ - .cbi-dropdown[open] > ul.dropdown { transition-duration: .01s; } + .cbi-dropdown[open] > ul.dropdown { transition-duration: .001s; } .cbi-progressbar > div { transition: none; } .vt-crumb-switch > svg, .vt-rail-caret, .vt-rail-pin svg { transition: none; } .v-nft-card > summary::before, .v-nft-jump::after { transition: none; }