🏅 Sync 2026-10-29 12:54:11

This commit is contained in:
github-actions[bot] committed 2026-10-29 12:54:11 +08:00
1 parent 96c15c82a4
commit 5536ec1272
34 files changed
+579 -158

No files matched your search

+6 -11
View File
@@ -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 <liudf0716@gmail.com>
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
+1 -1
View File
@@ -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
+3
View File
@@ -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 目录中缺少面板文件。您可以直接下载并部署。"
@@ -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 <target-dir> <archive-url>"
log "Error: usage: $0 <target-dir> <archive-url> [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
@@ -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/*"
]
}
@@ -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" };
@@ -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;
}
+1 -1
View File
@@ -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:= \
@@ -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
+1 -1
View File
@@ -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:= \
@@ -131,6 +131,16 @@ if api.is_finded("geoview") then
.. "<li>" .. translate("Analyzes and preloads GeoIP data to enhance the shunt performance.") .. "</li>"
.. "<li>" .. translate("Note: Increases resource usage.") .. " " .. translate("Maybe conflict with some special circumstances.") .. "</li>"
.. "</ul>"
-- 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"))
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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,
@@ -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;
}
}
+67 -30
View File
@@ -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;
}
+2 -2
View File
@@ -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
+20 -15
View File
@@ -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 引入的登录防护回归:达到失败阈值时,封禁地址被修复防火墙的读取过程清空,日志显示「封禁 失败」,新的攻击来源不会被封禁(已有封禁不受影响)。升级后会立即恢复正常封禁
+17 -1
View File
@@ -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,
@@ -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 '<span class="lg-kind lg-kind-' .. k[2] .. '">' .. k[1] .. '</span>'
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] =
'<tr class="lg-watch-row" data-ip="' .. esc(item.ip) .. '" data-index="' .. tostring(i) .. '"' .. style .. '><td><code class="lg-ip">' .. esc(item.ip) .. '</code></td>' ..
'<td>' .. kind_html(item.kind) .. '</td>' ..
'<td id="lg-watch-geo-initial-' .. tostring(i) .. '"><span style="color:var(--lg-muted)">查询中...</span></td>' ..
'<td><span style="font-weight:bold">' .. esc(item.count) .. ' / ' .. esc(threshold) .. '</span></td>' ..
'<td>' .. esc(item.last_seen) .. '</td>' ..
@@ -80,7 +88,7 @@ o.cfgvalue = function()
local initial_watching = '<div class="lg-empty">无</div>'
if #watching_rows > 0 then
initial_watching =
'<table class="lg-table lg-table-watch"><thead><tr><th>IP 地址</th><th>归属地</th><th>失败次数</th><th>最近访问时间</th><th>距首次失败</th></tr></thead><tbody>' ..
'<table class="lg-table lg-table-watch"><thead><tr><th>IP 地址</th><th>访问类型</th><th>归属地</th><th>失败次数</th><th>最近访问时间</th><th>距首次失败</th></tr></thead><tbody>' ..
table.concat(watching_rows, "") ..
'</tbody></table>'
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}
}
</style>
@@ -223,6 +235,13 @@ function lgGeoHtml(loc){
return loc?'<span style="font-size:11px">'+lgEscHtml(loc)+'</span>':'<span style="color:'+lgColor('muted')+'">查询中...</span>';
}
// 访问类型:ssh / luci / manual;旧版封禁记录没有类型
function lgKindHtml(kind){
var map={ssh:['SSH','ssh'],luci:['LuCI','luci'],manual:['手动','manual']};
var k=map[kind]||['未记录','none'];
return '<span class="lg-kind lg-kind-'+k[1]+'">'+k[0]+'</span>';
}
function lgEscHtml(s){
return String(s||'').replace(/[&<>"']/g,function(c){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
@@ -324,7 +343,7 @@ function lgRenderWatching(watching,threshold){
return;
}
var h2='<table class="lg-table lg-table-watch">';
h2+='<thead><tr><th>IP 地址</th><th>归属地</th><th>失败次数</th><th>最近访问时间</th><th>距首次失败</th></tr></thead><tbody>';
h2+='<thead><tr><th>IP 地址</th><th>访问类型</th><th>归属地</th><th>失败次数</th><th>最近访问时间</th><th>距首次失败</th></tr></thead><tbody>';
for(var j=0;j<watching.length;j++){
var w=watching[j]||{};
var ip=w.ip||'--';
@@ -335,6 +354,7 @@ function lgRenderWatching(watching,threshold){
var color=pct>=66?lgColor('danger'):(pct>=33?lgColor('warning'):lgColor('muted'));
h2+='<tr>';
h2+='<td><code class="lg-ip">'+lgEscHtml(ip)+'</code></td>';
h2+='<td>'+lgKindHtml(w.kind)+'</td>';
h2+='<td id="lg-watch-geo-'+j+'">'+lgGeoHtml(_lgGeoCache[ip])+'</td>';
h2+='<td><span style="color:'+color+';font-weight:bold">'+count+' / '+threshold+'</span></td>';
h2+='<td>'+lgEscHtml(lastSeen)+'</td>';
@@ -385,11 +405,12 @@ function lgApplyStatus(d){
listEl.innerHTML='<div class="lg-empty">暂无封禁</div>';
}else{
var h='<table class="lg-table lg-table-ban">';
h+='<thead><tr><th>IP 地址</th><th>归属地</th><th>剩余时间</th><th>操作</th></tr></thead><tbody>';
h+='<thead><tr><th>IP 地址</th><th>访问类型</th><th>归属地</th><th>剩余时间</th><th>操作</th></tr></thead><tbody>';
for(var i=0;i<banned.length;i++){
var b=banned[i];
h+='<tr>';
h+='<td><code class="lg-ip lg-ip-danger">'+lgEscHtml(b.ip)+'</code></td>';
h+='<td>'+lgKindHtml(b.kind)+'</td>';
h+='<td id="lg-geo-'+i+'">'+lgGeoHtml(_lgGeoCache[b.ip])+'</td>';
h+='<td>'+fmtDuration(b.remaining)+'</td>';
h+='<td><button class="lg-unban" onclick="lgUnban(\''+lgEscHtml(b.ip)+'\',this)">解封</button></td>';
+37
View File
@@ -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` 校验文件完整性。
+30 -19
View File
@@ -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"
}
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+60
View File
@@ -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/);
});
+24
View File
@@ -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)');
});
+23
View File
@@ -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, /<th>IP 地址<\/th><th>访问类型<\/th><th>归属地<\/th>/);
assert.match(banned, /103\.160\.205\.37<\/code><\/td><td><span class="lg-kind lg-kind-ssh">SSH<\/span>/);
assert.match(banned, /lg-kind-manual">手动</);
assert.match(banned, /5\.231\.242\.181<\/code><\/td><td><span class="lg-kind lg-kind-none">未记录</);
const watching = page.element('lg-watching-list').innerHTML;
assert.match(watching, /<th>访问类型<\/th>/);
assert.match(watching, /198\.51\.100\.20<\/code><\/td><td><span class="lg-kind lg-kind-luci">LuCI</);
});
+2 -2
View File
@@ -14,8 +14,8 @@ MISE_HASH_x86_64:=3832f39c325e343f81fe3d92b2447c5d1a5eea1bc85092bb7b6c2580622264
MISE_HASH_aarch64:=06186cfbfe947049b21d58575fb0ea800cc26ed1375f20f4b678cb3a9d679437
PKG_NAME:=mise
PKG_VERSION:=2026.10.21
PKG_RELEASE:=44
PKG_VERSION:=2026.10.22
PKG_RELEASE:=45
PKG_SOURCE:=$(PKG_NAME)-v$(PKG_VERSION)-linux-$(MISE_ARCH)-musl.tar.gz
PKG_SOURCE_URL:=https://github.com/jdx/mise/releases/download/v$(PKG_VERSION)/
PKG_HASH:=skip
+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=qBittorrent
PKG_VERSION:=5.3.0
PKG_RELEASE:=9
PKG_RELEASE:=10
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/qbittorrent/qBittorrent/tar.gz/release-$(PKG_VERSION)?
@@ -1,10 +1,10 @@
--- a/cmake/Modules/CommonConfig.cmake
+++ b/cmake/Modules/CommonConfig.cmake
@@ -60,7 +60,6 @@ if ((CMAKE_CXX_COMPILER_ID STREQUAL "GNU
-Wold-style-cast
-Wnon-virtual-dtor
@@ -57,7 +57,6 @@ if ((CMAKE_CXX_COMPILER_ID STREQUAL "GNU
-Wcast-align
-Winvalid-pch
-pedantic
- -pedantic-errors
)
# Clang 11 still doesn't support -Wstrict-null-sentinel
$<$<COMPILE_LANGUAGE:CXX>:
-Woverloaded-virtual
-Wold-style-cast
+2 -2
View File
@@ -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 <shevlako@132lan.ru>
PKG_LICENSE:=GPL-3.0
+3 -3
View File
@@ -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
+6 -5
View File
@@ -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 <liudf0716@gmail.com>
PKG_LICENSE:=GPL-3.0-or-later