🌴 Sync 2026-10-30 02:13:56

This commit is contained in:
github-actions[bot] committed 2026-10-30 02:13:56 +08:00
1 parent 8d20d896c0
commit 57d68d2cec
34 files changed
+1383 -304

No files matched your search

+2 -2
View File
@@ -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 <jihebube@users.noreply.github.com>
+5
View File
@@ -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
+8 -5
View File
@@ -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;不要改成空值。
@@ -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
+108 -15
View File
@@ -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 <<EOF
path usr/sbin/isp-split
path etc/init.d/isp-split
dir usr/lib/isp-split
path etc/isp-split.conf
glob etc/isp-split.conf.bak.*
dir etc/isp-split
base $BASE
path etc/uci-defaults/99-isp-split
dir www/luci-static/resources/view/isp-split
dir www/luci-static/resources/isp-split
@@ -139,6 +195,7 @@ print_list() {
case "$_k" in
path) echo " $_v" ;;
dir) echo " $_v/(目录)" ;;
base) echo " $_v/(数据目录,随 ISP_SPLIT_BASE 推导)" ;;
glob) echo " $_v(历史备份,默认删除;--keep-conf-bak 保留)" ;;
esac
done
@@ -229,6 +286,31 @@ _state_read() {
return 0
}
# A-05:DNSDIR 白名单(与 isp-split 主脚本同一口径)。只允许 $BASE 子树
# 或 /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
}
# 删除片段的根目录限制:绝不 rm -f /isp-split.conf 或 /etc/isp-split.conf。
_frag_delete_ok() {
_fdo=${1:-}
case "$_fdo" in "" | "/" | "/etc" | "." | ".." | *..*) return 1 ;; esac
case "$_fdo" in /*) ;; *) return 1 ;; esac
[ -d "$_fdo" ] || return 1
return 0
}
# 片段可能残留的目录:运行配置里的 conf-dir=(权威源)+ UCI 当前值 +
# 状态文件记录值 + 默认目录 + 旧版目录,去重后逐行输出。
_frag_dirs() {
@@ -238,7 +320,8 @@ _frag_dirs() {
sed -n 's/^[[:space:]]*conf-dir=//p' "$_fd_f" 2>/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 ;;
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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
@@ -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);',
+3 -3
View File
@@ -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
@@ -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 {
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="715" viewBox="-29.771 0 5703 1554" width="2500"><g fill="#02489d"><path d="m1014.229 261s-28-31-65-31c0 0-81 0-136 175 0 0-47 153-47 380 0 0 0 214 44 365 0 0 44 153 108 153 0 0 45 0 83-84 0 0 38-84 54-221l120-30s-31 255-127 420c0 0-99 166-224 166 0 0-150 0-256-225 0 0-106-225-106-544 0 0 0-319 106-546 0 0 106-226 256-226 0 0 150 0 257 230z"/><g><path d="M1436.229 568s-207 171 -537 254c0 0 -331 82 -594 28c0 0 -263 -56 -302 -217c0 0 -33 -134 112 -282c0 0 143 -147 400 -245l17 66s-150 64 -233 145c0 0 -85 85 -66 158c0 0 25 93 201 121c0 0 175 27 402 -29c0 0 225 -56 369 -163c0 0 142 -108 118 -202c0 0 -20 -81 -164 -116l-22 -86s187 7 311 69c0 0 127 62 153 167c0 0 41 160 -165 332"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 743 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 0 192.756 192.756"><g fill-rule="evenodd" clip-rule="evenodd"><g><path d="M59.293 54.077c0 -20.53 16.605 -37.136 37.135 -37.136c20.429 0 37.136 16.605 37.136 37.136c0 20.429 -16.707 37.035 -37.136 37.035c-20.53 -0.001 -37.135 -16.606 -37.135 -37.035zM75.596 47.434l3.724 3.02l15.397 -12.982c1.409 -1.107 3.22 -1.711 5.132 -1.711s3.725 0.705 5.234 1.812l19.422 15.8v3.623l-17.209 -13.888c-1.309 -1.007 -2.816 -1.51 -4.529 -1.51c-1.709 0 -3.32 0.604 -4.629 1.61l-0.201 0.101l-0.1 0.101c-1.611 1.409 -9.561 8.051 -13.587 11.372h0.101c0.805 1.308 2.918 1.51 4.83 0.402h0.101c4.328 -3.724 8.252 -7.044 8.655 -7.447c1.209 -1.006 2.717 -1.711 4.328 -1.711c1.609 0 3.02 0.604 4.125 1.51L118.769 57.6l3.623 3.019l2.113 1.711l-0.402 0.101l0.402 0.402s-16.404 13.989 -20.529 17.511c-2.014 2.114 -4.932 3.422 -8.152 3.422c-2.919 0 -5.736 -1.107 -7.75 -3.019L67.646 64.14v-5.133l20.329 16.505c1.409 1.006 3.019 1.61 4.931 1.61s3.724 -0.705 5.133 -1.812c0.805 -0.705 13.082 -11.171 18.617 -15.8l-3.623 -3.019l-15.498 13.084c-1.509 1.006 -3.22 1.61 -5.132 1.61c-2.013 0 -3.824 -0.604 -5.334 -1.812l-19.423 -15.8V49.95l17.31 13.989c1.208 0.906 2.717 1.51 4.428 1.51c1.812 0 3.422 -0.604 4.63 -1.61l0.201 -0.201l0.101 -0.101c1.711 -1.409 9.56 -7.951 13.585 -11.373h-0.1v-0.101l-1.006 -0.705c-1.008 -0.503 -2.416 -0.302 -3.725 0.403c-0.1 0 -0.1 0.101 -0.1 0c-4.328 3.724 -8.354 7.044 -8.756 7.447c-1.107 1.107 -2.717 1.711 -4.327 1.711c-1.51 0 -2.919 -0.503 -4.126 -1.409L73.483 49.246l-0.101 0.101l-3.522 -2.918l-1.007 -1.107l-1.207 -1.006h0.201l-0.201 -0.201s16.404 -13.989 20.53 -17.511c2.113 -2.113 4.931 -3.422 8.151 -3.422c3.02 0 5.737 1.208 7.749 3.02l20.43 16.706v5.132l-20.328 -16.605c-1.41 -1.006 -3.02 -1.61 -4.83 -1.61a8.446 8.446 0 0 0 -5.234 1.811c-0.805 0.702 -12.983 11.169 -18.518 15.798z" fill="#2767b0"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="1367" viewBox="0.122 228.756 611.755 334.503"><path fill="#D91A21" d="M380.786 389.207l20.073-19.89-15.851-15.729 15.851-15.974-20.073-19.706-15.79 15.667-10.465-10.465c-16.646-16.708-44.003-16.708-60.71 0-8.446 8.384-12.607 19.461-12.607 30.478 0 10.894 4.101 21.909 12.607 30.355 16.707 16.769 44.063 16.769 60.71 0a1481.013 1481.013 0 0 0 10.465-10.466l15.79 15.73zm-46.022-25.337c-5.875 5.875-15.239 5.875-21.053 0-2.815-2.815-4.223-6.671-4.223-10.343a15.23 15.23 0 0 1 4.223-10.526c5.813-5.813 15.178-5.813 21.053 0 4.59 4.651 10.404 10.526 10.404 10.526s-5.814 5.631-10.404 10.343zm264.567-40.82c-16.708-16.708-44.003-16.708-60.771 0-4.713 4.835-10.465 10.465-10.465 10.465l-15.852-15.728-19.89 19.706 15.789 15.973-15.789 15.729 19.89 19.891 15.79-15.79s5.753 5.691 10.465 10.466c16.77 16.769 44.064 16.769 60.771 0 8.385-8.446 12.607-19.462 12.607-30.355s-4.16-21.973-12.545-30.357zm-19.951 40.82c-5.753 5.875-15.116 5.875-20.931 0-4.712-4.712-10.465-10.343-10.465-10.343s5.753-5.875 10.465-10.526c5.814-5.813 15.178-5.813 20.931 0 2.876 2.877 4.284 6.671 4.284 10.526 0 3.672-1.346 7.527-4.284 10.343zm-112.73-10.343s46.695-46.757 51.286-51.408c16.829-16.769 16.829-43.941 0-60.832-16.708-16.708-43.881-16.708-60.772 0-6.548 6.731-10.403 10.526-10.403 10.526s-3.795-3.795-10.404-10.526c-16.83-16.708-44.003-16.708-60.833 0-16.769 16.891-16.769 44.063 0 60.832l51.408 51.408s-46.757 46.573-51.408 51.286c-16.769 16.769-16.769 44.003 0 60.833 16.83 16.707 44.003 16.707 60.833 0 6.609-6.732 10.404-10.649 10.404-10.649s3.855 3.917 10.403 10.649c16.892 16.707 44.064 16.707 60.772 0 16.829-16.83 16.829-44.064 0-60.833-4.591-4.713-51.286-51.286-51.286-51.286zm-71.237-71.298c-5.692-5.875-5.692-15.116 0-20.931 5.813-5.691 15.238-5.691 20.93 0l10.526 10.526-15.851 15.729 19.829 19.951 15.851-15.851 15.851 15.851 19.829-19.951-15.79-15.729s4.896-4.957 10.527-10.526c5.752-5.691 15.116-5.691 20.869 0 5.813 5.814 5.813 15.056 0 20.931-6.06 6.059-51.47 51.592-51.47 51.592s-45.165-45.533-51.101-51.592zm102.694 163.466c-5.753 5.691-15.116 5.691-20.869 0-5.631-5.569-10.526-10.466-10.526-10.466l15.789-15.851-19.828-19.891-15.912 15.79-15.852-15.851-19.828 19.89 15.851 15.852s-4.957 4.896-10.526 10.465c-5.691 5.691-15.116 5.691-20.931 0-5.691-5.753-5.691-15.239 0-20.931 5.937-6.059 51.225-51.592 51.225-51.592s45.41 45.533 51.469 51.592c5.751 5.692 5.751 15.24-.062 20.993z"/></svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

@@ -33,9 +33,51 @@ var ICONS = {
refresh: '<path d="M20 11a8 8 0 1 0-2.3 6.3"/><path d="M20 4v7h-7"/>',
pulse: '<path d="M2 12h4l2.5-6 3.5 12 2.5-6H22"/>',
chevron: '<path d="M6 9l6 6 6-6"/>',
shield: '<path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/>'
shield: '<path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/>',
/* 运营商兜底 glyph:wan 卡映射不到品牌 logo 时用它们占位
(与状态页同一套 24 格线稿;视图之间不共享模块,故此处自带一份) */
tel: '<path d="M12 9v12"/><circle cx="12" cy="5" r="2"/><path d="M8.6 8.4a5 5 0 0 1 6.8 0"/><path d="M6 5.8a8.4 8.4 0 0 1 12 0"/>',
uni: '<circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="12" cy="18" r="2"/><path d="M8 6h8"/><path d="M7 8l4 8"/><path d="M17 8l-4 8"/>',
cmcc: '<rect x="7" y="3" width="10" height="18" rx="2"/><path d="M11 18h2"/>',
cernet: '<path d="M3 9.5 12 5l9 4.5-9 4.5z"/><path d="M7 11.5V16c0 1.2 2.2 2.2 5 2.2s5-1 5-2.2v-4.5"/>',
other: '<circle cx="12" cy="12" r="7"/><path d="M12 5v14"/>'
};
/* 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 || _('未知') ]),
@@ -35,7 +35,13 @@ var ICONS = {
uni: '<circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="12" cy="18" r="2"/><path d="M8 6h8"/><path d="M7 8l4 8"/><path d="M17 8l-4 8"/>',
cmcc: '<rect x="7" y="3" width="10" height="18" rx="2"/><path d="M11 18h2"/>',
cernet: '<path d="M3 9.5 12 5l9 4.5-9 4.5z"/><path d="M7 11.5V16c0 1.2 2.2 2.2 5 2.2s5-1 5-2.2v-4.5"/>',
other: '<circle cx="12" cy="12" r="7"/><path d="M12 5v14"/>'
other: '<circle cx="12" cy="12" r="7"/><path d="M12 5v14"/>',
/* 兜底流量卡的身份 glyph:地球 + 经纬线("未归属任何运营商"的那部分流量)。
与上面一族同为 24 格 stroke 线稿(stroke-width 1.8、round 收口),
44px 下线条不会糊;颜色走 currentColor,深浅主题都不用另做一套。 */
net: '<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12h17"/>' +
'<path d="M12 3.5c2.4 2.4 3.8 5.4 3.8 8.5s-1.4 6.1-3.8 8.5"/>' +
'<path d="M12 3.5C9.6 5.9 8.2 8.9 8.2 12s1.4 6.1 3.8 8.5"/>'
};
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' })
@@ -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" ]
+1 -1
View File
@@ -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 <info@masmbit.com>
+12
View File
@@ -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 |
| :---: | :---: |
| <img width="1200" alt="01" src="https://github.com/user-attachments/assets/b5f13132-0414-4490-9c80-9a361e1bda40" /> | <img width="1200" alt="02" src="https://github.com/user-attachments/assets/7635f2bd-4839-45e4-9804-d01ce38bde7b" />
| <img width="2398" alt="03" src="https://github.com/user-attachments/assets/0170376e-3dfe-4ca3-8f03-d450cc7ed0e4" /> | <img width="2430" alt="15" src="https://github.com/user-attachments/assets/1710419c-4f14-4594-a3a4-d8147ec1fe50" />
| <img width="1796" alt="09" src="https://github.com/user-attachments/assets/1c77aefe-197d-4e64-ab87-898ff50faab1" /> | <img width="1798" alt="10" src="https://github.com/user-attachments/assets/f37365a6-4090-443b-89fe-05a5cd92e714" />
| <img width="1798" alt="13" src="https://github.com/user-attachments/assets/147250df-079c-4f77-9c5c-00819b4b495c" /> | <img width="1800" alt="11" src="https://github.com/user-attachments/assets/814bbeb0-a0b8-4b38-93b8-03a978d75692" />
| <img width="1798" alt="12" src="https://github.com/user-attachments/assets/57726ec2-c18d-4531-96ec-0d58a818648a" /> | <img width="2384" alt="04" src="https://github.com/user-attachments/assets/8470fa8c-b4a4-4369-b9c5-bfbd8e27526d" />
| <img width="2382" alt="05" src="https://github.com/user-attachments/assets/1ef91c41-675c-40d8-a9da-644370ac2fdf" /> | <img width="2394" alt="06" src="https://github.com/user-attachments/assets/95fbdf13-142f-4f67-9117-15eef266e304" />
## Configuration Layout
The application manages settings inside `/etc/config/openvpn`:
@@ -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"
@@ -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 ""
@@ -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 "数据包"
@@ -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
@@ -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
@@ -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' '<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;URL=/" /></head><body></body></html>' > "${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
}
@@ -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;
}
+1 -1
View File
@@ -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 <herboy2008@gmail.com>
+59 -9
View File
@@ -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
@@ -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;
+5 -4
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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; }