diff --git a/clashoo/Makefile b/clashoo/Makefile index f25397580..4e33cff40 100644 --- a/clashoo/Makefile +++ b/clashoo/Makefile @@ -5,14 +5,14 @@ PKG_UPSTREAM_VERSION:=1.19.33 PKG_UPSTREAM_TAG:=v$(PKG_UPSTREAM_VERSION) PKG_SHORT_SHA:=3f27ef3 -PKG_VERSION:=2026.10.25.$(PKG_UPSTREAM_VERSION) -PKG_RELEASE:=19 +PKG_VERSION:=2026.10.26-r3 +PKG_RELEASE:=20 PKG_SOURCE:=$(PKG_NAME)-stable-$(PKG_UPSTREAM_TAG)-$(PKG_SHORT_SHA).tar.gz PKG_SOURCE_SUBDIR:=$(PKG_NAME)-stable-$(PKG_UPSTREAM_TAG)-$(PKG_SHORT_SHA) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-clashoo/releases/download/mihomo-src -PKG_SOURCE_VERSION:=57468807be288af4250181a024e29c2971eb228c +PKG_SOURCE_VERSION:=392b4ed33f99b4025cf2ec0054993d01d86a085a PKG_HASH:=skip PKG_BUILD_VERSION:=$(PKG_UPSTREAM_TAG) @@ -128,6 +128,13 @@ if [ -z "$${IPKG_INSTROOT}" ]; then rm -f /usr/share/clashoo/cuslist.sh /usr/share/clashoo/groups.sh /usr/share/clashoo/load_groups.sh /usr/share/clashoo/panel_diag.sh rm -f /usr/share/clashoo/rmlist.sh /usr/share/clashoo/uplist.sh /usr/share/clashoo/rpc_restart_async.sh /usr/share/clashoo/rpc_update_china_ip_async.sh rm -rf /usr/share/clashoo/create + # 旧方案把 stable 内核放在 /usr/bin/mihomo-stable,现方案改用 + # /usr/libexec/clashoo/mihomo-stable(随包)+ /usr/share/clashoo/bin/(自定义下载)。 + # preinst 已把它抄到 /usr/share/clashoo/bin/mihomo-stable,确认内容相同后才删 + # 旧位置;目标已有其他自定义内核时保留旧文件,避免误删。 + if [ -f /usr/share/clashoo/bin/mihomo-stable ] && [ -f /usr/bin/mihomo-stable ] && [ ! -L /usr/bin/mihomo-stable ] && cmp -s /usr/bin/mihomo-stable /usr/share/clashoo/bin/mihomo-stable; then + rm -f /usr/bin/mihomo-stable + fi /usr/share/clashoo/migrate_lan_acl.sh >/dev/null 2>&1 || true /usr/share/clashoo/migrate_fake_ip_filter.sh >/dev/null 2>&1 || true /usr/share/clashoo/detect_core_arch.sh >/dev/null 2>&1 || true diff --git a/clashoo/files/etc/init.d/clashoo b/clashoo/files/etc/init.d/clashoo index f926fe636..fea070f01 100755 --- a/clashoo/files/etc/init.d/clashoo +++ b/clashoo/files/etc/init.d/clashoo @@ -2099,7 +2099,11 @@ _finish_geo_bootstrap() { read need_mmdb need_geosite < /var/run/clashoo_bootstrap_pending 2>/dev/null log_msg "Bootstrap: fetching geo data via proxy" "引导模式:正在通过代理补全 Geo 数据" - /usr/share/clashoo/update/geoip.sh >/dev/null 2>&1 + if [ "$need_geosite" = "1" ]; then + /usr/share/clashoo/update/geoip.sh --require-geosite >/dev/null 2>&1 + else + /usr/share/clashoo/update/geoip.sh >/dev/null 2>&1 + fi still=0 [ "$need_mmdb" = "1" ] && ! _has_valid_local_mmdb && still=1 diff --git a/clashoo/files/usr/share/clashoo/nftables/geoip6_cn.nft b/clashoo/files/usr/share/clashoo/nftables/geoip6_cn.nft index 69367b5b5..5cd2612d3 100644 --- a/clashoo/files/usr/share/clashoo/nftables/geoip6_cn.nft +++ b/clashoo/files/usr/share/clashoo/nftables/geoip6_cn.nft @@ -120,7 +120,6 @@ set clashoo_china6 { 2400:5280:1400::/38, 2400:5280:1c00::/38, 2400:5280:3400::/38, - 2400:5280:4c00::/38, 2400:5280:5000::/36, 2400:5280:6000::/35, 2400:5280:8000::/34, @@ -962,6 +961,7 @@ set clashoo_china6 { 2402:7d80::/32, 2402:7da0::/32, 2402:7e20::/32, + 2402:7ee0::/32, 2402:8180::/32, 2402:8300::/32, 2402:8380::/32, @@ -1791,7 +1791,11 @@ set clashoo_china6 { 2405:84c0:8050::/48, 2405:84c0:8053::/48, 2405:84c0:8058::/45, - 2405:84c0:8060::/43, + 2405:84c0:8061::/48, + 2405:84c0:8062::/47, + 2405:84c0:8064::/46, + 2405:84c0:8068::/45, + 2405:84c0:8070::/44, 2405:84c0:8080::/41, 2405:84c0:8100::/40, 2405:84c0:8200::/39, @@ -2050,7 +2054,10 @@ set clashoo_china6 { 2406:840:8a0::/43, 2406:840:8c0::/42, 2406:840:900::/40, - 2406:840:a00::/44, + 2406:840:a01::/48, + 2406:840:a02::/47, + 2406:840:a04::/46, + 2406:840:a08::/45, 2406:840:a12::/47, 2406:840:a14::/46, 2406:840:a18::/45, @@ -2248,7 +2255,8 @@ set clashoo_china6 { 2406:840:9610::/44, 2406:840:9630::/44, 2406:840:9640::/43, - 2406:840:9660::/46, + 2406:840:9660::/48, + 2406:840:9662::/47, 2406:840:9664::/47, 2406:840:9667::/48, 2406:840:9668::/45, @@ -2272,6 +2280,7 @@ set clashoo_china6 { 2406:840:9900::/42, 2406:840:9940::/43, 2406:840:9960::/47, + 2406:840:9963::/48, 2406:840:9965::/48, 2406:840:9968::/46, 2406:840:996d::/48, @@ -3144,8 +3153,7 @@ set clashoo_china6 { 240b:4006::/48, 240b:4006:1000::/43, 240b:4006:1020::/44, - 240b:4006:1100::/45, - 240b:4006:110c::/46, + 240b:4006:1100::/44, 240b:4006:1110::/45, 240b:4006:1118::/46, 240b:4007::/32, @@ -3261,8 +3269,6 @@ set clashoo_china6 { 2605:9d80:9042::/48, 2605:9d80:9071::/48, 2605:9d80:9092::/48, - 2620:57:4004::/47, - 2804:1e48:9002::/48, 2a03:f900::/29, 2a04:3e00::/48, 2a04:3e00:2::/47, diff --git a/clashoo/files/usr/share/clashoo/nftables/geoip_cn.nft b/clashoo/files/usr/share/clashoo/nftables/geoip_cn.nft index 7661766ef..26ac0cf79 100644 --- a/clashoo/files/usr/share/clashoo/nftables/geoip_cn.nft +++ b/clashoo/files/usr/share/clashoo/nftables/geoip_cn.nft @@ -394,16 +394,13 @@ set clashoo_china { 43.92.0.0/15, 43.94.0.0/18, 43.96.0.0/23, - 43.96.2.130/32, 43.96.3.0/24, 43.96.4.0/22, 43.96.8.0/22, - 43.96.16.130/32, 43.96.18.0/24, 43.96.20.0/22, 43.96.24.0/22, 43.96.32.0/22, - 43.96.36.130/32, 43.96.40.0/24, 43.96.42.0/24, 43.96.44.0/23, @@ -414,7 +411,6 @@ set clashoo_china { 43.96.62.0/23, 43.96.64.0/21, 43.96.72.0/22, - 43.96.77.131/32, 43.96.78.0/24, 43.96.80.0/23, 43.96.85.0/24, @@ -459,8 +455,7 @@ set clashoo_china { 43.123.0.0/17, 43.123.128.0/18, 43.124.0.0/16, - 43.125.0.0/20, - 43.125.24.0/21, + 43.125.0.0/19, 43.125.32.0/20, 43.125.48.0/21, 43.126.0.0/17, @@ -805,6 +800,7 @@ set clashoo_china { 45.117.68.0/22, 45.117.124.0/22, 45.117.252.0/22, + 45.119.42.0/23, 45.119.60.0/22, 45.119.64.0/21, 45.119.72.0/22, @@ -1124,6 +1120,7 @@ set clashoo_china { 47.246.192.0/20, 47.246.208.0/23, 47.246.212.0/22, + 47.246.216.0/22, 47.250.0.0/18, 47.250.68.0/22, 47.250.72.0/21, @@ -1478,8 +1475,7 @@ set clashoo_china { 103.8.108.0/22, 103.8.156.0/22, 103.8.200.0/21, - 103.8.221.0/24, - 103.8.222.0/23, + 103.8.220.0/22, 103.9.8.0/22, 103.9.24.0/22, 103.9.108.0/22, @@ -2113,7 +2109,6 @@ set clashoo_china { 103.89.184.0/21, 103.89.192.0/19, 103.89.224.0/21, - 103.90.51.0/24, 103.90.52.0/22, 103.90.56.0/23, 103.90.92.0/22, @@ -2683,6 +2678,7 @@ set clashoo_china { 103.186.228.0/23, 103.189.92.0/23, 103.189.152.0/22, + 103.189.197.0/24, 103.190.20.0/23, 103.190.71.0/24, 103.190.104.0/23, @@ -4062,10 +4058,6 @@ set clashoo_china { 124.67.0.0/16, 124.68.16.0/20, 124.68.64.0/18, - 124.68.176.0/20, - 124.68.224.0/20, - 124.68.244.0/22, - 124.68.248.0/21, 124.69.0.0/16, 124.70.0.0/16, 124.71.0.0/17, @@ -4445,6 +4437,7 @@ set clashoo_china { 160.202.62.0/24, 160.202.148.0/22, 160.202.152.0/22, + 160.202.210.0/23, 160.202.212.0/22, 160.202.216.0/21, 160.202.224.0/19, @@ -4741,6 +4734,7 @@ set clashoo_china { 192.140.160.0/19, 192.140.192.0/20, 192.140.208.0/21, + 192.140.244.0/23, 192.144.128.0/17, 192.163.11.0/24, 192.232.34.0/23, @@ -6497,12 +6491,14 @@ set clashoo_china { 223.112.0.0/14, 223.116.0.0/15, 223.121.128.0/17, - 223.122.4.0/22, - 223.122.12.0/22, + 223.122.5.0/24, + 223.122.6.0/23, + 223.122.13.0/24, + 223.122.14.0/23, 223.122.18.0/23, 223.122.20.0/22, 223.122.26.0/23, - 223.122.28.0/22, + 223.122.28.0/23, 223.123.196.0/22, 223.123.204.0/22, 223.123.208.0/20, diff --git a/clashoo/files/usr/share/clashoo/ruleset/cn-ip.srs b/clashoo/files/usr/share/clashoo/ruleset/cn-ip.srs index d0b0b7454..ef9118faf 100644 Binary files a/clashoo/files/usr/share/clashoo/ruleset/cn-ip.srs and b/clashoo/files/usr/share/clashoo/ruleset/cn-ip.srs differ diff --git a/clashoo/files/usr/share/clashoo/ruleset/cn.mrs b/clashoo/files/usr/share/clashoo/ruleset/cn.mrs index 536c85a75..610f2d2dd 100644 Binary files a/clashoo/files/usr/share/clashoo/ruleset/cn.mrs and b/clashoo/files/usr/share/clashoo/ruleset/cn.mrs differ diff --git a/clashoo/files/usr/share/clashoo/ruleset/geolocation-!cn.srs b/clashoo/files/usr/share/clashoo/ruleset/geolocation-!cn.srs index e3c6a5138..6f26b8989 100644 Binary files a/clashoo/files/usr/share/clashoo/ruleset/geolocation-!cn.srs and b/clashoo/files/usr/share/clashoo/ruleset/geolocation-!cn.srs differ diff --git a/clashoo/files/usr/share/clashoo/ruleset/geosite-cn.srs b/clashoo/files/usr/share/clashoo/ruleset/geosite-cn.srs index db4f38eb2..216609fe4 100644 Binary files a/clashoo/files/usr/share/clashoo/ruleset/geosite-cn.srs and b/clashoo/files/usr/share/clashoo/ruleset/geosite-cn.srs differ diff --git a/clashoo/files/usr/share/clashoo/ruleset/private-ip.srs b/clashoo/files/usr/share/clashoo/ruleset/private-ip.srs index 7fa17b9af..f825d93c0 100644 Binary files a/clashoo/files/usr/share/clashoo/ruleset/private-ip.srs and b/clashoo/files/usr/share/clashoo/ruleset/private-ip.srs differ diff --git a/clashoo/files/usr/share/clashoo/update/component_update.sh b/clashoo/files/usr/share/clashoo/update/component_update.sh index 3243fb432..ba0b92bc3 100755 --- a/clashoo/files/usr/share/clashoo/update/component_update.sh +++ b/clashoo/files/usr/share/clashoo/update/component_update.sh @@ -296,20 +296,27 @@ load_github_urls() { return 0 } +# `~` 是版本里的预发布分隔符,而 release 资产文件名会把它换成 `.` +# (clashoo_2026.10.25.6780eee-r3_x86_64.ipk 对应已安装的 2026.10.25~6780eee-r3)。 +# 只比分隔符就会误判成「校验失败」,所以比较前统一归一化,解析时保持文件名原样。 +version_equal() { + [ -n "$1" ] && [ "$(printf '%s' "$1" | tr '~' '.')" = "$(printf '%s' "$2" | tr '~' '.')" ] +} + package_version_from_url() { file="${1##*/}" case "$file" in clashoo_*_"$ARCH".ipk) v="${file#clashoo_}" - v="${v%_${ARCH}.ipk}" - printf '%s\n' "$v" | sed 's/^\([0-9][0-9][0-9][0-9]\.[0-9][0-9]*\.[0-9][0-9]*\)\./\1~/' + printf '%s\n' "${v%_${ARCH}.ipk}" ;; - clashoo_*.ipk) printf '%s\n' "$file" | sed -n 's/^clashoo_\(.*\)_[^_][^_]*\.ipk$/\1/p' | sed 's/^\([0-9][0-9][0-9][0-9]\.[0-9][0-9]*\.[0-9][0-9]*\)\./\1~/' ;; + # 架构名本身可能带下划线(aarch64_generic / arm_cortex-a7_neon-vfpv4), + # 只截到版本号为止,否则尾巴会混进版本串。 + clashoo_*.ipk) printf '%s\n' "$file" | sed -n 's/^clashoo_\([^_]*\)_.*\.ipk$/\1/p' ;; luci-app-clashoo_*.ipk) printf '%s\n' "$file" | sed -n 's/^luci-app-clashoo_\(.*\)_all\.ipk$/\1/p' ;; luci-i18n-clashoo-zh-cn_*.ipk) printf '%s\n' "$file" | sed -n 's/^luci-i18n-clashoo-zh-cn_\(.*\)_all\.ipk$/\1/p' ;; clashoo-*.apk) - v="$(printf '%s\n' "$file" | sed -n -e 's/^clashoo-\(.*-r[0-9][0-9]*\)-.*\.apk$/\1/p' -e 's/^clashoo-\(.*-r[0-9][0-9]*\)\.apk$/\1/p' | head -n 1)" - printf '%s\n' "$v" | sed 's/^\([0-9][0-9][0-9][0-9]\.[0-9][0-9]*\.[0-9][0-9]*\)\./\1~/' + printf '%s\n' "$file" | sed -n -e 's/^clashoo-\(.*-r[0-9][0-9]*\)-.*\.apk$/\1/p' -e 's/^clashoo-\(.*-r[0-9][0-9]*\)\.apk$/\1/p' | head -n 1 ;; luci-app-clashoo-*.apk) printf '%s\n' "$file" | sed -n -e 's/^luci-app-clashoo-\(.*-r[0-9][0-9]*\)-.*\.apk$/\1/p' -e 's/^luci-app-clashoo-\(.*-r[0-9][0-9]*\)\.apk$/\1/p' | head -n 1 ;; luci-i18n-clashoo-zh-cn-*.apk) printf '%s\n' "$file" | sed -n -e 's/^luci-i18n-clashoo-zh-cn-\(.*-r[0-9][0-9]*\)-.*\.apk$/\1/p' -e 's/^luci-i18n-clashoo-zh-cn-\(.*-r[0-9][0-9]*\)\.apk$/\1/p' | head -n 1 ;; @@ -405,14 +412,14 @@ run_pkg_update() { core_ver_before="$(installed_package_version clashoo)" apk_core_ready() { - package_installed clashoo && [ "$(installed_package_version clashoo)" = "$core_ver" ] \ + package_installed clashoo && version_equal "$(installed_package_version clashoo)" "$core_ver" \ && [ -x /etc/init.d/clashoo ] && [ -x /usr/share/clashoo/update/component_update.sh ] } apk_luci_ready() { - package_installed luci-app-clashoo && [ "$(installed_package_version luci-app-clashoo)" = "$luci_ver" ] \ + package_installed luci-app-clashoo && version_equal "$(installed_package_version luci-app-clashoo)" "$luci_ver" \ && { [ -z "$I18N_URL" ] || { package_installed luci-i18n-clashoo-zh-cn \ - && [ "$(installed_package_version luci-i18n-clashoo-zh-cn)" = "$i18n_ver" ]; }; } \ + && version_equal "$(installed_package_version luci-i18n-clashoo-zh-cn)" "$i18n_ver"; }; } \ && [ -r /usr/share/luci/menu.d/luci-app-clashoo.json ] \ && [ -r /usr/share/rpcd/ucode/luci.clashoo ] } @@ -434,7 +441,7 @@ run_pkg_update() { cur_ver="$(installed_package_version clashoo)" if [ "$PM" = "apk" ] && apk_update_ready; then finish 0 "Clashoo 核心已是最新版本" - elif [ "$PM" != "apk" ] && [ -n "$core_ver" ] && [ "$cur_ver" = "$core_ver" ]; then + elif [ "$PM" != "apk" ] && version_equal "$cur_ver" "$core_ver"; then finish 0 "Clashoo 核心已是最新版本" fi else @@ -442,7 +449,7 @@ run_pkg_update() { cur_ver="$(installed_package_version luci-app-clashoo)" if [ "$PM" = "apk" ] && apk_update_ready; then finish 0 "客户端已是最新版本" - elif [ "$PM" != "apk" ] && [ -n "$luci_ver" ] && [ "$cur_ver" = "$luci_ver" ]; then + elif [ "$PM" != "apk" ] && version_equal "$cur_ver" "$luci_ver"; then finish 0 "客户端已是最新版本" fi fi @@ -532,11 +539,11 @@ run_pkg_update() { apk_update_ready || validation_failed fi elif [ "$which" = "clashoo" ]; then - package_installed clashoo && [ "$(installed_package_version clashoo)" = "$core_ver" ] \ + package_installed clashoo && version_equal "$(installed_package_version clashoo)" "$core_ver" \ && [ -x /etc/init.d/clashoo ] && [ -x /usr/share/clashoo/update/component_update.sh ] \ || validation_failed else - package_installed luci-app-clashoo && [ "$(installed_package_version luci-app-clashoo)" = "$luci_ver" ] \ + package_installed luci-app-clashoo && version_equal "$(installed_package_version luci-app-clashoo)" "$luci_ver" \ && [ -r /usr/share/luci/menu.d/luci-app-clashoo.json ] \ && [ -r /usr/share/rpcd/ucode/luci.clashoo ] \ || validation_failed diff --git a/clashoo/files/usr/share/clashoo/update/geoip.sh b/clashoo/files/usr/share/clashoo/update/geoip.sh index 387c634f7..9c699e30a 100755 --- a/clashoo/files/usr/share/clashoo/update/geoip.sh +++ b/clashoo/files/usr/share/clashoo/update/geoip.sh @@ -12,6 +12,8 @@ cfg_mmdb_url="$(uci -q get clashoo.config.geoip_mmdb_url 2>/dev/null)" cfg_geosite_url="$(uci -q get clashoo.config.geosite_url 2>/dev/null)" cfg_geoip_dat_url="$(uci -q get clashoo.config.geoip_dat_url 2>/dev/null)" cfg_geoip_asn_url="$(uci -q get clashoo.config.geoip_asn_url 2>/dev/null)" +require_geosite=0 +[ "$1" = "--require-geosite" ] && require_geosite=1 DEFAULT_MMDB_URL="https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb" DEFAULT_GEOSITE_URL="https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/release/geosite.dat" @@ -36,6 +38,10 @@ cleanup() { } config_needs_geosite() { + _cfg="$(uci -q get clashoo.config.use_config 2>/dev/null)" + if [ -s "$_cfg" ] && grep -Eq "^[[:space:]]*-[[:space:]]*[\"']?GEOSITE,|^[[:space:]]*geosite:[[:space:]]*|^[[:space:]]*'geosite:|^[[:space:]]*-[[:space:]]*[\"']?geosite:" "$_cfg"; then + return 0 + fi _cfg="/etc/clashoo/config.yaml" [ -f "$_cfg" ] || return 1 grep -Eq "^[[:space:]]*-[[:space:]]*[\"']?GEOSITE,|^[[:space:]]*geosite:[[:space:]]*|^[[:space:]]*'geosite:|^[[:space:]]*-[[:space:]]*[\"']?geosite:" "$_cfg" @@ -133,19 +139,19 @@ download_geosite() { size=0 if [ -z "$url" ]; then log "GeoSite.dat skip (url empty)" - return 0 + return 1 fi rm -f "$tmp_target" >/dev/null 2>&1 if ! download_to "$url" "$tmp_target"; then rm -f "$tmp_target" >/dev/null 2>&1 log "GeoSite.dat update failed" - return 0 + return 1 fi size=$(wc -c <"$tmp_target" 2>/dev/null) if [ -z "$size" ] || [ "$size" -lt "$GEOSITE_MIN_SIZE" ]; then log "GeoSite.dat download invalid or incomplete (${size:-0} bytes)" rm -f "$tmp_target" >/dev/null 2>&1 - return 0 + return 1 fi mv -f "$tmp_target" /etc/clashoo/GeoSite.dat >/dev/null 2>&1 || return 1 cp -f /etc/clashoo/GeoSite.dat /etc/clashoo/geosite.dat >/dev/null 2>&1 || true @@ -248,8 +254,10 @@ if [ "$geoip_source" != "1" ]; then fi log "Country.mmdb updated" - if config_needs_geosite; then - download_geosite "$geosite_url" + if [ "$require_geosite" -eq 1 ] || config_needs_geosite; then + if ! download_geosite "$geosite_url"; then + exit 1 + fi else log "GeoSite.dat skip (no geosite reference in config)" fi @@ -257,6 +265,13 @@ if [ "$geoip_source" != "1" ]; then download_optional "$geoip_asn_url" /etc/clashoo/GeoLite2-ASN.mmdb "GeoLite2-ASN.mmdb" fi +if [ "$geoip_source" = "1" ] && { [ "$require_geosite" -eq 1 ] || config_needs_geosite; }; then + geosite_url="${cfg_geosite_url:-$DEFAULT_GEOSITE_URL}" + if ! download_geosite "$geosite_url"; then + exit 1 + fi +fi + touch /var/run/geoip_down_complete >/dev/null 2>&1 if pidof mihomo >/dev/null 2>&1 || pidof clash-meta >/dev/null 2>&1; then diff --git a/cloudflared-next/Makefile b/cloudflared-next/Makefile index 8f6f8bb3c..db31c53c2 100644 --- a/cloudflared-next/Makefile +++ b/cloudflared-next/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=cloudflared-next -PKG_VERSION:=2026.10.3 -PKG_RELEASE:=6 +PKG_VERSION:=2026.10.4 +PKG_RELEASE:=7 # Architecture mapping for pre-built binaries ifeq ($(ARCH),x86_64) diff --git a/gately/Makefile b/gately/Makefile index faa025c9b..38144ecf5 100644 --- a/gately/Makefile +++ b/gately/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=gately -PKG_VERSION:=0.5.9 -PKG_RELEASE:=12 +PKG_VERSION:=0.5.9.1 +PKG_RELEASE:=13 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/wangwanjie/gately/tar.gz/v$(PKG_VERSION)? diff --git a/kai-agent/Makefile b/kai-agent/Makefile index 34ddc2ff6..ca2373507 100644 --- a/kai-agent/Makefile +++ b/kai-agent/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=kai-agent -PKG_VERSION:=0.0.27 -PKG_RELEASE:=5 +PKG_VERSION:=0.0.28 +PKG_RELEASE:=6 PKG_SOURCE:=kai-agent-bundle-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/kai-runtime-v$(PKG_VERSION)/ PKG_HASH:=skip diff --git a/kai/Makefile b/kai/Makefile index 86aab9513..52bb70f83 100644 --- a/kai/Makefile +++ b/kai/Makefile @@ -10,8 +10,8 @@ include $(TOPDIR)/rules.mk PKG_ARCH_kai:=$(ARCH) PKG_NAME:=kai -PKG_VERSION:=0.0.27 -PKG_RELEASE:=10 +PKG_VERSION:=0.0.28 +PKG_RELEASE:=11 PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/kai-runtime-v$(PKG_VERSION)/ PKG_HASH:=skip @@ -28,7 +28,7 @@ define Package/$(PKG_NAME) SUBMENU:= TITLE:=KAI DEPENDS:=+kai_session +kai-agent +luci-lib-linkeaseauth +linkease-app-entry - EXTRA_DEPENDS:=kai-agent (>=0.0.27~0) + EXTRA_DEPENDS:=kai-agent (>=0.0.28~0), kai_session (>=0.0.28~0) URL:=https://github.com/linkease/istore-ai-helper endef diff --git a/kai/files/kai-session-launch b/kai/files/kai-session-launch index 8f8dabe6d..5c81c18fc 100755 --- a/kai/files/kai-session-launch +++ b/kai/files/kai-session-launch @@ -7,4 +7,7 @@ if [ "$#" -ne 1 ] || [ -z "$1" ]; then fi cd "$1" +OPENCODE_PASSWORD="$(cat "${KAI_ENGINE_PASSWORD_FILE:?missing KAI engine credential file}")" +[ -n "$OPENCODE_PASSWORD" ] || exit 1 +export OPENCODE_PASSWORD exec /usr/sbin/kai_session serve --port 8196 --hostname 127.0.0.1 diff --git a/kai/files/kai.init b/kai/files/kai.init index 30ce62a67..d15afbe58 100755 --- a/kai/files/kai.init +++ b/kai/files/kai.init @@ -12,7 +12,9 @@ get_config() { start_kai_bin(){ procd_open_instance kai_bin - procd_set_param env KAI_AUTH_MODE="$auth_mode" + procd_set_param env KAI_AUTH_MODE="$auth_mode" \ + KAI_ENGINE_API=v2 KAI_ENGINE_PASSWORD_FILE=/var/run/kai/engine-password \ + XDG_CONFIG_HOME="${data_dir}/config" XDG_DATA_HOME="${data_dir}/data" procd_set_param command /usr/sbin/kai_bin -a 0.0.0.0 -p "$port" procd_set_param stderr 1 procd_set_param respawn 3600 5 5 @@ -31,6 +33,8 @@ start_kai_session(){ return 1 fi procd_set_param env \ + KAI_ENGINE_API=v2 \ + KAI_ENGINE_PASSWORD_FILE=/var/run/kai/engine-password \ OPENCODE_CONFIG="${agent_root}/opencode.json" \ KAIPLUS_SKILLS_DIR="${agent_root}/skills" \ ISTORE_AI_HELPER_BASE="http://127.0.0.1:${port}" \ @@ -61,6 +65,16 @@ start_service() { mkdir_cwd "${data_dir}/data" mkdir_cwd "${data_dir}/config" mkdir_cwd "${data_dir}/state" + # Both processes read a private credential. Never put it in procd's JSON + # service definition or send it to the browser. + (umask 077 + mkdir -p /var/run/kai + chmod 700 /var/run/kai + if [ ! -s /var/run/kai/engine-password ]; then + head -c 32 /dev/urandom | hexdump -v -e '1/1 "%02x"' > /var/run/kai/engine-password + fi + chmod 600 /var/run/kai/engine-password + ) || return 1 start_kai_session || return 1 start_kai_bin logger -t kai "Starting KAI Service Completed" diff --git a/kai_session/Makefile b/kai_session/Makefile index f1697fe5a..9bb8a0204 100644 --- a/kai_session/Makefile +++ b/kai_session/Makefile @@ -10,8 +10,8 @@ include $(TOPDIR)/rules.mk PKG_ARCH_kai_session:=$(ARCH) PKG_NAME:=kai_session -PKG_VERSION:=0.0.27 -PKG_RELEASE:=9 +PKG_VERSION:=0.0.28 +PKG_RELEASE:=10 PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/kai-runtime-v$(PKG_VERSION)/ PKG_HASH:=skip diff --git a/luci-app-bandix/Makefile b/luci-app-bandix/Makefile index f434174fa..9a47358ad 100644 --- a/luci-app-bandix/Makefile +++ b/luci-app-bandix/Makefile @@ -11,7 +11,7 @@ LUCI_DEPENDS:=+luci-base +luci-lib-jsonc +curl +bandix PKG_MAINTAINER:=timsaya PKG_VERSION:=0.12.12 -PKG_RELEASE:=13 +PKG_RELEASE:=14 # Rebuild language packages when a package revision changes without a PO commit. PKG_PO_VERSION=$(if $(DUMP),x,$(strip $(call findrev)))-r$(PKG_RELEASE) diff --git a/luci-app-bandix/htdocs/luci-static/resources/bandix/devices.js b/luci-app-bandix/htdocs/luci-static/resources/bandix/devices.js new file mode 100644 index 000000000..2ee4c79bc --- /dev/null +++ b/luci-app-bandix/htdocs/luci-static/resources/bandix/devices.js @@ -0,0 +1,135 @@ +'use strict'; +'require baseclass'; +'require rpc'; +'require ui'; + +var callDeleteDevices = rpc.declare({ + object: 'luci.bandix', method: 'deleteDevices', params: ['macs'], nobatch: true, expect: {} +}); + +function key(mac) { return String(mac || '').toLowerCase(); } + +// Validate per-device results: HTTP success alone does not mean deletion succeeded. +function result(reply, requested) { + if (!reply || reply.status !== 'success' || !reply.data) { + var message = reply && (reply.message || reply.error); + if (message === 'Batch deletion is unavailable. Update the Bandix service.') message = _('Batch deletion is unavailable. Update the Bandix service.'); + throw new Error(message || _('Failed to delete devices')); + } + var data = reply.data, remaining = new Set(requested); + ['deleted', 'not_found', 'failed'].forEach(function (field) { + if (!Array.isArray(data[field])) throw new Error(_('Invalid batch deletion response')); + data[field].forEach(function (entry) { + var mac = field === 'failed' ? entry && entry.mac : entry; + if (typeof mac !== 'string' || !remaining.delete(key(mac))) throw new Error(_('Invalid batch deletion response')); + }); + }); + if (remaining.size) throw new Error(_('Invalid batch deletion response')); + return data; +} + +return baseclass.extend({ + create: function (options) { + var selected = new Set(), visible = new Map(), inputs = [], busy = false; + var writable = options.writable !== false; + var checkboxStyle = 'appearance:none;-webkit-appearance:none;display:block;position:static;transform:none;flex:0 0 16px;box-sizing:border-box;width:16px;height:16px;margin:0;padding:0;align-self:center'; + var selectAll = E('input', { type: 'checkbox', 'class': 'bandix-device-checkbox', disabled: true, style: checkboxStyle }); + var remove = E('button', { + type: 'button', 'class': 'cbi-button cbi-button-negative', disabled: true, + style: 'margin:0;min-height:32px;box-sizing:border-box;align-self:center' + }, _('Delete selected')); + var toolbar = E('div', { style: 'display:flex;align-items:center;gap:12px;margin:0;font-size:0.875rem;font-weight:normal;white-space:nowrap' }, [ + E('label', { style: 'display:inline-flex;align-items:center;gap:6px;height:32px;margin:0;padding:0;width:auto;line-height:16px;box-sizing:border-box;white-space:nowrap' }, [ + selectAll, E('span', { style: 'display:block;font-size:13px;line-height:16px;margin:0;padding:0' }, _('Select all')) + ]), remove + ]); + function sync() { + selectAll.checked = visible.size > 0 && selected.size === visible.size; + selectAll.indeterminate = selected.size > 0 && selected.size < visible.size; + selectAll.disabled = busy || !writable || !visible.size; + remove.disabled = busy || !writable || !selected.size; + remove.textContent = _('Delete selected') + (selected.size ? ' (' + selected.size + ')' : ''); + inputs.forEach(function (item) { + item.input.checked = selected.has(item.mac); + item.input.disabled = busy || !writable; + }); + } + selectAll.addEventListener('change', function () { + if (busy || !writable) return; + selected.clear(); + if (selectAll.checked) visible.forEach(function (_, mac) { selected.add(mac); }); + sync(); + }); + remove.addEventListener('click', function () { + if (busy || !writable || !selected.size) return; + // Capture what was selected when opening confirmation, even if polling redraws the list. + var macs = Array.from(selected); + options.confirm(_('Delete selected'), _('Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear.').format(macs.length), function () { + if (busy) return; + busy = true; sync(); + var counts = { deleted: 0, not_found: 0, failed: 0 }, failures = []; + function report(error) { + if (!error && !counts.failed) return; + var text = _('Deleted: %d; not found: %d; failed: %d.').format(counts.deleted, counts.not_found, counts.failed); + if (failures.length) text += '\n' + failures.join('\n'); + if (error) text = _('Failed to delete devices') + ': ' + (error.message || error) + '\n' + text; + ui.addNotification(null, E('p', { style: 'white-space:pre-line' }, text), error ? 'error' : 'warning'); + } + var chain = Promise.resolve(); + // Smaller requests leave time for cleanup within LuCI's RPC timeout. + for (var offset = 0; offset < macs.length; offset += 50) { + (function (batch) { + chain = chain.then(function () { + return callDeleteDevices(JSON.stringify(batch)).then(function (reply) { + var data = result(reply, batch); + ['deleted', 'not_found'].forEach(function (field) { + counts[field] += data[field].length; + data[field].forEach(function (mac) { selected.delete(key(mac)); }); + }); + counts.failed += data.failed.length; + data.failed.forEach(function (entry) { failures.push(entry.mac + ': ' + (entry.message || _('Failed to delete device'))); }); + }); + }); + })(macs.slice(offset, offset + 50)); + } + return chain.then(function () { + report(); + }).catch(function (error) { + report(error); + }).finally(function () { + busy = false; sync(); + return options.refresh(); + }); + }); + }); + return { + toolbar: toolbar, + setDevices: function (devices) { + visible.clear(); inputs = []; + devices.forEach(function (device) { if (device.mac) visible.set(key(device.mac), device); }); + // Selection follows the displayed list; hidden devices are never deleted by Select all. + selected.forEach(function (mac) { if (!visible.has(mac)) selected.delete(mac); }); + sync(); + }, + checkbox: function (device) { + var mac = key(device.mac); + var input = E('input', { + type: 'checkbox', 'class': 'bandix-device-checkbox', 'aria-label': _('Select device') + ': ' + (device.host || device.mac), + style: checkboxStyle + }); + // E() stringifies attributes: checked="false" still checks the box. + // Set boolean DOM properties after creation instead. + input.checked = selected.has(mac); + input.disabled = busy || !writable; + input.addEventListener('click', function (event) { event.stopPropagation(); }); + input.addEventListener('change', function () { + if (busy || !writable) return; + if (input.checked) selected.add(mac); else selected.delete(mac); + sync(); + }); + inputs.push({ mac: mac, input: input }); + return input; + } + }; + } +}); diff --git a/luci-app-bandix/htdocs/luci-static/resources/bandix/flows.js b/luci-app-bandix/htdocs/luci-static/resources/bandix/flows.js new file mode 100644 index 000000000..9f308f32b --- /dev/null +++ b/luci-app-bandix/htdocs/luci-static/resources/bandix/flows.js @@ -0,0 +1,44 @@ +'use strict'; +'require baseclass'; +'require bandix.geoip as geoip'; + +function normalize(value) { + return String(value == null ? '' : value).normalize('NFKC').toLowerCase(); +} + +// Index a loaded page once, including information hidden in tooltips/reply columns. +return baseclass.extend({ + index: function (items, information) { + information = information || {}; + return items.map(function (flow) { + var fields = [flow.protocol, flow.state]; + fields.push(Array.isArray(flow.flags) ? flow.flags.join(' ') : flow.flags); + [flow.orig, flow.repl].forEach(function (direction) { + if (!direction) return; + [['src', 'sport'], ['dst', 'dport']].forEach(function (endpoint) { + var address = direction[endpoint[0]], port = direction[endpoint[1]]; + fields.push(address, port); + if (address != null && port != null) { + fields.push(address + ':' + port); + if (String(address).indexOf(':') >= 0) fields.push('[' + address + ']:' + port); + } + var info = information[address]; + if (!info) return; + fields.push(info.country_code, info.organization, info.asn, info.asn != null ? 'AS' + info.asn : ''); + ['country_names', 'region_names', 'city_names'].forEach(function (field) { + Object.keys(info[field] || {}).forEach(function (language) { fields.push(info[field][language]); }); + }); + var description = geoip.describe(info); + if (description) fields.push(description.summary, description.details); + }); + }); + return { flow: flow, text: fields.map(normalize).join('\n') }; + }); + }, + filter: function (index, query) { + var terms = normalize(query).trim().split(/\s+/).filter(Boolean); + return index.filter(function (entry) { + return terms.every(function (term) { return entry.text.indexOf(term) >= 0; }); + }).map(function (entry) { return entry.flow; }); + } +}); diff --git a/luci-app-bandix/htdocs/luci-static/resources/bandix/geoip.js b/luci-app-bandix/htdocs/luci-static/resources/bandix/geoip.js index c4876b507..431e9d5aa 100644 --- a/luci-app-bandix/htdocs/luci-static/resources/bandix/geoip.js +++ b/luci-app-bandix/htdocs/luci-static/resources/bandix/geoip.js @@ -50,6 +50,8 @@ return baseclass.extend({ var country = countryName(info), region = localized(info.region_names), city = localized(info.city_names); var location = [country, region, city].filter(function (value, index, all) { return value && all.indexOf(value) === index; }).join(' · '); return { + location: location || scopes.unknown, + organization: info.organization || '', summary: [location, info.organization].filter(Boolean).join(' · ') || scopes.unknown, details: [location, info.asn ? 'AS' + info.asn : '', info.organization].filter(Boolean).join('\n') }; @@ -59,13 +61,14 @@ return baseclass.extend({ if (this._root) return this._root; var root, current = null, pending = false, refreshing = false, dirtyProfile = false; var badge = E('strong', {}, _('Loading…')); - var enabled = E('input', { type: 'checkbox', disabled: true }); - var profile = E('select', { 'class': 'cbi-input-select', 'aria-label': _('Database contents') }, [ - E('option', { value: 'country' }, _('Country/region — approximately 3.3 MiB')), - E('option', { value: 'country_asn' }, _('Country/region + network organization — approximately 12 MiB')), - E('option', { value: 'city_asn' }, _('Province/city + network organization — approximately 57 MiB')) - ]); - var importedCity = null; + var controlStyle = 'appearance:auto;display:block;position:static;transform:none;flex:0 0 16px;box-sizing:border-box;width:16px;height:16px;margin:0;padding:0;align-self:center;'; + var enabled = E('input', { type: 'checkbox', 'class': 'bandix-geoip-control', style: controlStyle + '-webkit-appearance:checkbox', disabled: true }); + var country = E('input', { type: 'radio', 'class': 'bandix-geoip-control', style: controlStyle + '-webkit-appearance:radio', name: 'bandix-geoip-location', value: 'country', checked: true, disabled: true }); + var city = E('input', { type: 'radio', 'class': 'bandix-geoip-control', style: controlStyle + '-webkit-appearance:radio', name: 'bandix-geoip-location', value: 'city', disabled: true }); + var asn = E('input', { type: 'checkbox', 'class': 'bandix-geoip-control', style: controlStyle + '-webkit-appearance:checkbox', disabled: true, 'aria-label': _('ASN / network organization') }); + var choices = [country, city, asn]; + var selection = E('div', { style: 'font-size:0.9em;margin:8px 0' }); + var installed = E('div', { style: 'margin-bottom:6px' }); var details = E('div', { style: 'color:var(--text-color-secondary,#888);font-size:0.9em;line-height:1.6;overflow-wrap:anywhere' }); var message = E('div', { role: 'status', 'aria-live': 'polite', style: 'font-size:0.9em;overflow-wrap:anywhere' }); var progressFill = E('div', { style: 'width:0%' }); @@ -75,6 +78,26 @@ return baseclass.extend({ var cancel = E('button', { type: 'button', 'class': 'btn cbi-button', style: 'display:none' }, _('Cancel')); var remove = E('button', { type: 'button', 'class': 'btn cbi-button cbi-button-negative', style: 'display:none' }, _('Delete')); + function controlLabel(input, text) { + return E('label', { style: 'display:inline-flex;align-items:center;gap:6px;min-height:32px;margin:0;padding:0;width:auto;line-height:16px;box-sizing:border-box' }, [ + input, E('span', { style: 'display:block;font-size:13px;line-height:16px;margin:0;padding:0' }, text) + ]); + } + function selectedProfile() { + return (city.checked ? 'city' : 'country') + (asn.checked ? '_asn' : ''); + } + function setProfile(value) { + city.checked = value === 'city' || value === 'city_asn'; + country.checked = !city.checked; + asn.checked = value === 'country_asn' || value === 'city_asn'; + } + function databaseName(kind) { + return { country: _('Country/region'), city: _('Country/region + province/city'), asn: _('ASN / network organization') }[kind] || kind; + } + function showSelection() { + selection.textContent = _('Download contents') + ': ' + [databaseName(city.checked ? 'city' : 'country'), asn.checked ? databaseName('asn') : ''].filter(Boolean).join(' + '); + selection.style.display = current && current.databases && current.databases.length && current.profile !== selectedProfile() ? '' : 'none'; + } function display(status) { var wasBusy = current && current.job && current.job.busy; if (wasBusy && status.job && !status.job.busy && status.job.phase === 'completed') dirtyProfile = false; @@ -83,29 +106,28 @@ return baseclass.extend({ badge.style.color = status.enabled ? 'var(--success-color,#2f9e44)' : ''; badge.textContent = status.enabled ? _('Enabled') : databases.length ? _('Disabled') : _('Not installed'); enabled.checked = !!status.enabled; enabled.disabled = busy || !databases.length; - if (!dirtyProfile && status.profile) { - if (status.profile === 'city' && !importedCity) { - importedCity = E('option', { value: 'city' }, _('Province/city (uploaded database)')); profile.appendChild(importedCity); - } - profile.value = status.profile; - } - if (job.busy && job.profile) profile.value = job.profile; - profile.disabled = busy; + if (!dirtyProfile && status.profile) setProfile(status.profile); + if (job.busy && job.profile) setProfile(job.profile); + choices.forEach(function (input) { input.disabled = busy; }); + showSelection(); download.disabled = busy; upload.disabled = busy; - download.textContent = databases.length ? _('Update database') : _('Download and enable'); + download.textContent = databases.length && status.profile === selectedProfile() ? _('Update database') : _('Download and enable'); remove.style.display = databases.length || status.load_error ? '' : 'none'; remove.disabled = busy; cancel.style.display = job.busy && job.phase !== 'activating' ? '' : 'none'; cancel.disabled = pending; + installed.textContent = _('Installed databases') + ': ' + (databases.length ? databases.map(function (db) { return databaseName(db.kind); }).join(' + ') : _('Not installed')); + installed.style.display = databases.length ? '' : 'none'; var builds = databases.map(function (db) { - var name = { country: _('Country/region'), city: _('Province/city'), asn: _('Network organization') }[db.kind] || db.kind; var date = '—'; if (db.build_epoch) { try { date = new Date(db.build_epoch * 1000).toLocaleDateString((L.env.lang || 'en').replace(/_/g, '-')); } catch (e) { date = new Date(db.build_epoch * 1000).toLocaleDateString(); } } - return name + ' ' + date; + return databaseName(db.kind) + ': ' + date; }); details.textContent = [ - builds.length ? _('Version') + ': ' + builds.join(' · ') : '', + status.source ? _('Installed data source') + ': ' + status.source : '', + status.version ? _('Release version') + ': ' + status.version : '', + builds.length ? _('Database build dates') + ':\n' + builds.join('\n') : '', _('Storage used') + ': ' + size(status.installed_bytes) + ' · ' + _('Available space') + ': ' + size(status.free_bytes), _('Database directory') + ': ' + status.directory ].filter(Boolean).join('\n'); details.style.whiteSpace = 'pre-line'; @@ -141,6 +163,7 @@ return baseclass.extend({ message.textContent = _('GeoIP service is unavailable. Enable connection monitoring and apply settings, or update Bandix.'); message.title = error.message || String(error); download.disabled = true; upload.disabled = true; enabled.disabled = true; + choices.forEach(function (input) { input.disabled = true; }); }).finally(function () { refreshing = false; }); } function manage(action, selected, checked) { @@ -152,9 +175,18 @@ return baseclass.extend({ ui.addNotification(null, E('p', _('Database operation failed') + ': ' + (error.message || error)), 'error'); }).finally(function () { pending = false; if (current) display(current); }); } - profile.addEventListener('change', function () { dirtyProfile = true; }); + choices.forEach(function (input) { + input.addEventListener('change', function () { + if (input === country || input === city) { + country.checked = input === country; + city.checked = input === city; + } + dirtyProfile = true; + if (current) display(current); else showSelection(); + }); + }); enabled.addEventListener('change', function () { manage('enable', null, enabled.checked); }); - download.addEventListener('click', function () { manage('download', profile.value); }); + download.addEventListener('click', function () { manage('download', selectedProfile()); }); cancel.addEventListener('click', function () { manage('cancel'); }); remove.addEventListener('click', function () { ui.showModal(_('Delete GeoIP databases'), [ @@ -202,20 +234,36 @@ return baseclass.extend({ }).finally(function () { pending = false; if (current) display(current); }); }); ui.showModal(_('Upload database'), [ + E('p', {}, _('Upload an uncompressed Country, City or ASN .mmdb file. To add ASN, install a Country or City database first.')), E('p', {}, _('Maximum upload size') + ': ' + size(limit)), selection, uploadError, E('div', { 'class': 'right' }, [E('button', { type: 'button', 'class': 'btn', click: ui.hideModal }, _('Cancel')), ' ', uploadAction]) ]); }); root = E('div', { style: 'max-width:780px' }, [ + E('style', {}, '.bandix-geoip-control::before,.bandix-geoip-control::after{content:none!important;display:none!important}'), E('div', { style: 'display:flex;flex-wrap:wrap;align-items:center;gap:16px;margin-bottom:12px' }, [ - E('label', { style: 'display:flex;align-items:center;gap:8px' }, [enabled, _('Enable IP location lookup')]), badge + controlLabel(enabled, _('Enable IP location lookup')), badge ]), - E('div', { style: 'display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:10px' }, [profile, download, upload, cancel, remove]), - details, progress, message, + E('fieldset', { style: 'border:1px solid var(--border-color,#aaa);border-radius:6px;padding:12px;margin:0 0 10px' }, [ + E('legend', {}, _('Database contents')), + E('div', { style: 'display:flex;flex-wrap:wrap;gap:12px;margin-bottom:10px' }, [ + controlLabel(country, _('Country/region')), + controlLabel(city, _('Country/region + province/city')) + ]), + controlLabel(asn, _('ASN / network organization')), + selection + ]), + E('div', { style: 'display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:10px' }, [download, upload, cancel, remove]), + progress, message, E('div', { style: 'font-size:0.85em;opacity:0.75;margin-top:8px' }, [ - _('Data source') + ': ', E('a', { href: 'https://github.com/P3TERX/GeoLite.mmdb', target: '_blank', rel: 'noopener noreferrer' }, 'P3TERX / MaxMind GeoLite2'), - ' · ', E('a', { href: 'https://www.maxmind.com/en/geolite2/eula', target: '_blank', rel: 'noopener noreferrer' }, _('Data terms')), - E('div', {}, _('Upload an uncompressed Country, City or ASN .mmdb file. To add ASN, install a Country or City database first.')) + _('Data source') + ': ', E('a', { href: 'https://github.com/P3TERX/GeoLite.mmdb/releases/latest', target: '_blank', rel: 'noopener noreferrer' }, 'P3TERX / MaxMind GeoLite2') + ]), + E('details', { style: 'margin-top:8px' }, [ + E('summary', { style: 'cursor:pointer' }, _('Database details')), + installed, details, + E('div', { style: 'font-size:0.85em;margin-top:6px' }, [ + E('a', { href: 'https://www.maxmind.com/en/geolite2/eula', target: '_blank', rel: 'noopener noreferrer' }, _('Data terms')) + ]) ]) ]); this._root = root; diff --git a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/connection.js b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/connection.js index 24852fb83..3af9908b4 100644 --- a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/connection.js +++ b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/connection.js @@ -5,6 +5,7 @@ 'require rpc'; 'require poll'; 'require bandix.geoip as geoip'; +'require bandix.flows as flowSearch'; var BANDIX_MODAL_BG_OPENWRT2020_LIGHT = '#ffffff'; var BANDIX_MODAL_BG_OPENWRT2020_DARK = '#2a2a2a'; @@ -148,9 +149,11 @@ function flowEndpointCell(addr, port, information) { E('span', { 'class': 'flows-port' }, port ? ':' + port : '') ])]; if (description) children.push(E('div', { - 'class': 'flows-geoip', title: description.details, - style: 'font-size:0.8rem;opacity:0.75;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:240px' - }, description.summary)); + 'class': 'flows-geoip', title: description.details + }, [ + E('div', {}, description.location || description.summary), + description.organization ? E('div', { 'class': 'flows-geoip-network' }, description.organization) : null + ])); return E('td', { 'class': 'flows-addr-cell' }, children); } @@ -645,8 +648,8 @@ return view.extend({ .flows-modal-overlay .flows-modal-content { margin: 16px; - width: 95vw; - max-width: 1200px; + width: calc(100vw - 32px); + max-width: 1680px; max-height: 85vh; display: flex; flex-direction: column; @@ -710,6 +713,7 @@ return view.extend({ } .flows-modal-content .flows-filters select, + .flows-modal-content .flows-filters input[type="search"], .flows-modal-content .flows-footer select { padding: 6px 10px; border-radius: 4px; @@ -718,12 +722,30 @@ return view.extend({ } .flows-modal-content.theme-dark .flows-filters select, + .flows-modal-content.theme-dark .flows-filters input[type="search"], .flows-modal-content.theme-dark .flows-footer select { background-color: #3a3a3a; border-color: rgba(255,255,255,0.2); color: #e5e5e5; } + .flows-modal-content .flows-search { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 320px; + min-width: 0; + max-width: 100%; + margin: 0; + } + + .flows-modal-content .flows-search input { + flex: 1; + min-width: 0; + width: 100%; + box-sizing: border-box; + } + .flows-modal-content .flows-table-wrap { overflow: auto; flex: 1; @@ -732,6 +754,7 @@ return view.extend({ .flows-modal-content .flows-table { width: 100%; + min-width: 1200px; table-layout: fixed; border-collapse: collapse; font-size: 0.85rem; @@ -757,6 +780,10 @@ return view.extend({ .flows-modal-content .flows-table td:nth-child(9) { width: 8%; } /* IPv6 没有 NAT,回复方向恒为源方向的镜像,隐藏后地址列能放下整条 IPv6 地址 */ + .flows-modal-content .flows-table.hide-repl { + min-width: 900px; + } + .flows-modal-content .flows-table.hide-repl th:nth-child(6), .flows-modal-content .flows-table.hide-repl td:nth-child(6), .flows-modal-content .flows-table.hide-repl th:nth-child(7), @@ -806,6 +833,21 @@ return view.extend({ white-space: nowrap; } + .flows-modal-content .flows-geoip { + margin-top: 6px; + font-family: sans-serif; + font-size: 0.8rem; + line-height: 1.5; + opacity: 0.75; + white-space: normal; + word-break: normal; + overflow-wrap: anywhere; + } + + .flows-modal-content .flows-geoip-network { + margin-top: 2px; + } + /* ESTABLISHED 是单个长单词,宽度不够时必须允许断开,否则会压到相邻列上 */ .flows-modal-content .flows-table td.flows-state-cell { overflow-wrap: anywhere; @@ -1136,6 +1178,15 @@ return view.extend({ var totalPages = 1; var totalItems = 0; var currentPageSize = 100; + var cachedPage = null; + var searchIndex = []; + var matchedItems = 0; + var requestVersion = 0; + var searchInput = E('input', { + type: 'search', id: 'flows-search', 'class': 'cbi-input-text', + placeholder: _('Search this page: protocol, IP, port, location, ASN…'), + 'aria-label': _('Search connections on this page'), autocomplete: 'off' + }); var protocolSelect = E('select', { 'class': 'cbi-input-select', 'id': 'flows-protocol' }, [ E('option', { 'value': '' }, _('All')), @@ -1186,6 +1237,9 @@ return view.extend({ function updatePaginationUi() { if (pageInfo) { pageInfo.textContent = _('Page') + ' ' + currentPage + '/' + totalPages + ' · ' + _('Total') + ': ' + totalItems; + if (cachedPage && searchInput.value.trim()) { + pageInfo.textContent += ' · ' + _('Matches on this page') + ': ' + matchedItems + '/' + cachedPage.items.length; + } } if (prevPageBtn) { prevPageBtn.disabled = currentPage <= 1; @@ -1197,7 +1251,7 @@ return view.extend({ function normalizeFlowsResponse(res) { if (!res || res.status !== 'success') { - return { items: [], total: 0, page: currentPage, page_size: currentPageSize, total_pages: 1 }; + throw new Error(res && (res.message || res.error) || _('Failed to load')); } if (Array.isArray(res.data)) { @@ -1223,10 +1277,51 @@ return view.extend({ }; } - return { items: [], total: 0, page: currentPage, page_size: currentPageSize, total_pages: 1 }; + throw new Error(_('Failed to load')); + } + + function renderFlows() { + if (!cachedPage) return; + var flows = flowSearch.filter(searchIndex, searchInput.value); + var information = cachedPage.geoip || {}; + matchedItems = flows.length; + updatePaginationUi(); + tableBody.innerHTML = ''; + if (!flows.length) { + tableBody.appendChild(E('tr', {}, [ + E('td', { 'colspan': 9, 'class': 'loading-state' }, cachedPage.items.length && searchInput.value.trim() ? _('No matching connections on this page') : _('No connections')) + ])); + return; + } + flows.forEach(function (f) { + var stateCls = ''; + if (f.state) { + if (f.state.indexOf('ESTABLISHED') >= 0) stateCls = 'flows-state-est'; + else if (f.state.indexOf('TIME_WAIT') >= 0) stateCls = 'flows-state-wait'; + else if (f.state.indexOf('CLOSE') >= 0) stateCls = 'flows-state-close'; + } + var sendStr = (f.orig_packets || 0) + ' ' + _('pkts') + ' / ' + formatSize(f.orig_bytes || 0); + var replyStr = (f.repl_packets || 0) + ' ' + _('pkts') + ' / ' + formatSize(f.repl_bytes || 0); + var flagsStr = (f.flags && f.flags.length) ? f.flags.join(' ') : '-'; + var protoCls = (f.protocol || '').toLowerCase() === 'tcp' ? 'flows-protocol-tcp' : 'flows-protocol-udp'; + tableBody.appendChild(E('tr', {}, [ + E('td', { 'class': protoCls }, (f.protocol || '-').toUpperCase()), + E('td', { 'class': 'flows-state-cell ' + stateCls }, f.state || '-'), + flowEndpointCell(f.orig && f.orig.src, f.orig && f.orig.sport, information[f.orig && f.orig.src]), + flowEndpointCell(f.orig && f.orig.dst, f.orig && f.orig.dport, information[f.orig && f.orig.dst]), + E('td', {}, sendStr), + flowEndpointCell(f.repl && f.repl.src, f.repl && f.repl.sport, information[f.repl && f.repl.src]), + flowEndpointCell(f.repl && f.repl.dst, f.repl && f.repl.dport, information[f.repl && f.repl.dst]), + E('td', {}, replyStr), + E('td', { 'style': 'font-size: 0.8rem;' }, flagsStr) + ])); + }); } function loadFlows(resetPage) { + var version = ++requestVersion; + cachedPage = null; + searchIndex = []; if (resetPage) { currentPage = 1; } @@ -1238,45 +1333,18 @@ return view.extend({ E('td', { 'colspan': 9, 'class': 'loading-state' }, _('Loading…')) ])); return callGetConnectionFlows(deviceIp, protocol || null, state || null, currentPage, currentPageSize).then(function (res) { - tableBody.innerHTML = ''; + if (version !== requestVersion) return; var normalized = normalizeFlowsResponse(res); - var flows = normalized.items; - var information = normalized.geoip || {}; totalItems = normalized.total; currentPage = normalized.page; totalPages = Math.max(1, normalized.total_pages); - updatePaginationUi(); - - if (!flows || flows.length === 0) { - tableBody.appendChild(E('tr', {}, [ - E('td', { 'colspan': 9, 'class': 'loading-state' }, _('No connections')) - ])); - return; - } - flows.forEach(function (f) { - var stateCls = ''; - if (f.state) { - if (f.state.indexOf('ESTABLISHED') >= 0) stateCls = 'flows-state-est'; - else if (f.state.indexOf('TIME_WAIT') >= 0) stateCls = 'flows-state-wait'; - else if (f.state.indexOf('CLOSE') >= 0) stateCls = 'flows-state-close'; - } - var sendStr = (f.orig_packets || 0) + ' ' + _('pkts') + ' / ' + formatSize(f.orig_bytes || 0); - var replyStr = (f.repl_packets || 0) + ' ' + _('pkts') + ' / ' + formatSize(f.repl_bytes || 0); - var flagsStr = (f.flags && f.flags.length) ? f.flags.join(' ') : '-'; - var protoCls = (f.protocol || '').toLowerCase() === 'tcp' ? 'flows-protocol-tcp' : 'flows-protocol-udp'; - tableBody.appendChild(E('tr', {}, [ - E('td', { 'class': protoCls }, (f.protocol || '-').toUpperCase()), - E('td', { 'class': 'flows-state-cell ' + stateCls }, f.state || '-'), - flowEndpointCell(f.orig && f.orig.src, f.orig && f.orig.sport, information[f.orig && f.orig.src]), - flowEndpointCell(f.orig && f.orig.dst, f.orig && f.orig.dport, information[f.orig && f.orig.dst]), - E('td', {}, sendStr), - flowEndpointCell(f.repl && f.repl.src, f.repl && f.repl.sport, information[f.repl && f.repl.src]), - flowEndpointCell(f.repl && f.repl.dst, f.repl && f.repl.dport, information[f.repl && f.repl.dst]), - E('td', {}, replyStr), - E('td', { 'style': 'font-size: 0.8rem;' }, flagsStr) - ])); - }); + searchIndex = flowSearch.index(normalized.items, normalized.geoip); + cachedPage = normalized; + renderFlows(); }).catch(function (err) { + if (version !== requestVersion) return; + cachedPage = null; + searchIndex = []; totalItems = 0; totalPages = 1; currentPage = 1; @@ -1293,6 +1361,7 @@ return view.extend({ } function hideFlowsModal() { + requestVersion++; var overlay = document.getElementById('flows-modal-overlay'); if (overlay) overlay.classList.remove('show'); } @@ -1312,6 +1381,7 @@ return view.extend({ pageSizeSelect.addEventListener('change', function () { loadFlows(true); }); + searchInput.addEventListener('input', renderFlows); var modalContent = E('div', { 'class': 'flows-modal-content theme-' + currentTheme }, [ E('div', { 'class': 'flows-modal-header' }, [ @@ -1319,6 +1389,9 @@ return view.extend({ E('div', { 'class': 'flows-modal-subtitle' }, (deviceInfo.ip4 || '') + (deviceInfo.mac ? ' · ' + deviceInfo.mac : '')) ]), E('div', { 'class': 'flows-filters' }, [ + E('label', { 'class': 'flows-search', 'for': 'flows-search' }, [ + E('span', {}, _('Search')), searchInput + ]), E('label', {}, _('Protocol') + ':'), protocolSelect, E('label', {}, _('State') + ':'), diff --git a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/index.js b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/index.js index 384f0668b..6fb39d7f2 100644 --- a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/index.js +++ b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/index.js @@ -4,6 +4,7 @@ 'require uci'; 'require rpc'; 'require poll'; +'require bandix.devices as devices'; // 颜色常量定义 var BANDIX_COLOR_UPLOAD = '#f97316'; // 橙色 - 上传/上行 @@ -504,6 +505,20 @@ return view.extend({ render: function (data) { + var deviceSelection = devices.create({ + writable: !!L.hasViewPermission(), + confirm: function (title, message, callback) { showConfirmDialog(title, message, callback); }, + refresh: function () { + return fetchAllTrafficQuotas(true).then(function () { + return updateDeviceData(); + }).then(function () { + updateTrafficStatistics(); + }).catch(function (error) { + ui.addNotification(null, E('p', {}, _('Unable to fetch data') + ': ' + (error.message || error)), 'error'); + }); + } + }); + // 生成样式字符串的函数 function generateStyles(colorScheme) { var scheme = colorScheme || 'light'; @@ -685,6 +700,68 @@ return view.extend({ width: 100%; table-layout: fixed; } + + .bandix-device-checkbox::before, + .bandix-device-checkbox::after { + content: none !important; + display: none !important; + } + + .bandix-container input.bandix-device-checkbox { + border: 1px solid rgba(100, 116, 139, 0.55) !important; + border-radius: 4px !important; + background-color: transparent !important; + background-image: none !important; + box-shadow: none !important; + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease; + } + + .bandix-container.theme-dark input.bandix-device-checkbox { + border-color: rgba(148, 163, 184, 0.65) !important; + } + + .bandix-container input.bandix-device-checkbox:hover:not(:disabled), + .bandix-container input.bandix-device-checkbox:focus-visible { + border-color: #3b82f6 !important; + } + + .bandix-container input.bandix-device-checkbox:focus-visible { + outline: 2px solid rgba(59, 130, 246, 0.5); + outline-offset: 2px; + } + + .bandix-container input.bandix-device-checkbox:checked, + .bandix-container input.bandix-device-checkbox:indeterminate { + border-color: #3b82f6 !important; + background-color: #3b82f6 !important; + background-repeat: no-repeat !important; + background-position: center !important; + background-size: 12px 12px !important; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2.5 6l2.3 2.3 4.7-4.6' fill='none' stroke='%23fff' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") !important; + } + + .bandix-container input.bandix-device-checkbox:indeterminate { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M3 6h6' stroke='%23fff' stroke-width='1.7' stroke-linecap='round'/%3E%3C/svg%3E") !important; + } + + .bandix-container input.bandix-device-checkbox:disabled { + opacity: 0.45; + cursor: default; + } + + @media (forced-colors: active) { + .bandix-container input.bandix-device-checkbox { + appearance: auto !important; + -webkit-appearance: checkbox !important; + background-image: none !important; + } + } + + .bandix-table th, + .bandix-table td { + box-sizing: border-box; + } .bandix-table th { padding: 10px 16px; @@ -796,6 +873,16 @@ return view.extend({ .bandix-table td:nth-child(5) { width: 16.5%; } + + .device-info-selection { + display: flex; + align-items: center; + gap: 8px; + } + + .device-info-selection .device-info { + min-width: 0; + } .device-actions { display: flex; @@ -1639,12 +1726,29 @@ return view.extend({ } .device-card { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + align-items: start; + column-gap: 6px; border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 8px; padding: 12px; margin-bottom: 12px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); } + + .device-card-selection { + grid-column: 1; + grid-row: 1; + display: flex; + justify-content: center; + padding-top: 4px; + } + + .device-card > :not(.device-card-selection) { + grid-column: 2; + min-width: 0; + } .device-card-header { @@ -2959,8 +3063,11 @@ return view.extend({ // 主要内容卡片 E('div', { 'class': 'cbi-section' }, [ - E('h3', { 'class': 'history-header', 'style': 'display: flex; align-items: center; justify-content: space-between;' }, [ - E('span', {}, _('Device List')), + E('h3', { 'class': 'history-header', 'style': 'display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px' }, [ + E('div', { style: 'display:flex;align-items:center;flex-wrap:wrap;gap:16px' }, [ + E('span', { style: 'white-space:nowrap' }, _('Device List')), + deviceSelection.toolbar + ]), E('div', { 'class': 'device-toolbar' }, [ E('div', { 'class': 'device-group' }, [ E('span', { 'class': 'device-group-label' }, _('Period')), @@ -5372,38 +5479,30 @@ return view.extend({ return timeDiff <= offlineThreshold; } - function channelToBand(ch) { - var c = Number(ch); - if (c < 1) return null; - if (c <= 14) return '2.4 GHz'; - if (c <= 165) return '5 GHz'; - if (c <= 233) return '6 GHz'; + function wifiBand(device) { + if (device.conn !== 'wifi') return null; + var frequency = Number(device.w_freq); + if (!Number.isInteger(frequency)) return null; + if (frequency >= 2400 && frequency < 2500) return { label: '2.4 GHz', css: 'g24' }; + if (frequency >= 4900 && frequency < 5925) return { label: '5 GHz', css: 'g5' }; + if (frequency >= 5925 && frequency <= 7125) return { label: '6 GHz', css: 'g6' }; return null; } - function channelToBandClass(ch) { - var c = Number(ch); - if (c <= 14) return 'g24'; - if (c <= 165) return 'g5'; - if (c <= 233) return 'g6'; - return ''; - } - function buildDeviceUplinkChBadges(device) { var badges = []; if (device.uplink && String(device.uplink).trim() !== '') { badges.push(E('span', { 'class': 'device-uplink-badge', 'title': _('Interface') }, device.uplink.trim())); } - if (device.w_ch && Number(device.w_ch) > 0) { + if (device.conn === 'wifi' && Number(device.w_ch) > 0) { var ch = Number(device.w_ch); - var band = channelToBand(ch); - var bandClass = channelToBandClass(ch); - if (band && bandClass) { - badges.push(E('span', { - 'class': 'device-ch-badge device-ch-' + bandClass, - 'title': _('WiFi Channel') + ' ' + ch - }, band)); - } + var band = wifiBand(device); + var title = _('WiFi Channel') + ' ' + ch; + if (band) title += ' · ' + Number(device.w_freq) + ' MHz'; + badges.push(E('span', { + 'class': 'device-ch-badge ' + (band ? 'device-ch-' + band.css : 'device-uplink-badge'), + 'title': title + }, band ? band.label : _('WiFi Channel') + ' ' + ch)); } return badges; } @@ -5411,9 +5510,9 @@ return view.extend({ function formatDeviceUplinkCh(device) { var parts = []; if (device.uplink && String(device.uplink).trim() !== '') parts.push(device.uplink.trim()); - if (device.w_ch && Number(device.w_ch) > 0) { - var band = channelToBand(Number(device.w_ch)); - if (band) parts.push(band); + if (device.conn === 'wifi' && Number(device.w_ch) > 0) { + var band = wifiBand(device); + parts.push(band ? band.label : _('WiFi Channel') + ' ' + Number(device.w_ch)); } return parts.join(' · '); } @@ -5984,6 +6083,7 @@ return view.extend({ var stats = result; if (!stats || !stats.d) { + deviceSelection.setDevices([]); if (trafficDiv) { trafficDiv.innerHTML = '
' + _('Unable to fetch data') + '
'; } @@ -6266,6 +6366,7 @@ return view.extend({ // 应用排序 filteredDevices = sortDevices(filteredDevices, currentSortBy, currentSortOrder); + deviceSelection.setDevices(filteredDevices); // 检查是否有任何设备有 IPv6 地址 var hasAnyIPv6 = filteredDevices.some(function (device) { @@ -6375,7 +6476,10 @@ return view.extend({ var row = E('tr', {}, [ // 设备信息 E('td', {}, [ - E('div', { 'class': 'device-info' }, deviceInfoElements) + E('div', { 'class': 'device-info-selection' }, [ + deviceSelection.checkbox(device), + E('div', { 'class': 'device-info' }, deviceInfoElements) + ]) ]), // LAN 流量 @@ -6563,6 +6667,7 @@ return view.extend({ // 创建移动端卡片 var card = E('div', { 'class': 'device-card' }, [ + E('div', { 'class': 'device-card-selection' }, [deviceSelection.checkbox(device)]), // 卡片头部 E('div', { 'class': 'device-card-header' }, [ E('div', { 'class': 'device-card-name' }, [ diff --git a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js index 5b472cc53..cf825a7e9 100644 --- a/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js +++ b/luci-app-bandix/htdocs/luci-static/resources/view/bandix/settings.js @@ -796,8 +796,7 @@ return view.extend({ o = s.option(form.DummyValue, '_geoip_manager', _('IP location database')); o.renderWidget = function () { return geoip.render(); }; - o = s.option(form.Value, 'geoip_dir', _('Database directory'), - _('Use persistent storage or an external drive. Save and apply directory changes before downloading.')); + o = s.option(form.Value, 'geoip_dir', _('Database directory')); o.default = '/usr/share/bandix-geoip'; o.placeholder = '/usr/share/bandix-geoip'; o.datatype = 'directory'; diff --git a/luci-app-bandix/po/es/bandix.po b/luci-app-bandix/po/es/bandix.po index ac2ce1fc9..9af8203e3 100644 --- a/luci-app-bandix/po/es/bandix.po +++ b/luci-app-bandix/po/es/bandix.po @@ -1510,3 +1510,71 @@ msgstr "Dirección de escucha" msgid "IP address for Bandix to listen on. Use 127.0.0.1 for local access or 0.0.0.0 / :: for all interfaces." msgstr "Dirección IP de escucha de Bandix. Use 127.0.0.1 para acceso local o 0.0.0.0 / :: para todas las interfaces." + +msgid "ASN / network organization" +msgstr "ASN / organización de red" + +msgid "Country/region + province/city" +msgstr "País/región + provincia/ciudad" + +msgid "Database build dates" +msgstr "Fechas de creación de las bases de datos" + +msgid "Download contents" +msgstr "Contenido de la descarga" + +msgid "Installed data source" +msgstr "Origen de los datos instalados" + +msgid "Installed databases" +msgstr "Bases de datos instaladas" + +msgid "Release version" +msgstr "Versión publicada" + +msgid "Location and ASN databases work together." +msgstr "Las bases de ubicación y ASN funcionan juntas." + +msgid "Database details" +msgstr "Detalles de las bases de datos" + +msgid "Apply directory changes before downloading." +msgstr "Aplique los cambios de directorio antes de descargar." + +msgid "Batch deletion is unavailable. Update the Bandix service." +msgstr "La eliminación por lotes no está disponible. Actualice el servicio Bandix." + +#, c-format +msgid "Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear." +msgstr "¿Eliminar los dispositivos seleccionados (%d)? También se eliminarán su historial de tráfico, sus asociaciones de nombres, sus límites de velocidad y sus cuotas. Los dispositivos conectados pueden reaparecer." + +msgid "Delete selected" +msgstr "Eliminar seleccionados" + +#, c-format +msgid "Deleted: %d; not found: %d; failed: %d." +msgstr "Eliminados: %d; no encontrados: %d; fallidos: %d." + +msgid "Failed to delete devices" +msgstr "No se pudieron eliminar los dispositivos" + +msgid "Invalid batch deletion response" +msgstr "Respuesta de eliminación por lotes no válida" + +msgid "Select all" +msgstr "Seleccionar todos" + +msgid "Select device" +msgstr "Seleccionar dispositivo" + +msgid "Search this page: protocol, IP, port, location, ASN…" +msgstr "Buscar en esta página: protocolo, IP, puerto, ubicación, ASN…" + +msgid "Search connections on this page" +msgstr "Buscar conexiones en esta página" + +msgid "Matches on this page" +msgstr "Coincidencias en esta página" + +msgid "No matching connections on this page" +msgstr "No hay conexiones coincidentes en esta página" diff --git a/luci-app-bandix/po/fr/bandix.po b/luci-app-bandix/po/fr/bandix.po index 5bdbad4c4..4695932db 100644 --- a/luci-app-bandix/po/fr/bandix.po +++ b/luci-app-bandix/po/fr/bandix.po @@ -1510,3 +1510,71 @@ msgstr "Adresse d’écoute" msgid "IP address for Bandix to listen on. Use 127.0.0.1 for local access or 0.0.0.0 / :: for all interfaces." msgstr "Adresse IP d’écoute de Bandix. Utilisez 127.0.0.1 pour un accès local ou 0.0.0.0 / :: pour toutes les interfaces." + +msgid "ASN / network organization" +msgstr "ASN / organisation réseau" + +msgid "Country/region + province/city" +msgstr "Pays/région + province/ville" + +msgid "Database build dates" +msgstr "Dates de création des bases de données" + +msgid "Download contents" +msgstr "Contenu à télécharger" + +msgid "Installed data source" +msgstr "Source des données installées" + +msgid "Installed databases" +msgstr "Bases de données installées" + +msgid "Release version" +msgstr "Version publiée" + +msgid "Location and ASN databases work together." +msgstr "Les bases de localisation et ASN fonctionnent ensemble." + +msgid "Database details" +msgstr "Détails des bases de données" + +msgid "Apply directory changes before downloading." +msgstr "Appliquez les changements de répertoire avant le téléchargement." + +msgid "Batch deletion is unavailable. Update the Bandix service." +msgstr "La suppression groupée est indisponible. Mettez à jour le service Bandix." + +#, c-format +msgid "Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear." +msgstr "Supprimer les appareils sélectionnés (%d) ? Leur historique de trafic, leurs associations de noms, leurs limites de débit et leurs quotas seront aussi supprimés. Les appareils en ligne peuvent réapparaître." + +msgid "Delete selected" +msgstr "Supprimer la sélection" + +#, c-format +msgid "Deleted: %d; not found: %d; failed: %d." +msgstr "Supprimés : %d ; introuvables : %d ; échecs : %d." + +msgid "Failed to delete devices" +msgstr "Échec de la suppression des appareils" + +msgid "Invalid batch deletion response" +msgstr "Réponse de suppression groupée invalide" + +msgid "Select all" +msgstr "Tout sélectionner" + +msgid "Select device" +msgstr "Sélectionner un appareil" + +msgid "Search this page: protocol, IP, port, location, ASN…" +msgstr "Rechercher sur cette page : protocole, IP, port, localisation, ASN…" + +msgid "Search connections on this page" +msgstr "Rechercher les connexions sur cette page" + +msgid "Matches on this page" +msgstr "Correspondances sur cette page" + +msgid "No matching connections on this page" +msgstr "Aucune connexion correspondante sur cette page" diff --git a/luci-app-bandix/po/ja/bandix.po b/luci-app-bandix/po/ja/bandix.po index f3c1f4c78..1aa14ad0c 100644 --- a/luci-app-bandix/po/ja/bandix.po +++ b/luci-app-bandix/po/ja/bandix.po @@ -1510,3 +1510,71 @@ msgstr "待ち受けアドレス" msgid "IP address for Bandix to listen on. Use 127.0.0.1 for local access or 0.0.0.0 / :: for all interfaces." msgstr "Bandix が待ち受ける IP アドレス。ローカルアクセスには 127.0.0.1、すべてのインターフェースには 0.0.0.0 / :: を指定します。" + +msgid "ASN / network organization" +msgstr "ASN / ネットワーク組織" + +msgid "Country/region + province/city" +msgstr "国・地域 + 都道府県・市区町村" + +msgid "Database build dates" +msgstr "データベースの作成日" + +msgid "Download contents" +msgstr "ダウンロード内容" + +msgid "Installed data source" +msgstr "インストール済みデータの提供元" + +msgid "Installed databases" +msgstr "インストール済みデータベース" + +msgid "Release version" +msgstr "リリースバージョン" + +msgid "Location and ASN databases work together." +msgstr "位置情報と ASN のデータベースは併用されます。" + +msgid "Database details" +msgstr "データベースの詳細" + +msgid "Apply directory changes before downloading." +msgstr "ダウンロードする前にディレクトリの変更を適用してください。" + +msgid "Batch deletion is unavailable. Update the Bandix service." +msgstr "一括削除を利用できません。Bandix サービスを更新してください。" + +#, c-format +msgid "Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear." +msgstr "選択したデバイス(%d 台)を削除しますか?通信履歴、ホスト名の割り当て、速度制限、通信量の上限設定も削除されます。オンラインのデバイスは再表示されることがあります。" + +msgid "Delete selected" +msgstr "選択したデバイスを削除" + +#, c-format +msgid "Deleted: %d; not found: %d; failed: %d." +msgstr "削除済み: %d、未検出: %d、失敗: %d。" + +msgid "Failed to delete devices" +msgstr "デバイスの削除に失敗しました" + +msgid "Invalid batch deletion response" +msgstr "一括削除の応答が無効です" + +msgid "Select all" +msgstr "すべて選択" + +msgid "Select device" +msgstr "デバイスを選択" + +msgid "Search this page: protocol, IP, port, location, ASN…" +msgstr "このページを検索: プロトコル、IP、ポート、位置、ASN…" + +msgid "Search connections on this page" +msgstr "このページの接続を検索" + +msgid "Matches on this page" +msgstr "このページの一致件数" + +msgid "No matching connections on this page" +msgstr "このページに一致する接続はありません" diff --git a/luci-app-bandix/po/pl/bandix.po b/luci-app-bandix/po/pl/bandix.po index 5401ef158..9cfc198fa 100644 --- a/luci-app-bandix/po/pl/bandix.po +++ b/luci-app-bandix/po/pl/bandix.po @@ -1510,3 +1510,71 @@ msgstr "Adres nasłuchiwania" msgid "IP address for Bandix to listen on. Use 127.0.0.1 for local access or 0.0.0.0 / :: for all interfaces." msgstr "Adres IP nasłuchiwania Bandix. Użyj 127.0.0.1 dla dostępu lokalnego lub 0.0.0.0 / :: dla wszystkich interfejsów." + +msgid "ASN / network organization" +msgstr "ASN / organizacja sieciowa" + +msgid "Country/region + province/city" +msgstr "Kraj/region + województwo/miasto" + +msgid "Database build dates" +msgstr "Daty utworzenia baz danych" + +msgid "Download contents" +msgstr "Zawartość do pobrania" + +msgid "Installed data source" +msgstr "Źródło zainstalowanych danych" + +msgid "Installed databases" +msgstr "Zainstalowane bazy danych" + +msgid "Release version" +msgstr "Wersja wydania" + +msgid "Location and ASN databases work together." +msgstr "Bazy lokalizacji i ASN działają wspólnie." + +msgid "Database details" +msgstr "Szczegóły baz danych" + +msgid "Apply directory changes before downloading." +msgstr "Zastosuj zmiany katalogu przed pobraniem." + +msgid "Batch deletion is unavailable. Update the Bandix service." +msgstr "Usuwanie zbiorcze jest niedostępne. Zaktualizuj usługę Bandix." + +#, c-format +msgid "Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear." +msgstr "Usunąć wybrane urządzenia (%d)? Ich historia ruchu, powiązania nazw, limity prędkości i limity transferu również zostaną usunięte. Urządzenia online mogą pojawić się ponownie." + +msgid "Delete selected" +msgstr "Usuń wybrane" + +#, c-format +msgid "Deleted: %d; not found: %d; failed: %d." +msgstr "Usunięto: %d; nie znaleziono: %d; błędy: %d." + +msgid "Failed to delete devices" +msgstr "Nie udało się usunąć urządzeń" + +msgid "Invalid batch deletion response" +msgstr "Nieprawidłowa odpowiedź usuwania zbiorczego" + +msgid "Select all" +msgstr "Zaznacz wszystkie" + +msgid "Select device" +msgstr "Wybierz urządzenie" + +msgid "Search this page: protocol, IP, port, location, ASN…" +msgstr "Szukaj na tej stronie: protokół, IP, port, lokalizacja, ASN…" + +msgid "Search connections on this page" +msgstr "Szukaj połączeń na tej stronie" + +msgid "Matches on this page" +msgstr "Dopasowania na stronie" + +msgid "No matching connections on this page" +msgstr "Brak pasujących połączeń na tej stronie" diff --git a/luci-app-bandix/po/ru/bandix.po b/luci-app-bandix/po/ru/bandix.po index 62ed7228f..fa27d4fa2 100644 --- a/luci-app-bandix/po/ru/bandix.po +++ b/luci-app-bandix/po/ru/bandix.po @@ -1510,3 +1510,71 @@ msgstr "Адрес прослушивания" msgid "IP address for Bandix to listen on. Use 127.0.0.1 for local access or 0.0.0.0 / :: for all interfaces." msgstr "IP-адрес прослушивания Bandix. Используйте 127.0.0.1 для локального доступа или 0.0.0.0 / :: для всех интерфейсов." + +msgid "ASN / network organization" +msgstr "ASN / сетевая организация" + +msgid "Country/region + province/city" +msgstr "Страна/регион + область/город" + +msgid "Database build dates" +msgstr "Даты сборки баз данных" + +msgid "Download contents" +msgstr "Содержимое загрузки" + +msgid "Installed data source" +msgstr "Источник установленных данных" + +msgid "Installed databases" +msgstr "Установленные базы данных" + +msgid "Release version" +msgstr "Версия выпуска" + +msgid "Location and ASN databases work together." +msgstr "Базы геолокации и ASN работают совместно." + +msgid "Database details" +msgstr "Сведения о базах данных" + +msgid "Apply directory changes before downloading." +msgstr "Примените изменения каталога перед загрузкой." + +msgid "Batch deletion is unavailable. Update the Bandix service." +msgstr "Пакетное удаление недоступно. Обновите службу Bandix." + +#, c-format +msgid "Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear." +msgstr "Удалить выбранные устройства (%d)? История трафика, привязки имён, ограничения скорости и квоты также будут удалены. Активные устройства могут появиться снова." + +msgid "Delete selected" +msgstr "Удалить выбранные" + +#, c-format +msgid "Deleted: %d; not found: %d; failed: %d." +msgstr "Удалено: %d; не найдено: %d; ошибок: %d." + +msgid "Failed to delete devices" +msgstr "Не удалось удалить устройства" + +msgid "Invalid batch deletion response" +msgstr "Некорректный ответ пакетного удаления" + +msgid "Select all" +msgstr "Выбрать все" + +msgid "Select device" +msgstr "Выбрать устройство" + +msgid "Search this page: protocol, IP, port, location, ASN…" +msgstr "Поиск на странице: протокол, IP, порт, геолокация, ASN…" + +msgid "Search connections on this page" +msgstr "Поиск соединений на этой странице" + +msgid "Matches on this page" +msgstr "Совпадения на странице" + +msgid "No matching connections on this page" +msgstr "На этой странице нет подходящих соединений" diff --git a/luci-app-bandix/po/zh_Hans/bandix.po b/luci-app-bandix/po/zh_Hans/bandix.po index 65379d5bb..f24b9c3f8 100644 --- a/luci-app-bandix/po/zh_Hans/bandix.po +++ b/luci-app-bandix/po/zh_Hans/bandix.po @@ -1537,3 +1537,71 @@ msgstr "监听地址" msgid "IP address for Bandix to listen on. Use 127.0.0.1 for local access or 0.0.0.0 / :: for all interfaces." msgstr "Bandix 监听的 IP 地址。127.0.0.1 仅供本机访问,0.0.0.0 / :: 监听所有接口。" + +msgid "ASN / network organization" +msgstr "ASN / 网络组织" + +msgid "Country/region + province/city" +msgstr "国家/地区 + 省市" + +msgid "Database build dates" +msgstr "数据库构建日期" + +msgid "Download contents" +msgstr "待下载内容" + +msgid "Installed data source" +msgstr "已安装数据来源" + +msgid "Installed databases" +msgstr "已安装数据库" + +msgid "Release version" +msgstr "发布版本" + +msgid "Location and ASN databases work together." +msgstr "位置库与 ASN 库共同生效。" + +msgid "Database details" +msgstr "数据库详情" + +msgid "Apply directory changes before downloading." +msgstr "更改目录后,请先保存并应用,再下载。" + +msgid "Batch deletion is unavailable. Update the Bandix service." +msgstr "批量删除不可用,请更新 Bandix 服务端。" + +#, c-format +msgid "Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear." +msgstr "删除所选的 %d 台设备?流量历史、主机名绑定、限速规则及配额也将被清除。在线设备可能重新出现。" + +msgid "Delete selected" +msgstr "删除所选设备" + +#, c-format +msgid "Deleted: %d; not found: %d; failed: %d." +msgstr "已删除:%d;不存在:%d;失败:%d。" + +msgid "Failed to delete devices" +msgstr "删除设备失败" + +msgid "Invalid batch deletion response" +msgstr "批量删除响应无效" + +msgid "Select all" +msgstr "全选" + +msgid "Select device" +msgstr "选择设备" + +msgid "Search this page: protocol, IP, port, location, ASN…" +msgstr "搜索本页:协议、IP、端口、归属地、ASN…" + +msgid "Search connections on this page" +msgstr "搜索本页连接" + +msgid "Matches on this page" +msgstr "本页匹配" + +msgid "No matching connections on this page" +msgstr "本页没有匹配的连接" diff --git a/luci-app-bandix/po/zh_Hant/bandix.po b/luci-app-bandix/po/zh_Hant/bandix.po index e12464726..4656e5173 100644 --- a/luci-app-bandix/po/zh_Hant/bandix.po +++ b/luci-app-bandix/po/zh_Hant/bandix.po @@ -1510,3 +1510,71 @@ msgstr "監聽位址" msgid "IP address for Bandix to listen on. Use 127.0.0.1 for local access or 0.0.0.0 / :: for all interfaces." msgstr "Bandix 監聽的 IP 位址。127.0.0.1 僅供本機存取,0.0.0.0 / :: 監聽所有介面。" + +msgid "ASN / network organization" +msgstr "ASN / 網路組織" + +msgid "Country/region + province/city" +msgstr "國家/地區 + 省市" + +msgid "Database build dates" +msgstr "資料庫建置日期" + +msgid "Download contents" +msgstr "待下載內容" + +msgid "Installed data source" +msgstr "已安裝資料來源" + +msgid "Installed databases" +msgstr "已安裝資料庫" + +msgid "Release version" +msgstr "發佈版本" + +msgid "Location and ASN databases work together." +msgstr "位置資料庫與 ASN 資料庫共同生效。" + +msgid "Database details" +msgstr "資料庫詳細資訊" + +msgid "Apply directory changes before downloading." +msgstr "變更目錄後,請先儲存並套用,再下載。" + +msgid "Batch deletion is unavailable. Update the Bandix service." +msgstr "批次刪除無法使用,請更新 Bandix 服務端。" + +#, c-format +msgid "Delete selected devices (%d)? Their traffic history, hostname bindings, rate limits and quotas will also be removed. Online devices may reappear." +msgstr "刪除所選的 %d 台裝置?流量歷史、主機名稱綁定、限速規則及配額也將被清除。線上裝置可能重新出現。" + +msgid "Delete selected" +msgstr "刪除所選裝置" + +#, c-format +msgid "Deleted: %d; not found: %d; failed: %d." +msgstr "已刪除:%d;不存在:%d;失敗:%d。" + +msgid "Failed to delete devices" +msgstr "刪除裝置失敗" + +msgid "Invalid batch deletion response" +msgstr "批次刪除回應無效" + +msgid "Select all" +msgstr "全選" + +msgid "Select device" +msgstr "選擇裝置" + +msgid "Search this page: protocol, IP, port, location, ASN…" +msgstr "搜尋本頁:協定、IP、連接埠、位置、ASN…" + +msgid "Search connections on this page" +msgstr "搜尋本頁連線" + +msgid "Matches on this page" +msgstr "本頁符合" + +msgid "No matching connections on this page" +msgstr "本頁沒有符合的連線" diff --git a/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix b/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix index d00115a7c..f8daf0b67 100755 --- a/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix +++ b/luci-app-bandix/root/usr/libexec/rpcd/luci.bandix @@ -225,6 +225,48 @@ delete_device() { echo "$response" } +# Batch deletion receives a JSON-encoded MAC array, matching the string RPC +# parameters used by other structured requests in this plugin. +delete_devices() { + local macs="$1" type keys count=0 key mac validated="" payload response http_code + [ "${#macs}" -le 65536 ] || { make_error "Too many MAC addresses"; return; } + json_init + json_load "{\"macs\":$macs}" 2>/dev/null || { make_error "Invalid MAC array"; return; } + json_get_type type macs + [ "$type" = array ] || { make_error "Invalid MAC array"; return; } + json_select macs + json_get_keys keys + for key in $keys; do + count=$((count + 1)) + [ "$count" -le 1000 ] || { make_error "macs must contain between 1 and 1000 MAC addresses"; return; } + json_get_type type "$key" + [ "$type" = string ] || { make_error "Invalid MAC address"; return; } + json_get_var mac "$key" + case "$mac" in + [[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]:[[:xdigit:]][[:xdigit:]]) ;; + *) make_error "Invalid MAC address"; return ;; + esac + validated="$validated $mac" + done + [ "$count" -gt 0 ] || { make_error "macs must contain between 1 and 1000 MAC addresses"; return; } + json_init + json_add_array macs + for mac in $validated; do json_add_string "" "$mac"; done + json_close_array + payload=$(json_dump) + json_cleanup + response=$(curl -s --connect-timeout 3 --max-time 15 -w '\n%{http_code}' -X DELETE -H 'Content-Type: application/json' --data-binary "$payload" "$BANDIX_DEVICES_API/batch" 2>/dev/null) + if [ $? -ne 0 ] || [ -z "$response" ]; then + make_error "Failed to connect to Bandix service" + return + fi + http_code=$(printf '%s\n' "$response" | tail -n 1) + case "$http_code" in + 404|405) make_error "Batch deletion is unavailable. Update the Bandix service." ;; + *) printf '%s\n' "$response" | sed '$d' ;; + esac +} + # 获取设备状态 # GET /api/traffic/devices -> $.data # { @@ -236,6 +278,7 @@ delete_device() { # conn, # 接入类型 (wifi/wired/router) # uplink, # 接入接口名 (eth1/lan2/phy0-ap0),无则空串 # w_ch, # WiFi 频道号 (1/36/149),非 WiFi 为 0 +# w_freq, # WiFi 主信道频率 (MHz),不可用时为 0 # last, # 最后在线时间戳(ms) # t_rx_b, t_tx_b, # 总下/上行字节 # t_rx_r, t_tx_r, # 总下/上行速率 @@ -1356,6 +1399,9 @@ case "$1" in json_add_object "deleteDevice" json_add_string "mac" json_close_object + json_add_object "deleteDevices" + json_add_string "macs" + json_close_object json_add_object "getGeoipStatus" json_close_object @@ -1579,6 +1625,13 @@ case "$1" in fi delete_device "$mac" ;; + deleteDevices) + input="" + read -r input + macs="$(printf '%s' "$input" | jsonfilter -e '$.macs' 2>/dev/null)" + [ -z "$macs" ] && macs="$(printf '%s' "$input" | jsonfilter -e '$[0]' 2>/dev/null)" + delete_devices "$macs" + ;; getGeoipStatus) get_geoip_status ;; diff --git a/luci-app-bandix/root/usr/share/rpcd/acl.d/luci-app-bandix.json b/luci-app-bandix/root/usr/share/rpcd/acl.d/luci-app-bandix.json index 573f9d0c7..f381c3f6a 100644 --- a/luci-app-bandix/root/usr/share/rpcd/acl.d/luci-app-bandix.json +++ b/luci-app-bandix/root/usr/share/rpcd/acl.d/luci-app-bandix.json @@ -47,6 +47,7 @@ "getConnectionFlows", "setHostname", "deleteDevice", + "deleteDevices", "getDnsQueries", "getDnsStats", "getScheduleLimits", diff --git a/luci-app-clashoo/Makefile b/luci-app-clashoo/Makefile index 742d691f8..bd4b7483b 100644 --- a/luci-app-clashoo/Makefile +++ b/luci-app-clashoo/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-clashoo PKG_VERSION:=1.31.0 -PKG_RELEASE:=9 +PKG_RELEASE:=10 PKG_MAINTAINER:=kenzok8 PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) diff --git a/luci-app-clashoo/root/usr/share/rpcd/ucode/luci.clashoo b/luci-app-clashoo/root/usr/share/rpcd/ucode/luci.clashoo index 73ba1c98f..9b128e33a 100755 --- a/luci-app-clashoo/root/usr/share/rpcd/ucode/luci.clashoo +++ b/luci-app-clashoo/root/usr/share/rpcd/ucode/luci.clashoo @@ -273,6 +273,8 @@ if (clashoo_running()) return 'mihomo'; return ''; } +let selected_mihomo_stable; + function find_binary_for_family(family) { if (family === 'mihomo') { let pid = procd_instance_pid('clashoo'); @@ -889,7 +891,7 @@ if (m) return m[1]; return full; } -function selected_mihomo_stable() { +selected_mihomo_stable = function() { let bundled = '/usr/libexec/clashoo/mihomo-stable'; let manual = '/usr/share/clashoo/bin/mihomo-stable'; if (access(manual, 'x') && read_first_line('/usr/share/clashoo/clash_meta_version') == 'custom-url') return manual; @@ -904,7 +906,7 @@ for (let i = 1; i <= 3; i++) { if (int(mv[i]) < int(bv[i])) return bundled; } return manual; -} +}; function component_installed_label(id) { if (id == 'clashoo') return pkg_version('clashoo') || '未安装'; diff --git a/luci-app-kai/Makefile b/luci-app-kai/Makefile deleted file mode 100755 index 05a70680a..000000000 --- a/luci-app-kai/Makefile +++ /dev/null @@ -1,28 +0,0 @@ -# -# Copyright (C) 2008-2014 The LuCI Team -# -# This is free software, licensed under the Apache License, Version 2.0 . -# - -include $(TOPDIR)/rules.mk - -LUCI_TITLE:=kai -PKG_VERSION:=1.0.2-r5 -PKG_RELEASE:=12 -LUCI_DEPENDS:=+kai +luci-compat +luci-lib-linkeaseauth +linkease-app-entry -LUCI_MINIFY_CSS:=0 -LUCI_MINIFY_JS:=0 - -define Package/luci-app-kai/conffiles -/etc/config/kai -endef - -define Package/luci-app-kai/postrm -#!/bin/sh -rm -f /tmp/luci-indexcache -exit 0 -endef - -include $(TOPDIR)/feeds/luci/luci.mk - -# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-kai/luasrc/controller/kai.lua b/luci-app-kai/luasrc/controller/kai.lua deleted file mode 100644 index 128d247ae..000000000 --- a/luci-app-kai/luasrc/controller/kai.lua +++ /dev/null @@ -1,29 +0,0 @@ -local http = require "luci.http" - -module("luci.controller.kai", package.seeall) - -function index() - if not nixio.fs.access("/etc/config/kai") then - return - end - local page - page = entry({"admin", "services","kai"}, cbi("kai"), _("KAI"), 100) - page.dependent = true - entry({"admin", "services", "kai_status"}, call("kai_status")) - -end - -function kai_status() - local sys = require "luci.sys" - local uci = require "luci.model.uci".cursor() - local port = tonumber(uci:get_first("kai", "kai", "port")) or 8197 - if port < 1 or port > 65535 or port % 1 ~= 0 then - port = 8197 - end - local status = { - running = (sys.call("pidof kai_bin >/dev/null") == 0), - port = port - } - http.prepare_content("application/json") - http.write_json(status) -end diff --git a/luci-app-kai/luasrc/model/cbi/kai.lua b/luci-app-kai/luasrc/model/cbi/kai.lua deleted file mode 100644 index 8c8fb48a2..000000000 --- a/luci-app-kai/luasrc/model/cbi/kai.lua +++ /dev/null @@ -1,32 +0,0 @@ -local m, s - -m = Map("kai", translate("KAI"), translate("KAI is an efficient AI tool.")) -m:section(SimpleSection).template = "kai/kai_status" - -s=m:section(TypedSection, "kai", translate("Global settings")) -s.addremove=false -s.anonymous=true - -s:option(Flag, "enabled", translate("Enable")).rmempty=false - -local kai_model = require "luci.model.kai" -local blocks = kai_model.blocks() -local home = kai_model.home() - -local data_dir = s:option(Value, "data_dir", translate("Data directory")) -data_dir.rmempty = false -data_dir.description = translate("Required. KAI session will store cwd/cache/data/config/state under this directory (subfolders: cwd, cache, data, config, state).") - -local paths, default_path = kai_model.find_paths(blocks, home, "Configs") -for _, val in pairs(paths) do - data_dir:value(val, val) -end -data_dir.default = default_path - -local port = s:option(Value, "port", translate("API port")) -port.default = "8197" -port.rmempty = false -port.datatype = "port" -port.description = translate("Port for KAI HTTP server (kai_bin). kai_session will read OPENCODE_CONFIG via 127.0.0.1:.") - -return m diff --git a/luci-app-kai/luasrc/model/kai.lua b/luci-app-kai/luasrc/model/kai.lua deleted file mode 100644 index b4ef84996..000000000 --- a/luci-app-kai/luasrc/model/kai.lua +++ /dev/null @@ -1,54 +0,0 @@ -local jsonc = require "luci.jsonc" - -local kai = {} - -kai.blocks = function() - local f = io.popen("lsblk -s -f -b -o NAME,FSSIZE,MOUNTPOINT --json", "r") - local vals = {} - if f then - local ret = f:read("*all") - f:close() - local obj = jsonc.parse(ret) - for _, val in pairs(obj and obj["blockdevices"] or {}) do - local fsize = val["fssize"] - if fsize ~= nil and string.len(fsize) > 10 and val["mountpoint"] then - vals[#vals + 1] = val["mountpoint"] - end - end - end - return vals -end - -kai.home = function() - local uci = require "luci.model.uci".cursor() - local home_dirs = {} - home_dirs["main_dir"] = uci:get_first("quickstart", "main", "main_dir", "/root") - home_dirs["Configs"] = uci:get_first("quickstart", "main", "conf_dir", home_dirs["main_dir"] .. "/Configs") - home_dirs["Public"] = uci:get_first("quickstart", "main", "pub_dir", home_dirs["main_dir"] .. "/Public") - home_dirs["Downloads"] = uci:get_first("quickstart", "main", "dl_dir", home_dirs["Public"] .. "/Downloads") - home_dirs["Caches"] = uci:get_first("quickstart", "main", "tmp_dir", home_dirs["main_dir"] .. "/Caches") - return home_dirs -end - -kai.find_paths = function(blocks, home_dirs, path_name) - local default_path = "" - local paths = {} - - default_path = home_dirs[path_name] .. "/KAI" - if #blocks == 0 then - table.insert(paths, default_path) - else - for _, val in pairs(blocks) do - table.insert(paths, val .. "/" .. path_name .. "/KAI") - end - local without_conf_dir = "/root/" .. path_name .. "/KAI" - if default_path == without_conf_dir then - default_path = paths[1] - end - end - - return paths, default_path -end - -return kai - diff --git a/luci-app-kai/luasrc/view/kai/kai_status.htm b/luci-app-kai/luasrc/view/kai/kai_status.htm deleted file mode 100644 index 43c2c0e37..000000000 --- a/luci-app-kai/luasrc/view/kai/kai_status.htm +++ /dev/null @@ -1,23 +0,0 @@ - - -
- <%:Status%> -

- <%:Collecting data...%> -

-
diff --git a/luci-app-kaiplus/Makefile b/luci-app-kaiplus/Makefile index d8cf3c305..7e97adc0d 100644 --- a/luci-app-kaiplus/Makefile +++ b/luci-app-kaiplus/Makefile @@ -7,8 +7,8 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=KaiPlus -PKG_VERSION:=1.0.9-r8 -PKG_RELEASE:=13 +PKG_VERSION:=1.0.9-r9 +PKG_RELEASE:=14 LUCI_DEPENDS:=+kaiplus +luci-compat +luci-lib-taskd +luci-lib-linkeaseauth +linkease-app-entry LUCI_MINIFY_CSS:=0 LUCI_MINIFY_JS:=0 diff --git a/luci-app-kaiplus/luasrc/controller/kaiplus.lua b/luci-app-kaiplus/luasrc/controller/kaiplus.lua index 67fa9c39f..f5871ec43 100644 --- a/luci-app-kaiplus/luasrc/controller/kaiplus.lua +++ b/luci-app-kaiplus/luasrc/controller/kaiplus.lua @@ -13,7 +13,11 @@ function index() if not nixio.fs.access("/etc/config/kaiplus") then return end local install_kai = entry({"admin", "services", "kaiplus", "install_kai"}, call("install_kai")) install_kai.leaf = true - entry({"admin", "services", "kaiplus"}, cbi("kaiplus"), _("KaiPlus"), 100).dependent = true + entry({"admin", "services", "kaiplus", "update_kai"}, call("update_kai")).leaf = true + entry({"admin", "services", "kaiplus"}, alias("admin", "services", "kaiplus", "settings"), _("KaiPlus"), 100).dependent = true + entry({"admin", "services", "kaiplus", "settings"}, cbi("kaiplus"), _("KaiPlus"), 10) + entry({"admin", "services", "kaiplus", "classic"}, cbi("kaiplus/classic"), _("CLASSIC KAI"), 20) + entry({"admin", "services", "kaiplus", "classic_status"}, call("classic_status")).leaf = true end local function write_json(obj) @@ -51,15 +55,11 @@ local function compat() end function kaiplus_status() - local fs = require "nixio.fs" local sys = require "luci.sys" local uci = require("luci.model.uci").cursor() compat():legacy_status("kaiplus", { running = sys.call("pidof kaiplus >/dev/null") == 0, - kai_installed = not not ( - fs.access("/usr/share/kai/kai-plugin.json") - and fs.access("/usr/lib/lua/luci/controller/kai.lua") - ), + kai_installed = require("luci.model.kaiplus.classic").installed(), port = uci:get_first("kaiplus", "kaiplus", "port") or "8189", base_path = "/apps/kaiplus/", listen_mode = "tcp", @@ -72,7 +72,11 @@ function kaiplus_open() compat():open("kaiplus") end -function install_kai() +function classic_status() + write_json(require("luci.model.kaiplus.classic").status()) +end + +local function classic_package_task(action) local fs = require "nixio.fs" local jsonc = require "luci.jsonc" local sys = require "luci.sys" @@ -83,11 +87,16 @@ function install_kai() return end - if fs.access("/usr/share/kai/kai-plugin.json") - and fs.access("/usr/lib/lua/luci/controller/kai.lua") then + local installed = require("luci.model.kaiplus.classic").installed() + if action == "install" and installed then write_json({ ok = true, installed = true }) return end + if action == "update" and not installed then + http.status(409, "Conflict") + write_json({ ok = false, error = "CLASSIC KAI is not installed" }) + return + end if not fs.access("/etc/init.d/tasks") then write_json({ ok = false, error = "taskd is not available" }) return @@ -96,6 +105,10 @@ function install_kai() write_json({ ok = false, error = "iStore package installer is not available" }) return end + if not fs.access("/usr/libexec/kaiplus-classic", "x") then + write_json({ ok = false, error = "CLASSIC KAI package helper is not available" }) + return + end local task_status = sys.exec("/etc/init.d/tasks task_status " .. task_id .. " 2>/dev/null") local status = jsonc.parse(task_status) or {} @@ -105,19 +118,20 @@ function install_kai() return end - local install_script = table.concat({ - 'set -e', - 'echo "[KaiPlus] Installing classic KAI..."', - '/bin/is-opkg install luci-app-kai', - 'rm -f /tmp/luci-indexcache', - 'echo "[KaiPlus] Classic KAI installed successfully."' - }, "\n") - local command = "/bin/sh -c " .. util.shellquote(install_script) + local command = "/usr/libexec/kaiplus-classic " .. action local rc = sys.call("/etc/init.d/tasks task_add " .. task_id .. " " .. util.shellquote(command) .. " >/dev/null 2>&1") if rc ~= 0 then - write_json({ ok = false, error = "failed to start KAI installation", task_id = task_id }) + write_json({ ok = false, error = "failed to start CLASSIC KAI package task", task_id = task_id }) return end write_json({ ok = true, task_id = task_id }) end + +function install_kai() + classic_package_task("install") +end + +function update_kai() + classic_package_task("update") +end diff --git a/luci-app-kaiplus/luasrc/model/cbi/kaiplus/classic.lua b/luci-app-kaiplus/luasrc/model/cbi/kaiplus/classic.lua new file mode 100644 index 000000000..ed1fdb2ee --- /dev/null +++ b/luci-app-kaiplus/luasrc/model/cbi/kaiplus/classic.lua @@ -0,0 +1,48 @@ +local classic = require "luci.model.kaiplus.classic" +local sys = require "luci.sys" +local m + +local description = translate("CLASSIC KAI can run alongside KaiPlus. Its settings and data are independent.") +if not classic.installed() then + m = SimpleForm("classic_kai", translate("CLASSIC KAI"), description) + m.submit = false + m.reset = false + m:section(SimpleSection).template = "kaiplus/classic_status" + return m +end + +m = Map("kai", translate("CLASSIC KAI"), description) +m:section(SimpleSection).template = "kaiplus/classic_status" + +m.on_after_commit = function(self) + local action = self.uci:get_first("kai", "kai", "enabled") == "1" and "restart" or "stop" + if sys.call("/etc/init.d/kai " .. action .. " >/dev/null 2>&1") ~= 0 then + self.message = translate("CLASSIC KAI could not apply the service settings. Check its configuration and system log.") + end +end + +local s = m:section(TypedSection, "kai", translate("Global settings")) +s.addremove = false +s.anonymous = true + +local enabled = s:option(Flag, "enabled", translate("Enable")) +enabled.rmempty = false +enabled.description = translate("Save & Apply starts or stops CLASSIC KAI without restarting KaiPlus.") + +local model = require "luci.model.kaiplus" +local paths, default_path = model.find_paths(model.blocks(), model.home(), "Configs", "KAI") +local data_dir = s:option(Value, "data_dir", translate("Data directory")) +data_dir.rmempty = false +data_dir.description = translate("Required. CLASSIC KAI stores its workspace, cache, config, and state here. Use a different directory from KaiPlus.") +for _, path in ipairs(paths) do + data_dir:value(path, path) +end +data_dir.default = default_path + +local port = s:option(Value, "port", translate("Access port")) +port.default = "8197" +port.rmempty = false +port.datatype = "port" +port.description = translate("CLASSIC KAI is available at http://router-address:port/apps/kai/web/. Use a different port from KaiPlus.") + +return m diff --git a/luci-app-kaiplus/luasrc/model/kaiplus.lua b/luci-app-kaiplus/luasrc/model/kaiplus.lua index b592386a5..48bfc036e 100644 --- a/luci-app-kaiplus/luasrc/model/kaiplus.lua +++ b/luci-app-kaiplus/luasrc/model/kaiplus.lua @@ -30,17 +30,18 @@ kaiplus.home = function() return home_dirs end -kaiplus.find_paths = function(blocks, home_dirs, path_name) - local default_path = home_dirs[path_name] .. "/KAIPlus" +kaiplus.find_paths = function(blocks, home_dirs, path_name, product) + product = product or "KAIPlus" + local default_path = home_dirs[path_name] .. "/" .. product local paths = {} if #blocks == 0 then table.insert(paths, default_path) else for _, val in pairs(blocks) do - table.insert(paths, val .. "/" .. path_name .. "/KAIPlus") + table.insert(paths, val .. "/" .. path_name .. "/" .. product) end - local without_conf_dir = "/root/" .. path_name .. "/KAIPlus" + local without_conf_dir = "/root/" .. path_name .. "/" .. product if default_path == without_conf_dir then default_path = paths[1] end diff --git a/luci-app-kaiplus/luasrc/model/kaiplus/classic.lua b/luci-app-kaiplus/luasrc/model/kaiplus/classic.lua new file mode 100644 index 000000000..77eb689c7 --- /dev/null +++ b/luci-app-kaiplus/luasrc/model/kaiplus/classic.lua @@ -0,0 +1,24 @@ +local fs = require "nixio.fs" +local sys = require "luci.sys" + +local classic = {} + +function classic.installed() + return not not ( + fs.access("/usr/sbin/kai_bin", "x") + and fs.access("/usr/sbin/kai_session", "x") + and fs.access("/etc/init.d/kai", "x") + and fs.access("/etc/config/kai") + and fs.access("/usr/share/kai/kai-plugin.json") + ) +end + +function classic.status() + local installed = classic.installed() + return { + installed = installed, + running = installed and sys.call("pidof kai_bin >/dev/null") == 0 + } +end + +return classic diff --git a/luci-app-kaiplus/luasrc/view/kaiplus/classic_status.htm b/luci-app-kaiplus/luasrc/view/kaiplus/classic_status.htm new file mode 100644 index 000000000..94cf2e3e8 --- /dev/null +++ b/luci-app-kaiplus/luasrc/view/kaiplus/classic_status.htm @@ -0,0 +1,99 @@ +<%+tasks/embed%> +<% local installed = require("luci.model.kaiplus.classic").installed() %> + + + + + +
+ <%:Status%> +

<%:Collecting data...%>

+ +
+ + + +
+
diff --git a/luci-app-kaiplus/luasrc/view/kaiplus/kaiplus_status.htm b/luci-app-kaiplus/luasrc/view/kaiplus/kaiplus_status.htm index ddb8a4228..37db650fb 100644 --- a/luci-app-kaiplus/luasrc/view/kaiplus/kaiplus_status.htm +++ b/luci-app-kaiplus/luasrc/view/kaiplus/kaiplus_status.htm @@ -1,5 +1,3 @@ -<%+tasks/embed%> - ", template, re.S).group(1) + script = re.sub(r'<%=\s*installed and "true" or "false"\s*%>', "PAGE_INSTALLED", script) + script = re.sub(r'<%=url\("([^"]+)"\)%>', r"/cgi-bin/luci/\1", script) + script = re.sub(r"<%:([^%]+)%>", r"\1", script) + self.assertNotIn("<%", script) + harness = (APP_DIR / "tests/classic_kai_browser.cjs").read_text() + result = subprocess.run([node, "-e", "const source = " + json.dumps(script) + ";\n" + harness], + text=True, capture_output=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) - def test_entry_opens_kai_luci_and_prompts_to_install_when_missing(self): - controller = self.read("luasrc/controller/kaiplus.lua") - status = self.read("luasrc/view/kaiplus/kaiplus_status.htm") + def test_package_updates_preserve_state_and_stop_on_failure(self): + source = (APP_DIR / "root/usr/libexec/kaiplus-classic").read_text() + with tempfile.TemporaryDirectory(prefix="classic-kai-test-") as directory: + root = Path(directory) + stub = '''#!/bin/sh +case "${0##*/}" in + pidof) [ "$TEST_RUNNING" = 1 ]; exit $?;; + uci) printf '%s\\n' "$TEST_ENABLED"; exit 0;; +esac +printf '%s %s\\n' "${0##*/}" "$*" >> "$TEST_LOG" +if [ "$TEST_FAIL" = "${0##*/}:$1" ]; then exit 1; fi +''' + for name in ("is-opkg", "kai", "pidof", "uci"): + path = root / name + path.write_text(stub) + path.chmod(0o755) + script = source.replace("/bin/is-opkg", shlex.quote(str(root / "is-opkg"))) + script = script.replace("/etc/init.d/kai", shlex.quote(str(root / "kai"))) + path = root / "helper.sh" + path.write_text(script) - self.assertNotIn('entry({"admin", "services", "kaiplus", "open_kai"}', controller) - self.assertIn('url("admin/services/kai")', status) - self.assertIn("st.kai_installed === true", status) - self.assertIn("function openClassicKai()", status) - self.assertIn("Classic KAI is not installed. Install it now?", status) - self.assertIn("window.location.href = kaiLuciUrl", status) + def run(action, running="1", enabled="1", fail=""): + log = root / "commands.log" + log.write_text("") + env = {**os.environ, "PATH": str(root) + os.pathsep + os.environ["PATH"], + "TEST_LOG": str(log), "TEST_RUNNING": running, + "TEST_ENABLED": enabled, "TEST_FAIL": fail} + result = subprocess.run(["sh", str(path), action], env=env, + text=True, capture_output=True, timeout=10) + return result, log.read_text().splitlines() - def test_missing_kai_starts_taskd_install_and_opens_log(self): - makefile = self.read("Makefile") - controller = self.read("luasrc/controller/kaiplus.lua") - status = self.read("luasrc/view/kaiplus/kaiplus_status.htm") - - self.assertIn("+luci-lib-taskd", makefile) - self.assertIn('<%+tasks/embed%>', status) - self.assertIn('entry({"admin", "services", "kaiplus", "install_kai"}', controller) - self.assertIn("require_post_csrf()", controller) - self.assertIn("/bin/is-opkg install luci-app-kai", controller) - self.assertNotIn("app-meta-kai", controller) - self.assertIn("/etc/init.d/tasks task_add ", controller) - self.assertIn("window.taskd.show_log", status) - self.assertIn("token: kaiCsrfToken", status) - - def test_action_buttons_share_one_row(self): - status = self.read("luasrc/view/kaiplus/kaiplus_status.htm") - - actions = status.index('
') - kaiplus = status.index('id="kaiplus-open"', actions) - classic = status.index('id="kaiplus-open-kai"', actions) - actions_end = status.index("
", classic) - self.assertLess(kaiplus, classic) - self.assertLess(classic, actions_end) - self.assertIn("margin: 16px;", status) + result, commands = run("install") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(commands, ["is-opkg install kai"]) + for running, enabled in (("1", "1"), ("0", "1"), ("1", "0"), ("0", "0")): + with self.subTest(running=running, enabled=enabled): + result, commands = run("update", running, enabled) + self.assertEqual(result.returncode, 0, result.stderr) + operation = "restart" if running == enabled == "1" else "stop" + self.assertEqual(commands, ["is-opkg update", "is-opkg upgrade kai kai_session", "kai " + operation]) + for failure, count in (("is-opkg:update", 1), ("is-opkg:upgrade", 2), ("kai:restart", 3)): + with self.subTest(failure=failure): + result, commands = run("update", fail=failure) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(len(commands), count) + self.assertNotIn("update completed", result.stdout) + result, commands = run("unknown") + self.assertEqual(result.returncode, 2) + self.assertEqual(commands, []) if __name__ == "__main__": diff --git a/luci-app-open-box/Makefile b/luci-app-open-box/Makefile index a3509f70a..3c83023b6 100644 --- a/luci-app-open-box/Makefile +++ b/luci-app-open-box/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-open-box -PKG_VERSION:=0.1.366 -PKG_RELEASE:=48 +PKG_VERSION:=0.1.369 +PKG_RELEASE:=49 PKG_MAINTAINER:=liandu2024 PKG_LICENSE:=GPL-3.0-or-later diff --git a/luci-app-openvpn-plus/Makefile b/luci-app-openvpn-plus/Makefile index db90c47b7..1f03366f9 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:=6 +PKG_RELEASE:=7 PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=Manfred Jaider 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 55bb34a3b..96492edbf 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 @@ -9,15 +9,50 @@ # luci-app-openvpn-plus : helper tools and background workers # /usr/libexec/luci-app-openvpn-plus -# Get mac address of lan interface +# Get the live hardware device name behind the logical 'lan' interface +getl3dev() { + local dump device + + # Step 1: Fetch network interface status from ubus + dump=$(ubus call network.interface.lan status 2>/dev/null) + if [ -z "$dump" ]; then + # Fallback if ubus fails + echo "br-lan" + return 1 + fi + + # Step 2: Try to get l3_device, then fallback to standard device + device=$(echo "$dump" | jsonfilter -e '@.l3_device' 2>/dev/null) + [ -z "$device" ] && device=$(echo "$dump" | jsonfilter -e '@.device' 2>/dev/null) + + # Step 3: Return the found device or the default fallback + if [ -n "$device" ]; then + echo "$device" + return 0 + else + echo "br-lan" + return 1 + fi +} + +# Get mac address of lan interface using the dynamic device name getmac() { - # no arguments (no shift) - if [ -f "/sys/class/net/br-lan/address" ]; then - cat /sys/class/net/br-lan/address + local device mac + + # Step 1: Get the dynamic device name (e.g. br-lan, eth0, lan0) + device=$(getl3dev) + + # Step 2: Fetch the live MAC address of that specific device + mac=$(ubus call network.device status "{\"name\":\"${device}\"}" 2>/dev/null | jsonfilter -e '@.macaddr' 2>/dev/null) + + # Step 3: Return the MAC or the default fallback address + if [ -n "$mac" ]; then + echo "$mac" + return 0 else echo "00:00:00:00:00:00" + return 1 fi - return 0 } # Get the active LAN IP with mask @@ -2052,6 +2087,7 @@ usage() { # Main entry point case "$1" in + getl3dev) getl3dev ;; getmac) getmac ;; lanip) lanip "$@" ;; getnetstat) getnetstat "$@" ;; diff --git a/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn.js b/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn.js index 5760073fb..476f6e16c 100755 --- a/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn.js +++ b/luci-app-openvpn-plus/root/www/luci-static/resources/view/vpn/openvpn.js @@ -219,6 +219,7 @@ const CFG = Object.freeze({ }), LIBEXEC: Object.freeze({ luci_app_openvpn_plus: '/usr/libexec/luci-app-openvpn-plus', + getl3dev: 'getl3dev', lanip: 'lanip', bestcrypto: 'bestcrypto', readfile: 'readfile', @@ -4785,6 +4786,13 @@ const generate_jmp_nft_file = async function (proto, port, instance_id, jmpScrip * Generate static rules file for Site-To-Site outbound and inbound forward */ const generate_s2s_nft_file = async function (deviceName, localSubnet, remoteSubnets, instance_id, s2sScriptPath) { + + let lanDevice = 'br-lan'; + const res = await luci_app_openvpn_plus([CFG.LIBEXEC.getl3dev]); + if (res && res !='' && !res.includes("ERROR:")) { + lanDevice = res; + } + let s2sNftContent = '#!/usr/sbin/nft -f\n'; s2sNftContent += '# Automatically generated by luci-app-openvpn-plus - OpenVPN Site-to-Site Firewall Rules\n'; @@ -4795,7 +4803,7 @@ const generate_s2s_nft_file = async function (deviceName, localSubnet, remoteSub if (!remoteSubnet) { continue; } - s2sNftContent += 'iifname "br-lan" ip daddr ' + remoteSubnet + ' accept comment "luci-app-openvpn-plus: Static Site-To-Site inbound forward for ' + instance_id + '";\n'; + s2sNftContent += 'iifname "' + lanDevice + '" ip daddr ' + remoteSubnet + ' accept comment "luci-app-openvpn-plus: Static Site-To-Site inbound forward for ' + instance_id + '";\n'; } s2sNftContent += ';\n'; diff --git a/luci-app-tailscale-community/root/etc/init.d/tailscale-settings b/luci-app-tailscale-community/root/etc/init.d/tailscale-settings index af252d6da..85a92a136 100755 --- a/luci-app-tailscale-community/root/etc/init.d/tailscale-settings +++ b/luci-app-tailscale-community/root/etc/init.d/tailscale-settings @@ -172,6 +172,11 @@ apply_settings() { config_load tailscale + if [ -x "$TAILSCALE_INIT" ] && ! tailscale_running; then + logger -t "$NAME" "tailscale is not running, skip applying settings" + return 0 + fi + local accept_routes advertise_exit_node exit_node exit_node_allow_lan_access local ssh shields_up runwebclient nosnat hostname local enable_relay relay_server_port disable_fw_config @@ -439,5 +444,6 @@ service_triggers() { reload_service() { mkdir -p "$STATE_DIR" + config_load tailscale handle_service_state } diff --git a/luci-theme-argon/Makefile b/luci-theme-argon/Makefile index 5b4d81d47..79f07b7f2 100644 --- a/luci-theme-argon/Makefile +++ b/luci-theme-argon/Makefile @@ -12,7 +12,7 @@ LUCI_DEPENDS:=+luci-base +USE_APK:wget-any +!USE_APK:wget +jsonfilter +ucode-mod # Keep existing translation package names and the argon-config message domain. LUCI_BASENAME:=argon-config PKG_VERSION:=3.1.0 -PKG_RELEASE:=33 +PKG_RELEASE:=34 PKG_LICENSE:=Apache-2.0 MIT # LuCI derives translations independently of the theme version. Keep release diff --git a/luci-theme-argon/htdocs/luci-static/argon/css/cascade.css b/luci-theme-argon/htdocs/luci-static/argon/css/cascade.css index 8c8fb034a..9d1102817 100644 --- a/luci-theme-argon/htdocs/luci-static/argon/css/cascade.css +++ b/luci-theme-argon/htdocs/luci-static/argon/css/cascade.css @@ -4129,6 +4129,23 @@ td > .ifacebadge, .ifacebadge > img + img { margin: 0 0.2rem 0 0; } +.ifacebadge[data-signal] { + min-width: min-content; + padding-inline: 0.5rem; + white-space: nowrap; +} +.ifacebadge[data-signal] > img { + flex: none; + margin: 0; +} +.ifacebadge[data-signal] > span { + flex-shrink: 0; + max-width: none; + direction: ltr; + unicode-bidi: isolate; + white-space: nowrap; + overflow-wrap: normal; +} .network-status-table { display: flex; flex-wrap: wrap; @@ -6762,6 +6779,7 @@ pre.command-output { [data-page="admin-status"] .argon-port-traffic > div, [data-page="admin-status-overview"] .argon-port-traffic > div { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem; @@ -6770,6 +6788,7 @@ pre.command-output { [data-page="admin-status"] .argon-port-traffic dt, [data-page="admin-status-overview"] .argon-port-traffic dt { display: inline-flex; + flex: none; align-items: center; gap: 0.25rem; color: var(--muted-text); @@ -6791,11 +6810,14 @@ pre.command-output { [data-page="admin-status"] .argon-port-traffic dd, [data-page="admin-status-overview"] .argon-port-traffic dd { margin: 0; + margin-inline-start: auto; + max-width: 100%; color: var(--text-strong); font-size: 0.75rem; font-weight: 500; font-variant-numeric: tabular-nums; - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; } [data-page="admin-status-realtime"] .cbi-section > div:has(> svg), [data-page="admin-status-realtime-load"] .cbi-section > div:has(> svg), @@ -8397,8 +8419,37 @@ pre.command-output { border-bottom: thin solid var(--border-subtle); box-shadow: 0 -1px 0 0 var(--border-subtle); } -#wifi_assoclist_table > .tr > .td[data-title="RX Rate / TX Rate"] { - width: 23rem; +#wifi_assoclist_table .ifacebadge[data-ssid] { + display: flex; + width: 100%; + gap: var(--control-gap); + padding: var(--control-gap); +} +#wifi_assoclist_table .ifacebadge[data-ssid] > img { + flex: none; + margin: 0; +} +#wifi_assoclist_table .ifacebadge[data-ssid] > span { + flex: 1 1 0; + min-width: 0; + text-align: start; +} +#wifi_assoclist_table .ifacebadge[data-ssid] small { + display: block; + direction: ltr; + unicode-bidi: isolate; +} +@media screen and (min-width: 1153px) { + #wifi_assoclist_table > .tr > :is(.th, .td):first-child { + width: 22%; + min-width: 14rem; + } +} +@media screen and (max-width: 600px) { + #wifi_assoclist_table .td:has(> .ifacebadge[data-ssid]) { + flex-basis: 100%; + padding-inline: var(--control-gap); + } } #cbi-wireless, #cbi-wireless *, @@ -8664,6 +8715,11 @@ pre.command-output { flex-wrap: wrap; justify-content: center; } + .table .td:has(.ifacebadge[data-signal]), + #cbi-wireless .td:has(.ifacebadge[data-signal]) { + min-width: min-content; + padding-inline: var(--control-gap); + } .td select, .td input[type="text"] { width: 100%; diff --git a/luci-theme-argon/htdocs/luci-static/resources/menu-argon.js b/luci-theme-argon/htdocs/luci-static/resources/menu-argon.js index dd84579ba..07dfee30e 100644 --- a/luci-theme-argon/htdocs/luci-static/resources/menu-argon.js +++ b/luci-theme-argon/htdocs/luci-static/resources/menu-argon.js @@ -201,6 +201,7 @@ return baseclass.extend({ * Load menu data and trigger rendering */ __init__: function () { + this.bindSidebarToggle(); this.patchDynamicListFocus(); ui.menu.load().then(L.bind(this.render, this)); }, @@ -222,6 +223,25 @@ return baseclass.extend({ prototype.setFocus = guardedFocus; }, + bindSidebarToggle: function () { + var sidebarToggle = document.querySelector('.showSide'); + var darkMask = document.querySelector('.darkMask'); + // Navigation is synchronous: keep its controls enabled and focused. + var toggle = L.bind(this.handleSidebarToggle, this); + + if (sidebarToggle) + sidebarToggle.addEventListener('click', toggle); + if (darkMask) + darkMask.addEventListener('click', toggle); + + document.addEventListener('keydown', function(ev) { + if (ev.key === 'Escape' && sidebarToggle && sidebarToggle.classList.contains('active')) { + sidebarToggle.click(); + sidebarToggle.focus(); + } + }); + }, + /** * Main render function for the menu system * @param {Object} tree - Menu tree structure from LuCI @@ -235,7 +255,8 @@ return baseclass.extend({ // Render tab menu if we're deep enough in the navigation hierarchy if (L.env.dispatchpath.length >= 3) { for (var i = 0; i < 3 && node; i++) { - node = node.children[L.env.dispatchpath[i]]; + // A cached menu can predate the current page's route. + node = node.children ? node.children[L.env.dispatchpath[i]] : null; url = url + (url ? '/' : '') + L.env.dispatchpath[i]; } @@ -243,23 +264,6 @@ return baseclass.extend({ this.renderTabMenu(node, url); } } - - // Attach event listeners for sidebar toggle functionality - var sidebarToggle = document.querySelector('.showSide'); - var darkMask = document.querySelector('.darkMask'); - - if (sidebarToggle) { - sidebarToggle.addEventListener('click', ui.createHandlerFn(this, 'handleSidebarToggle')); - } - if (darkMask) { - darkMask.addEventListener('click', ui.createHandlerFn(this, 'handleSidebarToggle')); - } - document.addEventListener('keydown', function(ev) { - if (ev.key === 'Escape' && sidebarToggle && sidebarToggle.classList.contains('active')) { - sidebarToggle.click(); - sidebarToggle.focus(); - } - }); }, /** diff --git a/luci-theme-argon/less/components/network.less b/luci-theme-argon/less/components/network.less index 6f49cecc9..d574ce931 100644 --- a/luci-theme-argon/less/components/network.less +++ b/luci-theme-argon/less/components/network.less @@ -33,6 +33,28 @@ td>.ifacebadge, margin: 0 .2rem 0 0; } +// Signal/noise readings are measurements, not descriptive interface names. +// Keep the value intact while allowing the icon to stack on narrow tables. +.ifacebadge[data-signal] { + min-width: min-content; + padding-inline: .5rem; + white-space: nowrap; + + > img { + flex: none; + margin: 0; + } + + > span { + flex-shrink: 0; + max-width: none; + direction: ltr; + unicode-bidi: isolate; + white-space: nowrap; + overflow-wrap: normal; + } +} + .network-status-table { display: flex; flex-wrap: wrap; diff --git a/luci-theme-argon/less/pages/overview.less b/luci-theme-argon/less/pages/overview.less index d73ba6e25..17b362e71 100644 --- a/luci-theme-argon/less/pages/overview.less +++ b/luci-theme-argon/less/pages/overview.less @@ -127,6 +127,7 @@ > div { display: flex; + flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .5rem; @@ -135,6 +136,7 @@ dt { display: inline-flex; + flex: none; align-items: center; gap: .25rem; color: var(--muted-text); @@ -156,11 +158,14 @@ dd { margin: 0; + margin-inline-start: auto; + max-width: 100%; color: var(--text-strong); font-size: .75rem; font-weight: 500; font-variant-numeric: tabular-nums; - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; } } } diff --git a/luci-theme-argon/less/pages/wireless.less b/luci-theme-argon/less/pages/wireless.less index 1acef8421..feac02b58 100644 --- a/luci-theme-argon/less/pages/wireless.less +++ b/luci-theme-argon/less/pages/wireless.less @@ -29,9 +29,43 @@ } #wifi_assoclist_table { - >.tr { - >.td[data-title="RX Rate / TX Rate"] { - width: 23rem; + // Keep the network identity readable without reserving the rate column + // by its translated heading. Let the remaining columns share the space. + .ifacebadge[data-ssid] { + display: flex; + width: 100%; + gap: var(--control-gap); + padding: var(--control-gap); + + > img { + flex: none; + margin: 0; + } + + > span { + flex: 1 1 0; + min-width: 0; + text-align: start; + } + + small { + display: block; + direction: ltr; + unicode-bidi: isolate; + } + } + + @media screen and (min-width: 1153px) { + > .tr > :is(.th, .td):first-child { + width: 22%; + min-width: 14rem; + } + } + + @media screen and (max-width: 600px) { + .td:has(> .ifacebadge[data-ssid]) { + flex-basis: 100%; + padding-inline: var(--control-gap); } } } diff --git a/luci-theme-argon/less/responsive.less b/luci-theme-argon/less/responsive.less index b0e748b79..a276a89b9 100644 --- a/luci-theme-argon/less/responsive.less +++ b/luci-theme-argon/less/responsive.less @@ -259,6 +259,13 @@ } + // Let a complete signal/noise reading move to its own line when needed. + .table .td:has(.ifacebadge[data-signal]), + #cbi-wireless .td:has(.ifacebadge[data-signal]) { + min-width: min-content; + padding-inline: var(--control-gap); + } + .td select, .td input[type="text"] { width: 100%; diff --git a/luci-theme-argon/tests/menu-navigation.test.js b/luci-theme-argon/tests/menu-navigation.test.js new file mode 100644 index 000000000..4621968d9 --- /dev/null +++ b/luci-theme-argon/tests/menu-navigation.test.js @@ -0,0 +1,109 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const source = fs.readFileSync(path.join(__dirname, '../htdocs/luci-static/resources/menu-argon.js'), 'utf8'); + +function fixture() { + function element() { + const classes = new Set(); + return { + attributes: {}, listeners: {}, disabled: false, focused: false, + classList: { + add: name => classes.add(name), + remove: name => classes.delete(name), + contains: name => classes.has(name) + }, + setAttribute(name, value) { this.attributes[name] = value; }, + addEventListener(type, handler) { (this.listeners[type] ||= []).push(handler); }, + click() { for (const handler of this.listeners.click || []) handler({ currentTarget: this }); }, + focus() { this.focused = true; } + }; + } + + const button = element(), sidebar = element(), mask = element(), content = element(); + const nodes = { '.showSide': button, '#mainmenu': sidebar, '.darkMask': mask, '.main-right': content }; + const document = { + listeners: {}, + querySelector: selector => nodes[selector] || null, + addEventListener(type, handler) { (this.listeners[type] ||= []).push(handler); } + }; + let finishLoad; + const loaded = new Promise(resolve => { finishLoad = resolve; }); + const menu = new Function('baseclass', 'ui', 'L', 'document', source)( + { extend: object => object }, + { + menu: { load: () => loaded }, + createHandlerFn() { throw new Error('Synchronous navigation must not disable its controls'); } + }, + { + bind: (fn, context) => fn.bind(context), + env: { dispatchpath: ['admin', 'status', 'overview'] } + }, + document + ); + let mainRenders = 0, tabRenders = 0; + menu.renderModeMenu = () => { mainRenders++; }; + menu.renderTabMenu = () => { tabRenders++; }; + return { menu, button, sidebar, mask, content, document, loaded, finishLoad, + mainRenders: () => mainRenders, tabRenders: () => tabRenders }; +} + +function assertOpen(f, open) { + for (const node of [f.button, f.sidebar, f.mask, f.content]) + assert.equal(node.classList.contains('active'), open); + assert.equal(f.button.attributes['aria-expanded'], String(open)); + assert.equal(f.button.disabled, false); +} + +test('the first sidebar tap is preserved while menu data is still loading', async () => { + const f = fixture(); + f.menu.__init__(); + f.button.click(); + assertOpen(f, true); + assert.equal(f.mainRenders(), 0); + + f.finishLoad({ children: { admin: { children: { status: {} } } } }); + await f.loaded; + assert.equal(f.mainRenders(), 1); + assertOpen(f, true); + f.mask.click(); + assertOpen(f, false); +}); + +test('cached menus missing the current route do not abort navigation rendering', () => { + for (const tree of [ + { children: { admin: { children: { status: {} } } } }, + { children: { admin: { children: {} } } }, + {} + ]) { + const f = fixture(); + f.menu.__init__(); + assert.doesNotThrow(() => f.menu.render(tree)); + assert.equal(f.mainRenders(), 1); + assert.equal(f.tabRenders(), 0); + f.button.click(); + assertOpen(f, true); + } +}); + +test('rendering tabs again does not duplicate sidebar events, and Escape restores focus', async () => { + const f = fixture(); + const tree = { children: { admin: { children: { status: { children: { overview: {} } } } } } }; + f.menu.__init__(); + f.finishLoad(tree); + await f.loaded; + f.menu.render(tree); + assert.equal(f.tabRenders(), 2); + assert.equal(f.button.listeners.click.length, 1); + assert.equal(f.mask.listeners.click.length, 1); + f.button.click(); + assertOpen(f, true); + for (const handler of f.document.listeners.keydown) + handler({ key: 'Escape' }); + assertOpen(f, false); + assert.equal(f.button.focused, true); +}); diff --git a/minigate/Makefile b/minigate/Makefile index f999657a1..9488a9b75 100644 --- a/minigate/Makefile +++ b/minigate/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=minigate -PKG_VERSION:=2026.10.25 -PKG_RELEASE:=6 +PKG_VERSION:=2026.10.26 +PKG_RELEASE:=7 PKG_LICENSE:=MIT PKG_MAINTAINER:=MiniGate diff --git a/minigate/README.md b/minigate/README.md index d8d9ab669..b82bbc507 100644 --- a/minigate/README.md +++ b/minigate/README.md @@ -2,9 +2,9 @@ 一个类似 Lucky 的轻量级 OpenWrt 应用,提供四大核心功能:DDNS、SSL 证书、反向代理、登录防护。 -当前版本:**v2026.10.25**。项目名称、仓库名及 OpenWrt 内部包名均为 **`minigate`**。 +当前版本:**v2026.10.26**。项目名称、仓库名及 OpenWrt 内部包名均为 **`minigate`**。 -> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.25_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。 +> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.26_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。 ## 项目定位 @@ -74,8 +74,8 @@ minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户 - 后台 watchdog 每 5 分钟自检 nft set,防 fw4 reload 后规则丢失 ### 👁 访客追踪 -- 总览页面实时显示访客 IP、归属地、在线状态 -- 5 分钟内有访问记为「在线」(绿点),否则「离线」(灰点) +- 总览页面显示来源 IP、归属地、最近请求结果、HTTP 状态及转发/拦截次数;被拒绝的扫描也保留在记录中 +- 「近5分钟有请求」仅表示近期出现请求,不代表访客在线;已转发也不代表已登录。旧日志缺少上游响应信息时,只能标记为已响应、请求失败或结果未知,不推断为准入放行 - IP 归属地后端查询(ip9.com.cn → ip-api.com → pconline 多源回退) ### 软件更新 @@ -93,20 +93,20 @@ minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户 从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载: -`minigate_2026.10.25_all.ipk` +`minigate_2026.10.26_all.ipk` **通过 LuCI 界面安装:** 1. 打开 LuCI → **系统** → **软件包** 2. 点击 **上传软件包** -3. 选择 `minigate_2026.10.25_all.ipk`,点击安装 +3. 选择 `minigate_2026.10.26_all.ipk`,点击安装 **通过命令行安装:** ```bash cd /tmp -wget -O minigate_2026.10.25_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.25/minigate_2026.10.25_all.ipk +wget -O minigate_2026.10.26_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate_2026.10.26_all.ipk opkg update -opkg install /tmp/minigate_2026.10.25_all.ipk +opkg install /tmp/minigate_2026.10.26_all.ipk rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null /etc/init.d/uhttpd restart /etc/init.d/minigate restart @@ -125,7 +125,7 @@ Release 用的 `.ipk` 请用仓库脚本生成,避免生成 Debian 风格 ar 脚本会输出: ```text -dist/minigate_2026.10.25_all.ipk +dist/minigate_2026.10.26_all.ipk ``` 该文件外层是 OpenWrt/ImmortalWrt 24.10 兼容的 `tar.gz`,内部成员顺序为 `debian-binary`、`control.tar.gz`、`data.tar.gz`。 @@ -153,7 +153,7 @@ wget -qO- https://raw.githubusercontent.com/tpxcer/minigate/main/scripts/install 需要指定并核对具体版本时,执行: ```bash -d="$(mktemp -d /tmp/minigate.XXXXXX)" && cd "$d" && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.25/minigate-v2026.10.25-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.25/SHA256SUMS && grep 'minigate-v2026.10.25-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.10.25-src.tar.gz && sh install.sh && /etc/init.d/uhttpd restart && /etc/init.d/minigate restart +d="$(mktemp -d /tmp/minigate.XXXXXX)" && cd "$d" && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate-v2026.10.26-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.26/SHA256SUMS && grep 'minigate-v2026.10.26-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.10.26-src.tar.gz && sh install.sh && /etc/init.d/uhttpd restart && /etc/init.d/minigate restart ``` 其中 `minigate.XXXXXX` 是临时目录模板,系统会自动把六个 `X` 替换为随机字符,避免与已有目录重名。该命令会校验源码包的 SHA-256、保留现有 minigate 配置并重启 LuCI 与 minigate 服务。 @@ -168,14 +168,14 @@ ssh root@192.168.1.1 # 2. 在路由器上直接下载 Release 源码包 cd /tmp -wget -O minigate-src.tar.gz https://github.com/tpxcer/minigate/releases/download/v2026.10.25/minigate-v2026.10.25-src.tar.gz -wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.10.25/SHA256SUMS -awk '$2 == "minigate-v2026.10.25-src.tar.gz" {print $1 " minigate-src.tar.gz"}' minigate-SHA256SUMS | sha256sum -c - || exit 1 +wget -O minigate-src.tar.gz https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate-v2026.10.26-src.tar.gz +wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.10.26/SHA256SUMS +awk '$2 == "minigate-v2026.10.26-src.tar.gz" {print $1 " minigate-src.tar.gz"}' minigate-SHA256SUMS | sha256sum -c - || exit 1 # 3. 解压到独立目录并安装 -mkdir -p /tmp/minigate-v2026.10.25 -tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.10.25 -cd /tmp/minigate-v2026.10.25 +mkdir -p /tmp/minigate-v2026.10.26 +tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.10.26 +cd /tmp/minigate-v2026.10.26 sh install.sh # 4. 启动服务 @@ -225,7 +225,7 @@ SSH 登录后执行上方「方法 2」的一条命令,保留原有配置并 ### IPK 升级 ```bash -opkg install --force-reinstall /tmp/minigate_2026.10.25_all.ipk +opkg install --force-reinstall /tmp/minigate_2026.10.26_all.ipk rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null /etc/init.d/minigate restart ``` @@ -408,6 +408,11 @@ minigate/ ## 更新日志 +### v2026.10.26 +- 访问记录区分「已拦截、已转发、已响应、请求失败、结果未知」,显示最近请求的 HTTP 状态与拦截/转发/其他次数 +- 「在线/离线」改为近期请求说明,避免把被拒绝的外地扫描误认为访问成功;保留拦截记录便于核查 +- 新日志记录上游响应,后端 401/403 不误判为地区拦截;兼容旧日志,缺少信息时不推断转发或登录成功 + ### v2026.10.25 - 新增可配置的「公网访问范围」:选择一个或多个省级地区,仅允许匹配的公网 IP 访问所有反代站点,其他及未知来源在首次转发前直接断开 - 同时检查 IPv4、IPv6,覆盖 HTTP 跳转与 WebSocket;保留局域网访问,不修改防火墙或限制 SSH、RustDesk diff --git a/minigate/luasrc/controller/minigate.lua b/minigate/luasrc/controller/minigate.lua index 834078eba..8f281744e 100644 --- a/minigate/luasrc/controller/minigate.lua +++ b/minigate/luasrc/controller/minigate.lua @@ -373,24 +373,38 @@ function action_proxy_access() end -- 读取最近 500 条日志,按 IP 聚合 local raw = sys.exec("cat /var/log/minigate-access.log.1 /var/log/minigate-access.log 2>/dev/null | tail -n 500") or "" - local visitors = {} -- ip -> {last_time, domain, count, first_time} + local visitors = {} local order = {} -- 保持顺序 for line in raw:gmatch("[^\n]+") do local time = line:match('"time":"([^"]*)"') local domain = line:match('"domain":"([^"]*)"') local client = line:match('"client":"([^"]*)"') + local status = tonumber(line:match('"status"%s*:%s*(%d+)')) + local upstream = line:match('"upstream_status":"([^"]*)"') if time and client and client ~= "" then - if not visitors[client] then - visitors[client] = { last_time = time, first_time = time, domain = domain or "", count = 1 } - order[#order + 1] = client - else - visitors[client].last_time = time - visitors[client].count = visitors[client].count + 1 - if domain and domain ~= "" then - visitors[client].domain = domain - end + local outcome = "unknown" + if status == 444 or (status == 400 and domain == "_") then + outcome = "blocked" + elseif status and (status >= 500 or status == 499) then + outcome = "error" + elseif status and upstream and upstream ~= "" and upstream ~= "-" then + outcome = "forwarded" + elseif status and status >= 100 and status < 400 then + outcome = "responded" + elseif status and status >= 400 then + outcome = "error" end + if not visitors[client] then + visitors[client] = { count = 0, forwarded_count = 0, blocked_count = 0, other_count = 0 } + order[#order + 1] = client + end + local v = visitors[client] + v.last_time, v.domain, v.status, v.outcome = time, domain or "", status, outcome + v.count = v.count + 1 + local counter = outcome == "forwarded" and "forwarded_count" or + (outcome == "blocked" and "blocked_count" or "other_count") + v[counter] = v[counter] + 1 end end @@ -405,19 +419,24 @@ function action_proxy_access() for i = 1, math.min(#order, limit) do local ip = order[i] local v = visitors[ip] - -- 解析 ISO 时间判断是否在线(5分钟内) - local online = false + -- 仅表示最近有请求,不能据此推断访客在线或已通过准入。 + local recent = false local y,mo,da,h,mi,se = v.last_time:match("(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)") if y then local ts = os.time({year=tonumber(y),month=tonumber(mo),day=tonumber(da),hour=tonumber(h),min=tonumber(mi),sec=tonumber(se)}) - online = (now - ts) < 300 + recent = now >= ts and (now - ts) < 300 end result[#result + 1] = { ip = ip, last_time = v.last_time, domain = v.domain, count = v.count, - online = online + recent = recent, + outcome = v.outcome, + status = v.status, + forwarded_count = v.forwarded_count, + blocked_count = v.blocked_count, + other_count = v.other_count } end diff --git a/minigate/luasrc/model/cbi/minigate/general.lua b/minigate/luasrc/model/cbi/minigate/general.lua index a87c2b46a..c7fce427e 100644 --- a/minigate/luasrc/model/cbi/minigate/general.lua +++ b/minigate/luasrc/model/cbi/minigate/general.lua @@ -57,8 +57,8 @@ o.cfgvalue = function() .mg-table tr:nth-child(even) td{background:var(--mg-surface-alt)} .mg-table tr:hover td{background:var(--mg-surface-hover)} .mg-status{display:inline-flex;align-items:center;gap:6px;white-space:nowrap} -.mg-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:#777} -.mg-dot.on{background:var(--mg-success);box-shadow:0 0 0 3px rgba(47,158,68,.14)} +.mg-request-result{display:flex;flex-direction:column;gap:4px} +.mg-request-detail{font-size:11px;color:var(--mg-text-muted);line-height:1.5;white-space:normal} .mg-ip{display:inline-block;border-radius:6px;background:var(--mg-code-bg);color:var(--mg-text);padding:2px 7px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px} .mg-empty{padding:18px 16px;color:var(--mg-text-muted);font-size:12px} .mg-update-body{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:15px 16px;flex-wrap:wrap} @@ -105,9 +105,9 @@ o.cfgvalue = function() .mg-table td:nth-child(1){order:2;justify-content:flex-end} .mg-table td:nth-child(n+3){order:3;grid-column:1/-1} .mg-table td:nth-child(3):before{content:"归属地"} -.mg-table td:nth-child(4):before{content:"最后访问"} +.mg-table td:nth-child(4):before{content:"最后请求"} .mg-table td:nth-child(5):before{content:"域名"} -.mg-table td:nth-child(6):before{content:"次数"} +.mg-table td:nth-child(6):before{content:"请求次数"} .mg-table td:before{flex:0 0 auto;color:var(--mg-text-muted)} } @@ -173,6 +173,7 @@ o.cfgvalue = function() 条 +
包含被拦截的请求;结果对应最后一次请求,次数为最近日志内按 IP 汇总。已转发不代表已登录。
加载中...
@@ -286,20 +287,24 @@ function loadVisitors(force){ } ct.textContent='('+visitors.length+' 个IP)'; var h=''; - h+=''; + h+=''; for(var i=0;i' - :''; - var stxt=v.online?'在线':'离线'; + var outcomes={blocked:['已拦截','danger'],forwarded:['已转发','success'],responded:['已响应','info'],error:['请求失败','warning']}; + var outcome=outcomes[v.outcome]||['结果未知','muted']; + var status=typeof v.status==='number'?('HTTP '+v.status):'无状态码'; + var activity=typeof v.recent==='boolean'?(v.recent?'近5分钟有请求':'5分钟内无新请求'):'请求时间未知'; h+=''; - h+=''; + h+=''; h+=''; h+=''; - h+=''; + h+=''; h+=''; - h+=''; + h+=''; h+=''; } h+='
状态IP 地址归属地最后访问域名次数
最近结果IP 地址归属地最后请求域名请求次数
'+dot+stxt+'
'+outcome[0]+''+escHtml(status)+'
'+escHtml(v.ip)+''+mgGeoHtml(_geoCache[v.ip])+''+escHtml(fmtT(v.last_time))+'
'+escHtml(fmtT(v.last_time))+''+activity+'
'+escHtml(v.domain)+''+escHtml(v.count)+'
总计 '+escHtml(String(v.count))+''; + if(typeof v.blocked_count==='number'&&typeof v.forwarded_count==='number'&&typeof v.other_count==='number'){ + h+='拦截 '+escHtml(String(v.blocked_count))+' · 转发 '+escHtml(String(v.forwarded_count))+' · 其他 '+escHtml(String(v.other_count))+''; + } + h+='
'; diff --git a/minigate/releases/v2026.10.26.md b/minigate/releases/v2026.10.26.md new file mode 100644 index 000000000..33f012e66 --- /dev/null +++ b/minigate/releases/v2026.10.26.md @@ -0,0 +1,24 @@ +## 访问记录结果区分 + +修复仅允许四川访问时,其他地区的被拒请求仍显示为“在线”、容易被误认为访问成功的问题。 + +- 访问记录显示最近一次请求结果:**已拦截、已转发、已响应、请求失败、结果未知**,同时显示 HTTP 状态码。 +- 按 IP 汇总最近 500 条日志中的总次数及拦截、转发、其他次数,保留被拦截的请求供核查;每行结果对应该 IP 最后一次请求。 +- 用“近5分钟有请求”替代“在线/离线”;近期出现请求不代表通过准入,已转发不代表已登录。 +- 新日志记录上游响应信息,后端 401/403 显示为已转发,不误判为地区拦截;5xx、499 显示请求失败并计入其他。 +- 旧日志继续可读,缺少上游信息的成功响应只显示已响应,信息不足显示结果未知;不会猜测已转发。 + +## 升级说明 + +现有地区访问范围和局域网访问策略保持不变。安装新版后生效,新日志才具备上游响应信息;已有日志不会补写。被拒请求仍会出现在记录中,并显示拦截结果,不表示地区限制失效。 + +## 验证 + +104 项本地测试全部通过,无跳过,覆盖真实 Lua 日志聚合与真实 Nginx 转发/拦截日志、HTTP 跳转和 WebSocket;Shell/Lua 语法、IPK 内容及桌面/手机深浅色测试页面检查通过。本次不自动更新路由器。 + +## 安装包 + +- opkg 安装包:`minigate_2026.10.26_all.ipk`。 +- 源码安装包:`minigate-v2026.10.26-src.tar.gz`。 +- 兼容旧更新器:`luci-app-minigate-v2026.10.26-src.tar.gz`。 +- 使用随附 `SHA256SUMS` 校验文件完整性。 diff --git a/minigate/root/usr/lib/minigate/proxy.sh b/minigate/root/usr/lib/minigate/proxy.sh index 20224b6b6..ca8781cce 100644 --- a/minigate/root/usr/lib/minigate/proxy.sh +++ b/minigate/root/usr/lib/minigate/proxy.sh @@ -69,6 +69,7 @@ EOF '"method":"$request_method",' '"uri":"$request_uri",' '"status":$status,' + '"upstream_status":"$upstream_status",' '"size":$body_bytes_sent,' '"referer":"$http_referer",' '"ua":"$http_user_agent"}'; diff --git a/minigate/root/usr/lib/minigate/update.sh b/minigate/root/usr/lib/minigate/update.sh index ad80e5c29..edfddf589 100755 --- a/minigate/root/usr/lib/minigate/update.sh +++ b/minigate/root/usr/lib/minigate/update.sh @@ -3,7 +3,7 @@ PATH=/usr/sbin:/usr/bin:/sbin:/bin export PATH -CURRENT_VERSION="2026.10.25" +CURRENT_VERSION="2026.10.26" REPOSITORY="tpxcer/minigate" API_URL="https://api.github.com/repos/${REPOSITORY}/releases/latest" DOWNLOAD_ROOT="https://github.com/${REPOSITORY}/releases/download" diff --git a/minigate/scripts/build-ipk.sh b/minigate/scripts/build-ipk.sh index 96b337e62..3a7f1c9bf 100755 --- a/minigate/scripts/build-ipk.sh +++ b/minigate/scripts/build-ipk.sh @@ -5,7 +5,7 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" OUT_DIR="${1:-$ROOT_DIR/dist}" PKG_NAME="minigate" -PKG_VERSION="2026.10.25" +PKG_VERSION="2026.10.26" PKG_ARCH="all" PKG_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.ipk" DEPS="libc, luci-base, luci-compat, nginx-ssl, nginx-mod-stream, openssl-util, wget, curl, jsonfilter, coreutils-stat, nftables" diff --git a/minigate/tests/access-log.test.cjs b/minigate/tests/access-log.test.cjs new file mode 100644 index 000000000..cdbdc367b --- /dev/null +++ b/minigate/tests/access-log.test.cjs @@ -0,0 +1,64 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const lua = process.env.MINIGATE_TEST_LUA; +const controller = path.resolve(__dirname, '../luasrc/controller/minigate.lua'); + +test('visitor aggregation distinguishes blocked, upstream, legacy and failed requests', { skip: !lua }, () => { + const input = [ + "local raw, response, limit = '', nil, '50'", + "package.preload['luci.sys'] = function() return {exec=function() return raw end} end", + "luci = {http={formvalue=function() return limit end, prepare_content=function() end, write_json=function(v) response=v end}}", + 'dofile(' + JSON.stringify(controller) + ')', + "local real_time = os.time", + "os.time = function(v) return v and real_time(v) or real_time({year=2026,month=10,day=26,hour=12,min=0,sec=0}) end", + "local function row(ip, status, upstream, domain, time)", + "raw = raw .. '{\"time\":\"' .. (time or '2026-10-26T11:59:00+08:00') .. '\",\"client\":\"' .. ip .. '\",\"domain\":\"' .. (domain or 'app.example.com') .. '\"' ..", + "(status and ',\"status\":' .. status or '') .. (upstream and ',\"upstream_status\":\"' .. upstream .. '\"' or '') .. '}\\n'", + "end", + "row('mixed', 200, '200')", + "row('mixed', 444, '-')", + "row('mixed', 308, '-')", + "row('blocked', 444, '-')", + "row('default', 400, '-', '_')", + "row('upstream403', 403, '403')", + "row('upstream401', 401, '401')", + "row('legacy200', 200)", + "row('legacy403', 403)", + "row('failure', 502, '502')", + "row('closed', 499, '-')", + "row('missing', 200, '200')", + "row('missing', nil, '200', '')", + "row('old', 200, '200', nil, '2026-10-26T11:54:59+08:00')", + "row('future', 200, '200', nil, '2026-10-26T12:01:00+08:00')", + "raw = raw .. 'invalid line\\n'", + "luci.controller.minigate.action_proxy_access()", + "local by_ip = {}", + "for _, v in ipairs(response.visitors) do", + "by_ip[v.ip] = v", + "assert(v.count == v.blocked_count + v.forwarded_count + v.other_count)", + "assert(v.online == nil)", + "end", + "local v = by_ip.mixed", + "assert(v.count == 3 and v.blocked_count == 1 and v.forwarded_count == 1 and v.other_count == 1)", + "assert(v.outcome == 'responded' and v.status == 308)", + "assert(by_ip.blocked.outcome == 'blocked' and by_ip.blocked.recent)", + "assert(by_ip.default.outcome == 'blocked')", + "assert(by_ip.upstream403.outcome == 'forwarded' and by_ip.upstream403.status == 403)", + "assert(by_ip.upstream401.outcome == 'forwarded')", + "assert(by_ip.legacy200.outcome == 'responded')", + "assert(by_ip.legacy403.outcome == 'error')", + "assert(by_ip.failure.outcome == 'error' and by_ip.closed.outcome == 'error')", + "assert(by_ip.missing.outcome == 'unknown' and by_ip.missing.status == nil and by_ip.missing.domain == '')", + "assert(not by_ip.old.recent and not by_ip.future.recent)", + "limit = 'invalid'", + "luci.controller.minigate.action_proxy_access()", + "assert(#response.visitors == 5)", + "print('visitor aggregation passed')" + ].join('\n'); + const result = spawnSync(lua, ['-'], { encoding: 'utf8', input }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /aggregation passed/); +}); diff --git a/minigate/tests/access-nginx.test.cjs b/minigate/tests/access-nginx.test.cjs index b0a93a444..2582360d9 100644 --- a/minigate/tests/access-nginx.test.cjs +++ b/minigate/tests/access-nginx.test.cjs @@ -78,6 +78,13 @@ test('real nginx enforces source policy before upstream, redirect and websocket' assert.equal(fixture.reached(), reached, '拒绝的请求不得到达后端'); assert.match(await request(fixture.redirectPath, '14.196.0.1'), /308 Permanent Redirect/); assert.match(await request(fixture.socketPath, '14.196.0.1', { websocket: true }), /101 Switching Protocols/); + const rows = fs.readFileSync(path.join(fixture.dir, 'access.log'), 'utf8').trim().split('\n').map(JSON.parse); + assert.ok(rows.some(row => row.status === 200 && row.upstream_status === '200')); + assert.ok(rows.some(row => row.status === 101 && row.upstream_status === '101')); + assert.ok(rows.some(row => row.status === 308 && !row.upstream_status)); + const blocked = rows.filter(row => row.status === 444); + assert.equal(blocked.length, 18); + assert.ok(blocked.every(row => !row.upstream_status), '被拒绝请求没有上游响应'); }); test('real nginx permits all sources when disabled and fails closed if selected data is missing', { skip: !nginx }, async t => { diff --git a/minigate/tests/ui.test.cjs b/minigate/tests/ui.test.cjs index be6098720..aaba9e7b5 100644 --- a/minigate/tests/ui.test.cjs +++ b/minigate/tests/ui.test.cjs @@ -102,6 +102,35 @@ test('overview polling pauses in a hidden tab and refreshes when visible again', assert.deepEqual(page.requests.map(item => item.url).sort(), ['au', 'su']); }); +test('overview distinguishes request results and never labels blocked requests as online', () => { + const page = fakePage(overviewScript); + const visitors = ['blocked', 'forwarded', 'responded', 'error', 'unknown'].map((outcome, i) => ({ + ip: '198.51.100.' + (i + 1), last_time: '2026-10-26T11:59:00+08:00', domain: 'app.example.com', + outcome, status: [444, 403, 308, 502, null][i], recent: i === 0, + count: 6, blocked_count: i === 0 ? 0 : 1, forwarded_count: 2, other_count: 3 + })); + take(page, 'au').callback(null, { visitors }); + const html = page.element('mg-v-list').innerHTML; + for (const label of ['已拦截', '已转发', '已响应', '请求失败', '结果未知', 'HTTP 444', 'HTTP 403', + '无状态码', '近5分钟有请求', '5分钟内无新请求', '总计 6', '拦截 1 · 转发 2 · 其他 3', '拦截 0 · 转发 2 · 其他 3']) { + assert.ok(html.includes(label), label); + } + assert.doesNotMatch(html, /在线|离线|mg-dot/); +}); + +test('overview renders legacy visitor responses as unknown and escapes log content', () => { + const page = fakePage(overviewScript); + take(page, 'au').callback(null, { visitors: [{ + ip: '198.51.100.1', domain: '', count: 1, + last_time: '2026-10-26T11:59:00+08:00', online: true + }] }); + const html = page.element('mg-v-list').innerHTML; + assert.match(html, /结果未知/); + assert.match(html, /请求时间未知/); + assert.match(html, /<img/); + assert.doesNotMatch(html, / { const page = fakePage(loginGuardScript); take(page, 'lu').callback(null, { enabled: '1', running: true, banned: {}, banned_total: 0, watching: {}, watching_total: 0, threshold: 3 }); diff --git a/nexttrace/Makefile b/nexttrace/Makefile index b2fee762a..caff631af 100644 --- a/nexttrace/Makefile +++ b/nexttrace/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=nexttrace -PKG_VERSION:=1.7.4 -PKG_RELEASE:=17 +PKG_VERSION:=1.7.5 +PKG_RELEASE:=18 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/nxtrace/NTrace-core/tar.gz/v$(PKG_VERSION)?