diff --git a/clashoo/Makefile b/clashoo/Makefile index 7ebbf6902..592dc6c57 100644 --- a/clashoo/Makefile +++ b/clashoo/Makefile @@ -1,17 +1,17 @@ include $(TOPDIR)/rules.mk PKG_NAME:=clashoo -PKG_SHORT_SHA:=bc76c13 -PKG_COMMIT_DATE:=2026.10.11 +PKG_SHORT_SHA:=94b13ae +PKG_COMMIT_DATE:=2026.10.13 PKG_VERSION:=$(PKG_COMMIT_DATE)~$(PKG_SHORT_SHA) -PKG_RELEASE:=12 +PKG_RELEASE:=13 PKG_SOURCE:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA).tar.gz PKG_SOURCE_SUBDIR:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-alpha-$(PKG_SHORT_SHA) PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-clashoo/releases/download/mihomo-src -PKG_SOURCE_VERSION:=38f826f2f89637be271fb441f2f10b0d66422b17 +PKG_SOURCE_VERSION:=4b487e294f46e98f51548f8c5253df804d7d4b38 PKG_HASH:=skip PKG_BUILD_VERSION:=alpha-$(PKG_SHORT_SHA) diff --git a/dockermanager/Makefile b/dockermanager/Makefile index 7ceb10825..3e5398b24 100644 --- a/dockermanager/Makefile +++ b/dockermanager/Makefile @@ -9,8 +9,8 @@ include $(TOPDIR)/rules.mk PKG_ARCH_DOCKERMANAGER:=$(ARCH) PKG_NAME:=dockermanager -PKG_VERSION:=0.1.1 -PKG_RELEASE:=8 +PKG_VERSION:=0.1.2 +PKG_RELEASE:=9 PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/dockermanager-runtime-v$(PKG_VERSION)/ PKG_HASH:=skip diff --git a/dockermanager/files/dockermanager-plugin.json b/dockermanager/files/dockermanager-plugin.json index 8ed774c4c..171e9cc04 100644 --- a/dockermanager/files/dockermanager-plugin.json +++ b/dockermanager/files/dockermanager-plugin.json @@ -21,6 +21,7 @@ } } }, + "auth": { "mode": "passthrough" }, "backend": { "type": "http", "transport": "unix", @@ -65,6 +66,6 @@ "height": 760, "singleton": true }, - "capabilities": ["docker"], + "capabilities": ["docker", "compose", "multi-target"], "categories": ["system"] } diff --git a/dockermanager/files/dockermanager.config b/dockermanager/files/dockermanager.config index d908496f4..3d519534c 100644 --- a/dockermanager/files/dockermanager.config +++ b/dockermanager/files/dockermanager.config @@ -7,3 +7,4 @@ config dockermanager option 'port' '8192' option 'bind_addr' '0.0.0.0' option 'base_path' '/apps/dockermanager/' + option 'integration_token_file' '/etc/lighthost/docker-manager.token' diff --git a/dockermanager/files/dockermanager.init b/dockermanager/files/dockermanager.init index 85faf1a15..41d05c167 100644 --- a/dockermanager/files/dockermanager.init +++ b/dockermanager/files/dockermanager.init @@ -6,63 +6,90 @@ USE_PROCD=1 get_config() { config_get_bool enabled "$1" enabled 0 config_get data_dir "$1" data_dir "" - config_get listen_mode "$1" listen_mode "auto" - config_get_bool external_port_enabled "$1" external_port_enabled 0 - config_get socket_path "$1" socket_path "/var/run/dockermanager.sock" config_get port "$1" port "8192" config_get bind_addr "$1" bind_addr "0.0.0.0" config_get base_path "$1" base_path "/apps/dockermanager/" + config_get listen_mode "$1" listen_mode "auto" + config_get_bool external_port_enabled "$1" external_port_enabled 0 + config_get socket_path "$1" socket_path "/var/run/dockermanager.sock" + config_get integration_token_file "$1" integration_token_file "/etc/lighthost/docker-manager.token" } -mkdir_p() { - [ -d "$1" ] || mkdir -p "$1" +valid_data_dir() { + case "$1" in + /mnt/*|/root/*) return 0 ;; + *) return 1 ;; + esac +} + +prepare_token() { + case "$integration_token_file" in + /etc/lighthost/*) ;; + *) + logger -t dockermanager "refusing unsafe integration_token_file: $integration_token_file" + return 1 + ;; + esac + mkdir -p "$(dirname "$integration_token_file")" || return 1 + if [ ! -s "$integration_token_file" ]; then + umask 077 + hexdump -v -n 32 -e '1/1 "%02x"' /dev/urandom > "$integration_token_file" || return 1 + fi + if grep -q '^lighthost:' /etc/group; then + chown root:lighthost "$integration_token_file" || return 1 + chmod 640 "$integration_token_file" || return 1 + else + chown root:root "$integration_token_file" || return 1 + chmod 600 "$integration_token_file" || return 1 + fi } start_service() { config_load dockermanager config_foreach get_config dockermanager - - if [ "$enabled" != 1 ]; then + [ "$enabled" = 1 ] || return 0 + valid_data_dir "$data_dir" || { + logger -t dockermanager "refusing unsafe data_dir: $data_dir" return 1 - fi - if [ -z "$data_dir" ]; then - logger -t dockermanager "data_dir is empty; set option data_dir in /etc/config/dockermanager" - return 1 - fi + } + mkdir -p "$data_dir/state" "$data_dir/cache" || return 1 + prepare_token || return 1 - mkdir_p "$data_dir" || return 1 - mkdir_p "$data_dir/state" || return 1 - mkdir_p "$data_dir/cache" || return 1 + local effective_listen_mode + effective_listen_mode="$listen_mode" + if [ "$effective_listen_mode" = auto ]; then + if [ "$external_port_enabled" = 1 ]; then + effective_listen_mode=tcp + else + effective_listen_mode=unix + fi + fi + case "$effective_listen_mode" in + tcp|unix) ;; + *) + logger -t dockermanager "invalid listen_mode: $effective_listen_mode" + return 1 + ;; + esac - logger -t dockermanager "Starting Docker Manager service" procd_open_instance docker-manager - if [ "$listen_mode" = "tcp" ] || [ "$external_port_enabled" = 1 ]; then - procd_set_param env \ - DOCKER_MANAGER_LISTEN_MODE="tcp" \ - DOCKER_MANAGER_ADDR="$bind_addr:$port" \ - DOCKER_MANAGER_BASE_PATH="$base_path" \ - DOCKER_MANAGER_DATA_DIR="$data_dir" \ - DOCKER_MANAGER_STATIC_ROOT="/usr/share/dockermanager/www" \ - DOCKER_MANAGER_PLATFORM="openwrt" \ - DOCKER_MANAGER_AUTH_MODE="openwrt_luci" - else - procd_set_param env \ - DOCKER_MANAGER_LISTEN_MODE="unix" \ - DOCKER_MANAGER_SOCKET_PATH="$socket_path" \ - DOCKER_MANAGER_BASE_PATH="$base_path" \ - DOCKER_MANAGER_DATA_DIR="$data_dir" \ - DOCKER_MANAGER_STATIC_ROOT="/usr/share/dockermanager/www" \ - DOCKER_MANAGER_PLATFORM="openwrt" \ - DOCKER_MANAGER_AUTH_MODE="openwrt_luci" - fi + procd_set_param env \ + "DOCKER_MANAGER_ADDR=$bind_addr:$port" \ + "DOCKER_MANAGER_BASE_PATH=$base_path" \ + "DOCKER_MANAGER_DATA_DIR=$data_dir" \ + "DOCKER_MANAGER_STATIC_ROOT=/usr/share/dockermanager/www" \ + "DOCKER_MANAGER_PLATFORM=openwrt" \ + "DOCKER_MANAGER_AUTH_MODE=openwrt_luci" \ + "DOCKER_MANAGER_LISTEN_MODE=$effective_listen_mode" \ + "DOCKER_MANAGER_SOCKET_PATH=$socket_path" \ + "DOCKER_MANAGER_INTEGRATION_TOKEN_FILE=$integration_token_file" procd_set_param command /usr/sbin/docker-manager procd_set_param stdout 1 procd_set_param stderr 1 - procd_set_param respawn + procd_set_param respawn 3600 5 5 procd_close_instance - logger -t dockermanager "Docker Manager service started" } service_triggers() { - procd_add_reload_trigger "dockermanager" + procd_add_reload_trigger dockermanager } diff --git a/easytier-noweb/Makefile b/easytier-noweb/Makefile index 1a9fda951..c18a01428 100644 --- a/easytier-noweb/Makefile +++ b/easytier-noweb/Makefile @@ -2,8 +2,8 @@ include $(TOPDIR)/rules.mk -include $(dir $(lastword $(MAKEFILE_LIST)))../version.mk PKG_NAME:=easytier-noweb -PKG_VERSION:=$(or $(EASYTIER_VERSION),2.6.4) -PKG_RELEASE:=3 +PKG_VERSION:=$(or $(EASYTIER_VERSION),2.7.0) +PKG_RELEASE:=4 ifeq ($(ARCH),mipsel) APP_ARCH:=mipsel diff --git a/easytier/Makefile b/easytier/Makefile index 8d843f971..4859ea7e4 100644 --- a/easytier/Makefile +++ b/easytier/Makefile @@ -2,8 +2,8 @@ include $(TOPDIR)/rules.mk -include $(dir $(lastword $(MAKEFILE_LIST)))../version.mk PKG_NAME:=easytier -PKG_VERSION:=$(or $(EASYTIER_VERSION),2.6.4) -PKG_RELEASE:=6 +PKG_VERSION:=$(or $(EASYTIER_VERSION),2.7.0) +PKG_RELEASE:=7 ifeq ($(ARCH),mipsel) APP_ARCH:=mipsel diff --git a/fastnet/Makefile b/fastnet/Makefile index a029f7111..b9f55b933 100644 --- a/fastnet/Makefile +++ b/fastnet/Makefile @@ -10,7 +10,7 @@ PKG_ARCH_FASTNET:=$(ARCH) PKG_NAME:=fastnet PKG_VERSION:=0.7.7 -PKG_RELEASE:=5 +PKG_RELEASE:=6 PKG_SOURCE:=$(PKG_NAME)-binary-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://github.com/istoreos/istoreos-app-hub/releases/download/fastnet-runtime-v$(PKG_VERSION)/ @@ -37,9 +37,6 @@ if [ "$${IPKG_INSTROOT}" = "" ]; then if [ "$$(readlink /usr/share/linkease/apps.d/17-fastnet.json 2>/dev/null)" = "/usr/share/fastnet/fastnet-plugin.json" ]; then rm -f /usr/share/linkease/apps.d/17-fastnet.json fi - if [ "$$(readlink /usr/share/linkeasefull/desktop-apps.d/17-fastnet.json 2>/dev/null)" = "/usr/share/fastnet/fastnet-plugin.json" ]; then - rm -f /usr/share/linkeasefull/desktop-apps.d/17-fastnet.json - fi fi exit 0 endef @@ -67,14 +64,13 @@ define Build/Compile endef define Package/$(PKG_NAME)/install - $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/config $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/usr/share/fastnet $(1)/usr/share/linkease/apps.d $(1)/usr/share/linkeasefull/desktop-apps.d + $(INSTALL_DIR) $(1)/usr/sbin $(1)/etc/config $(1)/etc/init.d $(1)/etc/uci-defaults $(1)/usr/share/fastnet $(1)/usr/share/linkease/apps.d $(INSTALL_BIN) $(PKG_BUILD_DIR)/FastNet.$(PKG_ARCH_FASTNET) $(1)/usr/sbin/FastNet $(INSTALL_CONF) ./files/fastnet.config $(1)/etc/config/fastnet $(INSTALL_BIN) ./files/fastnet.init $(1)/etc/init.d/fastnet $(INSTALL_BIN) ./files/fastnet.uci-default $(1)/etc/uci-defaults/fastnet $(INSTALL_DATA) ./files/fastnet-plugin.json $(1)/usr/share/fastnet/fastnet-plugin.json ln -sf /usr/share/fastnet/fastnet-plugin.json $(1)/usr/share/linkease/apps.d/17-fastnet.json - ln -sf /usr/share/fastnet/fastnet-plugin.json $(1)/usr/share/linkeasefull/desktop-apps.d/17-fastnet.json endef $(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/frpc-next/Makefile b/frpc-next/Makefile index a32cd5071..40326dbe3 100644 --- a/frpc-next/Makefile +++ b/frpc-next/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=frpc-next -PKG_VERSION:=0.71.0 -PKG_RELEASE:=1 +PKG_VERSION:=0.71.1 +PKG_RELEASE:=2 # Architecture mapping for pre-built binaries ifeq ($(ARCH),mipsel) diff --git a/honk/Makefile b/honk/Makefile index 9307d4a30..d6ddc8832 100644 --- a/honk/Makefile +++ b/honk/Makefile @@ -3,17 +3,17 @@ include $(TOPDIR)/rules.mk PKG_NAME:=honk -PKG_VERSION:=2026.10.12_pre1 -PKG_RELEASE:=18 +PKG_VERSION:=2026.10.15_pre1 +PKG_RELEASE:=19 # Raw upstream release tag. It keeps the dots in qualifiers (e.g. v0.0.1.beta.49) # because that is what the GitHub release asset name uses. -HONK_RELEASE_TAG:=debug.2026.10.12.native-api.1 +HONK_RELEASE_TAG:=debug.2026.10.15.native-api.1 HONK_ASSET_TAG:=debug # SHA256 of each release tarball, kept in sync by scripts/update_honk_version.sh. -HONK_HASH_X86_64:=09c4e52b17de28c8b96a61b7494da4196a87889aa8151bad3a027ac4a7ad9d86 -HONK_HASH_AARCH64:=dffa343b6c4b1d914bd5ac5f20727fa949761b827f73c3f95cde3d7f42d71701 +HONK_HASH_X86_64:=b492c22fbf9347b741fe8904fbe1c6fb128e1f7b391b60f3925cec4cf14b5085 +HONK_HASH_AARCH64:=ce25b0df9bfeb371a5340392df91606a8da6d209f1779d59dd1bd5abafe2c8a4 PKG_LICENSE:=AGPL-3.0-only PKG_LICENSE_FILE:=LICENSE diff --git a/linkease-app-entry/Makefile b/linkease-app-entry/Makefile index c8408329b..970192dad 100644 --- a/linkease-app-entry/Makefile +++ b/linkease-app-entry/Makefile @@ -8,8 +8,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=linkease-app-entry # This lightweight runtime is released independently from the full desktop. -PKG_VERSION:=3.0.22 -PKG_RELEASE:=3 +PKG_VERSION:=3.0.23 +PKG_RELEASE:=4 ARCH_HEXCODE:= ifeq ($(ARCH),x86_64) diff --git a/linkeasefull/Makefile b/linkeasefull/Makefile index 8fb19357a..8a5bf318e 100644 --- a/linkeasefull/Makefile +++ b/linkeasefull/Makefile @@ -10,8 +10,8 @@ PKG_ARCH_LINKEASE:=$(ARCH) PKG_NAME:=linkeasefull # use PKG_SOURCE_DATE instead of PKG_VERSION for compitable -PKG_SOURCE_DATE:=3.0.22 -PKG_RELEASE:=46 +PKG_SOURCE_DATE:=3.0.23 +PKG_RELEASE:=47 ARCH_HEXCODE:= ifeq ($(ARCH),x86_64) diff --git a/luci-app-adguardhome/.official_version_release b/luci-app-adguardhome/.official_version_release index 74a80ed3f..af3febe3e 100644 --- a/luci-app-adguardhome/.official_version_release +++ b/luci-app-adguardhome/.official_version_release @@ -1 +1 @@ -26.284.22350~8125d12 +26.287.39313~74c485a diff --git a/luci-app-adguardhome/.official_version_snapshot b/luci-app-adguardhome/.official_version_snapshot index af617f252..16a98f138 100644 --- a/luci-app-adguardhome/.official_version_snapshot +++ b/luci-app-adguardhome/.official_version_snapshot @@ -1 +1 @@ -26.285.40306~d12d553 +26.287.65283~327d358 diff --git a/luci-app-adguardhome/.upstream_meta b/luci-app-adguardhome/.upstream_meta index ac0302076..77f70e460 100644 --- a/luci-app-adguardhome/.upstream_meta +++ b/luci-app-adguardhome/.upstream_meta @@ -1,2 +1,2 @@ -9c9ad92d8e1621d0708c83afc215bb141e7bafb8 +327d358bd28096a6702da8dd71b422792331f659 e192c5983e50169ed984d48b094798869a0a780b diff --git a/luci-app-adguardhome/po/de/adguardhome.po b/luci-app-adguardhome/po/de/adguardhome.po index 9529ba323..bd3449215 100644 --- a/luci-app-adguardhome/po/de/adguardhome.po +++ b/luci-app-adguardhome/po/de/adguardhome.po @@ -3,19 +3,23 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: de\n" "Content-Transfer-Encoding: 8bit\n" -"Last-Translator: Pavel Borecki \n" +"Last-Translator: Pavel Borecki \n" "Language-Team: German \n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"PO-Revision-Date: 2026-09-23 19:35+0000\n" -"X-Generator: Weblate 2026.10.dev0\n" +"PO-Revision-Date: 2026-10-13 13:20+0000\n" +"X-Generator: Weblate 2026.11.dev0\n" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:244 +#, fuzzy msgid "" "A soft memory cap for the Go runtime, allowing the garbage collector to run " "more frequently as usage approaches the limit to prevent Out-of-Memory (OOM) " "kills." msgstr "" +"Eine weiche Speicherkappe für die Go-Laufzeit, die es dem garbage collector " +"ermöglicht, häufiger zu laufen, da die Nutzung sich der Grenze nähert, um " +"Out-of-Memory (OOM) kills zu verhindern." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:113 #: applications/luci-app-adguardhome/root/usr/share/luci/menu.d/luci-app-adguardhome.json:3 @@ -29,27 +33,32 @@ msgstr "Erweiterte Einstellungen" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:146 msgid "Configuration file" -msgstr "" +msgstr "Konfiguration fehlgeschlagen" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:89 #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:147 msgid "" "Configuration file must be stored in its own directory, and not in '/etc'." msgstr "" +"Die Konfigurationsdatei muss in ihrem eigenen Verzeichnis gespeichert werden " +"und nicht in '/etc'." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:159 msgid "Directory where filters, logs, and statistics are stored." msgstr "" +"Verzeichnis, in dem Filter, Protokolle und Statistiken gespeichert werden." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:132 msgid "File System Access" -msgstr "" +msgstr "Zugriff auf das Dateisystem" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:133 msgid "" "Files and directories that AdGuard Home should have read-only or read-write " "access to." msgstr "" +"Dateien und Verzeichnisse, auf die AdGuard Home nur lesen oder schreiben " +"soll." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:129 msgid "General Settings" @@ -59,14 +68,16 @@ msgstr "Allgemeine Einstellungen" msgid "" "Go environment variables that tune garbage collector and memory management." msgstr "" +"Go Umgebungsvariablen, die Garbage Kollektor und Speicher Management " +"verbessern." #: applications/luci-app-adguardhome/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json:3 msgid "Grant permissions for the AdGuard Home LuCI app" -msgstr "" +msgstr "Erlaube Zugriff auf die AdGuard Hime LuCI Applikation" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:181 msgid "Group the service runs under." -msgstr "" +msgstr "Gruppen Sie die Service Ausführung unter." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:149 #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:161 @@ -75,12 +86,13 @@ msgstr "" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:217 #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:232 #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:247 +#, fuzzy msgid "If empty, defaults to" -msgstr "" +msgstr "Wenn leer, standardmäßig" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:139 msgid "Modify at your own risk." -msgstr "" +msgstr "Ändern auf eigenes Risiko." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:24 msgid "Not running" @@ -88,24 +100,24 @@ msgstr "Angehalten" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:148 msgid "Parent directory will be owned by the service user." -msgstr "" +msgstr "Das Parent-Verzeichnis gehört dem Service-Benutzer." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:83 #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:99 msgid "Path must be absolute." -msgstr "" +msgstr "Pfad muss absolut sein." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:86 msgid "Path must not end with a slash." -msgstr "" +msgstr "Der Pfad darf nicht mit einem Slash enden." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:207 msgid "Read-only access" -msgstr "" +msgstr "Nur Lesezugriff" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:208 msgid "Read-write access" -msgstr "" +msgstr "Lese- und Schreibzugriff" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:23 msgid "Running" @@ -117,17 +129,20 @@ msgstr "Dienststatus" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:180 msgid "Service group" -msgstr "" +msgstr "Service- Gruppe" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:170 msgid "Service user" -msgstr "" +msgstr "Service-Benutzer" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:230 +#, fuzzy msgid "" "The maximum number of operating system threads that can execute user-level " "Go code simultaneously." msgstr "" +"Die maximale Anzahl von Betriebssystemausführungssträgen, die Benutzer-Level " +"Go-Code gleichzeitig ausführen können." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:215 msgid "" @@ -136,8 +151,9 @@ msgid "" msgstr "" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:171 +#, fuzzy msgid "User the service runs under." -msgstr "" +msgstr "Benutzer unter dem der Dienst läuft." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:191 msgid "Verbose logging" @@ -149,20 +165,20 @@ msgstr "Version" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:160 msgid "Will be owned by the service user." -msgstr "" +msgstr "Wird dem Service-Nutzer gehören." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:158 msgid "Working directory" -msgstr "" +msgstr "Arbeitsverzeichnis" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:247 msgid "unset" -msgstr "" +msgstr "nicht gesetzt" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:217 msgid "unset and 100" -msgstr "" +msgstr "nicht gesetzt und 100" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:232 msgid "unset and matching the number of CPUs" -msgstr "" +msgstr "ungesetzte und passende Zahl der CPUs" diff --git a/luci-app-adguardhome/po/pt_BR/adguardhome.po b/luci-app-adguardhome/po/pt_BR/adguardhome.po index 8f1e416a7..b9793a1b9 100644 --- a/luci-app-adguardhome/po/pt_BR/adguardhome.po +++ b/luci-app-adguardhome/po/pt_BR/adguardhome.po @@ -3,12 +3,13 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: pt_BR\n" "Content-Transfer-Encoding: 8bit\n" -"Last-Translator: Pavel Borecki \n" +"Last-Translator: Janderson Vieira Santos " +"\n" "Language-Team: Portuguese (Brazil) \n" "Plural-Forms: nplurals=2; plural=n > 1;\n" -"PO-Revision-Date: 2026-09-29 13:09+0000\n" -"X-Generator: Weblate 2026.10.dev0\n" +"PO-Revision-Date: 2026-10-13 13:20+0000\n" +"X-Generator: Weblate 2026.11.dev0\n" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:244 msgid "" @@ -16,6 +17,9 @@ msgid "" "more frequently as usage approaches the limit to prevent Out-of-Memory (OOM) " "kills." msgstr "" +"Um limite flexível de memória para o runtime do Go, permitindo que o coletor " +"de lixo seja executado com mais frequência à medida que o uso se aproxima do " +"limite para evitar finalizações por falta de memória (OOM)." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:113 #: applications/luci-app-adguardhome/root/usr/share/luci/menu.d/luci-app-adguardhome.json:3 @@ -29,27 +33,31 @@ msgstr "Configurações avançadas" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:146 msgid "Configuration file" -msgstr "" +msgstr "Arquivo de configuração" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:89 #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:147 msgid "" "Configuration file must be stored in its own directory, and not in '/etc'." msgstr "" +"O arquivo de configuração deve ser armazenado em seu próprio diretório, e " +"não em '/etc'." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:159 msgid "Directory where filters, logs, and statistics are stored." -msgstr "" +msgstr "Diretório onde filtros, registros e estatísticas são armazenados." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:132 msgid "File System Access" -msgstr "" +msgstr "Acesso ao sistema de arquivos" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:133 msgid "" "Files and directories that AdGuard Home should have read-only or read-write " "access to." msgstr "" +"Arquivos e diretórios aos quais o AdGuard Home deve ter acesso somente " +"leitura ou de leitura e escrita." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:129 msgid "General Settings" @@ -59,14 +67,16 @@ msgstr "Configurações gerais" msgid "" "Go environment variables that tune garbage collector and memory management." msgstr "" +"Variáveis de ambiente do Go que ajustam o coletor de lixo e o gerenciamento " +"de memória." #: applications/luci-app-adguardhome/root/usr/share/rpcd/acl.d/luci-app-adguardhome.json:3 msgid "Grant permissions for the AdGuard Home LuCI app" -msgstr "" +msgstr "Conceder permissões para o aplicativo LuCI do AdGuard Home" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:181 msgid "Group the service runs under." -msgstr "" +msgstr "Grupo sob o qual o serviço é executado." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:149 #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:161 @@ -76,36 +86,36 @@ msgstr "" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:232 #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:247 msgid "If empty, defaults to" -msgstr "" +msgstr "Se vazio, o padrão é" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:139 msgid "Modify at your own risk." -msgstr "" +msgstr "Modifique por sua conta e risco." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:24 msgid "Not running" -msgstr "" +msgstr "Não está em execução" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:148 msgid "Parent directory will be owned by the service user." -msgstr "" +msgstr "O diretório pai pertencerá ao usuário do serviço." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:83 #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:99 msgid "Path must be absolute." -msgstr "" +msgstr "O caminho deve ser absoluto." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:86 msgid "Path must not end with a slash." -msgstr "" +msgstr "O caminho não deve terminar com uma barra." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:207 msgid "Read-only access" -msgstr "" +msgstr "Acesso somente leitura" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:208 msgid "Read-write access" -msgstr "" +msgstr "Acesso de leitura e escrita" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:23 msgid "Running" @@ -117,31 +127,36 @@ msgstr "Condição do Serviço" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:180 msgid "Service group" -msgstr "" +msgstr "Grupo do serviço" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:170 msgid "Service user" -msgstr "" +msgstr "Usuário do serviço" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:230 msgid "" "The maximum number of operating system threads that can execute user-level " "Go code simultaneously." msgstr "" +"O número máximo de threads do sistema operacional que podem executar código " +"Go no nível de usuário simultaneamente." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:215 msgid "" "Tunes the garbage collector's aggressiveness by setting the percentage of " "heap growth allowed before the next collection cycle triggers." msgstr "" +"Ajusta a agressividade do coletor de lixo definindo a porcentagem de " +"crescimento da heap permitida antes do acionamento do próximo ciclo de " +"coleta." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:171 msgid "User the service runs under." -msgstr "" +msgstr "Usuário sob o qual o serviço é executado." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:191 msgid "Verbose logging" -msgstr "" +msgstr "Registro detalhado" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:119 msgid "Version" @@ -149,20 +164,20 @@ msgstr "Versão" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:160 msgid "Will be owned by the service user." -msgstr "" +msgstr "Pertencerá ao usuário do serviço." #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:158 msgid "Working directory" -msgstr "" +msgstr "Diretório de trabalho" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:247 msgid "unset" -msgstr "" +msgstr "não definido" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:217 msgid "unset and 100" -msgstr "" +msgstr "não definido e 100" #: applications/luci-app-adguardhome/htdocs/luci-static/resources/view/adguardhome/config.js:232 msgid "unset and matching the number of CPUs" -msgstr "" +msgstr "não definido e correspondente ao número de CPUs" diff --git a/luci-app-airoha-npu/Makefile b/luci-app-airoha-npu/Makefile index 891cdead0..dc69b8aca 100644 --- a/luci-app-airoha-npu/Makefile +++ b/luci-app-airoha-npu/Makefile @@ -3,7 +3,7 @@ include $(TOPDIR)/rules.mk PKG_VERSION:=1.0.1 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_LICENSE:=Apache-2.0 LUCI_TITLE:=LuCI Airoha SoC Status (NPU, CPU, Frame Engine) diff --git a/luci-app-airoha-npu/root/usr/libexec/rpcd/luci.airoha_npu b/luci-app-airoha-npu/root/usr/libexec/rpcd/luci.airoha_npu index b2bf7ae44..36741e24d 100755 --- a/luci-app-airoha-npu/root/usr/libexec/rpcd/luci.airoha_npu +++ b/luci-app-airoha-npu/root/usr/libexec/rpcd/luci.airoha_npu @@ -6,29 +6,34 @@ get_status() { # === NPU Status === - # Read the actually-loaded NPU firmware path from DTS (firmware-name property). - local npu_fw="" - local npu_dt="/proc/device-tree/soc/npu@1e900000/firmware-name" - if [ -f "$npu_dt" ]; then - # firmware-name is a string list (null-separated). First entry is the rv32 code. - local fw_rel=$(tr '\0' '\n' < "$npu_dt" 2>/dev/null | grep -m1 "rv32") - [ -n "$fw_rel" ] && npu_fw="/lib/firmware/$fw_rel" - fi - - local npu_ver="Unknown" - if [ -n "$npu_fw" ] && [ -f "$npu_fw" ]; then - npu_ver=$(strings "$npu_fw" 2>/dev/null | grep -oE '([0-9]+\.[0-9]+\.[0-9]+-)?TLB[0-9.]+[-_v0-9]*' | head -1) - [ -z "$npu_ver" ] && npu_ver="Unknown" - fi - local npu_loaded="false" local npu_device="" local npu_driver_path="/sys/bus/platform/drivers/airoha-npu" if [ -d "$npu_driver_path" ]; then npu_device=$(ls -1 "$npu_driver_path" 2>/dev/null | grep '\.npu$' | head -1) - if [ -n "$npu_device" ]; then - npu_loaded="true" + [ -n "$npu_device" ] && npu_loaded="true" + fi + + local npu_ver="" npu_fw="" fw_rel="" compatible="" + local npu_dt="$npu_driver_path/$npu_device/of_node" + if [ "$npu_loaded" = "true" ]; then + # Prefer the version reported by the running firmware. + npu_ver=$(dmesg 2>/dev/null | sed -n 's/.*airoha-npu .*NPU fw version: \([0-9][0-9]*\.[0-9][0-9]*\) *$/\1/p' | tail -1) + if [ -f "$npu_dt/firmware-name" ]; then + fw_rel=$(tr '\0' '\n' < "$npu_dt/firmware-name" | grep -m1 "rv32") + else + # Match the driver's defaults when DTS does not override them. + compatible=$(tr '\0' '\n' < "$npu_dt/compatible" 2>/dev/null) + case "$compatible" in + *airoha,en7581-npu*) fw_rel="airoha/en7581_npu_rv32.bin" ;; + *airoha,an7583-npu*) fw_rel="airoha/an7583_npu_rv32.bin" ;; + esac + fi + [ -n "$fw_rel" ] && npu_fw="/lib/firmware/$fw_rel" + # Boot messages may have rolled out of the kernel log buffer. + if [ -z "$npu_ver" ] && [ -n "$npu_fw" ] && [ -f "$npu_fw" ]; then + npu_ver=$(strings "$npu_fw" 2>/dev/null | grep -oE '([0-9]+\.[0-9]+\.[0-9]+-)?TLB[0-9.]+[-_v0-9]*' | head -1) fi fi diff --git a/luci-app-amlogic/Makefile b/luci-app-amlogic/Makefile index eae8c9de7..1b23827ed 100644 --- a/luci-app-amlogic/Makefile +++ b/luci-app-amlogic/Makefile @@ -22,11 +22,11 @@ PKG_NAME:=luci-app-amlogic # DD = day (01 = 1st) # NN = daily release sequence (01 = first release of the day, 02 = second, ...) # Example below: 26.10.0101 = October 1, 2026, 1st release of the day -PKG_VERSION:=26.10.0301 +PKG_VERSION:=26.10.1501 # PKG_RELEASE marks the LuCI variant; onekey-install.sh relies on it (do NOT bump) # 1 = Lua version (lua branch) → tag: 26.10.0101 # 2 = JS version (main branch) → tag: 26.10.0101-js -PKG_RELEASE:=20 +PKG_RELEASE:=21 PKG_LICENSE:=GPL-2.0 License PKG_MAINTAINER:=ophub diff --git a/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_firmware.sh b/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_firmware.sh index 732b7a903..e730da90d 100755 --- a/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_firmware.sh +++ b/luci-app-amlogic/root/usr/share/amlogic/amlogic_check_firmware.sh @@ -181,25 +181,40 @@ check_updated() { https://github.com/${server_firmware_url}/releases/expanded_assets/${firmware_releases_tag} )" - # Set the regular expression for the OpenWrt filename - op_file_pattern=".*_${BOARD}_.*k${main_line_version}\.[0-9]+.*${firmware_suffix}" - # Find the
  • list item where the OpenWrt file is located - li_block=$(awk -v pattern="${op_file_pattern}" -v RS='
  • ' '$0 ~ pattern { print $0 ""; exit }' <<<"${html_code}") - [[ -z "${li_block}" ]] && tolog "02.03.01 No matching download links found." "1" + # Find the
  • list items where the OpenWrt file is located. + asset_records="$(awk -v board="${BOARD}" -v kver="${main_line_version}" -v suffix="${firmware_suffix}" -v RS='
  • ' ' + BEGIN { + href_pattern = "/releases/download/[^\"]*_" board "_[^\"]*k" kver "\\.[0-9]+[^\"]*" suffix + block_pattern = ".*_" board "_.*k" kver "\\.[0-9]+.*" suffix + } + $0 ~ block_pattern { + file_name = ""; file_date = ""; file_sha256 = "" + if (match($0, href_pattern)) { + file_path = substr($0, RSTART, RLENGTH) + file_name = file_path; sub(/.*\//, "", file_name) + } + if (match($0, /datetime="[^"]*"/)) { file_date = substr($0, RSTART + 10, RLENGTH - 11) } + if (match($0, /value="sha256:[^"]*/)) { file_sha256 = substr($0, RSTART + 14, RLENGTH - 14) } + if (file_name != "") { printf "%s\t%s\t%s\n", file_date, file_name, file_sha256 } + }' <<<"${html_code}")" + [[ -z "${asset_records}" ]] && tolog "02.03.01 No matching download links found." "1" + + # Select the OpenWrt file with the newest upload date. + latest_record="$(echo "${asset_records}" | sort -t$'\t' -k1,1 | tail -n 1)" # Find the OpenWrt filename - latest_url=$(echo "${li_block}" | grep -oE "/[^\"]*_${BOARD}_.*k${main_line_version}\.[0-9]+[^\"]*${firmware_suffix}" | sort -urV | head -n 1 | xargs basename 2>/dev/null) + latest_url="$(echo "${latest_record}" | cut -f2)" tolog "02.03.02 OpenWrt file: ${latest_url}" # Find the date of the latest update - latest_updated_at=$(echo "${li_block}" | grep -o 'datetime="[^"]*"' | sed 's/datetime="//; s/"//') + latest_updated_at="$(echo "${latest_record}" | cut -f1)" tolog "02.03.03 Latest updated at: ${latest_updated_at}" # Format the date for display date_display_format="$(echo ${latest_updated_at} | tr 'T' '(' | tr 'Z' ')')" [[ -z "${latest_url}" || -z "${latest_updated_at}" ]] && tolog "02.03.04 The download URL or date is invalid." "1" # Find the firmware sha256 value - latest_firmware_sha256="$(echo "${li_block}" | grep -o 'value="sha256:[^"]*' | sed 's/value="sha256://')" + latest_firmware_sha256="$(echo "${latest_record}" | cut -f3)" tolog "02.03.05 OpenWrt sha256: ${latest_firmware_sha256}" # Check the firmware update code diff --git a/luci-app-dockermanager/Makefile b/luci-app-dockermanager/Makefile index b2290a00a..498f5fbe7 100644 --- a/luci-app-dockermanager/Makefile +++ b/luci-app-dockermanager/Makefile @@ -8,10 +8,13 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI support for Docker Manager LUCI_DEPENDS:=+dockermanager +luci-lib-linkeaseauth +linkease-app-entry +# apps_compat/apps_openwrt are absent from the legacy 1.0.3 auth package. +LUCI_EXTRA_DEPENDS:=luci-lib-linkeaseauth (>=1.2.1) LUCI_PKGARCH:=all -PKG_VERSION:=0.1.1 -PKG_RELEASE:=3 +PKG_VERSION:=0.1.2-r2 +# Keep the revision in PKG_VERSION for LuCI builders that clear PKG_RELEASE. +PKG_RELEASE:=4 include $(TOPDIR)/feeds/luci/luci.mk diff --git a/luci-app-easytier/Makefile b/luci-app-easytier/Makefile index e6363224b..94e3e8a79 100644 --- a/luci-app-easytier/Makefile +++ b/luci-app-easytier/Makefile @@ -7,8 +7,10 @@ include $(TOPDIR)/rules.mk -include $(dir $(lastword $(MAKEFILE_LIST)))../version.mk -PKG_VERSION:=$(or $(EASYTIER_VERSION),2.6.4) -PKG_RELEASE:=14 +PKG_VERSION:=$(or $(EASYTIER_VERSION),2.7.0) +PKG_RELEASE:=15 + +PKG_PO_VERSION:=$(PKG_VERSION) LUCI_TITLE:=LuCI support for EasyTier LUCI_DEPENDS:=+kmod-tun +easytier +luci-compat diff --git a/luci-app-easytier/luasrc/controller/easytier.lua b/luci-app-easytier/luasrc/controller/easytier.lua index 51e3594ae..a220795fa 100644 --- a/luci-app-easytier/luasrc/controller/easytier.lua +++ b/luci-app-easytier/luasrc/controller/easytier.lua @@ -163,7 +163,7 @@ function get_upload_config() easytierbin = uci:get_first("easytier", "easytier", "easytierbin") or "/usr/bin/easytier-core", webbin = uci:get_first("easytier", "easytier", "webbin") or "/usr/bin/easytier-web", github_proxys = {}, - fallback_version = uci:get_first("easytier", "easytier", "fallback_version") or "v2.6.4" + fallback_version = uci:get_first("easytier", "easytier", "fallback_version") or "v2.7.0" } -- 读取代理列表(list 类型) @@ -200,7 +200,7 @@ function save_upload_config() -- 保存配置 uci:set("easytier", "@easytier[0]", "easytierbin", data.easytierbin or "/usr/bin/easytier-core") uci:set("easytier", "@easytier[0]", "webbin", data.webbin or "/usr/bin/easytier-web") - uci:set("easytier", "@easytier[0]", "fallback_version", data.fallback_version or "v2.6.4") + uci:set("easytier", "@easytier[0]", "fallback_version", data.fallback_version or "v2.7.0") -- 删除旧的代理列表 uci:delete("easytier", "@easytier[0]", "github_proxys") @@ -595,69 +595,130 @@ function clear_version_cache() luci.http.write("OK") end +-- 连接信息各分区与 easytier-cli 子命令的对应关系。 +-- 实测每调用一次 easytier-cli 都要新起一个进程,单次进程启动开销约 200ms(路由器上更慢), +-- RPC 往返本身只要几毫秒,因此耗时几乎完全取决于进程个数,改为按分区按需加载。 +local CONNINFO_CLI_PARTS = { + node = "node", + peer = "peer", + route = "route", + connector = "connector", + stun = "stun", + peer_center = "peer-center", + vpn_portal = "vpn-portal", + proxy = "proxy", + acl = "acl stats", + mapped_listener = "mapped-listener", + stats = "stats", +} +-- cmdline 只读本地 /proc,不需要 easytier-cli +local CONNINFO_LOCAL_PARTS = { cmdline = true } + +-- 分区结果的短时缓存,避免轮询与手动刷新、来回切换分区时重复起进程 +local CONNINFO_CACHE_DIR = "/tmp/easytier_conninfo" +local CONNINFO_CACHE_TTL = 3 + +local function read_conninfo_cache(name) + local handle = io.open(CONNINFO_CACHE_DIR .. "/" .. name, "r") + if not handle then + return nil + end + local content = handle:read("*all") or "" + handle:close() + local ts, data = content:match("^(%d+)\n([%s%S]*)$") + if not ts or (os.time() - tonumber(ts)) > CONNINFO_CACHE_TTL then + return nil + end + return data +end + +local function write_conninfo_cache(name, data) + luci.sys.call("mkdir -p " .. CONNINFO_CACHE_DIR) + local handle = io.open(CONNINFO_CACHE_DIR .. "/" .. name, "w") + if handle then + handle:write(tostring(os.time()) .. "\n" .. data) + handle:close() + end +end + function act_conninfo() local e = {} local uci = require "luci.model.uci".cursor() local easytierbin = uci:get_first("easytier", "easytier", "easytierbin") or "/usr/bin/easytier-core" local clibin = easytierbin:gsub("easytier%-core$", "easytier-cli") - - local process_status = luci.sys.exec("pgrep easytier-core") - - if process_status ~= "" then - -- 获取各类CLI信息 - local function get_cli_output(cmd) - local handle = io.popen(clibin .. " " .. cmd .. " 2>&1") - if handle then - local result = handle:read("*all") - handle:close() - return result or "" + + -- 解析要加载的分区,前端只请求当前显示的分区; + -- 不传 parts 时加载除 stun 以外的所有分区(stun 会实时访问 STUN 服务器,单次实测约 3 秒) + local parts = luci.http.formvalue("parts") + local want_cli, want_local = {}, {} + if parts and parts ~= "" then + for name in parts:gmatch("[%w_]+") do + if CONNINFO_CLI_PARTS[name] then + want_cli[name] = true + elseif CONNINFO_LOCAL_PARTS[name] then + want_local[name] = true end - return "" end - - e.node = get_cli_output("node") - e.peer = get_cli_output("peer") - e.connector = get_cli_output("connector") - e.stun = get_cli_output("stun") - e.route = get_cli_output("route") - e.peer_center = get_cli_output("peer-center") - e.vpn_portal = get_cli_output("vpn-portal") - e.proxy = get_cli_output("proxy") - e.acl = get_cli_output("acl stats") - e.mapped_listener = get_cli_output("mapped-listener") - e.stats = get_cli_output("stats") - - -- 获取启动参数 - local cmdhandle = io.popen("cat /proc/$(pidof easytier-core)/cmdline 2>/dev/null | tr '\\0' ' '") - if cmdhandle then - e.cmdline = cmdhandle:read("*all") or "" - cmdhandle:close() - else - e.cmdline = "" + else + for name in pairs(CONNINFO_CLI_PARTS) do + if name ~= "stun" then + want_cli[name] = true + end end - - -- 检查是否使用配置文件启动 - if e.cmdline:match("%-%-config%-file") or e.cmdline:match("%-c%s+/") then - e.config_file = safe_read_file("/etc/easytier/config.toml") or "" - else - e.config_file = "" + for name in pairs(CONNINFO_LOCAL_PARTS) do + want_local[name] = true + end + end + + local process_status = luci.sys.exec("pgrep easytier-core") + + if process_status ~= "" then + -- 只运行被请求的分区,命中缓存的分区直接复用结果 + for name in pairs(want_cli) do + local cached = read_conninfo_cache(name) + if cached then + e[name] = cached + else + local handle = io.popen(clibin .. " " .. CONNINFO_CLI_PARTS[name] .. " 2>&1") + local result = "" + if handle then + result = handle:read("*all") or "" + handle:close() + end + e[name] = result + write_conninfo_cache(name, result) + end + end + + if want_local.cmdline then + -- 获取启动参数 + local cmdhandle = io.popen("cat /proc/$(pidof easytier-core)/cmdline 2>/dev/null | tr '\\0' ' '") + if cmdhandle then + e.cmdline = cmdhandle:read("*all") or "" + cmdhandle:close() + else + e.cmdline = "" + end + + -- 检查是否使用配置文件启动,配置内容随本次响应一起返回,前端无需再请求一次 + if e.cmdline:match("%-%-config%-file") or e.cmdline:match("%-c%s+/") then + e.config_file = safe_read_file("/etc/easytier/config.toml") or "" + else + e.config_file = "" + end end else local errMsg = i18n.translate("Error: Program not running! Please start the program and refresh") - e.node = errMsg - e.peer = errMsg - e.connector = errMsg - e.stun = errMsg - e.route = errMsg - e.peer_center = errMsg - e.vpn_portal = errMsg - e.proxy = errMsg - e.acl = errMsg - e.mapped_listener = errMsg - e.stats = errMsg - e.cmdline = errMsg + for name in pairs(want_cli) do + e[name] = errMsg + end + if want_local.cmdline then + e.cmdline = errMsg + end + -- 未运行时清掉缓存,避免下次启动后读到旧的错误输出 + luci.sys.call("rm -rf " .. CONNINFO_CACHE_DIR) end - + luci.http.prepare_content("application/json") luci.http.write_json(e) end @@ -682,6 +743,11 @@ function get_web_config() web_geoip_db = uci:get_first("easytier", "easytier", "web_geoip_db") or "", web_disable_registration = uci:get_first("easytier", "easytier", "web_disable_registration") or "0", web_allow_auto_create_user = uci:get_first("easytier", "easytier", "web_allow_auto_create_user") or "0", + -- EasyTier 2.7.0 新增的 web 参数 + web_heartbeat_min_response_ms = uci:get_first("easytier", "easytier", "web_heartbeat_min_response_ms") or "", + web_heartbeat_timeout_ms = uci:get_first("easytier", "easytier", "web_heartbeat_timeout_ms") or "", + web_gateway_peer_url = uci:get_first("easytier", "easytier", "web_gateway_peer_url") or "", + web_gateway_relay_data = uci:get_first("easytier", "easytier", "web_gateway_relay_data") or "0", web_weblog = uci:get_first("easytier", "easytier", "web_weblog") or "off", -- OIDC配置 web_oidc_enabled = uci:get_first("easytier", "easytier", "web_oidc_enabled") or "0", @@ -723,6 +789,11 @@ function save_web_config() local web_geoip_db = http.formvalue("web_geoip_db") or "" local web_disable_registration = http.formvalue("web_disable_registration") or "0" local web_allow_auto_create_user = http.formvalue("web_allow_auto_create_user") or "0" + -- EasyTier 2.7.0 新增的 web 参数 + local web_heartbeat_min_response_ms = http.formvalue("web_heartbeat_min_response_ms") or "" + local web_heartbeat_timeout_ms = http.formvalue("web_heartbeat_timeout_ms") or "" + local web_gateway_peer_url = http.formvalue("web_gateway_peer_url") or "" + local web_gateway_relay_data = http.formvalue("web_gateway_relay_data") or "0" local web_weblog = http.formvalue("web_weblog") or "off" -- OIDC local web_oidc_enabled = http.formvalue("web_oidc_enabled") or "0" @@ -756,6 +827,11 @@ function save_web_config() uci:set("easytier", "@easytier[0]", "web_geoip_db", web_geoip_db) uci:set("easytier", "@easytier[0]", "web_disable_registration", web_disable_registration) uci:set("easytier", "@easytier[0]", "web_allow_auto_create_user", web_allow_auto_create_user) + -- EasyTier 2.7.0 新增的 web 参数 + uci:set("easytier", "@easytier[0]", "web_heartbeat_min_response_ms", web_heartbeat_min_response_ms) + uci:set("easytier", "@easytier[0]", "web_heartbeat_timeout_ms", web_heartbeat_timeout_ms) + uci:set("easytier", "@easytier[0]", "web_gateway_peer_url", web_gateway_peer_url) + uci:set("easytier", "@easytier[0]", "web_gateway_relay_data", web_gateway_relay_data) uci:set("easytier", "@easytier[0]", "web_weblog", web_weblog) -- OIDC uci:set("easytier", "@easytier[0]", "web_oidc_enabled", web_oidc_enabled) diff --git a/luci-app-easytier/luasrc/model/cbi/easytier.lua b/luci-app-easytier/luasrc/model/cbi/easytier.lua index b6ddb1104..bfc7f1827 100644 --- a/luci-app-easytier/luasrc/model/cbi/easytier.lua +++ b/luci-app-easytier/luasrc/model/cbi/easytier.lua @@ -45,10 +45,11 @@ end web_config = s:taboption("general", Value, "web_config", translate("Web Server Address"), translate("Web configuration server address. (-w parameter)
    " - .. "For a self-hosted Web server, use the format: udp://server_address:22020/username
    " - .. "For the official Web server, use the format: username
    " + .. "Since EasyTier 2.7.0 a full URL is required, the token-only shorthand is no longer supported
    " + .. "Self-hosted Web server, use the format: udp://server_address:22020/username
    " + .. "Official Web server, use the format: udp://config-server.easytier.cn:22020/username
    " .. "Official Web Console: easytier.cn/web")) -web_config.placeholder = "admin" +web_config.placeholder = "udp://config-server.easytier.cn:22020/my-token" web_config:depends("etcmd", "web") network_name = s:taboption("general", Value, "network_name", translate("Network Name"), @@ -199,13 +200,53 @@ instance_name = s:taboption("privacy", Value, "instance_name", translate("Instan instance_name.placeholder = "default" instance_name:depends("etcmd", "etcmd") -vpn_portal = s:taboption("privacy", Value, "vpn_portal", translate("VPN Portal URL"), - translate("Defines the URL of the VPN portal, allowing other VPN clients to connect.
    " - .. "Example: wg://0.0.0.0:11011/10.14.14.0/24 means the VPN portal is a WireGuard server listening on vpn." - .. "example.com:11010, and the VPN clients are in the 10.14.14.0/24 network (--vpn-portal parameter)")) -vpn_portal.placeholder = "wg://0.0.0.0:11011/10.14.14.0/24" +vpn_portal = s:taboption("privacy", Value, "vpn_portal", translate("VPN Portal Listen Address"), + translate("WireGuard VPN portal listen address, allowing other VPN clients to connect.
    " + .. "Since EasyTier 2.7.0 only wg://HOST:PORT is accepted; the client network can no longer be " + .. "appended to the URL, please add each client in \"VPN Portal Clients\" below " + .. "(--vpn-portal parameter)")) +vpn_portal.placeholder = "wg://0.0.0.0:11011" +vpn_portal.validate = function(self, value, section) + value = value and tostring(value):match("^%s*(.-)%s*$") or "" + if value == "" then return value end + -- EasyTier 2.7.0 起不再允许把客户端网段写在 URL 里,这里提前拦截,避免核心启动失败 + if not (value:match("^wg://%d+%.%d+%.%d+%.%d+:%d+$") or value:match("^wg://%[[%x:]+%]:%d+$")) then + return nil, translate("Only wg://HOST:PORT is accepted since EasyTier 2.7.0, please fill the client network into \"VPN Portal Clients\"") + end + return value +end vpn_portal:depends("etcmd", "etcmd") +vpn_portal_clients = s:taboption("privacy", DynamicList, "vpn_portal_clients", translate("VPN Portal Clients"), + translate("One WireGuard client per entry in the form NAME=CIDR, for example client1=10.14.14.1/24.
    " + .. "NAME may contain letters, digits and dashes only; the address must be a usable host address " + .. "inside the client network. (--vpn-portal-client parameter)")) +vpn_portal_clients.placeholder = "client1=10.14.14.1/24" +vpn_portal_clients.validate = function(self, value, section) + if type(value) == "table" then + local i + for i = 1, #value do + local item = value[i] and tostring(value[i]):match("^%s*(.-)%s*$") or "" + if item ~= "" then + -- 与 easytier-core 的校验保持一致:名称只能是字母数字与短横线,地址必须是 主机/前缀 + if not item:match("^[%w][%w%-]*=[%d%.]+/%d+$") then + return nil, translate("Invalid client, expected NAME=CIDR, for example client1=10.14.14.1/24") + end + end + end + end + return value +end +vpn_portal_clients:depends("etcmd", "etcmd") + +vpn_portal_private_key = s:taboption("privacy", Value, "vpn_portal_private_key", translate("VPN Portal Private Key"), + translate("Base64 encoded 32-byte WireGuard private key of the portal.
    " + .. "Required since EasyTier 2.7.0, the portal will not start without it. " + .. "It is handed to easytier-core through an environment variable so that it never shows up in the process list. " + .. "(--vpn-portal-private-key parameter)")) +vpn_portal_private_key.password = true +vpn_portal_private_key:depends("etcmd", "etcmd") + mtu = s:taboption("privacy", Value, "mtu", translate("MTU"), translate("MTU for the TUN device, default is 1380 when unencrypted, and 1360 when encrypted")) mtu.datatype = "range(1,1500)" @@ -314,6 +355,23 @@ socks_port.datatype = "range(1,65535)" socks_port.placeholder = "1080" socks_port:depends("etcmd", "etcmd") +disable_upnp = s:taboption("privacy", Flag, "disable_upnp", translate("Disable UPnP"), + translate("EasyTier 2.7.0 tries to map listener ports through UPnP/NAT-PMP by default. " + .. "Enable this to turn the automatic port mapping off. (--disable-upnp parameter)")) +disable_upnp:depends("etcmd", "etcmd") + +stun_servers = s:taboption("privacy", DynamicList, "stun_servers", translate("STUN Servers"), + translate("Custom UDP STUN servers used for NAT type detection and hole punching. " + .. "Leave empty to use the built-in defaults. (--stun-servers parameter)")) +stun_servers.placeholder = "stun.miwifi.com:3478" +stun_servers:depends("etcmd", "etcmd") + +tcp_stun_servers = s:taboption("privacy", DynamicList, "tcp_stun_servers", translate("STUN Servers (TCP)"), + translate("Custom TCP STUN servers used for NAT type detection and hole punching. " + .. "Leave empty to use the built-in defaults. (--tcp-stun-servers parameter)")) +tcp_stun_servers.placeholder = "stun.miwifi.com:3478" +tcp_stun_servers:depends("etcmd", "etcmd") + disable_p2p = s:taboption("privacy", Flag, "disable_p2p", translate("Disable P2P"), translate("Disable P2P communication; only use nodes specified by -p to forward packets (--disable-p2p parameter)")) disable_p2p:depends("etcmd", "etcmd") @@ -322,6 +380,15 @@ p2p_only = s:taboption("privacy", Flag, "p2p_only", translate("P2P only"), translate("only communicate with peers that already establish p2p connection. (--p2p-only parameter)")) p2p_only:depends("etcmd", "etcmd") +lazy_p2p = s:taboption("privacy", Flag, "lazy_p2p", translate("Lazy P2P"), + translate("Only try to establish P2P connections when traffic actually needs to be forwarded. " + .. "(--lazy-p2p parameter)")) +lazy_p2p:depends("etcmd", "etcmd") + +need_p2p = s:taboption("privacy", Flag, "need_p2p", translate("Need P2P"), + translate("Require a P2P connection before forwarding traffic through relays. (--need-p2p parameter)")) +need_p2p:depends("etcmd", "etcmd") + disable_udp = s:taboption("privacy", Flag, "disable_udp", translate("Disable UDP"), translate("Disable UDP hole punching (--disable-udp-hole-punching parameter)")) disable_udp:depends("etcmd", "etcmd") @@ -373,6 +440,14 @@ relay_kcp = s:taboption("privacy", Flag, "relay_kcp", translate("Relay foreign n translate("If true, allow relay kcp packets from foreign network. default is false (not forward foreign network kcp packets). (--enable-relay-foreign-network-kcp parameter)")) relay_kcp:depends("etcmd", "etcmd") +disable_relay_quic = s:taboption("privacy", Flag, "disable_relay_quic", translate("Disable relay quic"), + translate("If true, disable relaying quic packets, avoiding consuming too much bandwidth. (--disable-relay-quic parameter)")) +disable_relay_quic:depends("etcmd", "etcmd") + +relay_quic = s:taboption("privacy", Flag, "relay_quic", translate("Relay foreign network quic"), + translate("If true, allow relaying quic packets from foreign networks. (--enable-relay-foreign-network-quic parameter)")) +relay_quic:depends("etcmd", "etcmd") + quic_proxy = s:taboption("privacy", Flag, "quic_proxy", translate("Enable QUIC Proxy"), translate("Proxy tcp streams with QUIC, improving the latency and throughput on the network with udp packet loss.
    " .. "All nodes in the virtual network must be using EasyTier version v2.3.2 or higher for this feature. " @@ -396,6 +471,12 @@ accept_dns = s:taboption("privacy", Flag, "accept_dns", translate("Enable Magic .. "Magic DNS will modify your system DNS settings, please enable with caution. (--accept-dns parameter)")) accept_dns:depends("etcmd", "etcmd") +tld_dns_zone = s:taboption("privacy", Value, "tld_dns_zone", translate("Magic DNS Zone"), + translate("Top level DNS zone used by Magic DNS, default is et.net. Only takes effect when Magic DNS is enabled. " + .. "(--tld-dns-zone parameter)")) +tld_dns_zone.placeholder = "et.net." +tld_dns_zone:depends("accept_dns", "1") + private_mode = s:taboption("privacy", Flag, "private_mode", translate("Enable Private Mode"), translate("When enabled, nodes with a different network name and password are not allowed to handshake or " .. "relay via this node. (--private-mode parameter)")) @@ -406,6 +487,17 @@ foreign_relay_bps_limit = s:taboption("privacy", Value, "foreign_relay_bps_limit .. "(--foreign-relay-bps-limit parameter)")) foreign_relay_bps_limit:depends("etcmd", "etcmd") +instance_recv_bps_limit = s:taboption("privacy", Value, "instance_recv_bps_limit", translate("Instance Receive Rate Limit"), + translate("the maximum inbound bps limit for this network instance, default is no limit. " + .. "unit: BPS (bytes per second). (--instance-recv-bps-limit parameter)")) +instance_recv_bps_limit:depends("etcmd", "etcmd") + +socket_mark = s:taboption("privacy", Value, "socket_mark", translate("Socket Mark"), + translate("Set SO_MARK on all underlay sockets, Linux only and requires CAP_NET_ADMIN. " + .. "Usually used together with policy routing. (--socket-mark parameter)")) +socket_mark.datatype = "range(0,4294967295)" +socket_mark:depends("etcmd", "etcmd") + extra_args = s:taboption("privacy", DynamicList, "extra_args", translate("Extra Parameters"), translate("Additional command-line arguments passed to the backend process")) extra_args.placeholder = "--tcp-whitelist=80" diff --git a/luci-app-easytier/luasrc/view/easytier/easytier_cli.htm b/luci-app-easytier/luasrc/view/easytier/easytier_cli.htm index ed9ae4eb1..47d564e76 100644 --- a/luci-app-easytier/luasrc/view/easytier/easytier_cli.htm +++ b/luci-app-easytier/luasrc/view/easytier/easytier_cli.htm @@ -900,18 +900,133 @@ var translations = { // Stats指标名称翻译 "traffic_control_bytes_rx": "<%:Traffic Control Bytes RX%>", "traffic_control_bytes_tx": "<%:Traffic Control Bytes TX%>", + "traffic_control_bytes_forwarded": "<%:Traffic Control Bytes Forwarded%>", "traffic_control_bytes_rx_by_instance": "<%:Traffic Control Bytes RX By Instance%>", "traffic_control_bytes_tx_by_instance": "<%:Traffic Control Bytes TX By Instance%>", "traffic_control_packets_rx": "<%:Traffic Control Packets RX%>", "traffic_control_packets_tx": "<%:Traffic Control Packets TX%>", + "traffic_control_packets_forwarded": "<%:Traffic Control Packets Forwarded%>", "traffic_control_packets_rx_by_instance": "<%:Traffic Control Packets RX By Instance%>", "traffic_control_packets_tx_by_instance": "<%:Traffic Control Packets TX By Instance%>", + "traffic_bytes_rx_by_instance": "<%:Traffic Bytes RX By Instance%>", + "traffic_bytes_tx_by_instance": "<%:Traffic Bytes TX By Instance%>", + "traffic_packets_rx_by_instance": "<%:Traffic Packets RX By Instance%>", + "traffic_packets_tx_by_instance": "<%:Traffic Packets TX By Instance%>", + "traffic_bytes_foreign_forward_rx": "<%:Traffic Bytes Foreign Forward RX%>", + "traffic_bytes_foreign_forward_tx": "<%:Traffic Bytes Foreign Forward TX%>", + "traffic_bytes_foreign_forward_forwarded": "<%:Traffic Bytes Foreign Forward Forwarded%>", + "traffic_packets_foreign_forward_rx": "<%:Traffic Packets Foreign Forward RX%>", + "traffic_packets_foreign_forward_tx": "<%:Traffic Packets Foreign Forward TX%>", + "traffic_packets_foreign_forward_forwarded": "<%:Traffic Packets Foreign Forward Forwarded%>", + "peer_rpc_errors": "<%:Peer RPC Errors%>", + "tcp_proxy_connect": "<%:TCP Proxy Connect%>", + "udp_broadcast_relay_packets_captured": "<%:UDP Broadcast Relay Packets Captured%>", + "udp_broadcast_relay_packets_forwarded": "<%:UDP Broadcast Relay Packets Forwarded%>", + "udp_broadcast_relay_packets_forward_failed": "<%:UDP Broadcast Relay Packets Forward Failed%>", + "udp_broadcast_relay_packets_ignored": "<%:UDP Broadcast Relay Packets Ignored%>", + "vpn_portal_client": "<%:VPN Portal Client%>", + "vpn_portal_client_bytes_rx": "<%:VPN Portal Client Bytes RX%>", + "vpn_portal_client_bytes_tx": "<%:VPN Portal Client Bytes TX%>", + "vpn_portal_client_packets_rx": "<%:VPN Portal Client Packets RX%>", + "vpn_portal_client_packets_tx": "<%:VPN Portal Client Packets TX%>", + // Stats 标签(Labels 列)字段翻译 + "compression_algo": "<%:Compression Algorithm%>", + "direction": "<%:Direction%>", + "dst_ip": "<%:Destination IP%>", + "mapped_dst_ip": "<%:Mapped Destination IP%>", + "error_type": "<%:Error Type%>", + "protocol": "<%:Protocol%>", "from_instance_id": "<%:From Instance ID%>", "to_instance_id": "<%:To Instance ID%>" }; var currentTab = 'node'; -var isRefreshing = false; + +// 连接信息分区表:id 为 TAB 元素后缀,key 为后端 api_conninfo?parts= 使用的分区名 +var connInfoSections = [ + {id: 'node', key: 'node'}, + {id: 'peer', key: 'peer'}, + {id: 'route', key: 'route'}, + {id: 'connector', key: 'connector'}, + {id: 'stun', key: 'stun'}, + {id: 'peercenter', key: 'peer_center'}, + {id: 'vpnportal', key: 'vpn_portal'}, + {id: 'proxy', key: 'proxy'}, + {id: 'acl', key: 'acl'}, + {id: 'mappedlistener', key: 'mapped_listener'}, + {id: 'stats', key: 'stats'}, + {id: 'cmdline', key: 'cmdline'} +]; + +// 已经成功加载过的分区,切换 TAB 时命中就不再请求 +var connInfoLoaded = {}; +// 待请求的分区队列:同一时刻只允许一个请求在跑,避免一次性起一堆 easytier-cli 进程 +var connInfoQueue = []; +var connInfoBusy = false; +// 最近一次响应中的配置文件内容,供"命令行"分区直接使用 +var lastConfigFile = null; + +// ACL 统计是 core 直接输出的英文结构体文本(core 侧没有多语言), +// 字段名单独放在这里精确匹配,避免 name / bytes 这类通用词影响其它卡片 +var aclFieldTranslations = { + "name": "<%:Name%>", + "prio": "<%:Priority%>", + "action": "<%:Action%>", + "enabled": "<%:Enabled%>", + "proto": "<%:Protocol%>", + "ports": "<%:Ports%>", + "src_ports": "<%:Source Ports%>", + "src_ips": "<%:Source IPs%>", + "dst_ips": "<%:Destination IPs%>", + "stateful": "<%:Stateful%>", + "rate": "<%:Rate Limit%>", + "burst": "<%:Burst Limit%>", + "pkts": "<%:Packets%>", + "bytes": "<%:Bytes%>", + "src": "<%:Source Address%>", + "dst": "<%:Destination Address%>", + "state": "<%:State%>", + "created": "<%:Created%>", + "last_seen": "<%:Last Seen%>" +}; + +// ACL 规则里的枚举取值 +var aclValueTranslations = { + "Allow": "<%:Allow%>", + "Drop": "<%:Drop (no response)%>", + "Noop": "<%:Noop%>", + "Any": "<%:Any%>", + "Unspecified": "<%:Unspecified%>", + "New": "<%:New%>", + "Established": "<%:Established%>", + "Related": "<%:Related%>", + "Invalid": "<%:Invalid%>", + "": "<%:Default / None%>" +}; + +// 翻译 ACL 统计文本:先翻译枚举取值,再翻译字段名 +function translateAclStats(content) { + var text = content; + + text = text.replace(/\baction: (Noop|Allow|Drop)\b/g, function(m, v) { + return 'action: ' + (aclValueTranslations[v] || v); + }); + text = text.replace(/\bproto: (Unspecified|Icmpv6|Tcp|Udp|Icmp|Any)\b/g, function(m, v) { + return 'proto: ' + (aclValueTranslations[v] || v); + }); + text = text.replace(/\bstate: (New|Established|Related|Invalid)\b/g, function(m, v) { + return 'state: ' + (aclValueTranslations[v] || v); + }); + + text = text.replace(/\b(name|prio|action|enabled|proto|ports|src_ports|src_ips|dst_ips|stateful|rate|burst|pkts|bytes|src|dst|state|created|last_seen): /g, + function(m, field) { + return (aclFieldTranslations[field] || field) + ': '; + }); + + text = text.replace(//g, aclValueTranslations[''] || ''); + + return text; +} function tr(text) { return translations[text] || text; @@ -996,37 +1111,16 @@ function parseTable(content, tabName) { var html = renderCopyablePre(content); // 检查是否使用配置文件启动 - if (content.indexOf('--config-file') !== -1 || content.match(/-c\s+\//)) { - XHR.get('<%=url("admin/vpn/easytier/api_conninfo")%>', null, function(x, data) { - if (data && data.config_file) { - var configDiv = document.createElement('div'); - configDiv.className = 'et-pre'; - configDiv.style.marginTop = '16px'; - configDiv.style.borderTop = '2px solid var(--border-color)'; - configDiv.style.paddingTop = '16px'; - - var title = document.createElement('div'); - title.style.fontWeight = '600'; - title.style.marginBottom = '8px'; - title.style.color = 'var(--primary-color)'; - title.textContent = tr('Configuration File Content') + ':'; - - var contentDiv = document.createElement('pre'); - contentDiv.style.margin = '0'; - contentDiv.style.whiteSpace = 'pre-wrap'; - contentDiv.style.overflowWrap = 'anywhere'; - contentDiv.style.wordBreak = 'break-word'; - contentDiv.textContent = data.config_file; - - configDiv.appendChild(title); - configDiv.appendChild(contentDiv); - - var container = document.getElementById('tab_cmdline'); - if (container) { - container.appendChild(configDiv); - } - } - }); + // 配置文件内容随 cmdline 分区一起返回,这里不再单独请求一次 api_conninfo + if ((content.indexOf('--config-file') !== -1 || content.match(/-c\s+\//)) && lastConfigFile) { + html += '
    ' + + '
    ' + + escapeHtml(tr('Configuration File Content')) + ':' + + '
    ' + + '
    ' +
    +					escapeHtml(lastConfigFile) +
    +				'
    ' + + '
    '; } return html; @@ -1091,7 +1185,12 @@ function parseTable(content, tabName) { translatedContent = translatedContent.replace(/###############\s*([^#]+)\s*###############/g, function(match, p1) { return '############### ' + tr(p1.trim()) + ' ###############'; }); - + + // ACL 统计的结构体字段名与枚举取值按精确规则翻译 + if (tabName === 'acl') { + translatedContent = translateAclStats(translatedContent); + } + return '
    ' + (translatedContent.indexOf(''; } @@ -1179,96 +1278,157 @@ function parseTable(content, tabName) { } function switchTab(tabName) { - if (isRefreshing) return; - var tabs = document.querySelectorAll('.et-tab'); var contents = document.querySelectorAll('.et-tab-content'); - + tabs.forEach(function(tab) { tab.classList.remove('active'); }); contents.forEach(function(content) { content.classList.remove('active'); }); - + var activeTab = document.querySelector('.et-tab[data-tab="' + tabName + '"]'); var activeContent = document.getElementById('tab_' + tabName); - + if (activeTab && activeContent) { activeTab.classList.add('active'); activeContent.classList.add('active'); currentTab = tabName; + // 按需加载:只请求当前显示的分区,没有加载过才发起请求 + queueConnInfo([tabName], false); + } +} + +// 把 TAB 名换算成后端分区名 +function connInfoKeyOfTab(tabName) { + for (var i = 0; i < connInfoSections.length; i++) { + if (connInfoSections[i].id === tabName) { + return connInfoSections[i].key; + } + } + return null; +} + +// 分区加载失败(超时/网络错误)时给出提示,避免加载动画一直转下去 +function reportConnInfoError(keys) { + for (var i = 0; i < keys.length; i++) { + var sec = null; + for (var j = 0; j < connInfoSections.length; j++) { + if (connInfoSections[j].key === keys[i]) sec = connInfoSections[j]; + } + if (!sec) continue; + var el = document.getElementById('tab_' + sec.id); + if (el) { + el.innerHTML = '
    <%:Loading timed out. Please click Refresh to try again.%>
    '; + el.setAttribute('data-loaded', '1'); + } + } +} + +// 把分区加入请求队列 +function queueConnInfo(tabNames, force) { + var changed = false; + for (var i = 0; i < tabNames.length; i++) { + var key = connInfoKeyOfTab(tabNames[i]); + if (!key) continue; + if (!force && connInfoLoaded[key]) continue; + if (connInfoQueue.indexOf(key) !== -1) continue; + connInfoQueue.push(key); + changed = true; + } + if (changed) { + pumpConnInfoQueue(); + } +} + +// 依次请求队列里的分区,同一时刻只允许一个请求在跑, +// 避免一次起多个 easytier-cli 进程把路由器拖慢 +function pumpConnInfoQueue() { + if (connInfoBusy || connInfoQueue.length === 0) return; + + var keys = connInfoQueue; + connInfoQueue = []; + connInfoBusy = true; + setRefreshBtnBusy(true); + + var xhr = new XMLHttpRequest(); + var finished = false; + + var onDone = function(data) { + if (finished) return; + finished = true; + connInfoBusy = false; + setRefreshBtnBusy(false); + if (data) { + if (typeof data.config_file === 'string') { + lastConfigFile = data.config_file; + } + for (var i = 0; i < keys.length; i++) { + connInfoLoaded[keys[i]] = true; + } + updateConnInfo(data); + } else { + reportConnInfoError(keys); + } + // 继续处理下一个分区 + pumpConnInfoQueue(); + }; + + xhr.open('GET', '<%=url("admin", "vpn", "easytier", "api_conninfo")%>?parts=' + encodeURIComponent(keys.join(',')), true); + xhr.timeout = 20000; + xhr.onreadystatechange = function() { + if (xhr.readyState !== 4) return; + var data = null; + if (xhr.status === 200) { + try { + data = JSON.parse(xhr.responseText); + } catch (e) { + data = null; + } + } + onDone(data); + }; + xhr.ontimeout = function() { onDone(null); }; + xhr.onerror = function() { onDone(null); }; + xhr.onabort = function() { onDone(null); }; + xhr.send(); +} + +function setRefreshBtnBusy(busy) { + var btn = document.getElementById('et_conninfo_refresh'); + if (!btn) return; + var btnText = btn.querySelector('span'); + btn.disabled = busy; + if (busy) { + btn.classList.add('loading'); + if (btnText) btnText.textContent = '<%:Refreshing...%>'; + } else { + btn.classList.remove('loading'); + if (btnText) btnText.textContent = '<%:Refresh%>'; } } function refreshConnInfo() { - if (isRefreshing) return; - - isRefreshing = true; - var btn = document.getElementById('et_conninfo_refresh'); - var btnText = btn.querySelector('span'); - var originalText = btnText.textContent; - - btn.disabled = true; - btn.classList.add('loading'); - btnText.textContent = '<%:Refreshing...%>'; - - var xhr = new XMLHttpRequest(); - xhr.open('GET', '<%=url("admin", "vpn", "easytier", "api_conninfo")%>', true); - xhr.onreadystatechange = function() { - if (xhr.readyState === 4) { - isRefreshing = false; - btn.disabled = false; - btn.classList.remove('loading'); - btnText.textContent = originalText; - - if (xhr.status === 200) { - try { - var data = JSON.parse(xhr.responseText); - updateConnInfo(data); - } catch (e) { - console.error('Failed to parse response:', e); - } - } + // 手动刷新:立即刷新当前显示的分区,其余分区标记失效,切过去时再重新加载, + // 这样点一次刷新不会同时起十几个 easytier-cli 进程 + var activeKey = connInfoKeyOfTab(currentTab); + for (var i = 0; i < connInfoSections.length; i++) { + if (connInfoSections[i].key !== activeKey) { + delete connInfoLoaded[connInfoSections[i].key]; } - }; - xhr.onerror = function() { - isRefreshing = false; - btn.disabled = false; - btn.classList.remove('loading'); - btnText.textContent = originalText; - }; - xhr.onabort = function() { - isRefreshing = false; - btn.disabled = false; - btn.classList.remove('loading'); - btnText.textContent = originalText; - }; - xhr.send(); + } + queueConnInfo([currentTab], true); } function updateConnInfo(data) { if (!data) return; - - var sections = [ - {id: 'node', key: 'node'}, - {id: 'peer', key: 'peer'}, - {id: 'route', key: 'route'}, - {id: 'connector', key: 'connector'}, - {id: 'stun', key: 'stun'}, - {id: 'peercenter', key: 'peer_center'}, - {id: 'vpnportal', key: 'vpn_portal'}, - {id: 'proxy', key: 'proxy'}, - {id: 'acl', key: 'acl'}, - {id: 'mappedlistener', key: 'mapped_listener'}, - {id: 'stats', key: 'stats'}, - {id: 'cmdline', key: 'cmdline'} - ]; - - sections.forEach(function(sec) { + + connInfoSections.forEach(function(sec) { var el = document.getElementById('tab_' + sec.id); if (el && data[sec.key] !== undefined) { el.innerHTML = parseTable(data[sec.key], sec.id); + el.setAttribute('data-loaded', '1'); } }); } @@ -1290,9 +1450,16 @@ document.addEventListener('DOMContentLoaded', function() { // 加载贡献者 loadContributors(); - - // 初始加载数据 - refreshConnInfo(); + + // 初始只加载当前显示的分区,其余分区等切过去时再按需请求 + queueConnInfo([currentTab], false); + + // 定时刷新:只刷新当前显示的分区,不再每次把十几个 easytier-cli 全部跑一遍。 + // 间隔取 10 秒,低性能设备上也能跑得过来(单次只起一个进程) + window.setInterval(function() { + if (document.hidden) return; + queueConnInfo([currentTab], true); + }, 10000); }); // 贡献者轮播管理 (ES5) @@ -1506,11 +1673,6 @@ function loadContributors() { contributorsCarousel.init(); } -// 自动刷新 -XHR.poll(5, '<%=url("admin", "vpn", "easytier", "api_conninfo")%>', null, function(x, data) { - if (!isRefreshing && data) { - updateConnInfo(data); - } -}); +// 自动刷新已改为只刷新当前显示的分区,见上方 DOMContentLoaded 中的定时器 //]]> diff --git a/luci-app-easytier/luasrc/view/easytier/easytier_upload.htm b/luci-app-easytier/luasrc/view/easytier/easytier_upload.htm index eb0137442..f3ed04835 100644 --- a/luci-app-easytier/luasrc/view/easytier/easytier_upload.htm +++ b/luci-app-easytier/luasrc/view/easytier/easytier_upload.htm @@ -383,7 +383,7 @@ local translate = i18n.translate - +
    - - - + + + ]] end diff --git a/minigate/luasrc/model/cbi/minigate/ddns.lua b/minigate/luasrc/model/cbi/minigate/ddns.lua index 346bf648b..3e2d47c30 100644 --- a/minigate/luasrc/model/cbi/minigate/ddns.lua +++ b/minigate/luasrc/model/cbi/minigate/ddns.lua @@ -1,12 +1,18 @@ local m,s,o local sys=require"luci.sys" +local function esc(v) + v=tostring(v or "") + v=v:gsub("&","&"):gsub("<","<"):gsub(">",">") + v=v:gsub('"',"""):gsub("'","'") + return v +end + m=Map("minigate","MiniGate - 动态DNS","支持多域名、IPv4/IPv6双栈。保存后立即同步。需先启用全局开关。") +-- 只重新同步 DDNS,不重启反向代理和登录防护 m.on_after_commit=function(self) - sys.call("/bin/sh /usr/lib/minigate/ddns.sh force >/dev/null 2>&1 &") - local en=self.uci:get("minigate","global","enabled") - if en=="1"then sys.call("/etc/init.d/minigate restart >/dev/null 2>&1 &") end + sys.call("/bin/sh /usr/lib/minigate/after_commit.sh ddns >/dev/null 2>&1") end s=m:section(TypedSection,"ddns","DDNS 记录") @@ -67,6 +73,7 @@ o:depends({ip_source="url",ip_version="ipv6"}) o:depends({ip_source="url",ip_version="dual"}) o=s:option(Value,"check_interval","间隔(秒)"); o.datatype="uinteger"; o.default="300" +o.description="最小 300 秒。定时任务每 5 分钟检查一次,实际间隔按 5 分钟向上取整。" -- 状态+手动同步 o=s:option(DummyValue,"_info","运行状态"); o.rawhtml=true @@ -85,15 +92,15 @@ o.cfgvalue=function(self,section) elseif st=="partial"then h='⚠ 部分成功' elseif st=="error"then - h='✗ '..(msg~=""and msg or"异常")..'' + h='✗ '..esc(msg~=""and msg or"异常")..'' else h='未同步' end -- 显示 IP 地址 - if ip~=""then h=h..'
    A: '..ip..'' end - if ip6~=""then h=h..'
    AAAA: '..ip6..'' end - if msg~=""and st~="error"then h=h..'
    '..msg..'' end - if lu~=""then h=h..'
    更新: '..lu..'' end - if ns~=""then h=h..'
    下次: '..ns..'' end + if ip~=""then h=h..'
    A: '..esc(ip)..'' end + if ip6~=""then h=h..'
    AAAA: '..esc(ip6)..'' end + if msg~=""and st~="error"then h=h..'
    '..esc(msg)..'' end + if lu~=""then h=h..'
    更新: '..esc(lu)..'' end + if ns~=""then h=h..'
    下次: '..esc(ns)..'' end h=h..'
    ' @@ -110,7 +117,25 @@ s=m:section(NamedSection,"global","global"); s.anonymous=true o=s:option(DummyValue,"_js"," "); o.rawhtml=true o.cfgvalue=function() local url=luci.dispatcher.build_url("admin/services/minigate/ddns_sync") - return'' + return[[]] end return m diff --git a/minigate/luasrc/model/cbi/minigate/general.lua b/minigate/luasrc/model/cbi/minigate/general.lua index 96240ecbe..a87c2b46a 100644 --- a/minigate/luasrc/model/cbi/minigate/general.lua +++ b/minigate/luasrc/model/cbi/minigate/general.lua @@ -5,11 +5,7 @@ m = Map("minigate", "minigate", "轻量级网关管理:动态域名解析(IP m.on_after_commit = function(self) local en = self.uci:get("minigate", "global", "enabled") - if en == "1" then - sys.call("/etc/init.d/minigate restart >/dev/null 2>&1 &") - else - sys.call("/etc/init.d/minigate stop >/dev/null 2>&1 &") - end + sys.call("/bin/sh /usr/lib/minigate/after_commit.sh " .. (en == "1" and "restart" or "stop") .. " >/dev/null 2>&1") end s = m:section(NamedSection, "global", "global", "服务状态") @@ -100,7 +96,20 @@ o.cfgvalue = function() :root:not([data-darkmode]) .mg-map{--mg-danger:#e58a84;--mg-danger-hover:rgba(229,138,132,.1);--mg-danger-border:rgba(229,138,132,.45)} } @media(max-width:900px){.mg-status-grid{grid-template-columns:1fr}.mg-card{min-height:auto}} -@media(max-width:640px){.mg-page-head{flex-direction:column;gap:12px}.mg-title-action{width:100%;margin-top:0}.mg-uninstall-trigger{width:100%}.mg-modal{padding:12px}.mg-modal-dialog{max-height:88vh}.mg-modal-actions{display:grid;grid-template-columns:1fr 1fr}.mg-modal-actions button{width:100%;min-width:0}} +@media(max-width:640px){.mg-page-head{flex-direction:column;gap:12px}.mg-title-action{width:100%;margin-top:0}.mg-uninstall-trigger{width:100%}.mg-modal{padding:12px}.mg-modal-dialog{max-height:88vh}.mg-modal-actions{display:grid;grid-template-columns:1fr 1fr}.mg-modal-actions button{width:100%;min-width:0} +.mg-table{min-width:0}.mg-table thead{display:none} +.mg-table tbody tr{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;padding:12px 14px;border-bottom:1px solid var(--mg-border-soft)} +.mg-table tbody tr:nth-child(even){background:var(--mg-surface-alt)} +.mg-table td{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0;padding:2px 0;border:0!important;background:transparent!important;overflow-wrap:anywhere} +.mg-table td:nth-child(2){order:1;justify-content:flex-start} +.mg-table td:nth-child(1){order:2;justify-content:flex-end} +.mg-table td:nth-child(n+3){order:3;grid-column:1/-1} +.mg-table td:nth-child(3):before{content:"归属地"} +.mg-table td:nth-child(4):before{content:"最后访问"} +.mg-table td:nth-child(5):before{content:"域名"} +.mg-table td:nth-child(6):before{content:"次数"} +.mg-table td:before{flex:0 0 auto;color:var(--mg-text-muted)} +}
    @@ -194,9 +203,29 @@ o.cfgvalue = function() ]] end diff --git a/minigate/luasrc/model/cbi/minigate/login_guard.lua b/minigate/luasrc/model/cbi/minigate/login_guard.lua index 393d7079c..13a73567d 100644 --- a/minigate/luasrc/model/cbi/minigate/login_guard.lua +++ b/minigate/luasrc/model/cbi/minigate/login_guard.lua @@ -7,7 +7,7 @@ m = Map("minigate", "登录防护 (Login Guard)", "适用于 SSH 端口被映射到公网、防爆破。LAN 私有 IP 自动豁免。") m.on_after_commit = function(self) - sys.call("/etc/init.d/minigate reload >/dev/null 2>&1 &") + sys.call("/bin/sh /usr/lib/minigate/after_commit.sh reload >/dev/null 2>&1") end -- ============== 实时状态 ============== @@ -83,7 +83,7 @@ o.cfgvalue = function() local initial_watching = '
    无
    ' if #watching_rows > 0 then initial_watching = - '' .. + '
    IP 地址归属地失败次数最近访问时间距首次失败
    ' .. table.concat(watching_rows, "") .. '
    IP 地址归属地失败次数最近访问时间距首次失败
    ' end @@ -121,6 +121,22 @@ o.cfgvalue = function() :root:not([data-darkmode]) .lg-wrap{--lg-success:#78c98d;--lg-warning:#d7a64a;--lg-danger:#e58a84;--lg-muted:#9aa7b3;--lg-primary:#8e8add;--lg-surface:#171e26;--lg-surface-muted:#131a22;--lg-surface-alt:#19212a;--lg-surface-hover:#202a34;--lg-control:#111820;--lg-text:#d5dbe3;--lg-text-muted:#9aa7b3;--lg-border:rgba(148,163,184,.18);--lg-border-soft:rgba(148,163,184,.12);--lg-code-bg:#253142;--lg-danger-bg:#42303a;--lg-danger-text:#efb2b0;--lg-shadow:none} } @media(max-width:900px){.lg-summary{grid-template-columns:1fr}.lg-card{min-height:auto}.lg-input{width:100%;min-width:0}.lg-tools{width:100%}} +@media(max-width:640px){ +.lg-table{min-width:0}.lg-table thead{display:none} +.lg-table tbody tr{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;padding:12px 14px;border-bottom:1px solid var(--lg-border-soft)} +.lg-table tbody tr:nth-child(even){background:var(--lg-surface-alt)} +.lg-table td{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0;padding:2px 0;border:0!important;background:transparent!important;overflow-wrap:anywhere} +.lg-table td:nth-child(1){justify-content:flex-start} +.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-tools .lg-btn{flex:1} +}
    @@ -181,6 +197,28 @@ var _lgGeoCache={}; var _lgGeoPending={}; var _lgBanLimit=5; var _lgWatchLimit=5; +var _lgBanGen=0; +var _lgWatchGen=0; +var _lgReq=0; +var _lgLoading=false; + +// LuCI 的 XHR 在网络错误时不会调用回调;统一回调一次,避免按钮和刷新卡住。 +function lgRequest(method,url,args,cb){ + var done=false; + function finish(x,d){if(done)return;done=true;cb(x,d);} + var p=XHR[method](url,args,finish); + if(p&&typeof p.catch==='function')p.catch(function(){finish(null,null);}); +} + +// LuCI 把空 Lua 表编码成 {},按空列表处理 +function lgList(value){ + if(Array.isArray(value))return value; + return value&&typeof value==='object'&&Object.keys(value).length===0?[]:null; +} + +function lgGeoHtml(loc){ + return loc?''+lgEscHtml(loc)+'':'查询中...'; +} function lgEscHtml(s){ return String(s||'').replace(/[&<>"']/g,function(c){ @@ -201,11 +239,11 @@ function lgQueryGeo(ip,cb){ return; } _lgGeoPending[ip]=[cb]; - XHR.get(']] .. gu .. [[',{ip:ip},function(x,d){ + lgRequest('get',']] .. gu .. [[',{ip:ip},function(x,d){ var loc=(d&&d.geo)?d.geo:'未知'; var list=_lgGeoPending[ip]||[]; delete _lgGeoPending[ip]; - _lgGeoCache[ip]=loc; + if(d&&d.geo)_lgGeoCache[ip]=loc; for(var i=0;i'; - setTimeout(next,150); + if(ge)ge.innerHTML=lgGeoHtml(loc); + setTimeout(next,cached?0:150); }); } next(); @@ -245,9 +284,9 @@ function fmtDuration(s){ function lgUnban(ip,btn){ if(!confirm('确认解封 '+ip+' ?'))return; btn.disabled=true; btn.textContent='...'; - XHR.get(']] .. uu .. [[',{ip:ip},function(x,d){ + lgRequest('post',']] .. uu .. [[',{ip:ip},function(x,d){ if(d&&d.success){lgRefresh();} - else{btn.disabled=false;btn.textContent='解封';alert('失败');} + else{btn.disabled=false;btn.textContent='解封';alert((d&&d.error)||'解封失败,请刷新页面后重试');} }); } @@ -255,31 +294,33 @@ function lgManualBan(){ var ip=document.getElementById('lg-ban-input').value.trim(); if(!ip){alert('请输入 IP');return;} if(!ip.match(/^\d+\.\d+\.\d+\.\d+$/)){alert('IP 格式不对');return;} - XHR.get(']] .. bu .. [[',{ip:ip},function(x,d){ + lgRequest('post',']] .. bu .. [[',{ip:ip},function(x,d){ if(d&&d.success){ document.getElementById('lg-ban-input').value=''; lgRefresh(); - }else alert('失败'); + }else alert((d&&d.error)||'封禁失败,请刷新页面后重试'); }); } function lgFlushAll(){ if(!confirm('确认清空所有封禁?此操作不可撤销。'))return; - XHR.get(']] .. fu .. [[',null,function(x,d){ + lgRequest('post',']] .. fu .. [[',null,function(x,d){ if(d&&d.success)lgRefresh(); + else alert('清空失败,请刷新页面后重试'); }); } function lgRenderWatching(watching,threshold){ var wEl=document.getElementById('lg-watching-list'); if(!wEl)return; - watching=watching||[]; + var gen=++_lgWatchGen; + watching=lgList(watching)||[]; threshold=Number(threshold)||3; if(watching.length==0){ wEl.innerHTML='
    无
    '; return; } - var h2=''; + var h2='
    '; h2+=''; for(var j=0;j=66?lgColor('danger'):(pct>=33?lgColor('warning'):lgColor('muted')); h2+=''; h2+=''; - h2+=''; + h2+=''; h2+=''; h2+=''; h2+=''; @@ -302,16 +343,14 @@ function lgRenderWatching(watching,threshold){ // 与总览“访问记录”保持一致:逐个查询归属地,避免并发太多。 var qi=0; function nextWatchingGeo(){ + if(gen!==_lgWatchGen)return; + while(qi=watching.length)return; var idx=qi;qi++; - var row=watching[idx]||{}; - if(!row.ip){ - setTimeout(nextWatchingGeo,0); - return; - } - lgQueryGeo(row.ip,function(loc){ + lgQueryGeo(watching[idx].ip,function(loc){ + if(gen!==_lgWatchGen)return; var ge=document.getElementById('lg-watch-geo-'+idx); - if(ge) ge.innerHTML=''+lgEscHtml(loc)+''; + if(ge) ge.innerHTML=lgGeoHtml(loc); setTimeout(nextWatchingGeo,150); }); } @@ -337,30 +376,35 @@ function lgApplyStatus(d){ // 已封禁列表 var listEl=document.getElementById('lg-banned-list'); - if(!d.banned||d.banned.length==0){ + var banned=lgList(d.banned)||[]; + var banGen=++_lgBanGen; + if(banned.length==0){ listEl.innerHTML='
    暂无封禁
    '; }else{ - var h='
    IP 地址归属地失败次数最近访问时间距首次失败
    '+lgEscHtml(ip)+'查询中...'+lgGeoHtml(_lgGeoCache[ip])+''+count+' / '+threshold+''+lgEscHtml(lastSeen)+''+fmtDuration(age)+'
    '; + var h='
    '; h+=''; - for(var i=0;i'; - h+=''; + h+=''; h+=''; - h+=''; + h+=''; h+=''; } h+='
    IP 地址归属地剩余时间操作
    查询中...'+lgGeoHtml(_lgGeoCache[b.ip])+''+fmtDuration(b.remaining)+'
    '; listEl.innerHTML=h; - // 异步查归属地 + // 异步查询未缓存的归属地;列表重绘后旧队列停止写入 var qi=0; (function next(){ - if(qi>=d.banned.length)return; + if(banGen!==_lgBanGen)return; + while(qi=banned.length)return; var idx=qi; qi++; - lgQueryGeo(d.banned[idx].ip,function(loc){ + lgQueryGeo(banned[idx].ip,function(loc){ + if(banGen!==_lgBanGen)return; var ge=document.getElementById('lg-geo-'+idx); - if(ge) ge.innerHTML=''+lgEscHtml(loc)+''; + if(ge) ge.innerHTML=lgGeoHtml(loc); setTimeout(next,150); }); })(); @@ -376,7 +420,11 @@ function lgApplyStatus(d){ } function lgRefresh(){ - XHR.get(']] .. lu .. [[',{ban_limit:_lgBanLimit,watch_limit:_lgWatchLimit},function(x,d){ + var req=++_lgReq; + _lgLoading=true; + lgRequest('get',']] .. lu .. [[',{ban_limit:_lgBanLimit,watch_limit:_lgWatchLimit},function(x,d){ + if(req!==_lgReq)return; + _lgLoading=false; lgApplyStatus(d); }); } @@ -406,7 +454,9 @@ if(lgWatchLimitSel){ lgApplyInitialWatchingLimit(); lgQueryInitialWatchingGeo(); lgRefresh(); -setInterval(lgRefresh,8000); +// 页面在后台时暂停刷新,切回前台立即刷新 +setInterval(function(){if(!document.hidden&&!_lgLoading)lgRefresh();},8000); +document.addEventListener('visibilitychange',function(){if(!document.hidden)lgRefresh();}); ]] end diff --git a/minigate/luasrc/model/cbi/minigate/proxy.lua b/minigate/luasrc/model/cbi/minigate/proxy.lua index 81bf542e5..6a9f64647 100644 --- a/minigate/luasrc/model/cbi/minigate/proxy.lua +++ b/minigate/luasrc/model/cbi/minigate/proxy.lua @@ -5,7 +5,7 @@ local uc = require "luci.model.uci".cursor() m = Map("minigate", "MiniGate - 反向代理", "保存后自动生效。") m.on_after_commit = function(self) - sys.call("sleep 1 && /bin/sh /usr/lib/minigate/proxy.sh reload >/dev/null 2>&1 &") + sys.call("/bin/sh /usr/lib/minigate/after_commit.sh proxy >/dev/null 2>&1") end local ddns_domains = {} diff --git a/minigate/luasrc/view/minigate/log.htm b/minigate/luasrc/view/minigate/log.htm index 33ee13c72..a772704a0 100644 --- a/minigate/luasrc/view/minigate/log.htm +++ b/minigate/luasrc/view/minigate/log.htm @@ -12,9 +12,10 @@ var lt; var lu='<%=luci.dispatcher.build_url("admin/services/minigate/get_log")%>'; function rl(){var s=document.getElementById('ls').value;var c=document.getElementById('lc'); -XHR.get(lu,{source:s},function(x,d){if(d&&d.log){c.textContent=d.log||'(暂无)';c.scrollTop=c.scrollHeight}else c.textContent='(无法读取)'})} -function sa(){if(lt)clearInterval(lt);if(document.getElementById('ar').checked)lt=setInterval(rl,5000)} +XHR.get(lu,{source:s},function(x,d){if(d&&typeof d.log==='string'){c.textContent=d.log||'(暂无)';c.scrollTop=c.scrollHeight}else c.textContent='(无法读取)'})} +function sa(){if(lt)clearInterval(lt);if(document.getElementById('ar').checked)lt=setInterval(function(){if(!document.hidden)rl()},5000)} document.getElementById('ar').addEventListener('change',sa); +document.addEventListener('visibilitychange',function(){if(!document.hidden&&document.getElementById('ar').checked)rl()}); document.getElementById('ls').addEventListener('change',rl); rl();sa(); diff --git a/minigate/releases/v2026.10.14-1.md b/minigate/releases/v2026.10.14-1.md new file mode 100644 index 000000000..fe1f529df --- /dev/null +++ b/minigate/releases/v2026.10.14-1.md @@ -0,0 +1,26 @@ +## 修复内容 + +- 卸载:LuCI 一键卸载和命令行卸载都会在停止服务后被自身的结束信号中断,程序和 LuCI 页面没有删除;现在只结束 minigate 自身运行的脚本,卸载可以完整执行。保留数据的临时备份改为仅 root 可读。 +- SSL 证书:每日自动续期替换证书后会重载 Nginx,避免运行中的进程继续使用旧证书直至过期;总览中的证书过期时间同步更新。 +- 登录防护:修复定时检查逻辑写反的问题,正常运行时不再每 5 分钟改写一次封禁文件;防火墙重载导致封禁规则丢失后,会立即恢复已有封禁。 +- 动态 DNS:获取公网 IP 和调用 Cloudflare 的请求都增加总超时,避免卡住的连接让定时任务不断堆积;同步过程串行执行,避免重复创建 Cloudflare 记录;Cloudflare 令牌不再出现在进程参数中;定时同步与手动同步共用同一份 IP 缓存。 +- 动态 DNS「间隔(秒)」设置现在生效:每条记录按自己的间隔检查(最小 300 秒,按 5 分钟取整);未到期时不写日志、不写配置;页面显示的下次同步时间与实际一致。 +- 保存设置:LuCI 会在配置真正提交前执行保存钩子,后台动作可能读到旧配置;现在等配置提交后再应用。保存动态 DNS 页面只重新同步 DDNS,不再重启反向代理和登录防护。 +- 日志:访问日志、Nginx 错误日志和登录防护日志设置大小上限,避免长期运行占满内存;总览的访问记录继续显示最近的访客。 +- 安全:封禁、解封、清空封禁、证书安装与签发、DDNS 手动同步改为必须 POST 并校验 CSRF 令牌,并增加请求参数校验。 + +## 界面优化 + +- 访问记录、封禁列表为空时正确显示“暂无”,读取失败时显示失败提示,不再出现“undefined”或只有表头的空表格。 +- 已查询过的归属地直接显示,定时刷新不再闪回“查询中...”。 +- 页面在后台标签时暂停轮询,切回前台立即刷新;网络出错时按钮不会一直处于禁用状态。 +- 手机端访问记录和封禁列表改为卡片布局;证书页提示框适配深色主题;证书签发请求超时时说明可能仍在后台进行;日志为空时显示“(暂无)”。 + +## 安装与兼容性 + +- opkg 安装包:`minigate_2026.10.14-1_all.ipk`。 +- 源码安装包:`minigate-v2026.10.14-1-src.tar.gz`。 +- 继续附带 `luci-app-minigate-v2026.10.14-1-src.tar.gz`,兼容旧更新器识别的源码包名。 +- 更新后 MiniGate 重启时会新增每 10 分钟一次的日志大小检查定时任务。 +- 更新前已经打开的 LuCI 页面请刷新后再操作,否则封禁、同步、签发等按钮会因接口改为 POST 而提示失败。 +- 本次发布不会自动安装到软路由;请在 LuCI 中确认更新内容后手动更新。 diff --git a/minigate/releases/v2026.10.14-2.md b/minigate/releases/v2026.10.14-2.md new file mode 100644 index 000000000..58308c143 --- /dev/null +++ b/minigate/releases/v2026.10.14-2.md @@ -0,0 +1,16 @@ +## 修复内容 + +- 登录防护:停止或重启时只结束了监控脚本本身,它启动的 `logread -f`、`tail -f` 子进程会继续残留。从 LuCI 或更新流程启动时,这些子进程忽略管道断开信号,不会自行退出,每次重启 MiniGate、保存登录防护设置或执行更新都会多留一两个。现在会先结束子进程,再结束监控脚本。 +- 升级:一键更新会先调用旧版服务脚本停止服务。安装脚本现在会先完整结束旧的登录防护进程树,因此从旧版本升级的这一次也不会再留下残留进程。 + +## 影响说明 + +- 残留进程不读取任何数据,不影响封禁判断,也不持有更新锁;主要是每个约占用 1.5 MB 内存,并且会随重启次数增加。 +- 更新前已经残留的进程,在本次升级时如果父进程已不存在,仍需手动结束或重启路由器后消失。 + +## 安装与兼容性 + +- opkg 安装包:`minigate_2026.10.14-2_all.ipk`。 +- 源码安装包:`minigate-v2026.10.14-2-src.tar.gz`。 +- 继续附带 `luci-app-minigate-v2026.10.14-2-src.tar.gz`,兼容旧更新器识别的源码包名。 +- 本次发布不会自动安装到软路由;请在 LuCI 中确认更新内容后手动更新。 diff --git a/minigate/releases/v2026.10.14-3.md b/minigate/releases/v2026.10.14-3.md new file mode 100644 index 000000000..4cdde982a --- /dev/null +++ b/minigate/releases/v2026.10.14-3.md @@ -0,0 +1,16 @@ +## 修复内容 + +- 登录防护进程匹配过宽:v2026.10.14-2 按"命令行包含 `/usr/lib/minigate/login_guard.sh run`"查找登录防护并结束整个进程树。BusyBox 的 `pgrep` 不排除祖先进程,如果启动更新或重启的那个 shell 命令行里恰好含有这段文本,它会被误认成登录防护,连同正在执行的安装脚本一起被结束,更新器随即自动回滚。现在只匹配登录防护的实际启动命令 `/bin/sh /usr/lib/minigate/login_guard.sh run`,服务脚本、安装脚本、卸载脚本和 LuCI 状态查询统一采用这一规则。 +- 包含 v2026.10.14-2 的修复:停止或重启登录防护时会先结束 `logread -f`、`tail -f` 子进程,不再累积孤儿进程;从旧版本升级时安装脚本也会先完整结束旧的登录防护进程树。 + +## 影响说明 + +- 通过 LuCI 一键更新时,启动更新的命令行不含登录防护文本,v2026.10.14-2 通常可以正常安装;本次修复消除了通过 SSH 等方式执行时的误杀风险。 +- 更新前已经残留的孤儿进程,如果父进程已不存在,仍需手动结束或重启路由器后消失。 + +## 安装与兼容性 + +- opkg 安装包:`minigate_2026.10.14-3_all.ipk`。 +- 源码安装包:`minigate-v2026.10.14-3-src.tar.gz`。 +- 继续附带 `luci-app-minigate-v2026.10.14-3-src.tar.gz`,兼容旧更新器识别的源码包名。 +- 本次发布不会自动安装到软路由;请在 LuCI 中确认更新内容后手动更新。 diff --git a/minigate/releases/v2026.10.14.md b/minigate/releases/v2026.10.14.md new file mode 100644 index 000000000..332befb35 --- /dev/null +++ b/minigate/releases/v2026.10.14.md @@ -0,0 +1,25 @@ +## 修复内容 + +- 公网 IP 更新并经 Cloudflare 确认成功后,MiniGate 会刷新本机 sing-box 和 dnsmasq 的 DNS 缓存,减少局域网设备继续解析到旧公网地址。 +- sing-box 通过本机 Clash API 的 `POST /cache/dns/flush` 清缓存;dnsmasq 只接收 `SIGHUP`,不会停止进程或丢失 DHCP 租约。 +- 整个过程不会重启 Open-Box、sing-box 或 MiniGate 反向代理,不主动中断现有外网连接。 + +## 触发边界 + +- Cloudflare 记录实际新增、改写或清理同名重复记录后触发一次。 +- Cloudflare 已是新地址、但 MiniGate 本地仍保存旧 IP 时也触发一次,用于补偿上一次更新后的本地缓存遗漏。 +- 普通定时检查确认地址未变时不清缓存;Cloudflare 更新失败时也不清缓存。 +- sing-box 接口或 dnsmasq 不存在、暂时不可用时只记录诊断,不把已经成功的公网 DDNS 更新标记为失败。 + +## 兼容性说明 + +- Open-Box 的 sing-box 配置存在时,MiniGate 会从本机配置读取 Clash API 地址所需的密钥;密钥不写入日志。 +- 没有安装 Open-Box/sing-box 的设备会跳过对应步骤,dnsmasq 刷新仍可独立执行。 +- 已经缓存到手机、电脑或浏览器进程内的旧答案,仍可能保留到原 TTL 到期;路由器无法主动清除每台终端的进程内缓存。 + +## 安装包 + +- opkg 安装包:`minigate_2026.10.14_all.ipk`。 +- 源码安装包:`minigate-v2026.10.14-src.tar.gz`。 +- 继续附带 `luci-app-minigate-v2026.10.14-src.tar.gz`,兼容旧更新器识别的源码包名。 +- 本次发布不会自动安装到软路由。 diff --git a/minigate/root/etc/init.d/minigate b/minigate/root/etc/init.d/minigate index 8376f2e43..264ff6bdf 100644 --- a/minigate/root/etc/init.d/minigate +++ b/minigate/root/etc/init.d/minigate @@ -1,23 +1,36 @@ #!/bin/sh /etc/rc.common START=99 STOP=10 +LOGIN_GUARD=/usr/lib/minigate/login_guard.sh # === 启动登录防护 watcher(独立子进程) === start_login_guard() { local lg_en=$(uci -q get minigate.login_guard.enabled) [ "$lg_en" = "1" ] || return 0 # 已在跑则跳过 - pgrep -f '/usr/lib/minigate/login_guard.sh run' >/dev/null 2>&1 && return 0 - /bin/sh /usr/lib/minigate/login_guard.sh run >/dev/null 2>&1 & + pgrep -f "^/bin/sh ${LOGIN_GUARD} run" >/dev/null 2>&1 && return 0 + /bin/sh "$LOGIN_GUARD" run >/dev/null 2>&1 & logger -t minigate "登录防护已启动" } +# 先结束子进程:logread -f / tail -f 忽略 SIGPIPE,父 shell 退出后不会自行结束 +stop_process_tree() { + local child + for child in $(pgrep -P "$1" 2>/dev/null); do + stop_process_tree "$child" + done + kill "$1" >/dev/null 2>&1 +} + +# 只匹配实际启动形式;调用方命令行里恰好含有这段文本时(BusyBox pgrep 不排除祖先),不能结束调用方 stop_login_guard() { - local pids - pids=$(pgrep -f '/usr/lib/minigate/login_guard.sh run' 2>/dev/null) - [ -n "$pids" ] && kill $pids >/dev/null 2>&1 + local pids pid + pids=$(pgrep -f "^/bin/sh ${LOGIN_GUARD} run" 2>/dev/null) + for pid in $pids; do + stop_process_tree "$pid" + done sleep 1 - pids=$(pgrep -f '/usr/lib/minigate/login_guard.sh run' 2>/dev/null) + pids=$(pgrep -f "^/bin/sh ${LOGIN_GUARD} run" 2>/dev/null) [ -n "$pids" ] && kill -9 $pids >/dev/null 2>&1 # 移除 nft 中的 drop 规则(保留 set,让现有封禁继续生效到自然过期) local handle=$(nft -a list chain inet fw4 input 2>/dev/null | grep "minigate-login-guard" | grep -oE 'handle [0-9]+' | awk '{print $2}') @@ -39,6 +52,7 @@ start() { echo "*/5 * * * * /bin/sh /usr/lib/minigate/ddns.sh" >> /etc/crontabs/root echo "0 3 * * * /bin/sh /usr/lib/minigate/acme.sh renew" >> /etc/crontabs/root echo "*/2 * * * * /bin/sh /usr/lib/minigate/geofence.sh scan" >> /etc/crontabs/root + echo "*/10 * * * * /bin/sh /usr/lib/minigate/proxy.sh trim-logs" >> /etc/crontabs/root /etc/init.d/cron restart 2>/dev/null logger -t minigate "已启动" } diff --git a/minigate/root/usr/lib/minigate/acme.sh b/minigate/root/usr/lib/minigate/acme.sh index 25bea1dbc..d695c87d5 100644 --- a/minigate/root/usr/lib/minigate/acme.sh +++ b/minigate/root/usr/lib/minigate/acme.sh @@ -1,8 +1,9 @@ #!/bin/sh -ACME_HOME="/etc/minigate/acme/data" +ACME_HOME="${MINIGATE_ACME_HOME:-/etc/minigate/acme/data}" ACME_BIN="${ACME_HOME}/acme.sh" -CERT_DIR="/etc/minigate/certs" -LOGFILE="/var/log/minigate-acme.log" +CERT_DIR="${MINIGATE_CERT_DIR:-/etc/minigate/certs}" +LOGFILE="${MINIGATE_ACME_LOG:-/var/log/minigate-acme.log}" +PROXY_SCRIPT="${MINIGATE_PROXY_SCRIPT:-/usr/lib/minigate/proxy.sh}" log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [ACME] $*" >> "$LOGFILE"; } @@ -32,7 +33,7 @@ keep_existing_cert_ok() { uci -q set minigate.acme.cert_expiry="$expiry" uci -q set minigate.acme.last_error="$reason" uci commit minigate - [ -f /usr/lib/minigate/proxy.sh ] && /bin/sh /usr/lib/minigate/proxy.sh reload 2>/dev/null + [ -f "$PROXY_SCRIPT" ] && /bin/sh "$PROXY_SCRIPT" reload 2>/dev/null return 0 fi return 1 @@ -144,7 +145,7 @@ do_issue() { local base=$(echo "$domain" | sed 's/^\*\.//') [ ! -e "${CERT_DIR}/${base}" ] && ln -sf "${safe}" "${CERT_DIR}/${base}" fi - [ -f /usr/lib/minigate/proxy.sh ] && /bin/sh /usr/lib/minigate/proxy.sh reload 2>/dev/null + [ -f "$PROXY_SCRIPT" ] && /bin/sh "$PROXY_SCRIPT" reload 2>/dev/null return 0 else keep_existing_cert_ok "$domain" "签发失败 (exit=$ret)" && return 0 @@ -152,6 +153,28 @@ do_issue() { fi } -do_renew() { do_install || return 1; log "续期..."; "$ACME_BIN" --home "$ACME_HOME" --renew-all --server letsencrypt >> "$LOGFILE" 2>&1; log "完成"; } +certs_fingerprint() { + cat "$CERT_DIR"/*/fullchain.pem 2>/dev/null | md5sum | cut -d' ' -f1 +} + +do_renew() { + local before domain cert + do_install || return 1 + before=$(certs_fingerprint) + log "续期..." + "$ACME_BIN" --home "$ACME_HOME" --renew-all --server letsencrypt >> "$LOGFILE" 2>&1 + log "完成" + # acme.sh 只替换证书文件,运行中的 Nginx 必须重载才会使用新证书。 + [ "$(certs_fingerprint)" = "$before" ] && return 0 + log "证书已更新,重载反向代理" + domain=$(uci -q get minigate.acme.last_domain) + cert="$(cert_path_for_domain "$domain")" + if [ -n "$domain" ] && [ -f "$cert" ]; then + uci -q set minigate.acme.cert_expiry="$(cert_expiry "$cert")" + uci commit minigate + fi + [ -f "$PROXY_SCRIPT" ] && /bin/sh "$PROXY_SCRIPT" reload 2>/dev/null + return 0 +} case "${1:-issue}" in install) do_install;; issue) do_issue "$2";; renew) do_renew;; *) echo "用法: $0 {install|issue [域名]|renew}";; esac diff --git a/minigate/root/usr/lib/minigate/after_commit.sh b/minigate/root/usr/lib/minigate/after_commit.sh new file mode 100755 index 000000000..853529868 --- /dev/null +++ b/minigate/root/usr/lib/minigate/after_commit.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# LuCI 的 on_after_commit 在浏览器提交配置之前执行。这里先记录当前配置摘要, +# 后台等配置文件变化(最多 WAIT 秒)后再应用,避免后台脚本读到旧配置。 +CONFIG="${MINIGATE_CONFIG_FILE:-/etc/config/minigate}" +WAIT="${MINIGATE_COMMIT_WAIT:-15}" +INIT="${MINIGATE_INIT:-/etc/init.d/minigate}" +LIB_DIR="${MINIGATE_LIB_DIR:-/usr/lib/minigate}" + +config_digest() { + md5sum "$CONFIG" 2>/dev/null | cut -d' ' -f1 +} + +case "$1" in + restart|stop|reload|ddns|proxy) ;; + *) + echo "用法: $0 {restart|stop|reload|ddns|proxy}" >&2 + exit 2 + ;; +esac + +before=$(config_digest) +( + i=0 + while [ "$i" -lt "$WAIT" ] && [ "$(config_digest)" = "$before" ]; do + sleep 1 + i=$((i + 1)) + done + case "$1" in + restart|stop|reload) exec "$INIT" "$1" ;; + ddns) exec /bin/sh "$LIB_DIR/ddns.sh" force ;; + proxy) exec /bin/sh "$LIB_DIR/proxy.sh" reload ;; + esac +) /dev/null 2>&1 & diff --git a/minigate/root/usr/lib/minigate/ddns.sh b/minigate/root/usr/lib/minigate/ddns.sh index 80df8f795..6e2754d0b 100644 --- a/minigate/root/usr/lib/minigate/ddns.sh +++ b/minigate/root/usr/lib/minigate/ddns.sh @@ -1,11 +1,29 @@ #!/bin/sh -LOGFILE="/var/log/minigate-ddns.log" +LOGFILE="${MINIGATE_DDNS_LOG:-/var/log/minigate-ddns.log}" HISTORY_DIR="${MINIGATE_HISTORY_DIR:-/etc/minigate/ddns-history}" HISTORY_WINDOW="${MINIGATE_HISTORY_WINDOW:-86400}" +CACHE_DIR="${MINIGATE_CACHE_DIR:-/tmp}" +SINGBOX_CONFIG="${MINIGATE_SINGBOX_CONFIG:-/opt/open-box/etc/config.json}" +CLASH_API_URL="${MINIGATE_CLASH_API_URL:-http://127.0.0.1:9095}" +LOCK_FILE="${MINIGATE_DDNS_LOCK:-/var/lock/minigate-ddns.lock}" log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [DDNS] $*" >> "$LOGFILE"; } trim_log() { [ -f "$LOGFILE" ] && [ "$(wc -l < "$LOGFILE")" -gt 300 ] && { tail -n 150 "$LOGFILE" > "${LOGFILE}.tmp"; mv "${LOGFILE}.tmp" "$LOGFILE"; }; } +# 定时、保存页面和手动同步可能同时运行,串行化避免重复创建 Cloudflare 记录。 +# 定时检查遇到正在运行的同步直接跳过,强制同步排队等待。 +lock_ddns() { + command -v flock >/dev/null 2>&1 || return 0 + mkdir -p "$(dirname "$LOCK_FILE")" 2>/dev/null + touch "$LOCK_FILE" 2>/dev/null || return 0 + exec 7>"$LOCK_FILE" + if [ "$1" = "wait" ]; then + flock -x 7 + else + flock -n 7 + fi +} + history_now() { if [ -n "${MINIGATE_HISTORY_NOW:-}" ]; then printf '%s\n' "$MINIGATE_HISTORY_NOW" @@ -83,6 +101,55 @@ record_ip_history() { fi } +# 只清运行中 DNS 缓存,不重启 sing-box、dnsmasq 或代理链路。 +flush_singbox_dns_cache() { + local secret http_code + [ -r "$SINGBOX_CONFIG" ] || return 2 + command -v jsonfilter >/dev/null 2>&1 || return 2 + command -v curl >/dev/null 2>&1 || return 2 + + secret=$(jsonfilter -i "$SINGBOX_CONFIG" -e '@.experimental.clash_api.secret' 2>/dev/null) + if [ -n "$secret" ]; then + http_code=$(printf 'Authorization: Bearer %s\n' "$secret" | \ + curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 1 --max-time 3 \ + -X POST -H @- "${CLASH_API_URL}/cache/dns/flush" 2>/dev/null) + else + http_code=$(curl -sS -o /dev/null -w '%{http_code}' --connect-timeout 1 --max-time 3 \ + -X POST "${CLASH_API_URL}/cache/dns/flush" 2>/dev/null) + fi + case "$http_code" in 2??) return 0 ;; *) return 1 ;; esac +} + +send_hup() { + kill -HUP "$@" +} + +flush_dnsmasq_cache() { + local pids + command -v pidof >/dev/null 2>&1 || return 2 + pids=$(pidof dnsmasq 2>/dev/null) + [ -n "$pids" ] || return 2 + send_hup $pids >/dev/null 2>&1 +} + +refresh_local_dns_caches() { + local domain="$1" result + if flush_singbox_dns_cache; then + log "$domain: sing-box DNS缓存已刷新" + else + result=$? + [ "$result" = "2" ] || log "$domain: sing-box DNS缓存刷新失败(接口不可用)" + fi + + if flush_dnsmasq_cache; then + log "$domain: dnsmasq DNS缓存已刷新" + else + result=$? + [ "$result" = "2" ] || log "$domain: dnsmasq DNS缓存刷新失败" + fi + return 0 +} + # ====== IPv4 获取 ====== get_ip4_iface() { local ip="" @@ -105,13 +172,13 @@ get_ip4_url() { [ -z "$port" ] && port="80" # 方法1: 直连 - ip=$(curl -4 -s --connect-timeout 3 "$url" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1) + ip=$(curl -4 -s --connect-timeout 3 --max-time 8 "$url" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1) [ -n "$ip" ] && { echo "$ip"; return; } # 方法2: DNS 解析后用 Host 头直连(绕过代理) local resolved=$(nslookup "$host" 223.5.5.5 2>/dev/null | grep -A1 'Name:' | grep 'Address:' | head -1 | awk '{print $2}') if [ -n "$resolved" ]; then - ip=$(curl -4 -s --connect-timeout 3 -H "Host: ${host}" "http://${resolved}:${port}${path}" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1) + ip=$(curl -4 -s --connect-timeout 3 --max-time 8 -H "Host: ${host}" "http://${resolved}:${port}${path}" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1) [ -n "$ip" ] && { echo "$ip"; return; } fi done @@ -136,7 +203,7 @@ get_ip6_url() { local port=$(echo "$url" | grep -oE ':[0-9]+' | head -1 | tr -d ':') # 先尝试直连 - ip=$(curl -6 -s --connect-timeout 5 "$url" 2>/dev/null | grep -oE '([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}' | head -1) + ip=$(curl -6 -s --connect-timeout 5 --max-time 10 "$url" 2>/dev/null | grep -oE '([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}' | head -1) # 如果失败,DNS 解析后直连绕过代理 if [ -z "$ip" ]; then @@ -145,60 +212,87 @@ get_ip6_url() { local scheme="http" echo "$url" | grep -q '^https' && scheme="https" [ -z "$port" ] && { [ "$scheme" = "https" ] && port="443" || port="80"; } - ip=$(curl -6 -s --connect-timeout 5 --resolve "${host}:${port}:${resolved}" "$url" 2>/dev/null | grep -oE '([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}' | head -1) + ip=$(curl -6 -s --connect-timeout 5 --max-time 10 --resolve "${host}:${port}:${resolved}" "$url" 2>/dev/null | grep -oE '([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}' | head -1) fi fi echo "$ip" } +# Token 经标准输入交给 curl,不出现在进程参数中。 +cf_api() { + local token="$1" + shift + printf 'Authorization: Bearer %s\n' "$token" | \ + curl -s --connect-timeout 10 --max-time 20 -H @- -H "Content-Type: application/json" "$@" 2>/dev/null +} + # ====== Cloudflare API:支持 A 和 AAAA 记录 ====== cf_update() { local zone_id="$1" token="$2" domain="$3" ip="$4" record_type="$5" # record_type: A 或 AAAA [ -z "$record_type" ] && record_type="A" - local resp match_resp rids rid keep_id result - resp=$(curl -s --connect-timeout 10 -H "Authorization: Bearer $token" -H "Content-Type: application/json" \ - "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records?type=${record_type}&name=${domain}" 2>/dev/null) + local resp match_resp rids rid keep_id result changed=0 + resp=$(cf_api "$token" "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records?type=${record_type}&name=${domain}") result=$(echo "$resp" | jsonfilter -e '$.success' 2>/dev/null) [ "$result" = "true" ] || { echo "false"; return 1; } rids=$(echo "$resp" | jsonfilter -e '$.result[*].id' 2>/dev/null) if [ -n "$rids" ]; then - match_resp=$(curl -s --connect-timeout 10 -H "Authorization: Bearer $token" -H "Content-Type: application/json" \ - "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records?type=${record_type}&name=${domain}&content=${ip}" 2>/dev/null) + match_resp=$(cf_api "$token" "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records?type=${record_type}&name=${domain}&content=${ip}") result=$(echo "$match_resp" | jsonfilter -e '$.success' 2>/dev/null) [ "$result" = "true" ] || { echo "false"; return 1; } keep_id=$(echo "$match_resp" | jsonfilter -e '$.result[0].id' 2>/dev/null) if [ -z "$keep_id" ]; then for rid in $rids; do keep_id="$rid"; break; done - resp=$(curl -s --connect-timeout 10 -X PUT -H "Authorization: Bearer $token" -H "Content-Type: application/json" \ + resp=$(cf_api "$token" -X PUT \ -d "{\"type\":\"$record_type\",\"name\":\"$domain\",\"content\":\"$ip\",\"ttl\":120,\"proxied\":false}" \ - "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${keep_id}" 2>/dev/null) + "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${keep_id}") result=$(echo "$resp" | jsonfilter -e '$.success' 2>/dev/null) [ "$result" = "true" ] || { echo "false"; return 1; } + changed=1 fi for rid in $rids; do [ "$rid" = "$keep_id" ] && continue - resp=$(curl -s --connect-timeout 10 -X DELETE -H "Authorization: Bearer $token" -H "Content-Type: application/json" \ - "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${rid}" 2>/dev/null) + resp=$(cf_api "$token" -X DELETE "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${rid}") result=$(echo "$resp" | jsonfilter -e '$.success' 2>/dev/null) [ "$result" = "true" ] || { echo "false"; return 1; } + changed=1 done - echo "true" + [ "$changed" = "1" ] && echo "changed" || echo "unchanged" else - resp=$(curl -s --connect-timeout 10 -X POST -H "Authorization: Bearer $token" -H "Content-Type: application/json" \ + resp=$(cf_api "$token" -X POST \ -d "{\"type\":\"$record_type\",\"name\":\"$domain\",\"content\":\"$ip\",\"ttl\":120,\"proxied\":false}" \ - "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records" 2>/dev/null) - echo "$resp" | jsonfilter -e '$.success' 2>/dev/null + "https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records") + result=$(echo "$resp" | jsonfilter -e '$.success' 2>/dev/null) + [ "$result" = "true" ] && echo "changed" || { echo "false"; return 1; } fi } +ddns_interval() { + local interval + interval=$(uci -q get minigate.${1}.check_interval) + case "$interval" in ''|*[!0-9]*) interval=300 ;; esac + [ "$interval" -ge 300 ] 2>/dev/null || interval=300 + echo "$interval" +} + +# 定时任务每 5 分钟触发;未到本记录的检查间隔时跳过,留 30 秒余量吸收启动抖动。 +ddns_due() { + local sec="$1" last now + last=$(cat "${CACHE_DIR}/minigate_ddns_${sec}.checked" 2>/dev/null) + case "$last" in ''|*[!0-9]*) return 0 ;; esac + now=$(date +%s) + [ "$now" -ge "$last" ] || return 0 + [ $((now - last + 30)) -ge "$(ddns_interval "$sec")" ] +} + +# 下一次实际执行的整 5 分钟时刻,与 ddns_due 的判断一致 calc_next() { local interval="${1:-300}" local now=$(date +%s) - local next=$((now + interval)) + local next=$(( (now + interval + 269) / 300 * 300 )) date -d "@${next}" '+%H:%M:%S' 2>/dev/null || echo "${interval}秒后" } @@ -206,6 +300,7 @@ update_one() { local sec="$1" force="$2" local enabled=$(uci -q get minigate.${sec}.enabled) [ "$enabled" = "1" ] || return 0 + date +%s > "${CACHE_DIR}/minigate_ddns_${sec}.checked" local domain=$(uci -q get minigate.${sec}.domain) local zone_id=$(uci -q get minigate.${sec}.cf_zone_id) @@ -213,7 +308,7 @@ update_one() { local ip_source=$(uci -q get minigate.${sec}.ip_source) local iface=$(uci -q get minigate.${sec}.interface) local ip_url=$(uci -q get minigate.${sec}.ip_url) - local interval=$(uci -q get minigate.${sec}.check_interval); interval=${interval:-300} + local interval=$(ddns_interval "$sec") local ip_version=$(uci -q get minigate.${sec}.ip_version); ip_version=${ip_version:-ipv4} local iface6=$(uci -q get minigate.${sec}.interface6); iface6=${iface6:-wan6} local ip_url6=$(uci -q get minigate.${sec}.ip_url6); ip_url6=${ip_url6:-https://api6.ipify.org} @@ -224,14 +319,14 @@ update_one() { return 1 } - local cache="/tmp/minigate_ddns_${sec}.cache" - local cache6="/tmp/minigate_ddns_${sec}_v6.cache" + local cache="${CACHE_DIR}/minigate_ddns_${sec}.cache" + local cache6="${CACHE_DIR}/minigate_ddns_${sec}_v6.cache" local cached=$(cat "$cache" 2>/dev/null) local cached6=$(cat "$cache6" 2>/dev/null) local next_time=$(calc_next "$interval") local any_ok=0 any_fail=0 local result_ip="" result_ip6="" - local msgs="" + local msgs="" refresh_dns=0 # ====== IPv4 处理 ====== if [ "$ip_version" = "ipv4" ] || [ "$ip_version" = "dual" ]; then @@ -249,12 +344,15 @@ update_one() { if [ -n "$ip4" ] && { [ "$force" = "1" ] || [ "$ip4" != "$cached" ]; }; then log "$domain: IPv4 ${cached:-无} -> $ip4" local result=$(cf_update "$zone_id" "$token" "$domain" "$ip4" "A") - if [ "$result" = "true" ]; then + if [ "$result" = "changed" ] || [ "$result" = "unchanged" ]; then log "$domain: A记录同步成功" echo "$ip4" > "$cache" result_ip="$ip4" msgs="${msgs}A:${ip4} ✓; " any_ok=1 + if [ "$result" = "changed" ] || { [ -n "$cached" ] && [ "$cached" != "$ip4" ]; }; then + refresh_dns=1 + fi else log "$domain: A记录同步失败" msgs="${msgs}A记录失败; " @@ -285,12 +383,15 @@ update_one() { if [ -n "$ip6" ] && { [ "$force" = "1" ] || [ "$ip6" != "$cached6" ]; }; then log "$domain: IPv6 ${cached6:-无} -> $ip6" local result=$(cf_update "$zone_id" "$token" "$domain" "$ip6" "AAAA") - if [ "$result" = "true" ]; then + if [ "$result" = "changed" ] || [ "$result" = "unchanged" ]; then log "$domain: AAAA记录同步成功" echo "$ip6" > "$cache6" result_ip6="$ip6" msgs="${msgs}AAAA:${ip6} ✓; " any_ok=1 + if [ "$result" = "changed" ] || { [ -n "$cached6" ] && [ "$cached6" != "$ip6" ]; }; then + refresh_dns=1 + fi else log "$domain: AAAA记录同步失败" msgs="${msgs}AAAA记录失败; " @@ -317,6 +418,7 @@ update_one() { [ -n "$result_ip6" ] && uci -q set minigate.${sec}.last_ip6="$result_ip6" uci -q set minigate.${sec}.last_update="$(date '+%Y-%m-%d %H:%M:%S')" uci -q set minigate.${sec}.next_sync="$next_time" + [ "$refresh_dns" = "1" ] && refresh_local_dns_caches "$domain" || true } main() { @@ -327,10 +429,24 @@ main() { fi local force=0; [ "$1" = "force" ] && force=1 + if [ "$force" = "1" ]; then + lock_ddns wait + elif ! lock_ddns; then + log "上一轮同步仍在运行,跳过本次定时检查" + return 0 + fi + # 与 LuCI 手动同步使用相同的节名,两条路径共享同一份 IP 缓存 + local sections=$(uci -q -X show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1) + local due="" sec + for sec in $sections; do + [ "$(uci -q get minigate.${sec}.enabled)" = "1" ] || continue + if [ "$force" = "1" ] || ddns_due "$sec"; then due="$due $sec"; fi + done + # 定时检查没有到期记录时静默退出,不写日志、不提交配置 + [ -n "$sections" ] && [ -z "$due" ] && return 0 log "--- 开始同步 ---" - local sections=$(uci -q show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1) local c=0 - for sec in $sections; do update_one "$sec" "$force"; c=$((c + 1)); done + for sec in $due; do update_one "$sec" "$force"; c=$((c + 1)); done [ "$c" = "0" ] && log "无 DDNS 记录" uci commit minigate log "--- 同步完成 ($c 条) ---" @@ -343,6 +459,7 @@ fi # 手动单条同步 if [ "$1" = "single" ] && [ -n "$2" ]; then + lock_ddns wait log "手动同步: $2" update_one "$2" "1" uci commit minigate diff --git a/minigate/root/usr/lib/minigate/login_guard.sh b/minigate/root/usr/lib/minigate/login_guard.sh index 5643bfbb5..fe074b4b1 100755 --- a/minigate/root/usr/lib/minigate/login_guard.sh +++ b/minigate/root/usr/lib/minigate/login_guard.sh @@ -17,11 +17,11 @@ THRESHOLD=$(uci -q get minigate.login_guard.threshold || echo "3") BANTIME=$(uci -q get minigate.login_guard.bantime || echo "43200") WINDOW=$(uci -q get minigate.login_guard.window || echo "600") -DATA_DIR=/etc/minigate/login-guard -RUN_DIR=/var/run/minigate/login-guard +DATA_DIR="${MINIGATE_LG_DATA_DIR:-/etc/minigate/login-guard}" +RUN_DIR="${MINIGATE_LG_RUN_DIR:-/var/run/minigate/login-guard}" BANS_FILE="$DATA_DIR/bans.txt" COUNTER_DIR="$RUN_DIR/counters" -LOG_FILE=/var/log/minigate-login-guard.log +LOG_FILE="${MINIGATE_LG_LOG:-/var/log/minigate-login-guard.log}" ACCESS_LOG=/var/log/minigate-access.log SET="login_banned_v4" TABLE="inet fw4" @@ -64,6 +64,7 @@ is_whitelisted() { } # === 确保 nft set + drop rule 存在(fw4 reload 后自动重建) === +# 返回 0 表示刚重建过,调用方需要从持久化文件恢复封禁。 ensure_nft() { local recreated=0 if ! nft list set $TABLE $SET >/dev/null 2>&1; then @@ -74,8 +75,9 @@ ensure_nft() { nft "insert rule $TABLE input ip saddr @$SET counter drop comment \"minigate-login-guard\"" 2>/dev/null recreated=1 fi - [ $recreated -eq 1 ] && log "已创建 nft set + drop 规则" - return $recreated + [ $recreated -eq 1 ] || return 1 + log "已创建 nft set + drop 规则" + return 0 } # === 把 IP 加入封禁集合 + 持久化 === @@ -138,7 +140,7 @@ handle_failure() { if [ "$count" -ge "$THRESHOLD" ]; then # 加 ban 前再确认 nft 还在(fw4 可能重载) local ban_target="$ip" - ensure_nft && restore_bans + repair_nft ban_ip "$ban_target" fi } @@ -169,13 +171,24 @@ access_log_loop() { done } +repair_nft() { + ensure_nft && restore_bans + return 0 +} + +# 日志在内存 /var/log 中,长期遭遇爆破时需要限制大小 +trim_log() { + [ -f "$LOG_FILE" ] || return 0 + [ "$(wc -c < "$LOG_FILE")" -gt 262144 ] || return 0 + tail -n 500 "$LOG_FILE" > "${LOG_FILE}.tmp" && mv -f "${LOG_FILE}.tmp" "$LOG_FILE" +} + # === 后台 watchdog:每 5 分钟检查 nft 资源 === watchdog_loop() { while true; do sleep 300 - if ensure_nft; then - restore_bans - fi + repair_nft + trim_log done } @@ -326,6 +339,10 @@ cmd_log() { fi } +if [ "${MINIGATE_LG_LIBRARY_ONLY:-0}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi + # === 入口 === case "${1:-help}" in run) run_watcher ;; diff --git a/minigate/root/usr/lib/minigate/proxy.sh b/minigate/root/usr/lib/minigate/proxy.sh index 5cbd8b08c..1f6a1156b 100644 --- a/minigate/root/usr/lib/minigate/proxy.sh +++ b/minigate/root/usr/lib/minigate/proxy.sh @@ -10,6 +10,7 @@ NGINX_ERROR_LOG="${MINIGATE_NGINX_ERROR_LOG:-/var/log/minigate-nginx-error.log}" ACCESS_LOG="${MINIGATE_ACCESS_LOG:-/var/log/minigate-access.log}" SOCKET_DIR="${MINIGATE_SOCKET_DIR:-/var/run/minigate}" STREAM_MODULE="${MINIGATE_STREAM_MODULE:-/usr/lib/nginx/modules/ngx_stream_module.so}" +ACCESS_LOG_MAX="${MINIGATE_ACCESS_LOG_MAX:-1048576}" log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [PROXY] $*" >> "$LOGFILE"; } @@ -427,10 +428,26 @@ do_reload() { fi } +# /var/log 在内存中且 nginx 不会自行轮转。访问日志保留最近记录到 .1 后原地清空: +# nginx 以追加方式继续写入,登录防护的 tail -f 从头读取新内容,不会重放旧记录。 +trim_logs() { + local log_file + if [ -f "$ACCESS_LOG" ] && [ "$(wc -c < "$ACCESS_LOG")" -gt "$ACCESS_LOG_MAX" ]; then + tail -n 1000 "$ACCESS_LOG" > "${ACCESS_LOG}.1.tmp" && mv -f "${ACCESS_LOG}.1.tmp" "${ACCESS_LOG}.1" + : > "$ACCESS_LOG" + fi + for log_file in "$NGINX_ERROR_LOG" "$LOGFILE"; do + [ -f "$log_file" ] && [ "$(wc -c < "$log_file")" -gt 262144 ] || continue + tail -n 300 "$log_file" > "${log_file}.tmp" && cat "${log_file}.tmp" > "$log_file" + rm -f "${log_file}.tmp" + done +} + case "$1" in start) do_start ;; stop) do_stop ;; reload) do_reload ;; test) prepare_config ;; - *) echo "用法: $0 {start|stop|reload|test}" ;; + trim-logs) trim_logs ;; + *) echo "用法: $0 {start|stop|reload|test|trim-logs}" ;; esac diff --git a/minigate/root/usr/lib/minigate/uninstall.sh b/minigate/root/usr/lib/minigate/uninstall.sh index b3ce03be5..154792202 100755 --- a/minigate/root/usr/lib/minigate/uninstall.sh +++ b/minigate/root/usr/lib/minigate/uninstall.sh @@ -1,5 +1,6 @@ #!/bin/sh set -u +umask 077 PURGE="${1:-0}" ROOT="${MINIGATE_ROOT:-}" @@ -108,11 +109,12 @@ stop_process_tree() { } stop_services() { - local init pid + local init pid program_dir [ "$SKIP_SERVICES" = "1" ] && return 0 + program_dir="$(root_path /usr/lib/minigate)" - if [ -z "$ROOT" ] && command -v pgrep >/dev/null 2>&1; then - for pid in $(pgrep -f '/usr/lib/minigate/login_guard.sh run' 2>/dev/null); do + if command -v pgrep >/dev/null 2>&1; then + for pid in $(pgrep -f "^/bin/sh ${program_dir}/login_guard.sh run" 2>/dev/null); do stop_process_tree "$pid" done fi @@ -129,11 +131,14 @@ stop_services() { ''|*[!0-9]*) ;; *) kill "$pid" 2>/dev/null || true ;; esac - if command -v pgrep >/dev/null 2>&1; then - for pid in $(pgrep -f '/usr/lib/minigate/' 2>/dev/null); do - stop_process_tree "$pid" - done - fi + fi + + # 只结束以 /bin/sh 直接运行的 minigate 脚本。BusyBox pgrep 只排除自身, + # 宽泛匹配会命中本脚本或 LuCI 启动卸载的外层 sh -c,导致卸载中途自杀。 + if command -v pgrep >/dev/null 2>&1; then + for pid in $(pgrep -f "^/bin/sh ${program_dir}/" 2>/dev/null); do + [ "$pid" = "$$" ] || stop_process_tree "$pid" + done fi } @@ -224,10 +229,11 @@ remove_runtime_files() { "$tmp_dir"/minigate_proxy_*.tmp \ "$tmp_dir"/minigate_default_*.tmp \ "$tmp_dir"/minigate_stream_*.tmp \ - "$tmp_dir"/minigate_ddns_*.cache + "$tmp_dir"/minigate_ddns_*.cache \ + "$tmp_dir"/minigate_ddns_*.checked if [ "$PURGE" = "1" ]; then - rm -f "$(root_path /etc/config/minigate)" "$log_dir"/minigate-*.log + rm -f "$(root_path /etc/config/minigate)" "$log_dir"/minigate-*.log "$log_dir"/minigate-*.log.1 rm -rf "$(root_path /etc/minigate)" fi } diff --git a/minigate/root/usr/lib/minigate/update.sh b/minigate/root/usr/lib/minigate/update.sh index 25029737a..febfbee58 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.9.18-2" +CURRENT_VERSION="2026.10.14-3" 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 b8dfadcab..e9b4a1cc8 100755 --- a/minigate/scripts/build-ipk.sh +++ b/minigate/scripts/build-ipk.sh @@ -5,10 +5,10 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" OUT_DIR="${1:-$ROOT_DIR/dist}" PKG_NAME="minigate" -PKG_VERSION="2026.9.18-2" +PKG_VERSION="2026.10.14-3" PKG_ARCH="all" PKG_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.ipk" -DEPS="libc, luci-base, nginx-ssl, nginx-mod-stream, openssl-util, wget, curl, jsonfilter, coreutils-stat, nftables" +DEPS="libc, luci-base, luci-compat, nginx-ssl, nginx-mod-stream, openssl-util, wget, curl, jsonfilter, coreutils-stat, nftables" WORK_DIR="$(mktemp -d)" diff --git a/minigate/tests/acme.test.cjs b/minigate/tests/acme.test.cjs new file mode 100644 index 000000000..12e1550ea --- /dev/null +++ b/minigate/tests/acme.test.cjs @@ -0,0 +1,50 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const root = path.resolve(__dirname, '..'); +const script = path.join(root, 'root/usr/lib/minigate/acme.sh'); + +function runRenew({ renewChangesCert }) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-acme-test-')); + const acmeHome = path.join(dir, 'acme'); + const certDir = path.join(dir, 'certs'); + const reloadLog = path.join(dir, 'reload.log'); + const cert = path.join(certDir, '_wildcard_.example.com/fullchain.pem'); + fs.mkdirSync(path.join(acmeHome, 'dnsapi'), { recursive: true }); + fs.mkdirSync(path.dirname(cert), { recursive: true }); + fs.writeFileSync(cert, 'old-certificate\n'); + fs.writeFileSync(path.join(acmeHome, 'acme.sh'), renewChangesCert + ? `#!/bin/sh\nprintf 'renewed-certificate\\n' > ${JSON.stringify(cert)}\n` + : '#!/bin/sh\necho "Skip, Next renewal time is in the future"\n'); + fs.chmodSync(path.join(acmeHome, 'acme.sh'), 0o755); + fs.writeFileSync(path.join(dir, 'proxy.sh'), `printf '%s\\n' "$1" >> ${JSON.stringify(reloadLog)}\n`); + + const result = spawnSync('/bin/sh', [script, 'renew'], { + encoding: 'utf8', + env: { + ...process.env, + MINIGATE_ACME_HOME: acmeHome, + MINIGATE_CERT_DIR: certDir, + MINIGATE_ACME_LOG: path.join(dir, 'acme.log'), + MINIGATE_PROXY_SCRIPT: path.join(dir, 'proxy.sh') + } + }); + const reloads = fs.existsSync(reloadLog) ? fs.readFileSync(reloadLog, 'utf8').trim().split('\n') : []; + return { result, reloads }; +} + +test('renewal reloads the reverse proxy when a certificate file changes', () => { + const { result, reloads } = runRenew({ renewChangesCert: true }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.deepEqual(reloads, ['reload']); +}); + +test('renewal without certificate changes leaves the running proxy alone', () => { + const { result, reloads } = runRenew({ renewChangesCert: false }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.deepEqual(reloads, []); +}); diff --git a/minigate/tests/after-commit.test.cjs b/minigate/tests/after-commit.test.cjs new file mode 100644 index 000000000..279a551e1 --- /dev/null +++ b/minigate/tests/after-commit.test.cjs @@ -0,0 +1,92 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const root = path.resolve(__dirname, '..'); +const script = path.join(root, 'root/usr/lib/minigate/after_commit.sh'); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +function fixture() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-after-commit-test-')); + const config = path.join(dir, 'minigate'); + const marker = path.join(dir, 'applied.log'); + fs.writeFileSync(config, "option enabled '0'\n"); + fs.mkdirSync(path.join(dir, 'lib')); + fs.writeFileSync(path.join(dir, 'lib/ddns.sh'), `printf 'ddns %s %s\\n' "$1" "$(cat ${JSON.stringify(config)})" >> ${JSON.stringify(marker)}\n`); + fs.writeFileSync(path.join(dir, 'lib/proxy.sh'), `printf 'proxy %s\\n' "$1" >> ${JSON.stringify(marker)}\n`); + fs.writeFileSync(path.join(dir, 'init'), `#!/bin/sh\nprintf 'init %s\\n' "$1" >> ${JSON.stringify(marker)}\n`); + fs.chmodSync(path.join(dir, 'init'), 0o755); + return { dir, config, marker }; +} + +function run(fx, action, wait) { + return spawnSync('/bin/sh', [script, action], { + encoding: 'utf8', + timeout: 5000, + env: { + ...process.env, + MINIGATE_CONFIG_FILE: fx.config, + MINIGATE_COMMIT_WAIT: String(wait), + MINIGATE_INIT: path.join(fx.dir, 'init'), + MINIGATE_LIB_DIR: path.join(fx.dir, 'lib') + } + }); +} + +const applied = fx => (fs.existsSync(fx.marker) ? fs.readFileSync(fx.marker, 'utf8').trim() : ''); + +async function waitFor(check, timeout = 4000) { + const started = Date.now(); + while (Date.now() - started < timeout) { + if (check()) return true; + await sleep(100); + } + return check(); +} + +test('saved settings are applied only after LuCI commits the new configuration', async () => { + const fx = fixture(); + const started = Date.now(); + const result = run(fx, 'ddns', 10); + assert.equal(result.status, 0, result.stderr); + assert.ok(Date.now() - started < 1000, 'the LuCI hook must return immediately'); + + await sleep(1300); + assert.equal(applied(fx), '', 'must not run against the old configuration'); + fs.writeFileSync(fx.config, "option enabled '1'\n"); + assert.ok(await waitFor(() => applied(fx) !== '')); + assert.equal(applied(fx), "ddns force option enabled '1'"); +}); + +test('an unchanged save still applies after the wait limit', async () => { + const fx = fixture(); + assert.equal(run(fx, 'proxy', 1).status, 0); + assert.ok(await waitFor(() => applied(fx) !== '')); + assert.equal(applied(fx), 'proxy reload'); +}); + +test('service actions go through the init script and unknown actions are rejected', async () => { + const fx = fixture(); + assert.equal(run(fx, 'restart', 1).status, 0); + assert.ok(await waitFor(() => applied(fx) !== '')); + assert.equal(applied(fx), 'init restart'); + + const bad = fixture(); + assert.equal(run(bad, 'reboot', 1).status, 2); + await sleep(1500); + assert.equal(applied(bad), ''); +}); + +test('LuCI pages defer their actions and saving DDNS no longer restarts the proxy', () => { + const page = name => fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate', name), 'utf8'); + assert.match(page('general.lua'), /after_commit\.sh " \.\. \(en == "1" and "restart" or "stop"\)/); + assert.match(page('ddns.lua'), /after_commit\.sh ddns/); + assert.match(page('proxy.lua'), /after_commit\.sh proxy/); + assert.match(page('login_guard.lua'), /after_commit\.sh reload/); + assert.doesNotMatch(page('ddns.lua'), /minigate restart/); + const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8'); + assert.match(makefile, /root\/usr\/lib\/minigate\/after_commit\.sh/); +}); diff --git a/minigate/tests/ddns-history.test.cjs b/minigate/tests/ddns-history.test.cjs index d6ba4eb55..ef9dc3a0b 100644 --- a/minigate/tests/ddns-history.test.cjs +++ b/minigate/tests/ddns-history.test.cjs @@ -17,6 +17,7 @@ function runHistory(script, options = {}) { 'set -eu', 'export MINIGATE_DDNS_LIBRARY_ONLY=1', `export MINIGATE_HISTORY_DIR=${JSON.stringify(historyDir)}`, + `export MINIGATE_CACHE_DIR=${JSON.stringify(historyDir)}`, `export MINIGATE_HISTORY_WINDOW=${options.window || 86400}`, `. ${JSON.stringify(ddnsScript)}`, script @@ -30,6 +31,85 @@ function readRows(file) { return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean); } +function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-ddns-update-')); + const cacheFile = path.join(tempDir, 'minigate_ddns_cfg01.cache'); + const refreshLog = path.join(tempDir, 'refresh.log'); + const uciLog = path.join(tempDir, 'uci.log'); + if (cachedIp) fs.writeFileSync(cacheFile, `${cachedIp}\n`); + + const command = ` + set -u + export MINIGATE_DDNS_LIBRARY_ONLY=1 + export MINIGATE_CACHE_DIR=${JSON.stringify(tempDir)} + export MINIGATE_HISTORY_DIR=${JSON.stringify(path.join(tempDir, 'history'))} + export MINIGATE_DDNS_LOG=${JSON.stringify(path.join(tempDir, 'ddns.log'))} + . ${JSON.stringify(ddnsScript)} + uci() { + [ "\${1:-}" = "-q" ] && shift + case "\${1:-}:\${2:-}" in + get:minigate.cfg01.enabled) printf '1\\n' ;; + get:minigate.cfg01.domain) printf '*.example.com\\n' ;; + get:minigate.cfg01.cf_zone_id) printf 'zone\\n' ;; + get:minigate.cfg01.cf_api_token) printf 'token\\n' ;; + get:minigate.cfg01.ip_source) printf 'url\\n' ;; + get:minigate.cfg01.interface) printf 'wan\\n' ;; + get:minigate.cfg01.ip_url) printf 'http://ip.example/\\n' ;; + get:minigate.cfg01.check_interval) printf '300\\n' ;; + get:minigate.cfg01.ip_version) printf 'ipv4\\n' ;; + set:*) printf '%s\\n' "\$2" >> ${JSON.stringify(uciLog)} ;; + esac + return 0 + } + get_ip4_url() { printf '198.51.100.20\\n'; } + record_ip_history() { return 0; } + calc_next() { printf '12:00:00\\n'; } + cf_update() { + printf '%s\\n' ${JSON.stringify(cfResult)} + [ ${JSON.stringify(cfResult)} != false ] + } + refresh_local_dns_caches() { + printf '%s\\n' "\$1" >> ${JSON.stringify(refreshLog)} + return ${Number(refreshResult)} + } + update_one cfg01 1 + `; + const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' }); + const refreshes = fs.existsSync(refreshLog) ? readRows(refreshLog) : []; + const uciWrites = fs.existsSync(uciLog) ? readRows(uciLog) : []; + const cache = fs.existsSync(cacheFile) ? fs.readFileSync(cacheFile, 'utf8').trim() : ''; + return { result, refreshes, uciWrites, cache }; +} + +function runCloudflareScenario(mode) { + const command = ` + set -u + export MINIGATE_DDNS_LIBRARY_ONLY=1 + . ${JSON.stringify(ddnsScript)} + CF_MODE=${JSON.stringify(mode)} + curl() { + case "\$*" in + *'-X PUT'*|*'-X DELETE'*|*'-X POST'*) printf 'WRITE\\n' ;; + *'&content='*) printf 'MATCH\\n' ;; + *) printf 'LIST\\n' ;; + esac + } + jsonfilter() { + input=; IFS= read -r input || true + expression="\${2:-}" + case "\$expression:\$input:\$CF_MODE" in + '$.success:'*) [ "\$CF_MODE" = failure ] && printf 'false\\n' || printf 'true\\n' ;; + '$.result[*].id:LIST:create') ;; + '$.result[*].id:LIST:duplicates') printf 'r1\\nr2\\n' ;; + '$.result[*].id:LIST:'*) printf 'r1\\n' ;; + '$.result[0].id:MATCH:unchanged'|'$.result[0].id:MATCH:duplicates') printf 'r1\\n' ;; + esac + } + cf_update zone token example.com 198.51.100.20 A + `; + return spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' }); +} + test('DDNS history records only actual IPv4 changes', () => { const { historyDir, output } = runHistory(` export MINIGATE_HISTORY_NOW=100000 @@ -84,6 +164,180 @@ test('DDNS writer and controller both use the domain history key', () => { assert.doesNotMatch(controller, /history_section_name\(section\.name\)/); }); +test('Cloudflare updater distinguishes unchanged records from authoritative changes', () => { + for (const mode of ['create', 'replace', 'duplicates']) { + const result = runCloudflareScenario(mode); + assert.equal(result.status, 0, `${mode}: ${result.stderr || result.stdout}`); + assert.equal(result.stdout.trim(), 'changed', mode); + } + const unchanged = runCloudflareScenario('unchanged'); + assert.equal(unchanged.status, 0, unchanged.stderr || unchanged.stdout); + assert.equal(unchanged.stdout.trim(), 'unchanged'); + + const failure = runCloudflareScenario('failure'); + assert.notEqual(failure.status, 0); + assert.equal(failure.stdout.trim(), 'false'); +}); + +test('Cloudflare token stays out of curl arguments and every API call is time-bounded', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-cf-token-')); + const calls = path.join(tempDir, 'calls.log'); + const command = ` + set -u + export MINIGATE_DDNS_LIBRARY_ONLY=1 + . ${JSON.stringify(ddnsScript)} + curl() { printf 'args %s\\n' "\$*" >> ${JSON.stringify(calls)}; sed 's/^/stdin /' >> ${JSON.stringify(calls)}; printf '{}'; } + jsonfilter() { cat >/dev/null; [ "\${2:-}" = '$.success' ] && printf 'true\\n'; return 0; } + cf_update zone secret-token example.com 198.51.100.20 A + `; + const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(result.stdout.trim(), 'changed'); + const lines = readRows(calls); + const args = lines.filter(line => line.startsWith('args ')); + assert.equal(args.length, 2, 'list + create'); + for (const line of args) { + assert.doesNotMatch(line, /secret-token/); + assert.match(line, /--max-time \d+/); + assert.match(line, /-H @-/); + } + assert.equal(lines.filter(line => line === 'stdin Authorization: Bearer secret-token').length, 2); +}); + +test('scheduled and manual DDNS runs share section names and serialize with a lock', () => { + const source = fs.readFileSync(ddnsScript, 'utf8'); + assert.match(source, /uci -q -X show minigate \| grep '=ddns\$'/); + assert.match(source, /if \[ "\$1" = "single" \] && \[ -n "\$2" \]; then\n lock_ddns wait/); + assert.match(source, /elif ! lock_ddns; then/); + for (const line of source.split('\n').filter(item => /\bcurl -[46]/.test(item))) { + assert.match(line, /--max-time \d+/, line.trim()); + } +}); + +function runScheduled({ force = false, checked }) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-ddns-interval-')); + const calls = path.join(tempDir, 'calls.log'); + for (const [sec, ts] of Object.entries(checked)) fs.writeFileSync(path.join(tempDir, `minigate_ddns_${sec}.checked`), `${ts}\n`); + const command = ` + export MINIGATE_DDNS_LIBRARY_ONLY=1 + export MINIGATE_CACHE_DIR=${JSON.stringify(tempDir)} + export MINIGATE_DDNS_LOCK=${JSON.stringify(path.join(tempDir, 'ddns.lock'))} + export MINIGATE_DDNS_LOG=${JSON.stringify(path.join(tempDir, 'ddns.log'))} + . ${JSON.stringify(ddnsScript)} + date() { [ "\${1:-}" = '+%s' ] && printf '1000000\\n' || printf 'now\\n'; } + uci() { + [ "\${1:-}" = "-q" ] && shift + [ "\${1:-}" = "-X" ] && shift + case "\${1:-}:\${2:-}" in + get:minigate.global.enabled|get:minigate.cfg01.enabled|get:minigate.cfg02.enabled) printf '1\\n' ;; + get:minigate.cfg03.enabled) printf '0\\n' ;; + get:minigate.cfg01.check_interval) printf '300\\n' ;; + get:minigate.cfg02.check_interval) printf '900\\n' ;; + show:minigate) printf 'minigate.cfg01=ddns\\nminigate.cfg02=ddns\\nminigate.cfg03=ddns\\n' ;; + commit:minigate) printf 'commit\\n' >> ${JSON.stringify(calls)} ;; + esac + return 0 + } + update_one() { printf '%s %s\\n' "\$1" "\$2" >> ${JSON.stringify(calls)}; } + main ${force ? 'force' : ''} + `; + const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' }); + assert.equal(result.stderr, ''); + return { + calls: fs.existsSync(calls) ? readRows(calls) : [], + logged: fs.existsSync(path.join(tempDir, 'ddns.log')) + }; +} + +test('scheduled DDNS checks honour each record interval and skip disabled records', () => { + const due = runScheduled({ checked: { cfg01: 1000000 - 280, cfg02: 1000000 - 600 } }); + assert.deepEqual(due.calls, ['cfg01 0', 'commit']); + + const idle = runScheduled({ checked: { cfg01: 1000000 - 100, cfg02: 1000000 - 600 } }); + assert.deepEqual(idle.calls, [], 'nothing due: no update and no config commit'); + assert.equal(idle.logged, false, 'nothing due: no log noise every five minutes'); + + const forced = runScheduled({ force: true, checked: { cfg01: 1000000 - 10, cfg02: 1000000 - 10 } }); + assert.deepEqual(forced.calls, ['cfg01 1', 'cfg02 1', 'commit']); +}); + +test('DDNS interval is at least five minutes and the next run matches the cron grid', () => { + const { output } = runHistory(` + uci() { printf '%s\\n' "\${INTERVAL}"; } + date() { [ "\${1:-}" = '+%s' ] && printf '300150\\n' || printf '%s\\n' "\${2:-}"; } + INTERVAL=60; printf '%s ' "$(ddns_interval cfg01)" + INTERVAL=abc; printf '%s ' "$(ddns_interval cfg01)" + INTERVAL=600; printf '%s ' "$(ddns_interval cfg01)" + printf '%s %s ' "$(calc_next 300)" "$(calc_next 600)" + printf '%s\\n' 300300 > "\${MINIGATE_CACHE_DIR}/minigate_ddns_cfg09.checked" + INTERVAL=300; ddns_due cfg09 && printf 'clock-back-due' + `); + assert.equal(output, '300 300 600 @300600 @300900 clock-back-due'); +}); + +test('successful Cloudflare changes refresh local DNS caches once', () => { + const scenario = runUpdateScenario({ cfResult: 'changed' }); + assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout); + assert.deepEqual(scenario.refreshes, ['*.example.com']); + assert.equal(scenario.cache, '198.51.100.20'); + assert.ok(scenario.uciWrites.includes('minigate.cfg01.status=ok')); +}); + +test('unchanged forced sync does not flush caches, but a stale local IP does', () => { + const current = runUpdateScenario({ cfResult: 'unchanged', cachedIp: '198.51.100.20' }); + assert.equal(current.result.status, 0, current.result.stderr || current.result.stdout); + assert.deepEqual(current.refreshes, []); + + const stale = runUpdateScenario({ cfResult: 'unchanged', cachedIp: '198.51.100.19' }); + assert.equal(stale.result.status, 0, stale.result.stderr || stale.result.stdout); + assert.deepEqual(stale.refreshes, ['*.example.com']); + assert.equal(stale.cache, '198.51.100.20'); +}); + +test('Cloudflare failure never flushes local caches', () => { + const scenario = runUpdateScenario({ cfResult: 'false', cachedIp: '198.51.100.19' }); + assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout); + assert.deepEqual(scenario.refreshes, []); + assert.equal(scenario.cache, '198.51.100.19'); + assert.ok(scenario.uciWrites.includes('minigate.cfg01.status=error')); +}); + +test('cache refresh failure does not turn a successful DDNS update into an error', () => { + const scenario = runUpdateScenario({ cfResult: 'changed', refreshResult: 1 }); + assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout); + assert.deepEqual(scenario.refreshes, ['*.example.com']); + assert.ok(scenario.uciWrites.includes('minigate.cfg01.status=ok')); +}); + +test('local cache refresh uses sing-box API and dnsmasq SIGHUP without service restart', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-dns-flush-')); + const config = path.join(tempDir, 'config.json'); + const calls = path.join(tempDir, 'calls.log'); + fs.writeFileSync(config, '{}\n'); + const command = ` + set -eu + export MINIGATE_DDNS_LIBRARY_ONLY=1 + export MINIGATE_SINGBOX_CONFIG=${JSON.stringify(config)} + export MINIGATE_CLASH_API_URL=http://127.0.0.1:19095 + export MINIGATE_DDNS_LOG=${JSON.stringify(path.join(tempDir, 'ddns.log'))} + . ${JSON.stringify(ddnsScript)} + jsonfilter() { printf 'local-secret\\n'; } + curl() { printf 'curl %s\\n' "\$*" >> ${JSON.stringify(calls)}; sed 's/^/header /' >> ${JSON.stringify(calls)}; printf '204'; } + pidof() { [ "\$1" = dnsmasq ] && printf '101 202\\n'; } + send_hup() { printf 'hup %s\\n' "\$*" >> ${JSON.stringify(calls)}; } + refresh_local_dns_caches '*.example.com' + `; + const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const callLog = fs.readFileSync(calls, 'utf8'); + const curlLine = callLog.split('\n').find(line => line.startsWith('curl ')) || ''; + assert.match(callLog, /curl .* -X POST -H @- http:\/\/127\.0\.0\.1:19095\/cache\/dns\/flush/); + assert.match(callLog, /header Authorization: Bearer local-secret/); + assert.doesNotMatch(curlLine, /local-secret/, 'secret must not appear in the curl process arguments'); + assert.match(callLog, /hup 101 202/); + assert.doesNotMatch(callLog, /restart|reload|stop|start/); +}); + test('controller exposes a read-only rolling 24-hour history endpoint', () => { assert.match(controller, /entry\(\{"admin","services","minigate","ddns_history"\}, call\("action_ddns_history"\)\)/); assert.match(controller, /local cutoff = now - 86400/); diff --git a/minigate/tests/install.test.cjs b/minigate/tests/install.test.cjs index 06d054c5a..a0a4e22c1 100644 --- a/minigate/tests/install.test.cjs +++ b/minigate/tests/install.test.cjs @@ -149,3 +149,31 @@ test('README exposes the short command and keeps a fixed-version fallback', () = assert.match(readme, /curl -fsSL https:\/\/raw\.githubusercontent\.com\/tpxcer\/minigate\/main\/scripts\/install\.sh \| sh/); assert.ok(readme.includes(`releases/download/v${currentVersion}/minigate-v${currentVersion}-src.tar.gz`)); }); + +function runSourceDependencies({ cbiPresent, compatRegistered }) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-source-deps-')); + const calls = path.join(tempDir, 'apk-calls'); + const luciDir = path.join(tempDir, 'luci'); + fs.mkdirSync(luciDir); + if (cbiPresent) fs.writeFileSync(path.join(luciDir, 'cbi.lua'), ''); + fs.writeFileSync(path.join(tempDir, 'openssl'), '#!/bin/sh\n[ "$1" = version ]\n', { mode: 0o755 }); + fs.writeFileSync(path.join(tempDir, 'apk'), `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(calls)}\nif [ "$1" = info ]; then [ "$3" != luci-compat ] || [ ${compatRegistered ? 1 : 0} = 1 ]; else exit 0; fi\n`, { mode: 0o755 }); + const result = shell(`${sourceDependencyFunctions}\ninstall_dependencies`, { PATH: tempDir, MINIGATE_LUCI_DIR: luciDir }); + assert.equal(result.status, 0, result.stderr); + return fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : ''; +} + +test('source installer skips luci-compat when the CBI module already exists', () => { + assert.doesNotMatch(runSourceDependencies({ cbiPresent: true, compatRegistered: false }), /luci-compat/); +}); + +test('source installer installs luci-compat when the CBI module is missing', () => { + assert.match(runSourceDependencies({ cbiPresent: false, compatRegistered: false }), /add luci-compat/); +}); + +test('packages declare the luci-compat dependency required by the CBI pages', () => { + const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8'); + const builder = fs.readFileSync(path.join(root, 'scripts/build-ipk.sh'), 'utf8'); + assert.match(makefile, /DEPENDS:=.*\+luci-compat\b/); + assert.match(builder, /^DEPS=".*\bluci-compat\b/m); +}); diff --git a/minigate/tests/login-guard.test.cjs b/minigate/tests/login-guard.test.cjs new file mode 100644 index 000000000..70ee1b920 --- /dev/null +++ b/minigate/tests/login-guard.test.cjs @@ -0,0 +1,113 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn, spawnSync } = require('node:child_process'); + +const root = path.resolve(__dirname, '..'); +const script = path.join(root, 'root/usr/lib/minigate/login_guard.sh'); + +function runRepair({ firewallReady }) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-login-guard-test-')); + const dataDir = path.join(dir, 'data'); + const bans = path.join(dataDir, 'bans.txt'); + const calls = path.join(dir, 'nft.log'); + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(bans, '198.51.100.4 9999999999\n198.51.100.5 1\n'); + const before = fs.statSync(bans).ino; + + const command = ` + set -u + logger() { :; } + uci() { return 1; } + nft() { + printf '%s\\n' "$*" >> ${JSON.stringify(calls)} + case "$*" in + 'list set inet fw4 login_banned_v4') [ ${firewallReady ? 1 : 0} = 1 ]; return ;; + 'list chain inet fw4 input') [ ${firewallReady ? 1 : 0} = 1 ] && printf 'ip saddr @login_banned_v4 counter drop\\n'; return 0 ;; + esac + return 0 + } + export MINIGATE_LG_LIBRARY_ONLY=1 + export MINIGATE_LG_DATA_DIR=${JSON.stringify(dataDir)} + export MINIGATE_LG_RUN_DIR=${JSON.stringify(path.join(dir, 'run'))} + export MINIGATE_LG_LOG=${JSON.stringify(path.join(dir, 'login-guard.log'))} + . ${JSON.stringify(script)} + repair_nft + `; + const result = spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' }); + return { + result, + calls: fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : '', + bans: fs.readFileSync(bans, 'utf8'), + rewritten: fs.statSync(bans).ino !== before + }; +} + +test('periodic nft check does not rewrite the persisted ban list when rules are intact', () => { + const scenario = runRepair({ firewallReady: true }); + assert.equal(scenario.result.status, 0, scenario.result.stderr); + assert.doesNotMatch(scenario.calls, /add element/); + assert.equal(scenario.rewritten, false, 'bans.txt must not be rewritten on every watchdog tick'); +}); + +test('a firewall reload that drops the set restores persisted bans immediately', () => { + const scenario = runRepair({ firewallReady: false }); + assert.equal(scenario.result.status, 0, scenario.result.stderr); + assert.match(scenario.calls, /add set inet fw4 login_banned_v4/); + assert.match(scenario.calls, /insert rule inet fw4 input ip saddr @login_banned_v4/); + assert.match(scenario.calls, /add element inet fw4 login_banned_v4 \{ 198\.51\.100\.4 timeout \d+s \}/); + assert.doesNotMatch(scenario.calls, /198\.51\.100\.5/, 'expired bans must not be restored'); + assert.equal(scenario.bans, '198.51.100.4 9999999999\n'); +}); + +// BusyBox pgrep 只排除自身,不像 macOS 那样默认排除祖先进程。 +function busyboxPgrep(dir) { + const shim = path.join(dir, 'bin/pgrep'); + fs.mkdirSync(path.dirname(shim), { recursive: true }); + fs.writeFileSync(shim, `#!/bin/sh +self=$$ +case "$1" in + -f) ps -A -o pid= -o ppid= -o args= | awk -v self="$self" -v re="$2" ' + { pid=$1; ppid=$2; if (pid == self || ppid == self) next; $1=""; $2=""; sub(/^ +/, ""); if ($0 ~ re) print pid }' ;; + -P) ps -A -o pid= -o ppid= | awk -v self="$self" -v parent="$2" '$2 == parent && $1 != self { print $1 }' ;; +esac +`); + fs.chmodSync(shim, 0o755); + return path.dirname(shim); +} + +test('stopping the login guard ends its children but not a caller that mentions it', async () => { + const init = path.join(root, 'root/etc/init.d/minigate'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-login-guard-stop-')); + const followed = path.join(dir, 'access.log'); + const guardScript = path.join(dir, 'login_guard.sh'); + const marker = path.join(dir, 'caller-survived'); + fs.writeFileSync(followed, ''); + // 与 login_guard.sh run 相同的结构:后台子 shell + tail -f 管道;忽略 SIGPIPE 的 tail 失去读端后不会自行退出 + fs.writeFileSync(guardScript, `trap "" PIPE\n( while :; do sleep 1; done ) &\ntail -n 0 -f ${JSON.stringify(followed)} | while read -r line; do :; done\n`); + const guard = spawn('/bin/sh', [guardScript, 'run'], { detached: true, stdio: 'ignore' }); + guard.unref(); + const pgrep = pattern => spawnSync('pgrep', ['-f', pattern], { encoding: 'utf8' }).stdout.trim(); + const started = Date.now(); + while (!pgrep(`tail -n 0 -f ${followed}`) && Date.now() - started < 3000) await new Promise(resolve => setTimeout(resolve, 100)); + assert.ok(pgrep(`tail -n 0 -f ${followed}`), 'fake login guard did not start'); + + // 调用方的命令行里含有登录防护的命令文本(例如 SSH 一次性执行的脚本),不能被当成登录防护结束 + const caller = `logger() { :; }; nft() { return 1; }; . ${JSON.stringify(init)}; LOGIN_GUARD=${JSON.stringify(guardScript)}; echo "checking ${guardScript} run" >/dev/null; stop_login_guard; echo ok > ${JSON.stringify(marker)}`; + const result = spawnSync('/bin/sh', ['-c', caller], { encoding: 'utf8', env: { ...process.env, PATH: `${busyboxPgrep(dir)}:${process.env.PATH}` } }); + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.existsSync(marker), true, 'stop_login_guard killed its own caller'); + await new Promise(resolve => setTimeout(resolve, 300)); + assert.equal(pgrep(`tail -n 0 -f ${followed}`), '', 'tail -f must not be left behind'); + assert.equal(pgrep(`${guardScript} run`), ''); +}); + +test('upgrades stop the old login guard tree before running the previous init script', () => { + const installer = fs.readFileSync(path.join(root, 'install.sh'), 'utf8'); + const treeStop = installer.indexOf("pgrep -f '^/bin/sh /usr/lib/minigate/login_guard.sh run'"); + const initStop = installer.indexOf('/etc/init.d/minigate stop'); + assert.ok(treeStop > 0 && treeStop < initStop, 'installer must stop the login guard tree before the old init script'); + assert.match(installer, /stop_process_tree\(\) \{[\s\S]*pgrep -P "\$1"/); +}); diff --git a/minigate/tests/proxy.test.cjs b/minigate/tests/proxy.test.cjs new file mode 100644 index 000000000..5a1cab1a0 --- /dev/null +++ b/minigate/tests/proxy.test.cjs @@ -0,0 +1,67 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const root = path.resolve(__dirname, '..'); +const script = path.join(root, 'root/usr/lib/minigate/proxy.sh'); + +function trim(files, maxAccess = 4096) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-proxy-log-test-')); + const paths = {}; + for (const [name, content] of Object.entries(files)) { + paths[name] = path.join(dir, name); + fs.writeFileSync(paths[name], content); + } + const result = spawnSync('/bin/sh', [script, 'trim-logs'], { + encoding: 'utf8', + env: { + ...process.env, + MINIGATE_ACCESS_LOG: path.join(dir, 'access.log'), + MINIGATE_NGINX_ERROR_LOG: path.join(dir, 'error.log'), + MINIGATE_LOGFILE: path.join(dir, 'proxy.log'), + MINIGATE_ACCESS_LOG_MAX: String(maxAccess) + } + }); + return { result, dir }; +} + +function lines(count, prefix) { + return Array.from({ length: count }, (_, i) => `${prefix}-${i + 1} ${'x'.repeat(60)}`).join('\n') + '\n'; +} + +test('oversized access log keeps recent records in .1 and is truncated in place', () => { + const { result, dir } = trim({ 'access.log': lines(1500, 'req') }); + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(path.join(dir, 'access.log'), 'utf8'), ''); + const rotated = fs.readFileSync(path.join(dir, 'access.log.1'), 'utf8').trim().split('\n'); + assert.equal(rotated.length, 1000); + assert.match(rotated[0], /^req-501 /); + assert.match(rotated[999], /^req-1500 /); +}); + +test('small logs are left untouched', () => { + const content = lines(10, 'req'); + const { result, dir } = trim({ 'access.log': content, 'error.log': 'warn\n' }); + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(path.join(dir, 'access.log'), 'utf8'), content); + assert.equal(fs.existsSync(path.join(dir, 'access.log.1')), false); + assert.equal(fs.readFileSync(path.join(dir, 'error.log'), 'utf8'), 'warn\n'); +}); + +test('oversized nginx error log keeps its newest lines in the same file', () => { + const { result, dir } = trim({ 'error.log': lines(5000, 'err') }); + assert.equal(result.status, 0, result.stderr); + const kept = fs.readFileSync(path.join(dir, 'error.log'), 'utf8').trim().split('\n'); + assert.equal(kept.length, 300); + assert.match(kept[299], /^err-5000 /); +}); + +test('log trimming is scheduled and the visitor list reads the rotated access log', () => { + const init = fs.readFileSync(path.join(root, 'root/etc/init.d/minigate'), 'utf8'); + const controller = fs.readFileSync(path.join(root, 'luasrc/controller/minigate.lua'), 'utf8'); + assert.match(init, /\/bin\/sh \/usr\/lib\/minigate\/proxy\.sh trim-logs/); + assert.match(controller, /cat \/var\/log\/minigate-access\.log\.1 \/var\/log\/minigate-access\.log 2>\/dev\/null \| tail -n 500/); +}); diff --git a/minigate/tests/ui.test.cjs b/minigate/tests/ui.test.cjs new file mode 100644 index 000000000..be6098720 --- /dev/null +++ b/minigate/tests/ui.test.cjs @@ -0,0 +1,128 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const root = path.resolve(__dirname, '..'); + +function pageScript(file) { + const source = fs.readFileSync(path.join(root, file), 'utf8'); + const start = source.indexOf('', start); + return source.slice(start + '