From 5536ec1272338a4c9cf2d43c598d3381a9e9e6b8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 29 Oct 2026 12:54:11 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=85=20Sync=202026-10-29=2012:54:11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apfree-wifidog/Makefile | 17 +-- luci-app-honk/Makefile | 2 +- luci-app-honk/po/zh_Hans/honk.po | 3 + .../root/usr/share/honk/download_dashboard.sh | 36 +++-- .../usr/share/rpcd/acl.d/luci-app-honk.json | 2 + .../root/usr/share/rpcd/ucode/honk.uc | 35 +++-- .../resources/honk/dashprofiles.js | 21 +++ luci-app-passwall/Makefile | 2 +- .../root/etc/uci-defaults/luci-app-passwall | 2 +- luci-app-passwall2/Makefile | 2 +- .../client/include/shunt_options.lua | 10 ++ .../root/etc/uci-defaults/luci-app-passwall2 | 2 +- luci-app-wwand/Makefile | 6 +- luci-theme-argon/Makefile | 2 +- .../htdocs/luci-static/argon/css/cascade.css | 128 ++++++++++++++---- luci-theme-argon/less/pages/dockerman.less | 38 ++++++ luci-theme-argon/less/pages/passwall.less | 97 +++++++++---- minigate/Makefile | 4 +- minigate/README.md | 35 +++-- minigate/luasrc/controller/minigate.lua | 18 ++- .../luasrc/model/cbi/minigate/login_guard.lua | 39 ++++-- minigate/releases/v2026.10.29.md | 37 +++++ minigate/root/usr/lib/minigate/login_guard.sh | 49 ++++--- minigate/root/usr/lib/minigate/update.sh | 2 +- minigate/scripts/build-ipk.sh | 2 +- minigate/tests/login-guard.test.cjs | 60 ++++++++ minigate/tests/lua.test.cjs | 24 ++++ minigate/tests/ui.test.cjs | 23 ++++ mise/Makefile | 4 +- qbittorrent/Makefile | 2 +- .../010-fix-build-with-recent-fortify.patch | 12 +- tun2socks/Makefile | 4 +- wwand/Makefile | 6 +- xkcptun/Makefile | 11 +- 34 files changed, 579 insertions(+), 158 deletions(-) create mode 100644 minigate/releases/v2026.10.29.md diff --git a/apfree-wifidog/Makefile b/apfree-wifidog/Makefile index 613804c47..5b049f98b 100644 --- a/apfree-wifidog/Makefile +++ b/apfree-wifidog/Makefile @@ -8,13 +8,13 @@ include $(TOPDIR)/rules.mk PKG_NAME:=apfree-wifidog -PKG_VERSION:=9.10.2968 -PKG_RELEASE:=8 +PKG_RELEASE:=9 -PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz -PKG_SOURCE_URL:=https://codeload.github.com/liudf0716/$(PKG_NAME)/tar.gz/$(PKG_VERSION)? -PKG_HASH:=skip -PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION) +PKG_SOURCE_PROTO:=git +PKG_SOURCE_URL:=https://github.com/liudf0716/apfree-wifidog.git +PKG_SOURCE_DATE:=2026-10-25 +PKG_SOURCE_VERSION:=4075182ffedaf19b36926dc2f068fe413901aba3 +PKG_MIRROR_HASH:=skip PKG_MAINTAINER:=Dengfeng Liu PKG_LICENSE:=GPL-3.0-or-later @@ -23,11 +23,6 @@ PKG_LICENSE_FILES:=COPYING include $(INCLUDE_DIR)/package.mk include $(INCLUDE_DIR)/cmake.mk -define Build/Prepare - mkdir -p $(PKG_BUILD_DIR) - $(CP) /media/windows/github/wifidogx/apfree-wifidog/* $(PKG_BUILD_DIR)/ -endef - define Package/apfree-wifidog SUBMENU:=Captive Portals SECTION:=net diff --git a/luci-app-honk/Makefile b/luci-app-honk/Makefile index 76c683b23..77e4864fd 100644 --- a/luci-app-honk/Makefile +++ b/luci-app-honk/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-honk PKG_VERSION:=1.1.4 -PKG_RELEASE:=27 +PKG_RELEASE:=28 LUCI_TITLE:=LuCI support for honk LUCI_PKGARCH:=all diff --git a/luci-app-honk/po/zh_Hans/honk.po b/luci-app-honk/po/zh_Hans/honk.po index 02b948310..e388c5c9c 100644 --- a/luci-app-honk/po/zh_Hans/honk.po +++ b/luci-app-honk/po/zh_Hans/honk.po @@ -285,6 +285,9 @@ msgstr "选择当前生效的 Web 管理面板。" msgid "Doona" msgstr "Doona" +msgid "Doona Lite" +msgstr "Doona Lite" + msgid "Native API is configured, but dashboard files are missing in the UI directory. You can download and deploy it directly." msgstr "Native API 已配置,但 UI 目录中缺少面板文件。您可以直接下载并部署。" diff --git a/luci-app-honk/root/usr/share/honk/download_dashboard.sh b/luci-app-honk/root/usr/share/honk/download_dashboard.sh index af54c76a5..4345e22fd 100755 --- a/luci-app-honk/root/usr/share/honk/download_dashboard.sh +++ b/luci-app-honk/root/usr/share/honk/download_dashboard.sh @@ -3,6 +3,7 @@ TARGET_DIR="${1:-}" DOWNLOAD_URL="${2:-}" +TARGET_TYPE="${3:-}" LOG_FILE="/tmp/honk_dashboard_download.log" STATUS_FILE="/tmp/honk_dashboard_download.status" @@ -21,7 +22,7 @@ set_status() { : > "$LOG_FILE" if [ -z "$TARGET_DIR" ] || [ -z "$DOWNLOAD_URL" ]; then - log "Error: usage: $0 " + log "Error: usage: $0 [target-type]" set_status "FAILED" exit 1 fi @@ -61,6 +62,14 @@ case "$DOWNLOAD_URL" in PREFIX="${DOWNLOAD_URL%%https://github.com/*}" log "Checking latest release..." + IS_LITE=0 + case "$TARGET_TYPE" in + *lite*) IS_LITE=1 ;; + esac + case "$TARGET_DIR" in + *lite*) IS_LITE=1 ;; + esac + RESOLVED_URL="" MAX_RETRIES=3 attempt=1 @@ -75,15 +84,26 @@ case "$DOWNLOAD_URL" in if [ -n "$LATEST_TAG" ]; then log "Latest tag: $LATEST_TAG" + TAG_NO_V="${LATEST_TAG#[vV]}" ASSETS_HTML=$(fetch_text "https://github.com/Zakkaus/doona/releases/expanded_assets/$LATEST_TAG") - ASSET_PATH=$(printf "%s" "$ASSETS_HTML" | grep -o '/Zakkaus/doona/releases/download/[^"]*/doona-[^"]*\.tar\.gz' | grep -v 'fonts' | head -n 1) - if [ -n "$ASSET_PATH" ]; then - RESOLVED_URL="https://github.com${ASSET_PATH}" - log "Found package: $ASSET_PATH" + if [ "$IS_LITE" -eq 1 ]; then + ASSET_PATH=$(printf "%s" "$ASSETS_HTML" | grep -o '/Zakkaus/doona/releases/download/[^"]*/doona-lite-[^"]*\.tar\.gz' | head -n 1) + if [ -n "$ASSET_PATH" ]; then + RESOLVED_URL="https://github.com${ASSET_PATH}" + log "Found package: $ASSET_PATH" + else + RESOLVED_URL="https://github.com/Zakkaus/doona/releases/download/${LATEST_TAG}/doona-lite-${TAG_NO_V}.tar.gz" + log "Inferred package URL: $RESOLVED_URL" + fi else - TAG_NO_V="${LATEST_TAG#[vV]}" - RESOLVED_URL="https://github.com/Zakkaus/doona/releases/download/${LATEST_TAG}/doona-${TAG_NO_V}.tar.gz" - log "Inferred package URL: $RESOLVED_URL" + ASSET_PATH=$(printf "%s" "$ASSETS_HTML" | grep -o '/Zakkaus/doona/releases/download/[^"]*/doona-[^"]*\.tar\.gz' | grep -v 'fonts' | grep -v 'lite' | grep -v 'precompressed' | head -n 1) + if [ -n "$ASSET_PATH" ]; then + RESOLVED_URL="https://github.com${ASSET_PATH}" + log "Found package: $ASSET_PATH" + else + RESOLVED_URL="https://github.com/Zakkaus/doona/releases/download/${LATEST_TAG}/doona-${TAG_NO_V}.tar.gz" + log "Inferred package URL: $RESOLVED_URL" + fi fi break fi diff --git a/luci-app-honk/root/usr/share/rpcd/acl.d/luci-app-honk.json b/luci-app-honk/root/usr/share/rpcd/acl.d/luci-app-honk.json index 1123b56a7..cd2eff1aa 100644 --- a/luci-app-honk/root/usr/share/rpcd/acl.d/luci-app-honk.json +++ b/luci-app-honk/root/usr/share/rpcd/acl.d/luci-app-honk.json @@ -10,6 +10,7 @@ "/etc/honk/config.dae", "/etc/honk/config.d/*", "/etc/honk/doona/*", + "/etc/honk/doona-lite/*", "/etc/honk/zashboard/*" ] }, @@ -22,6 +23,7 @@ "/etc/honk/config.dae", "/etc/honk/config.d/*", "/etc/honk/doona/*", + "/etc/honk/doona-lite/*", "/etc/honk/zashboard/*" ] } diff --git a/luci-app-honk/root/usr/share/rpcd/ucode/honk.uc b/luci-app-honk/root/usr/share/rpcd/ucode/honk.uc index e7db72c84..1a035f992 100644 --- a/luci-app-honk/root/usr/share/rpcd/ucode/honk.uc +++ b/luci-app-honk/root/usr/share/rpcd/ucode/honk.uc @@ -11,17 +11,35 @@ const DEFAULT_SECRET = "honk114514"; const DASHBOARD_DIRS = { zashboard: "/etc/honk/zashboard", - doona: "/etc/honk/doona" + doona: "/etc/honk/doona", + doona_lite: "/etc/honk/doona-lite" }; +function normalize_dashboard_type(type) { + if (type == "doona-lite") + return "doona_lite"; + return type; +} + function get_dashboard_dir(type) { + type = normalize_dashboard_type(type); return DASHBOARD_DIRS[type] || DASHBOARD_DIRS.doona; } +function is_other_default_ui(ui_path, current_type) { + current_type = normalize_dashboard_type(current_type); + for (let t, dir in DASHBOARD_DIRS) { + if (t != current_type && dir == ui_path) + return true; + } + return false; +} + function is_valid_dashboard_type(type) { if (type == "none") return true; + type = normalize_dashboard_type(type); for (let known in keys(DASHBOARD_DIRS)) if (known == type) return true; @@ -328,8 +346,6 @@ function get_dashboard_info(req) { } let target_default_ui = get_dashboard_dir(requested_type); - let other_type = (requested_type == "zashboard") ? "doona" : "zashboard"; - let other_default_ui = get_dashboard_dir(other_type); if (parsed_native && parsed_native.enabled && parsed_native.listen) { res.external_controller = parsed_native.listen; @@ -337,7 +353,7 @@ function get_dashboard_info(req) { res.port = parsed_native.port || DEFAULT_PORT; res.secret = parsed_native.secret; - res.external_ui = (parsed_native.ui && parsed_native.ui != other_default_ui) ? parsed_native.ui : target_default_ui; + res.external_ui = (parsed_native.ui && !is_other_default_ui(parsed_native.ui, requested_type)) ? parsed_native.ui : target_default_ui; let index_path = res.external_ui + "/index.html"; let s = stat(index_path); @@ -345,7 +361,7 @@ function get_dashboard_info(req) { if (!res.has_ui) { res.configured = true; - } else if (parsed_native.ui && parsed_native.ui != other_default_ui) { + } else if (parsed_native.ui && !is_other_default_ui(parsed_native.ui, requested_type)) { res.configured = true; } else { res.configured = false; @@ -363,6 +379,7 @@ function download_dashboard(req) { if (!is_valid_dashboard_type(requested_type) || requested_type == "none") { requested_type = "doona"; } + requested_type = normalize_dashboard_type(requested_type); let url = (req && req.args && req.args.url) ? req.args.url : ""; if (!match(url, /^https?:\/\//) || match(url, /[ \t\r\n'"`]/)) { @@ -371,10 +388,8 @@ function download_dashboard(req) { let api_cfg = get_api_config(); let parsed_native = api_cfg.parsed_native; - let other_type = (requested_type == "zashboard") ? "doona" : "zashboard"; - let other_default_ui = get_dashboard_dir(other_type); let target_dir = get_dashboard_dir(requested_type); - if (parsed_native && parsed_native.ui && parsed_native.ui != other_default_ui && + if (parsed_native && parsed_native.ui && !is_other_default_ui(parsed_native.ui, requested_type) && is_safe_ui_dir(parsed_native.ui)) { target_dir = parsed_native.ui; } @@ -391,8 +406,9 @@ function download_dashboard(req) { let safe_script = replace(script, "'", "'\\''"); let safe_target = replace(target_dir, "'", "'\\''"); let safe_url = replace(url, "'", "'\\''"); + let safe_type = replace(requested_type, "'", "'\\''"); - let cmd = sprintf("/bin/sh '%s' '%s' '%s' >/dev/null 2>&1 &", safe_script, safe_target, safe_url); + let cmd = sprintf("/bin/sh '%s' '%s' '%s' '%s' >/dev/null 2>&1 &", safe_script, safe_target, safe_url, safe_type); let rc = system(cmd); if (rc != 0) return { success: false, message: "failed to dispatch the download task" }; @@ -403,6 +419,7 @@ function download_dashboard(req) { function switch_dashboard_api(target_type) { cached_pid = null; + target_type = normalize_dashboard_type(target_type); if (!is_valid_dashboard_type(target_type)) return { success: false, message: "Invalid dashboard type" }; diff --git a/luci-app-honk/root/www/luci-static/resources/honk/dashprofiles.js b/luci-app-honk/root/www/luci-static/resources/honk/dashprofiles.js index 866b85950..299a97f46 100644 --- a/luci-app-honk/root/www/luci-static/resources/honk/dashprofiles.js +++ b/luci-app-honk/root/www/luci-static/resources/honk/dashprofiles.js @@ -50,6 +50,25 @@ var DASHBOARD_PROFILES = { ghfastMirror: 'https://ghfast.top/https://github.com/Zakkaus/doona', ghproxyMirror: 'https://ghproxy.net/https://github.com/Zakkaus/doona', pkgName: 'doona-*.tar.gz' + }, + doona_lite: { + name: 'Doona Lite', + label: function() { return _('Doona Lite'); }, + apiName: 'Native API', + defaultPort: DEFAULT_PORT, + defaultDir: '/etc/honk/doona-lite', + buildUrl: function(info, targetHost, port, secret, protocol, forceFresh) { + var hostPart = (targetHost.indexOf(':') !== -1 && targetHost.charAt(0) !== '[') ? '[' + targetHost + ']' : targetHost; + var url = protocol + '://' + hostPart + ':' + port + '/ui/'; + if (forceFresh) { + url += '?_t=' + Date.now(); + } + return url; + }, + githubRelease: 'https://github.com/Zakkaus/doona', + ghfastMirror: 'https://ghfast.top/https://github.com/Zakkaus/doona', + ghproxyMirror: 'https://ghproxy.net/https://github.com/Zakkaus/doona', + pkgName: 'doona-lite-*.tar.gz' } }; @@ -61,6 +80,8 @@ function exampleConfig(profile) { } function getProfile(type) { + if (type === 'doona-lite') + type = 'doona_lite'; return DASHBOARD_PROFILES[type] || DASHBOARD_PROFILES.zashboard; } diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile index cc73f81b9..68aef9c70 100644 --- a/luci-app-passwall/Makefile +++ b/luci-app-passwall/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall PKG_VERSION:=26.10.29 -PKG_RELEASE:=354 +PKG_RELEASE:=355 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall/root/etc/uci-defaults/luci-app-passwall b/luci-app-passwall/root/etc/uci-defaults/luci-app-passwall index d7df019c2..cb9b94209 100644 --- a/luci-app-passwall/root/etc/uci-defaults/luci-app-passwall +++ b/luci-app-passwall/root/etc/uci-defaults/luci-app-passwall @@ -113,7 +113,7 @@ uci -q commit passwall sed -i "s#add_from#group#g" /etc/config/passwall 2>/dev/null -rm -f /tmp/luci-indexcache /tmp/luci-indexcache.* +rm -f /tmp/etc/passwall_tmp/binver_* /tmp/luci-indexcache /tmp/luci-indexcache.* rm -rf /tmp/luci-modulecache/ killall -HUP rpcd 2>/dev/null diff --git a/luci-app-passwall2/Makefile b/luci-app-passwall2/Makefile index 1ee56e6fe..1f232cd85 100644 --- a/luci-app-passwall2/Makefile +++ b/luci-app-passwall2/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall2 PKG_VERSION:=26.10.29 -PKG_RELEASE:=155 +PKG_RELEASE:=156 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall2/luasrc/model/cbi/passwall2/client/include/shunt_options.lua b/luci-app-passwall2/luasrc/model/cbi/passwall2/client/include/shunt_options.lua index ef527b272..54223c33b 100644 --- a/luci-app-passwall2/luasrc/model/cbi/passwall2/client/include/shunt_options.lua +++ b/luci-app-passwall2/luasrc/model/cbi/passwall2/client/include/shunt_options.lua @@ -131,6 +131,16 @@ if api.is_finded("geoview") then .. "
  • " .. translate("Analyzes and preloads GeoIP data to enhance the shunt performance.") .. "
  • " .. "
  • " .. translate("Note: Increases resource usage.") .. " " .. translate("Maybe conflict with some special circumstances.") .. "
  • " .. "" + -- The preloaded NFTSet/IPSet are kept across restarts, so they must be + -- flushed when this option changes, otherwise stale GeoIP entries stay. + local write = o.write + o.write = function(self, section, value) + local old = m:get(current_node_id, self.option) or "0" + write(self, section, value) + if (m:get(current_node_id, self.option) or "0") ~= old then + m:set("@global[0]", "flush_set", "1") + end + end end shunt_group = add_option(ListValue, "shunt_group", translate("Shunt Rule Group")) diff --git a/luci-app-passwall2/root/etc/uci-defaults/luci-app-passwall2 b/luci-app-passwall2/root/etc/uci-defaults/luci-app-passwall2 index 4eb9f862d..8aeacb142 100644 --- a/luci-app-passwall2/root/etc/uci-defaults/luci-app-passwall2 +++ b/luci-app-passwall2/root/etc/uci-defaults/luci-app-passwall2 @@ -42,7 +42,7 @@ uci commit firewall chmod +x /usr/share/passwall2/*.sh -rm -f /tmp/etc/passwall2_tmp/passwall2_version /tmp/luci-indexcache /tmp/luci-indexcache.* +rm -f /tmp/etc/passwall2_tmp/passwall2_version /tmp/etc/passwall2_tmp/binver_* /tmp/luci-indexcache /tmp/luci-indexcache.* rm -rf /tmp/luci-modulecache/ killall -HUP rpcd 2>/dev/null diff --git a/luci-app-wwand/Makefile b/luci-app-wwand/Makefile index 3c30a5e1e..40528a54a 100644 --- a/luci-app-wwand/Makefile +++ b/luci-app-wwand/Makefile @@ -17,12 +17,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wwand -PKG_VERSION:=1.8.0_pre1 -PKG_RELEASE:=46 +PKG_VERSION:=1.8.0_pre3 +PKG_RELEASE:=47 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git -PKG_SOURCE_VERSION:=9101de373fe07ae2c52f171639cc6e05d689cd59 +PKG_SOURCE_VERSION:=9bf66bcf76fa083eb3fff7ed1a6d266a26f871a4 PKG_MIRROR_HASH:=skip PKG_LICENSE:=GPL-2.0-only diff --git a/luci-theme-argon/Makefile b/luci-theme-argon/Makefile index 164cee40d..defd1cd40 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.1 -PKG_RELEASE:=38 +PKG_RELEASE:=39 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 463504b1b..e9ad3343f 100644 --- a/luci-theme-argon/htdocs/luci-static/argon/css/cascade.css +++ b/luci-theme-argon/htdocs/luci-static/argon/css/cascade.css @@ -7389,6 +7389,39 @@ pre.command-output { #cbi-dockerd .cbi-section.cbi-tblsection { padding: 0; } +[data-page="admin-docker"] #docker_apply_overlay, +[data-page^="admin-docker-"] #docker_apply_overlay { + position: fixed; + inset: 0; + padding: var(--page-gutter); + overflow-y: auto; + overscroll-behavior: contain; +} +[data-page="admin-docker"] #docker_apply_overlay > .alert-message, +[data-page^="admin-docker-"] #docker_apply_overlay > .alert-message { + position: static; + inset: auto; + transform: none; + width: 100%; + max-width: 60rem; + max-height: calc(100vh - 2 * var(--page-gutter)); + max-height: calc(100dvh - 2 * var(--page-gutter)); + min-width: 0; + margin: auto; + overflow: auto; +} +[data-page="admin-docker"] #docker_apply_overlay > .alert-message > span, +[data-page^="admin-docker-"] #docker_apply_overlay > .alert-message > span { + flex: 1 1 0; + min-width: 0; + max-width: 100%; +} +[data-page="admin-docker"].apply-overlay-active #docker_apply_overlay, +[data-page^="admin-docker-"].apply-overlay-active #docker_apply_overlay { + display: flex; + align-items: center; + justify-content: center; +} #cbi-smartdns #service_status { padding: 16px; } @@ -8306,20 +8339,20 @@ pre.command-output { flex-direction: column; gap: var(--field-gap); } -[data-page="admin-vpn-passwall"] #app_version > .cbi-value > .cbi-value-field, -[data-page^="admin-vpn-passwall-"] #app_version > .cbi-value > .cbi-value-field, -[data-page="admin-services-passwall"] #app_version > .cbi-value > .cbi-value-field, -[data-page^="admin-services-passwall-"] #app_version > .cbi-value > .cbi-value-field { +[data-page="admin-vpn-passwall"] .cbi-value-field:has(> input[data-update-app]), +[data-page^="admin-vpn-passwall-"] .cbi-value-field:has(> input[data-update-app]), +[data-page="admin-services-passwall"] .cbi-value-field:has(> input[data-update-app]), +[data-page^="admin-services-passwall-"] .cbi-value-field:has(> input[data-update-app]) { display: grid; - grid-template-columns: minmax(0, 10rem) auto; + grid-template-columns: minmax(0, 10rem) max-content max-content minmax(0, 1fr); align-items: center; justify-content: start; gap: var(--control-gap) var(--action-gap); } -[data-page="admin-vpn-passwall"] #app_version > .cbi-value > .cbi-value-field > span:first-child, -[data-page^="admin-vpn-passwall-"] #app_version > .cbi-value > .cbi-value-field > span:first-child, -[data-page="admin-services-passwall"] #app_version > .cbi-value > .cbi-value-field > span:first-child, -[data-page^="admin-services-passwall-"] #app_version > .cbi-value > .cbi-value-field > span:first-child { +[data-page="admin-vpn-passwall"] .cbi-value-field:has(> input[data-update-app]) > span:first-child, +[data-page^="admin-vpn-passwall-"] .cbi-value-field:has(> input[data-update-app]) > span:first-child, +[data-page="admin-services-passwall"] .cbi-value-field:has(> input[data-update-app]) > span:first-child, +[data-page^="admin-services-passwall-"] .cbi-value-field:has(> input[data-update-app]) > span:first-child { grid-column: 1; grid-row: 1; min-width: 0; @@ -8327,30 +8360,77 @@ pre.command-output { overflow-wrap: anywhere; unicode-bidi: plaintext; } -[data-page="admin-vpn-passwall"] #app_version > .cbi-value > .cbi-value-field > input[data-update-app], -[data-page^="admin-vpn-passwall-"] #app_version > .cbi-value > .cbi-value-field > input[data-update-app], -[data-page="admin-services-passwall"] #app_version > .cbi-value > .cbi-value-field > input[data-update-app], -[data-page^="admin-services-passwall-"] #app_version > .cbi-value > .cbi-value-field > input[data-update-app] { +[data-page="admin-vpn-passwall"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app], +[data-page^="admin-vpn-passwall-"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app], +[data-page="admin-services-passwall"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app], +[data-page^="admin-services-passwall-"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app] { grid-column: 2; + grid-row: 1; justify-self: start; + min-width: 0; max-width: 100%; } -[data-page="admin-vpn-passwall"] #app_version > .cbi-value > .cbi-value-field > span[id$="-detail"], -[data-page^="admin-vpn-passwall-"] #app_version > .cbi-value > .cbi-value-field > span[id$="-detail"], -[data-page="admin-services-passwall"] #app_version > .cbi-value > .cbi-value-field > span[id$="-detail"], -[data-page^="admin-services-passwall-"] #app_version > .cbi-value > .cbi-value-field > span[id$="-detail"] { - grid-column: 1 / -1; +[data-page="admin-vpn-passwall"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app][id$="-force_btn"], +[data-page^="admin-vpn-passwall-"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app][id$="-force_btn"], +[data-page="admin-services-passwall"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app][id$="-force_btn"], +[data-page^="admin-services-passwall-"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app][id$="-force_btn"] { + grid-column: 3; +} +[data-page="admin-vpn-passwall"] .cbi-value-field:has(> input[data-update-app]) > span[id$="-detail"], +[data-page^="admin-vpn-passwall-"] .cbi-value-field:has(> input[data-update-app]) > span[id$="-detail"], +[data-page="admin-services-passwall"] .cbi-value-field:has(> input[data-update-app]) > span[id$="-detail"], +[data-page^="admin-services-passwall-"] .cbi-value-field:has(> input[data-update-app]) > span[id$="-detail"] { + grid-column: 4; + grid-row: 1; min-width: 0; + max-width: 100%; line-height: 1.5; overflow-wrap: anywhere; } -[data-page="admin-vpn-passwall"] #app_version > .cbi-value > .cbi-value-field > span[id$="-detail"]:empty, -[data-page^="admin-vpn-passwall-"] #app_version > .cbi-value > .cbi-value-field > span[id$="-detail"]:empty, -[data-page="admin-services-passwall"] #app_version > .cbi-value > .cbi-value-field > span[id$="-detail"]:empty, -[data-page^="admin-services-passwall-"] #app_version > .cbi-value > .cbi-value-field > span[id$="-detail"]:empty { - display: none; -} @container (max-width: 40rem) { + [data-page="admin-vpn-passwall"] .cbi-value-field:has(> input[data-update-app]), + [data-page^="admin-vpn-passwall-"] .cbi-value-field:has(> input[data-update-app]), + [data-page="admin-services-passwall"] .cbi-value-field:has(> input[data-update-app]), + [data-page^="admin-services-passwall-"] .cbi-value-field:has(> input[data-update-app]) { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: var(--control-gap) var(--action-gap); + } + [data-page="admin-vpn-passwall"] .cbi-value-field:has(> input[data-update-app]) > span:first-child, + [data-page^="admin-vpn-passwall-"] .cbi-value-field:has(> input[data-update-app]) > span:first-child, + [data-page="admin-services-passwall"] .cbi-value-field:has(> input[data-update-app]) > span:first-child, + [data-page^="admin-services-passwall-"] .cbi-value-field:has(> input[data-update-app]) > span:first-child { + flex: 0 0 100%; + min-width: 0; + } + [data-page="admin-vpn-passwall"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app], + [data-page^="admin-vpn-passwall-"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app], + [data-page="admin-services-passwall"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app], + [data-page^="admin-services-passwall-"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app] { + flex: 0 0 auto; + min-width: max-content; + max-width: 100%; + grid-column: auto; + grid-row: auto; + } + [data-page="admin-vpn-passwall"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app][id$="-force_btn"], + [data-page^="admin-vpn-passwall-"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app][id$="-force_btn"], + [data-page="admin-services-passwall"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app][id$="-force_btn"], + [data-page^="admin-services-passwall-"] .cbi-value-field:has(> input[data-update-app]) > input[data-update-app][id$="-force_btn"] { + grid-column: auto; + } + [data-page="admin-vpn-passwall"] .cbi-value-field:has(> input[data-update-app]) > span[id$="-detail"], + [data-page^="admin-vpn-passwall-"] .cbi-value-field:has(> input[data-update-app]) > span[id$="-detail"], + [data-page="admin-services-passwall"] .cbi-value-field:has(> input[data-update-app]) > span[id$="-detail"], + [data-page^="admin-services-passwall-"] .cbi-value-field:has(> input[data-update-app]) > span[id$="-detail"] { + flex: 1 1 100%; + min-width: 0; + max-width: 100%; + grid-column: auto; + grid-row: auto; + } [data-page="admin-vpn-passwall"] .cbi-value:has(> .cbi-value-field > .share-toolbar) > .cbi-value-title, [data-page^="admin-vpn-passwall-"] .cbi-value:has(> .cbi-value-field > .share-toolbar) > .cbi-value-title, [data-page="admin-services-passwall"] .cbi-value:has(> .cbi-value-field > .share-toolbar) > .cbi-value-title, diff --git a/luci-theme-argon/less/pages/dockerman.less b/luci-theme-argon/less/pages/dockerman.less index dc70ce173..058b47534 100644 --- a/luci-theme-argon/less/pages/dockerman.less +++ b/luci-theme-argon/less/pages/dockerman.less @@ -30,3 +30,41 @@ } } + +// Dockerman appends its own overlay styles after the theme. Scope these rules +// to its pages so positioning wins without changing the native show/hide state. +[data-page="admin-docker"], +[data-page^="admin-docker-"] { + #docker_apply_overlay { + position: fixed; + inset: 0; + padding: var(--page-gutter); + overflow-y: auto; + overscroll-behavior: contain; + + > .alert-message { + position: static; + inset: auto; + transform: none; + width: 100%; + max-width: 60rem; + max-height: calc(100vh - 2 * var(--page-gutter)); + max-height: calc(100dvh - 2 * var(--page-gutter)); + min-width: 0; + margin: auto; + overflow: auto; + + > span { + flex: 1 1 0; + min-width: 0; + max-width: 100%; + } + } + } + + &.apply-overlay-active #docker_apply_overlay { + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/luci-theme-argon/less/pages/passwall.less b/luci-theme-argon/less/pages/passwall.less index 409624c87..9719cade1 100644 --- a/luci-theme-argon/less/pages/passwall.less +++ b/luci-theme-argon/less/pages/passwall.less @@ -193,41 +193,47 @@ display: flex; flex-direction: column; gap: var(--field-gap); + } - > .cbi-value > .cbi-value-field { - display: grid; - grid-template-columns: minmax(0, 10rem) auto; - align-items: center; - justify-content: start; - gap: var(--control-gap) var(--action-gap); + // Match update fields even when a plugin version adds a section wrapper. + .cbi-value-field:has(> input[data-update-app]) { + display: grid; + grid-template-columns: minmax(0, 10rem) max-content max-content minmax(0, 1fr); + align-items: center; + justify-content: start; + gap: var(--control-gap) var(--action-gap); - > span:first-child { - grid-column: 1; - grid-row: 1; - min-width: 0; - line-height: 1.5; - overflow-wrap: anywhere; - unicode-bidi: plaintext; - } + > span:first-child { + grid-column: 1; + grid-row: 1; + min-width: 0; + line-height: 1.5; + overflow-wrap: anywhere; + unicode-bidi: plaintext; + } - > input[data-update-app] { - grid-column: 2; - justify-self: start; - max-width: 100%; - } + > input[data-update-app] { + grid-column: 2; + grid-row: 1; + justify-self: start; + min-width: 0; + max-width: 100%; - > span[id$="-detail"] { - grid-column: 1 / -1; - min-width: 0; - line-height: 1.5; - overflow-wrap: anywhere; - - &:empty { - display: none; - } + &[id$="-force_btn"] { + grid-column: 3; } } + + > span[id$="-detail"] { + grid-column: 4; + grid-row: 1; + min-width: 0; + max-width: 100%; + line-height: 1.5; + overflow-wrap: anywhere; + } } + } @container (max-width: 40rem) { @@ -235,8 +241,39 @@ [data-page^="admin-vpn-passwall-"], [data-page="admin-services-passwall"], [data-page^="admin-services-passwall-"] { - // Its label is an ideographic space in the native template; when - // fields stack, it otherwise leaves a blank line above the actions. + .cbi-value-field:has(> input[data-update-app]) { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: var(--control-gap) var(--action-gap); + + > span:first-child { + flex: 0 0 100%; + min-width: 0; + } + + > input[data-update-app] { + flex: 0 0 auto; + min-width: max-content; + max-width: 100%; + grid-column: auto; + grid-row: auto; + + &[id$="-force_btn"] { + grid-column: auto; + } + } + + > span[id$="-detail"] { + flex: 1 1 100%; + min-width: 0; + max-width: 100%; + grid-column: auto; + grid-row: auto; + } + } + .cbi-value:has(> .cbi-value-field > .share-toolbar) > .cbi-value-title { display: none; } diff --git a/minigate/Makefile b/minigate/Makefile index 0b1aa9cec..b250c8084 100644 --- a/minigate/Makefile +++ b/minigate/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=minigate -PKG_VERSION:=2026.10.28-2 -PKG_RELEASE:=9 +PKG_VERSION:=2026.10.29 +PKG_RELEASE:=10 PKG_LICENSE:=MIT PKG_MAINTAINER:=MiniGate diff --git a/minigate/README.md b/minigate/README.md index 91ee179f3..fb07eaa4b 100644 --- a/minigate/README.md +++ b/minigate/README.md @@ -2,9 +2,9 @@ 一个类似 Lucky 的轻量级 OpenWrt 应用,提供四大核心功能:DDNS、SSL 证书、反向代理、登录防护。 -当前版本:**v2026.10.28-2**。界面名称为 **MiniGate**,仓库名及 OpenWrt 内部包名均为 **`minigate`**。 +当前版本:**v2026.10.29**。界面名称为 **MiniGate**,仓库名及 OpenWrt 内部包名均为 **`minigate`**。 -> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.28-2_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。 +> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.29_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。 ## 项目定位 @@ -78,6 +78,7 @@ MiniGate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户 - 持久化存储 `/etc/minigate/login-guard/bans.txt`,重启/固件升级后已生效封禁自动恢复 - LuCI 页面实时显示封禁列表、剩余时间、归属地(结合归属地查询 API) - 失败计数列表默认显示 5 条,支持切换 5 / 20 / 30 条,并复用访客追踪归属地查询;显示最近访问时间并按最新时间排序 +- 封禁列表和失败计数列表显示**访问类型**:SSH、LuCI(含经反代登录)或手动封禁;升级前的旧记录显示「未记录」 - 支持网页**一键解封**、**手动封禁**、**清空全部**(清空按钮在列表底部,避免误点) - 封禁集合与拦截规则随包放在 `/usr/share/nftables.d`,防火墙每次重载自动加载;重载清空的封禁 30 秒内从持久化文件恢复 - 由 procd 管理,异常退出后自动拉起;停止时结束所有子进程 @@ -102,20 +103,20 @@ MiniGate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户 从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载: -`minigate_2026.10.28-2_all.ipk` +`minigate_2026.10.29_all.ipk` **通过 LuCI 界面安装:** 1. 打开 LuCI → **系统** → **软件包** 2. 点击 **上传软件包** -3. 选择 `minigate_2026.10.28-2_all.ipk`,点击安装 +3. 选择 `minigate_2026.10.29_all.ipk`,点击安装 **通过命令行安装:** ```bash cd /tmp -wget -O minigate_2026.10.28-2_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.28-2/minigate_2026.10.28-2_all.ipk +wget -O minigate_2026.10.29_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.29/minigate_2026.10.29_all.ipk opkg update -opkg install /tmp/minigate_2026.10.28-2_all.ipk +opkg install /tmp/minigate_2026.10.29_all.ipk rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null /etc/init.d/uhttpd restart /etc/init.d/minigate restart @@ -134,7 +135,7 @@ Release 用的 `.ipk` 请用仓库脚本生成,避免生成 Debian 风格 ar 脚本会输出: ```text -dist/minigate_2026.10.28-2_all.ipk +dist/minigate_2026.10.29_all.ipk ``` 该文件外层是 OpenWrt/ImmortalWrt 24.10 兼容的 `tar.gz`,内部成员顺序为 `debian-binary`、`control.tar.gz`、`data.tar.gz`。 @@ -162,7 +163,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.28-2/minigate-v2026.10.28-2-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.28-2/SHA256SUMS && grep 'minigate-v2026.10.28-2-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.10.28-2-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.29/minigate-v2026.10.29-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.29/SHA256SUMS && grep 'minigate-v2026.10.29-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.10.29-src.tar.gz && sh install.sh && /etc/init.d/uhttpd restart && /etc/init.d/minigate restart ``` 其中 `minigate.XXXXXX` 是临时目录模板,系统会自动把六个 `X` 替换为随机字符,避免与已有目录重名。该命令会校验源码包的 SHA-256、保留现有 minigate 配置并重启 LuCI 与 minigate 服务。 @@ -177,14 +178,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.28-2/minigate-v2026.10.28-2-src.tar.gz -wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.10.28-2/SHA256SUMS -awk '$2 == "minigate-v2026.10.28-2-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.29/minigate-v2026.10.29-src.tar.gz +wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.10.29/SHA256SUMS +awk '$2 == "minigate-v2026.10.29-src.tar.gz" {print $1 " minigate-src.tar.gz"}' minigate-SHA256SUMS | sha256sum -c - || exit 1 # 3. 解压到独立目录并安装 -mkdir -p /tmp/minigate-v2026.10.28-2 -tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.10.28-2 -cd /tmp/minigate-v2026.10.28-2 +mkdir -p /tmp/minigate-v2026.10.29 +tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.10.29 +cd /tmp/minigate-v2026.10.29 sh install.sh # 4. 启动服务 @@ -234,7 +235,7 @@ SSH 登录后执行上方「方法 2」的一条命令,保留原有配置并 ### IPK 升级 ```bash -opkg install --force-reinstall /tmp/minigate_2026.10.28-2_all.ipk +opkg install --force-reinstall /tmp/minigate_2026.10.29_all.ipk rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null /etc/init.d/minigate restart ``` @@ -425,6 +426,10 @@ minigate/ ## 更新日志 +### v2026.10.29 +- 登录防护的「已封禁 IP 列表」和「失败计数中」新增「访问类型」列,区分 SSH、LuCI(直接登录或经反代登录)和手动封禁;升级前已有的封禁没有类型,显示「未记录」 +- 封禁文件和失败计数文件以可选的第三列保存类型,旧格式照常可读;防火墙重载后恢复封禁时类型保持不变;登录防护日志的失败和封禁记录也带上类型 + ### v2026.10.28-2 - 修复 v2026.10.28 引入的登录防护回归:达到失败阈值时,封禁地址被修复防火墙的读取过程清空,日志显示「封禁 失败」,新的攻击来源不会被封禁(已有封禁不受影响)。升级后会立即恢复正常封禁 diff --git a/minigate/luasrc/controller/minigate.lua b/minigate/luasrc/controller/minigate.lua index a9ced26b6..5f6b02f9d 100644 --- a/minigate/luasrc/controller/minigate.lua +++ b/minigate/luasrc/controller/minigate.lua @@ -740,6 +740,19 @@ local function valid_ban_ip(ip) return #ip <= 39 and ip:match("^[%x:]+$") ~= nil and select(2, ip:gsub(":", "")) >= 2 end +-- bans.txt 每行「IP 到期时间 [访问类型]」;旧版记录没有类型 +local function ban_kinds() + local kinds = {} + local file = io.open("/etc/minigate/login-guard/bans.txt", "r") + if not file then return kinds end + for line in file:lines() do + local ip, kind = line:match("^(%S+)%s+%d+%s+(%a+)") + if ip then kinds[ip] = kind end + end + file:close() + return kinds +end + local function parse_ban_set(raw, banned) -- nft -j 在 key 和 value 之间有空格,要用 %s* 匹配;按 elem 块配对 val 与 expires for block in raw:gmatch('{[^{}]-"val"%s*:%s*"[^"]+"[^{}]-}') do @@ -775,6 +788,8 @@ function action_lg_status() local banned = {} parse_ban_set(sys.exec("nft -j list set inet fw4 login_banned_v4 2>/dev/null") or "", banned) parse_ban_set(sys.exec("nft -j list set inet fw4 login_banned_v6 2>/dev/null") or "", banned) + local kinds = ban_kinds() + for _, item in ipairs(banned) do item.kind = kinds[item.ip] or "" end -- 按剩余时间倒序 table.sort(banned, function(a,b) return a.remaining > b.remaining end) local banned_total = #banned @@ -794,7 +809,7 @@ function action_lg_status() if fh then local line = fh:read("*l") or "" fh:close() - local first, count = line:match("(%d+)%s+(%d+)") + local first, count, kind = line:match("^(%d+)%s+(%d+)%s*(%a*)") if first and count then local first_time = tonumber(first) local stat = fs.stat(path) @@ -803,6 +818,7 @@ function action_lg_status() if age <= window_s then watching[#watching+1] = { ip = ip, + kind = kind, count = tonumber(count), age = age, first_time = first_time, diff --git a/minigate/luasrc/model/cbi/minigate/login_guard.lua b/minigate/luasrc/model/cbi/minigate/login_guard.lua index 224644147..aea742c5e 100644 --- a/minigate/luasrc/model/cbi/minigate/login_guard.lua +++ b/minigate/luasrc/model/cbi/minigate/login_guard.lua @@ -27,6 +27,12 @@ o.cfgvalue = function() return v end + local function kind_html(kind) + local labels = {ssh = {"SSH", "ssh"}, luci = {"LuCI", "luci"}, manual = {"手动", "manual"}} + local k = labels[kind or ""] or {"未记录", "none"} + return '' .. k[1] .. '' + end + local function fmt_duration(s) s = tonumber(s) or 0 if s >= 3600 then @@ -49,12 +55,13 @@ o.cfgvalue = function() if fh then local line = fh:read("*l") or "" fh:close() - local first, count = line:match("^(%d+)%s+(%d+)") + local first, count, kind = line:match("^(%d+)%s+(%d+)%s*(%a*)") if first and count then local stat = fs.stat(path) local last_time = (stat and stat.mtime) or tonumber(first) watching_items[#watching_items + 1] = { ip = ip, + kind = kind, count = tonumber(count) or 0, age = now - tonumber(first), last_time = last_time, @@ -71,6 +78,7 @@ o.cfgvalue = function() local style = (i > 5) and ' style="display:none"' or '' watching_rows[#watching_rows + 1] = '' .. esc(item.ip) .. '' .. + '' .. kind_html(item.kind) .. '' .. '查询中...' .. '' .. esc(item.count) .. ' / ' .. esc(threshold) .. '' .. '' .. esc(item.last_seen) .. '' .. @@ -80,7 +88,7 @@ o.cfgvalue = function() local initial_watching = '
    无
    ' if #watching_rows > 0 then initial_watching = - '' .. + '
    IP 地址归属地失败次数最近访问时间距首次失败
    ' .. table.concat(watching_rows, "") .. '
    IP 地址访问类型归属地失败次数最近访问时间距首次失败
    ' end @@ -115,6 +123,9 @@ o.cfgvalue = function() .lg-ip{display:inline-block;border-radius:6px;background:var(--lg-code-bg);color:var(--lg-text);padding:2px 7px;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px} .lg-ip-danger{background:var(--lg-danger-bg);color:var(--lg-danger-text)} .lg-empty{padding:18px 16px;color:var(--lg-text-muted);font-size:12px} +.lg-kind{display:inline-block;min-width:44px;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;text-align:center;white-space:nowrap;background:var(--lg-code-bg);color:var(--lg-text-muted)} +.lg-kind-ssh{background:rgba(240,140,0,.16);color:var(--lg-warning)} +.lg-kind-luci{background:rgba(91,85,200,.16);color:var(--lg-primary)} .lg-unban{height:26px;border:1px solid var(--lg-border);border-radius:6px;background:var(--lg-control);color:var(--lg-text);padding:0 10px;font-size:11px;cursor:pointer} :root[data-darkmode="true"] .lg-wrap{--lg-success:#78c98d;--lg-warning:#d7a64a;--lg-danger:#e58a84;--lg-muted:#9aa7b3;--lg-primary:#8e8add;--lg-surface:var(--background-color-medium,#171e26);--lg-surface-muted:var(--background-color-high,#131a22);--lg-surface-alt:var(--background-color-low,#19212a);--lg-surface-hover:#202a34;--lg-control:var(--background-color-high,#111820);--lg-text:var(--text-color-high,#d5dbe3);--lg-text-muted:var(--text-color-medium,#9aa7b3);--lg-border:var(--border-color-medium,rgba(148,163,184,.18));--lg-border-soft:var(--border-color-low,rgba(148,163,184,.12));--lg-code-bg:var(--background-color-low,#253142);--lg-danger-bg:#42303a;--lg-danger-text:#efb2b0;--lg-shadow:none} @media(prefers-color-scheme:dark){ @@ -130,11 +141,12 @@ o.cfgvalue = function() .lg-table td:nth-child(n+3){grid-column:1/-1} .lg-table td:before{flex:0 0 auto;color:var(--lg-text-muted)} .lg-table-ban td:nth-child(2),.lg-table-watch td:nth-child(2){justify-content:flex-end} -.lg-table-ban td:nth-child(3):before{content:"剩余时间"} -.lg-table-ban td:nth-child(4){justify-content:flex-end} -.lg-table-watch td:nth-child(3):before{content:"失败次数"} -.lg-table-watch td:nth-child(4):before{content:"最近访问"} -.lg-table-watch td:nth-child(5):before{content:"距首次失败"} +.lg-table-ban td:nth-child(3):before,.lg-table-watch td:nth-child(3):before{content:"归属地"} +.lg-table-ban td:nth-child(4):before{content:"剩余时间"} +.lg-table-ban td:nth-child(5){justify-content:flex-end} +.lg-table-watch td:nth-child(4):before{content:"失败次数"} +.lg-table-watch td:nth-child(5):before{content:"最近访问"} +.lg-table-watch td:nth-child(6):before{content:"距首次失败"} .lg-tools .lg-btn{flex:1} } @@ -223,6 +235,13 @@ function lgGeoHtml(loc){ return loc?''+lgEscHtml(loc)+'':'查询中...'; } +// 访问类型:ssh / luci / manual;旧版封禁记录没有类型 +function lgKindHtml(kind){ + var map={ssh:['SSH','ssh'],luci:['LuCI','luci'],manual:['手动','manual']}; + var k=map[kind]||['未记录','none']; + return ''+k[0]+''; +} + function lgEscHtml(s){ return String(s||'').replace(/[&<>"']/g,function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; @@ -324,7 +343,7 @@ function lgRenderWatching(watching,threshold){ return; } var h2=''; - h2+=''; + h2+=''; for(var j=0;j=66?lgColor('danger'):(pct>=33?lgColor('warning'):lgColor('muted')); h2+=''; h2+=''; + h2+=''; h2+=''; h2+=''; h2+=''; @@ -385,11 +405,12 @@ function lgApplyStatus(d){ listEl.innerHTML='
    暂无封禁
    '; }else{ var h='
    IP 地址归属地失败次数最近访问时间距首次失败
    IP 地址访问类型归属地失败次数最近访问时间距首次失败
    '+lgEscHtml(ip)+''+lgKindHtml(w.kind)+''+lgGeoHtml(_lgGeoCache[ip])+''+count+' / '+threshold+''+lgEscHtml(lastSeen)+'
    '; - h+=''; + h+=''; for(var i=0;i'; + h+=''; h+=''; h+=''; h+=''; diff --git a/minigate/releases/v2026.10.29.md b/minigate/releases/v2026.10.29.md new file mode 100644 index 000000000..7f72c6ed3 --- /dev/null +++ b/minigate/releases/v2026.10.29.md @@ -0,0 +1,37 @@ +## 登录防护显示访问类型 + +「登录防护」页的「已封禁 IP 列表」和「失败计数中(未达阈值)」新增「访问类型」列,可以直接看出每个地址是从哪里触发的: + +- **SSH**:dropbear 登录失败。 +- **LuCI**:网页登录失败,包括直接登录和经 MiniGate 反代登录。 +- **手动**:在页面上手动封禁。 +- **未记录**:升级前已有的封禁,当时没有记录类型。 + +手机上,类型标签显示在 IP 同一行的右侧。登录防护日志的失败和封禁记录也带上类型,例如 `BAN 139.162.161.162 30天(SSH,达到 3 次失败)`。 + +## 兼容性 + +- 封禁文件 `/etc/minigate/login-guard/bans.txt` 和失败计数文件,以可选的第三列保存类型。旧格式照常可读,已有封禁的到期时间不变。 +- 防火墙重载后恢复封禁时,类型保持不变。 + +## 说明 + +「公网访问范围」只限制 MiniGate 反代的网站。四川以外的公网 IP 经反代访问 LuCI 时会被直接断开,打不开登录页,也就不会出现 LuCI 登录失败。所以四川以外的封禁通常来自 SSH。 + +## 验证 + +- 本地完整测试全部通过,无跳过。新增测试覆盖: + - 记录 SSH 和 LuCI 失败、手动封禁; + - 兼容旧记录; + - 防火墙重载后按类型恢复封禁; + - 状态接口返回类型; + - 表格渲染类型列。 +- 在浏览器中检查了深色主题下桌面和 375px 手机宽度,显示正常,页面没有横向溢出;页面外框为模拟。 +- 本次不自动更新路由器。 + +## 安装包 + +- opkg 安装包:`minigate_2026.10.29_all.ipk`。 +- 源码安装包:`minigate-v2026.10.29-src.tar.gz`。 +- 兼容旧更新器:`luci-app-minigate-v2026.10.29-src.tar.gz`。 +- 使用随附的 `SHA256SUMS` 校验文件完整性。 diff --git a/minigate/root/usr/lib/minigate/login_guard.sh b/minigate/root/usr/lib/minigate/login_guard.sh index cc5d6fb34..e72cd94f6 100755 --- a/minigate/root/usr/lib/minigate/login_guard.sh +++ b/minigate/root/usr/lib/minigate/login_guard.sh @@ -209,11 +209,11 @@ ensure_nft() { # 防火墙重载后,随包的集合会重新出现但内容为空;持久化文件里仍有未过期封禁时恢复 sets_lost_bans() { # ip/exp 必须是局部变量:shell 的 local 是动态作用域,读到文件末尾会清空调用方的 ip - local now set ip exp + local now set ip exp kind now=$(date +%s) for set in "$SET4" "$SET6"; do nft list set $TABLE $set 2>/dev/null | grep -q 'elements' && continue - while read -r ip exp; do + while read -r ip exp kind; do [ -n "$ip" ] || continue case "$exp" in ''|*[!0-9]*) continue ;; esac [ "$exp" -gt "$now" ] || continue @@ -230,23 +230,32 @@ add_ban_element() { nft add element $TABLE $set "{ $ip timeout ${seconds}s }" 2>/dev/null } +# 访问类型:ssh / luci / manual。bans.txt 每行「IP 到期时间 类型」,旧版没有第三列 +kind_label() { + case "$1" in + ssh) echo "SSH" ;; + luci) echo "LuCI" ;; + manual) echo "手动" ;; + esac +} + persist_ban() { - local ip="$1" exp="$2" + local ip="$1" exp="$2" kind="$3" grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true - echo "$ip $exp" >> "${BANS_FILE}.tmp" + echo "$ip $exp${kind:+ $kind}" >> "${BANS_FILE}.tmp" mv "${BANS_FILE}.tmp" "$BANS_FILE" } # === 把 IP 加入封禁集合 + 持久化 === ban_ip() { - local ip="$1" + local ip="$1" kind="$2" local exp=$(($(date +%s) + BANTIME)) if ! add_ban_element "$ip" "$BANTIME"; then log "封禁 $ip 失败:防火墙未接受该地址" return 1 fi - persist_ban "$ip" "$exp" - log "BAN $ip $(fmt_duration "$BANTIME")(达到 $THRESHOLD 次失败)" + persist_ban "$ip" "$exp" "$kind" + log "BAN $ip $(fmt_duration "$BANTIME")(${kind:+$(kind_label "$kind"),}达到 $THRESHOLD 次失败)" rm -f "$COUNTER_DIR/$ip" } @@ -256,13 +265,13 @@ restore_bans() { local now=$(date +%s) local tmp="${BANS_FILE}.tmp" > "$tmp" - local count=0 ip exp - while read -r ip exp; do + local count=0 ip exp kind + while read -r ip exp kind; do [ -z "$ip" ] && continue case "$exp" in ''|*[!0-9]*) continue ;; esac if [ "$exp" -gt "$now" ] && [ -n "$(set_for_ip "$ip")" ]; then add_ban_element "$ip" $((exp - now)) - echo "$ip $exp" >> "$tmp" + echo "$ip $exp${kind:+ $kind}" >> "$tmp" count=$((count + 1)) fi done < "$BANS_FILE" @@ -272,7 +281,7 @@ restore_bans() { # === 处理一条登录失败 === handle_failure() { - local ip="$1" + local ip="$1" kind="$2" [ -z "$ip" ] && return [ -n "$(ip_family "$ip")" ] || return is_whitelisted "$ip" && return @@ -282,8 +291,9 @@ handle_failure() { local now=$(date +%s) local first=0 local count=0 + local last_kind if [ -f "$f" ]; then - read -r first count < "$f" + read -r first count last_kind < "$f" if [ -n "$first" ] && [ $((now - first)) -gt "$WINDOW" ]; then first=$now count=0 @@ -292,13 +302,14 @@ handle_failure() { first=$now fi count=$((count + 1)) - echo "$first $count" > "$f" - log "FAIL $ip ($count/$THRESHOLD)" + # 计数文件第三列记录最近一次失败的访问类型 + echo "$first $count${kind:+ $kind}" > "$f" + log "FAIL $ip ${kind:+$(kind_label "$kind") }($count/$THRESHOLD)" if [ "$count" -ge "$THRESHOLD" ]; then # 加 ban 前再确认 nft 还在(fw4 可能重载) repair_nft - ban_ip "$ip" + ban_ip "$ip" "$kind" fi } @@ -335,7 +346,7 @@ access_log_loop() { tail -n 0 -f "$ACCESS_LOG" 2>/dev/null | while read -r line; do is_luci_failed_post "$line" || continue ip=$(echo "$line" | extract_json_field client) - handle_failure "$ip" + handle_failure "$ip" luci done } @@ -344,10 +355,10 @@ syslog_loop() { case "$line" in *"Bad password attempt"*"from"*|\ *"Login attempt for nonexistent user"*"from"*) - handle_failure "$(extract_ip "$line" 1)" + handle_failure "$(extract_ip "$line" 1)" ssh ;; *"luci: failed login"*"from"*) - handle_failure "$(extract_ip "$line" 0)" + handle_failure "$(extract_ip "$line" 0)" luci ;; esac done @@ -509,7 +520,7 @@ cmd_ban() { echo "✗ 封禁 $ip 失败" exit 1 fi - persist_ban "$ip" $(($(date +%s) + BANTIME)) + persist_ban "$ip" $(($(date +%s) + BANTIME)) manual echo "✓ 已封禁 $ip" log "MANUAL ban $ip" } diff --git a/minigate/root/usr/lib/minigate/update.sh b/minigate/root/usr/lib/minigate/update.sh index 12af15b3c..763489a55 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.28-2" +CURRENT_VERSION="2026.10.29" 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 16e6885d9..04a6fefe6 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.28-2" +PKG_VERSION="2026.10.29" 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/login-guard.test.cjs b/minigate/tests/login-guard.test.cjs index 054dce04c..623808c64 100644 --- a/minigate/tests/login-guard.test.cjs +++ b/minigate/tests/login-guard.test.cjs @@ -213,3 +213,63 @@ test('reaching the threshold bans the attacking address even when other bans are assert.equal(fs.existsSync(path.join(dir, 'run/counters/139.162.161.162')), false, 'counter is cleared after a ban'); } }); + +function guardLibrary(dir, body) { + return spawnSync('/bin/sh', ['-c', ` + logger() { :; } + uci() { case "$3" in minigate.login_guard.threshold) echo 3 ;; *) return 1 ;; esac; } + nft() { + printf '%s\\n' "$*" >> ${JSON.stringify(path.join(dir, 'nft.log'))} + case "$*" in + 'list set inet fw4 login_banned_v4'|'list set inet fw4 login_banned_v6') [ -f ${JSON.stringify(path.join(dir, 'sets-empty'))} ] || echo 'elements = { 1.1.1.1 }' ;; + 'list chain inet fw4 input') echo '@login_banned_v4 @login_banned_v6' ;; + esac + return 0 + } + export MINIGATE_LG_LIBRARY_ONLY=1 MINIGATE_LG_DATA_DIR=${JSON.stringify(path.join(dir, 'data'))} + export MINIGATE_LG_RUN_DIR=${JSON.stringify(path.join(dir, 'run'))} MINIGATE_LG_LOG=${JSON.stringify(path.join(dir, 'lg.log'))} + . ${JSON.stringify(script)} + ${body} + `], { encoding: 'utf8' }); +} + +test('bans and failure counters record whether the attempt came over SSH or LuCI', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-lg-kind-')); + fs.mkdirSync(path.join(dir, 'data')); + fs.writeFileSync(path.join(dir, 'data/bans.txt'), '111.170.34.182 9999999999\n'); + const result = guardLibrary(dir, ` + handle_failure 198.51.100.30 luci + cat "$COUNTER_DIR/198.51.100.30" + for i in 1 2 3; do handle_failure 139.162.161.162 ssh; done + cmd_ban 198.51.100.40 >/dev/null + `); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^\d+ 1 luci$/m); + const bans = fs.readFileSync(path.join(dir, 'data/bans.txt'), 'utf8'); + assert.match(bans, /^111\.170\.34\.182 9999999999$/m, 'old records without a type are kept as they are'); + assert.match(bans, /^139\.162\.161\.162 \d+ ssh$/m); + assert.match(bans, /^198\.51\.100\.40 \d+ manual$/m); + const log = fs.readFileSync(path.join(dir, 'lg.log'), 'utf8'); + assert.match(log, /FAIL 198\.51\.100\.30 LuCI \(1\/3\)/); + assert.match(log, /BAN 139\.162\.161\.162 12小时(SSH,达到 3 次失败)/); +}); + +test('typed ban records survive a firewall reload and are restored with their type', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-lg-kind-restore-')); + fs.mkdirSync(path.join(dir, 'data')); + fs.writeFileSync(path.join(dir, 'sets-empty'), ''); + fs.writeFileSync(path.join(dir, 'data/bans.txt'), '139.162.161.162 9999999999 ssh\n2001:db8::9 9999999999 luci\n5.231.242.181 9999999999\n'); + const result = guardLibrary(dir, 'repair_nft'); + assert.equal(result.status, 0, result.stderr); + const calls = fs.readFileSync(path.join(dir, 'nft.log'), 'utf8'); + assert.match(calls, /add element inet fw4 login_banned_v4 \{ 139\.162\.161\.162 timeout \d+s \}/); + assert.match(calls, /add element inet fw4 login_banned_v6 \{ 2001:db8::9 timeout \d+s \}/); + assert.equal(fs.readFileSync(path.join(dir, 'data/bans.txt'), 'utf8'), '139.162.161.162 9999999999 ssh\n2001:db8::9 9999999999 luci\n5.231.242.181 9999999999\n'); +}); + +test('each log source reports its own access type', () => { + const source = fs.readFileSync(script, 'utf8'); + assert.match(source, /handle_failure "\$\(extract_ip "\$line" 1\)" ssh/); + assert.match(source, /handle_failure "\$\(extract_ip "\$line" 0\)" luci/); + assert.match(source, /handle_failure "\$ip" luci/); +}); diff --git a/minigate/tests/lua.test.cjs b/minigate/tests/lua.test.cjs index 0457d4703..e71b25153 100644 --- a/minigate/tests/lua.test.cjs +++ b/minigate/tests/lua.test.cjs @@ -208,3 +208,27 @@ test('LuCI forms validate domains, Cloudflare IDs, intervals, thresholds and whi 'range(1,100)\ttrue\tfalse\tfalse' ].join('\n')); }); + +test('login guard status adds the access type from the persisted ban list and counters', { skip: !lua }, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-lg-status-')); + fs.writeFileSync(path.join(dir, 'bans.txt'), '103.160.205.37 1795830696 ssh\n5.231.242.181 1795794101\n'); + const prelude = controllerPrelude.replace("get=function(_, _, sec) if sec == 'cfg01' then return 'ddns' end end", + "get=function(_, _, sec) local v; if sec == 'cfg01' then v = 'ddns' end; return v end"); + assert.notEqual(prelude, controllerPrelude); + const out = runLua(prelude.replace("exec=function() return '' end", `exec=function(cmd) + if cmd:find('login_banned_v4', 1, true) then + return '{"nftables": [{"set": {"elem": [{"elem": {"val": "103.160.205.37", "timeout": 2592000, "expires": 3600}}, {"elem": {"val": "5.231.242.181", "timeout": 2592000, "expires": 60}}]}}]}' + end + return '' + end`) + ` + local real_open = io.open + io.open = function(file, mode) + if file == '/etc/minigate/login-guard/bans.txt' then file = ${JSON.stringify(path.join(dir, 'bans.txt'))} end + return real_open(file, mode) + end + package.loaded['nixio.fs'] = {dir=function() return nil end, stat=function() return nil end} + c.action_lg_status() + for _, b in ipairs(response.banned) do print(b.ip, b.remaining, b.kind == '' and '(none)' or b.kind) end + `); + assert.equal(out, '103.160.205.37\t3600\tssh\n5.231.242.181\t60\t(none)'); +}); diff --git a/minigate/tests/ui.test.cjs b/minigate/tests/ui.test.cjs index 9e5fb0598..6be209e02 100644 --- a/minigate/tests/ui.test.cjs +++ b/minigate/tests/ui.test.cjs @@ -248,3 +248,26 @@ test('state-changing LuCI endpoints require POST with the CSRF token', () => { assert.match(controller, /"acme_job"\}, call\("action_acme_job"\)\)/); assert.match(controller, /sec:match\("\^\[%w_\]\+\$"\)/); }); + +test('login guard lists show whether each address failed over SSH, LuCI or was banned by hand', () => { + const page = fakePage(loginGuardScript); + take(page, 'lu').callback(null, { + enabled: '1', running: true, threshold: 3, + banned: [ + { ip: '103.160.205.37', remaining: 3600, kind: 'ssh' }, + { ip: '198.51.100.9', remaining: 600, kind: 'manual' }, + { ip: '5.231.242.181', remaining: 60, kind: '' } + ], + banned_total: 3, + watching: [{ ip: '198.51.100.20', kind: 'luci', count: 1, age: 5, last_seen: '2026-10-29 12:00:00' }], + watching_total: 1 + }); + const banned = page.element('lg-banned-list').innerHTML; + assert.match(banned, /
    IP 地址归属地剩余时间操作
    IP 地址访问类型归属地剩余时间操作
    '+lgKindHtml(b.kind)+''+lgGeoHtml(_lgGeoCache[b.ip])+''+fmtDuration(b.remaining)+'IP 地址<\/th>访问类型<\/th>归属地<\/th>/); + assert.match(banned, /103\.160\.205\.37<\/code><\/td>SSH<\/span>/); + assert.match(banned, /lg-kind-manual">手动<\/td>未记录访问类型<\/th>/); + assert.match(watching, /198\.51\.100\.20<\/code><\/td>LuCI: + -Woverloaded-virtual + -Wold-style-cast diff --git a/tun2socks/Makefile b/tun2socks/Makefile index 791f895e4..755f15435 100644 --- a/tun2socks/Makefile +++ b/tun2socks/Makefile @@ -2,11 +2,11 @@ include $(TOPDIR)/rules.mk PKG_NAME:=tun2socks PKG_VERSION:=2.7.0 -PKG_RELEASE:=15 +PKG_RELEASE:=16 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/xjasonlyu/tun2socks.git -PKG_SOURCE_VERSION:=b986c2434d62da41c26d17b7050df37f293a3328 +PKG_SOURCE_VERSION:=9c66760ef5887e7c2613983885cb41a07bc2e85a PKG_MAINTAINER:=Konstantine Shevlakov PKG_LICENSE:=GPL-3.0 diff --git a/wwand/Makefile b/wwand/Makefile index 215922be5..317a7de56 100644 --- a/wwand/Makefile +++ b/wwand/Makefile @@ -50,12 +50,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=wwand -PKG_VERSION:=1.8.0_pre29 -PKG_RELEASE:=83 +PKG_VERSION:=1.8.0_pre31 +PKG_RELEASE:=84 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git -PKG_SOURCE_VERSION:=0944a14ea95e90b6596f81bc49f07e4f73d905e3 +PKG_SOURCE_VERSION:=ae002a7e9a7a9d45b267b961e45ef1ec88fddebe PKG_MIRROR_HASH:=skip PKG_LICENSE:=GPL-2.0-only diff --git a/xkcptun/Makefile b/xkcptun/Makefile index 2cc82a0ae..5b6dd9974 100644 --- a/xkcptun/Makefile +++ b/xkcptun/Makefile @@ -8,12 +8,13 @@ include $(TOPDIR)/rules.mk PKG_NAME:=xkcptun -PKG_VERSION:=1.10.567 -PKG_RELEASE:=8 +PKG_RELEASE:=9 -PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz -PKG_SOURCE_URL:=https://codeload.github.com/liudf0716/xkcptun/tar.gz/$(PKG_VERSION)? -PKG_HASH:=skip +PKG_SOURCE_PROTO:=git +PKG_SOURCE_URL:=https://github.com/liudf0716/xkcptun.git +PKG_SOURCE_DATE:=2026-10-25 +PKG_SOURCE_VERSION:=e5ec5ed74a20a75e9c3b22221d7f07346ffa0d1e +PKG_MIRROR_HASH:=skip PKG_MAINTAINER:=Dengfeng Liu PKG_LICENSE:=GPL-3.0-or-later