diff --git a/.github/diy/packages/luci-app-clashoo/Makefile b/.github/diy/packages/luci-app-clashoo/Makefile.k similarity index 100% rename from .github/diy/packages/luci-app-clashoo/Makefile rename to .github/diy/packages/luci-app-clashoo/Makefile.k diff --git a/cpulimit/Makefile b/cpulimit/Makefile index 6ebc527bb..6b40cd81d 100644 --- a/cpulimit/Makefile +++ b/cpulimit/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=cpulimit -PKG_VERSION:=1.0.2 -PKG_RELEASE:=3 +PKG_VERSION:=1.0.3 +PKG_RELEASE:=4 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/denji/cpulimit/tar.gz/$(PKG_VERSION)? diff --git a/gately/Makefile b/gately/Makefile index 674c0c45e..7441ba110 100644 --- a/gately/Makefile +++ b/gately/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=gately -PKG_VERSION:=0.6.0.1 -PKG_RELEASE:=14 +PKG_VERSION:=0.6.0.2 +PKG_RELEASE:=15 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/wangwanjie/gately/tar.gz/v$(PKG_VERSION)? diff --git a/luci-app-clashoo/Makefile b/luci-app-clashoo/Makefile index b9317dc35..f3a5c428e 100644 --- a/luci-app-clashoo/Makefile +++ b/luci-app-clashoo/Makefile @@ -1,100 +1,14 @@ +# See /LICENSE for more information. +# This is free software, licensed under the GNU General Public License v2. + include $(TOPDIR)/rules.mk -PKG_NAME:=luci-app-clashoo -PKG_VERSION:=1.31.0 -PKG_RELEASE:=11 +LUCI_TITLE:=LuCI app for Clashoo +LUCI_DEPENDS:=+luci-base +clashoo + PKG_MAINTAINER:=kenzok8 -PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) +PKG_LICENSE:=GPL-3.0-or-later -include $(INCLUDE_DIR)/package.mk +include $(TOPDIR)/feeds/luci/luci.mk -define Package/luci-app-clashoo - SECTION:=luci - CATEGORY:=LuCI - SUBMENU:=3. Applications - TITLE:=LuCI app for Clashoo - DEPENDS:=+luci-base +clashoo +luci-i18n-clashoo-zh-cn - PKGARCH:=all - MAINTAINER:=kenzok8 -endef - -define Package/luci-app-clashoo/description - LuCI web interface for Clashoo -endef - -define Build/Configure -endef - -define Build/Prepare - mkdir -p $(PKG_BUILD_DIR) - po2lmo ./po/zh-cn/clashoo.po $(PKG_BUILD_DIR)/clashoo.zh-cn.lmo -endef - -define Build/Compile -endef - -define Package/luci-app-clashoo/install - $(INSTALL_DIR) $(1)/usr/share/rpcd/ucode - $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d - $(INSTALL_DIR) $(1)/usr/share/clashoo/rpc - $(INSTALL_DIR) $(1)/usr/share/luci/menu.d - $(INSTALL_DIR) $(1)/www/luci-static/resources/tools - $(INSTALL_DIR) $(1)/www/luci-static/resources/view/clashoo - $(INSTALL_DIR) $(1)/www/luci-static/resources/cm/mode - $(INSTALL_DIR) $(1)/www/luci-static/resources/cm/addon - $(INSTALL_DIR) $(1)/www/luci-static/clashoo - - $(INSTALL_BIN) ./root/usr/share/rpcd/ucode/luci.clashoo $(1)/usr/share/rpcd/ucode/luci.clashoo - $(INSTALL_BIN) ./root/usr/share/clashoo/rpc/backup_archive.sh $(1)/usr/share/clashoo/rpc/backup_archive.sh - $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-clashoo.json $(1)/usr/share/rpcd/acl.d/luci-app-clashoo.json - $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-clashoo.json $(1)/usr/share/luci/menu.d/luci-app-clashoo.json - - $(INSTALL_DATA) ./htdocs/luci-static/resources/tools/clashoo.js $(1)/www/luci-static/resources/tools/clashoo.js - $(INSTALL_DATA) ./htdocs/luci-static/resources/view/clashoo/* $(1)/www/luci-static/resources/view/clashoo/ - $(INSTALL_DATA) ./htdocs/luci-static/resources/cm/codemirror.js $(1)/www/luci-static/resources/cm/codemirror.js - $(INSTALL_DATA) ./htdocs/luci-static/resources/cm/codemirror.css $(1)/www/luci-static/resources/cm/codemirror.css - $(INSTALL_DATA) ./htdocs/luci-static/resources/cm/mode/* $(1)/www/luci-static/resources/cm/mode/ - $(INSTALL_DATA) ./htdocs/luci-static/resources/cm/addon/* $(1)/www/luci-static/resources/cm/addon/ - $(INSTALL_DATA) ./htdocs/luci-static/clashoo/logo.png $(1)/www/luci-static/clashoo/logo.png -endef - -define Package/luci-app-clashoo/postinst -#!/bin/sh -if [ -z "$${IPKG_INSTROOT}" ]; then - rm -rf /tmp/luci-indexcache /tmp/luci-modulecache - # reload (not restart) rpcd so the LuCI login session survives the upgrade — - # rpcd restart drops every ubus session and forces a re-login (issue #12). - # reload still picks up the new ucode backend + ACL. uhttpd needs nothing: - # updated static assets are served on the next request. - /etc/init.d/rpcd reload >/dev/null 2>&1 || true -fi -exit 0 -endef - -define Package/luci-i18n-clashoo-zh-cn - SECTION:=luci - CATEGORY:=LuCI - TITLE:=luci-app-clashoo - Simplified Chinese (zh-cn) - DEPENDS:=+luci-base - DEFAULT:=LUCI_LANG_zh_Hans||(ALL&&m) - HIDDEN:=1 - PKGARCH:=all -endef - -define Package/luci-i18n-clashoo-zh-cn/install - $(INSTALL_DIR) $(1)/usr/lib/lua/luci/i18n - $(INSTALL_DATA) $(PKG_BUILD_DIR)/clashoo.zh-cn.lmo $(1)/usr/lib/lua/luci/i18n/clashoo.zh-cn.lmo -endef - -define Package/luci-i18n-clashoo-zh-cn/postinst -#!/bin/sh -if [ -z "$${IPKG_INSTROOT}" ]; then - # just clear the LuCI cache so the new translations load on next request; - # no service restart needed (and none that would drop the login session). - rm -rf /tmp/luci-indexcache /tmp/luci-modulecache -fi -exit 0 -endef - -$(eval $(call BuildPackage,luci-app-clashoo)) -$(eval $(call BuildPackage,luci-i18n-clashoo-zh-cn)) +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-honk/Makefile b/luci-app-honk/Makefile index 66950ba18..76c683b23 100644 --- a/luci-app-honk/Makefile +++ b/luci-app-honk/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-honk PKG_VERSION:=1.1.4 -PKG_RELEASE:=26 +PKG_RELEASE:=27 LUCI_TITLE:=LuCI support for honk LUCI_PKGARCH:=all diff --git a/luci-app-honk/root/etc/uci-defaults/luci-honk b/luci-app-honk/root/etc/uci-defaults/luci-honk index 711152bf0..5d136fee9 100644 --- a/luci-app-honk/root/etc/uci-defaults/luci-honk +++ b/luci-app-honk/root/etc/uci-defaults/luci-honk @@ -60,7 +60,7 @@ if [ -f /etc/honk/config.dae ]; then ' 2>/dev/null || true fi -rm -rf /tmp/luci-* +rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache /tmp/luci-*.lmo /etc/init.d/rpcd reload 2>/dev/null exit 0 diff --git a/luci-app-open-box/Makefile b/luci-app-open-box/Makefile index 3c83023b6..6929f64b8 100644 --- a/luci-app-open-box/Makefile +++ b/luci-app-open-box/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-open-box -PKG_VERSION:=0.1.369 -PKG_RELEASE:=49 +PKG_VERSION:=0.1.370 +PKG_RELEASE:=50 PKG_MAINTAINER:=liandu2024 PKG_LICENSE:=GPL-3.0-or-later diff --git a/luci-app-quickstart/luasrc/controller/istore_backend.lua b/luci-app-quickstart/luasrc/controller/istore_backend.lua index a5c2c6c63..75bde7923 100644 --- a/luci-app-quickstart/luasrc/controller/istore_backend.lua +++ b/luci-app-quickstart/luasrc/controller/istore_backend.lua @@ -22,7 +22,7 @@ local function sink_socket(sock, io_err) if not chunk then return 1 else - return sock:send(chunk) + return sock:sendall(chunk) end end else @@ -64,14 +64,17 @@ local function chunksource(sock, buffer) return function() local output local _, endp, count = buffer:find("^([0-9a-fA-F]+);?.-\r\n") - while not count and #buffer <= 1024 do + while not count and #buffer < 1024 do local newblock, code = sock:recv(1024 - #buffer) - if not newblock then + if not newblock or newblock == "" then return nil, code end buffer = buffer .. newblock _, endp, count = buffer:find("^([0-9a-fA-F]+);?.-\r\n") end + if not count then + return nil, -1, "invalid encoding" + end count = tonumber(count, 16) if not count then return nil, -1, "invalid encoding" @@ -83,6 +86,7 @@ local function chunksource(sock, buffer) return output else output = buffer:sub(endp+1, endp+count) + local buffered_tail = #buffer - endp - #output buffer = "" if count - #output > 0 then local remain, code = sock:recvall(count-#output) @@ -92,7 +96,7 @@ local function chunksource(sock, buffer) output = output .. remain count, code = sock:recvall(2) else - count, code = sock:recvall(count+2-#buffer+endp) + count, code = sock:recvall(2-buffered_tail) end if not count then return nil, code diff --git a/luci-app-quickstart/luasrc/controller/quickstart.lua b/luci-app-quickstart/luasrc/controller/quickstart.lua index 3667039a5..84ddac57b 100644 --- a/luci-app-quickstart/luasrc/controller/quickstart.lua +++ b/luci-app-quickstart/luasrc/controller/quickstart.lua @@ -95,14 +95,30 @@ function auto_setup() -- form style local packages = luci.http.formvalue("packages") local pkgs = "" + local function valid_package(value) + return type(value) == "string" and #value <= 128 and + value:match("^[%w][%w%.%+_%-]*$") ~= nil + end + local function invalid_package() + luci.http.prepare_content("application/json") + luci.http.write_json({success=1, scope="params", error="Invalid package name"}) + end if type(packages) == "table" then if #packages > 0 then for k, v in pairs(packages) do + if not valid_package(v) then + invalid_package() + return + end pkgs = pkgs .. " " .. luci.util.shellquote(v) end end else if packages ~= nil and packages ~= "" then + if not valid_package(packages) then + invalid_package() + return + end pkgs = luci.util.shellquote(packages) end end diff --git a/luci-app-quickstart/root/usr/libexec/quickstart/auto_setup.sh b/luci-app-quickstart/root/usr/libexec/quickstart/auto_setup.sh index a69d2f433..fe0869401 100755 --- a/luci-app-quickstart/root/usr/libexec/quickstart/auto_setup.sh +++ b/luci-app-quickstart/root/usr/libexec/quickstart/auto_setup.sh @@ -1,12 +1,22 @@ #!/bin/sh +# Validate all arguments before touching logs, installing packages or sourcing +# metadata scripts. This script is also callable without the LuCI controller. +[ "$#" -gt 0 ] || { echo "No packages specified" >&2; exit 1; } +for pkg in "$@"; do + case "$pkg" in + ''|[!a-zA-Z0-9]*|*[!a-zA-Z0-9_.+-]*) echo "Invalid package name" >&2; exit 1 ;; + esac + [ "${#pkg}" -le 128 ] || { echo "Invalid package name" >&2; exit 1; } +done + > /var/log/auto_setup.success > /var/log/auto_setup.failed > /var/log/auto_setup.input save_input() { local pkg - for pkg in $@; do + for pkg in "$@"; do echo "$pkg" >> /var/log/auto_setup.input done } @@ -37,14 +47,14 @@ load_quickstart_cfg() { auto_setup_app() { local pkg=$1 is-opkg install "app-meta-$pkg" || return 1 - sh -c ". '/usr/libexec/istorea/$pkg.sh'" + sh -c '. "$1"' sh "/usr/libexec/istorea/$pkg.sh" } auto_setup_apps() { local pkg - for pkg in $@; do + for pkg in "$@"; do echo "Setting up $pkg..." - if auto_setup_app $pkg; then + if auto_setup_app "$pkg"; then echo "Set up $pkg success" echo "$pkg" >> /var/log/auto_setup.success else diff --git a/luci-app-quickstart/tests/auto_setup_test.py b/luci-app-quickstart/tests/auto_setup_test.py new file mode 100644 index 000000000..c03920eab --- /dev/null +++ b/luci-app-quickstart/tests/auto_setup_test.py @@ -0,0 +1,55 @@ +"""Exercise the real script with task-owned paths and mocked package installs.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +class AutoSetupTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.logs = self.root / "log" + self.logs.mkdir() + (self.root / "bin").mkdir() + (self.root / "metadata").mkdir() + self.installed = self.root / "installed" + self.configured = self.root / "configured" + helper = self.root / "functions.sh" + helper.write_text('config_load() { return 0; }\nconfig_get() { eval "$1=\\\"$FIXTURE_MAIN\\\""; }\n') + installer = self.root / "bin/is-opkg" + installer.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >> "$FIXTURE_INSTALLED"\n') + installer.chmod(0o700) + source = (Path(__file__).resolve().parents[1] / "root/usr/libexec/quickstart/auto_setup.sh").read_text() + source = source.replace("/var/log", str(self.logs)).replace("/lib/functions.sh", str(helper)).replace("/usr/libexec/istorea", str(self.root / "metadata")) + self.script = self.root / "auto_setup.sh" + self.script.write_text(source) + self.env = dict(os.environ, PATH=str(self.root / "bin") + ":" + os.environ["PATH"], FIXTURE_MAIN=str(self.root / "data"), FIXTURE_INSTALLED=str(self.installed), FIXTURE_CONFIGURED=str(self.configured)) + + def run_script(self, *args): + return subprocess.run(["sh", str(self.script), *args], env=self.env, capture_output=True, text=True, timeout=5) + + def test_rejects_all_invalid_arguments_before_side_effects(self): + for name in ("../other", "--help", "two packages", "pkg'", "", "a" * 129): + result = self.run_script("aria2", name) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.installed.exists()) + self.assertEqual(list(self.logs.iterdir()), []) + + def test_normal_packages_install_configure_and_report(self): + packages = ("aria2", "foo+bar_1.2-3") + for name in packages: + (self.root / "metadata" / (name + ".sh")).write_text('printf "%s\\n" "' + name + '" >> "$FIXTURE_CONFIGURED"\n') + result = self.run_script(*packages) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.installed.read_text().splitlines(), ["install", "app-meta-aria2", "install", "app-meta-foo+bar_1.2-3"]) + self.assertEqual(self.configured.read_text().splitlines(), list(packages)) + self.assertEqual((self.logs / "auto_setup.success").read_text().splitlines(), list(packages)) + self.assertEqual((self.logs / "auto_setup.input").read_text().splitlines(), list(packages)) + self.assertEqual((self.logs / "auto_setup.failed").read_text(), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/luci-app-quickstart/tests/security_regression.lua b/luci-app-quickstart/tests/security_regression.lua new file mode 100644 index 000000000..0d788d583 --- /dev/null +++ b/luci-app-quickstart/tests/security_regression.lua @@ -0,0 +1,88 @@ +-- Run against a staged source copy: lua tests/security_regression.lua ROOT +-- All LuCI, filesystem and command dependencies are mocked. +local root = assert(arg[1], "source root required") +local forms, response, executions = {}, nil, {} +local http = { + formvalue = function(key) return forms[key] end, + prepare_content = function() end, + write_json = function(value) response = value end, +} +local fs = { readfile = function() return "" end, unlink = function() end } +local nixio = { bit = { rshift = function(value, bits) return math.floor(value / 2^bits) end } } +local util = { shellquote = function(value) return "'" .. value:gsub("'", "'\\''") .. "'" end } +package.preload["luci.http"] = function() return http end +package.preload["luci.util"] = function() return util end +package.preload["luci.ltn12"] = function() return {} end +package.preload["nixio"] = function() return nixio end +package.preload["nixio.fs"] = function() return fs end +_G.luci = { http = http, util = util } +_G.nixio = nixio +local original_execute = os.execute +os.execute = function(command) executions[#executions+1] = command; return 0 end +assert(loadfile(root .. "/luasrc/controller/quickstart.lua"))() +local quickstart = package.loaded["luci.controller.quickstart"] +for _, packages in ipairs({"../other", "--help", "two packages", "pkg'", {"aria2", "../other"}, {"aria2", {}}}) do + forms, response, executions = {packages=packages}, nil, {} + quickstart.auto_setup() + assert(response and response.success ~= 0 and response.scope == "params", "invalid package accepted") + assert(#executions == 0, "invalid package reached shell") +end +for _, packages in ipairs({"aria2", {"qbittorrent", "foo+bar_1.2-3"}}) do + forms, response, executions = {packages=packages}, nil, {} + quickstart.auto_setup() + assert(response and response.success == 0 and #executions == 1, "normal package failed") +end +os.execute = original_execute + +assert(loadfile(root .. "/luasrc/controller/istore_backend.lua"))() +local proxy = package.loaded["luci.controller.istore_backend"].istore_backend +local function upvalue(name) + for i=1,100 do + local key, value = debug.getupvalue(proxy, i) + if not key then break end + if key == name then return value end + end + error("missing proxy helper " .. name) +end +local sink = upvalue("sink_socket") +local sent = "" +assert(sink({sendall=function(_, chunk) sent=sent..chunk; return #chunk end})("whole body")) +assert(sent == "whole body", "proxy did not send complete input") + +local chunksource = upvalue("chunksource") +local wire = "5\r\nhello\r\n5\r\nworld\r\n0\r\n\r\n" +for split=0,#wire do + local pos, calls = split+1, 0 + local sock = {} + function sock:recv(n) + assert(n > 0, "zero-length receive") + calls = calls + 1 + assert(calls < 100, "unbounded receive loop") + local data=wire:sub(pos, pos+math.min(n, 3)-1) + pos=pos+#data + return data + end + function sock:recvall(n) + local result="" + while #result= 1000 && i < u.length - 1) { bps /= 1000; i++; } return { v: i === 0 ? bps.toFixed(0) : sig3(bps), u: u[i] }; }, @@ -50,7 +50,7 @@ return baseclass.extend({ bytes: function(b) { b = num(b); if (b == null || b < 0) return DASH; - var u = [ _('B'), _('KiB'), _('MiB'), _('GiB'), _('TiB'), _('PiB') ], i = 0; + var u = [ _('B', 'vantage'), _('KiB', 'vantage'), _('MiB', 'vantage'), _('GiB', 'vantage'), _('TiB', 'vantage'), _('PiB') ], i = 0; while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; } return (i === 0 ? b.toFixed(0) : b >= 100 ? b.toFixed(0) : b >= 10 ? b.toFixed(1) : b.toFixed(2)) + ' ' + u[i]; }, @@ -85,10 +85,10 @@ return baseclass.extend({ ms = num(ms); if (ms == null || ms < 0) return DASH; var s = Math.round(ms / 1000); - if (s < 3) return _('just now'); - if (s < 60) return _('%d s ago').format(s); - if (s < 3600) return _('%d min ago').format(Math.floor(s / 60)); - return _('%d h ago').format(Math.floor(s / 3600)); + if (s < 3) return _('just now', 'vantage'); + if (s < 60) return _('%d s ago', 'vantage').format(s); + if (s < 3600) return _('%d min ago', 'vantage').format(Math.floor(s / 60)); + return _('%d h ago', 'vantage').format(Math.floor(s / 3600)); }, dbm: function(v) { diff --git a/luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js b/luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js index 7a0f69048..0d7eb0770 100644 --- a/luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js +++ b/luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js @@ -42,19 +42,19 @@ return baseclass.extend({ var out = [], dev = model.device || {}, up = model.uplink; /* uplink: live traffic is the value, the negotiated link the context */ - if (!up) out.push({ id: 'uplink', level: 'err', title: _('Uplink'), value: _('Down'), reason: _('No interface with a route'), + if (!up) out.push({ id: 'uplink', level: 'err', title: _('Uplink', 'vantage'), value: _('Down', 'vantage'), reason: _('No interface with a route'), detail: _('No network interface with a route is up'), page: PAGES.network }); - else if (up.carrier === false) out.push({ id: 'uplink', level: 'err', title: _('Uplink'), value: _('No link'), reason: _('%s: no carrier').format(up.dev), + else if (up.carrier === false) out.push({ id: 'uplink', level: 'err', title: _('Uplink', 'vantage'), value: _('No link'), reason: _('%s: no carrier').format(up.dev), detail: _('%s has no carrier: cable unplugged or switch port down').format(up.dev), page: PAGES.network }); - else if (up.speed && up.speed.duplex === 'half') out.push({ id: 'uplink', level: 'warn', title: _('Uplink'), value: _('Half duplex'), reason: _('%s · check cable').format(up.dev), + else if (up.speed && up.speed.duplex === 'half') out.push({ id: 'uplink', level: 'warn', title: _('Uplink', 'vantage'), value: _('Half duplex'), reason: _('%s · check cable').format(up.dev), detail: _('%s negotiated half duplex; check the cable and switch port').format(up.dev), page: PAGES.network }); - else if (up.speed && up.speed.mbps < 1000) out.push({ id: 'uplink', level: 'warn', title: _('Uplink'), value: _('%s Mbit/s').format(up.speed.mbps), reason: _('%s · slow link').format(up.dev), + else if (up.speed && up.speed.mbps < 1000) out.push({ id: 'uplink', level: 'warn', title: _('Uplink', 'vantage'), value: _('%s Mbit/s').format(up.speed.mbps), reason: _('%s · slow link').format(up.dev), detail: _('%s negotiated only %d Mbit/s; a cable pair or port may be faulty').format(up.dev, up.speed.mbps), page: PAGES.network }); else { var rate = live.uplinkRate && finite(live.uplinkRate.rx) && finite(live.uplinkRate.tx) ? live.uplinkRate : null; /* no-break spaces keep '↑ 2.9 Mbit/s' and '2.5G link' whole when the chip wraps */ var link = up.speed ? _('%s link').format(speedShort(up.speed.mbps)).replace(/ /g, '\u00a0') : null; - out.push({ id: 'uplink', level: 'ok', title: _('Uplink'), value: rate ? '↓ ' + fmt.bits(rate.rx) : _('Up'), + out.push({ id: 'uplink', level: 'ok', title: _('Uplink', 'vantage'), value: rate ? '↓ ' + fmt.bits(rate.rx) : _('Up', 'vantage'), reason: [ rate ? ('↑ ' + fmt.bits(rate.tx)).replace(/ /g, '\u00a0') : null, up.dev, link ].filter(Boolean).join(' · '), detail: (up.speed ? _('%s up at %s').format(up.dev, speedLong(up.speed.mbps)) : _('%s up').format(up.dev)) + (up.gateway ? ', ' + _('gateway %s').format(up.gateway) : '') + @@ -65,8 +65,8 @@ return baseclass.extend({ /* CPU */ var cpu = live.cpu && finite(live.cpu.pct) ? live.cpu.pct : null, cores = (live.cpu && live.cpu.cores && live.cpu.cores.length) || 1; var load = (dev.load || [])[0]; - if (cpu == null) out.push({ id: 'cpu', level: 'info', title: _('CPU'), value: fmt.DASH, reason: _('Measuring…'), detail: _('Measuring over the next poll'), page: PAGES.processes }); - else out.push({ id: 'cpu', level: cpu >= 90 ? 'err' : cpu >= 70 ? 'warn' : 'ok', title: _('CPU'), value: fmt.pct(cpu), + if (cpu == null) out.push({ id: 'cpu', level: 'info', title: _('CPU', 'vantage'), value: fmt.DASH, reason: _('Measuring…'), detail: _('Measuring over the next poll'), page: PAGES.processes }); + else out.push({ id: 'cpu', level: cpu >= 90 ? 'err' : cpu >= 70 ? 'warn' : 'ok', title: _('CPU', 'vantage'), value: fmt.pct(cpu), reason: cpu >= 70 ? _('Busy · load %s').format(fmt.load(load)) : _('Load %s · %d cores').format(fmt.load(load), cores), detail: cpu >= 70 ? _('Busy; load %s on %d cores; see the busiest processes').format(fmt.load(load), cores) : _('Load %s on %d cores').format(fmt.load(load), cores), page: PAGES.processes }); @@ -75,7 +75,7 @@ return baseclass.extend({ var mem = dev.memory; if (mem) { var avail = finite(mem.availPct) ? mem.availPct : 100 - num(mem.usedPct); - out.push({ id: 'memory', level: mem.usedPct >= 90 ? 'err' : mem.usedPct >= 80 ? 'warn' : 'ok', title: _('Memory'), value: _('%s available').format(fmt.pct(avail)), + out.push({ id: 'memory', level: mem.usedPct >= 90 ? 'err' : mem.usedPct >= 80 ? 'warn' : 'ok', title: _('Memory'), value: _('%s available', 'vantage').format(fmt.pct(avail)), reason: _('%s of %s').format(fmt.bytes(mem.available), fmt.bytes(mem.total)), detail: _('%s of %s available (%s in use)').format(fmt.bytes(mem.available), fmt.bytes(mem.total), fmt.pct(mem.usedPct)), page: PAGES.processes }); } @@ -156,12 +156,12 @@ return baseclass.extend({ } if (c.legacyOnRadio) - factors.push({ cost: 5, text: _('%s device on a newer radio; uses more airtime per byte').format(c.gen === 'legacy' ? _('Legacy') : c.gen), + factors.push({ cost: 5, text: _('%s device on a newer radio; uses more airtime per byte').format(c.gen === 'legacy' ? _('Legacy', 'vantage') : c.gen), short: _('Older %s device').format(c.gen === 'legacy' ? _('legacy') : c.gen) }); var cost = factors.reduce(function(a, f) { return a + f.cost; }, 0); var score = Math.max(0, Math.min(100, 100 - cost)); - var grade = score >= 85 ? _('Excellent') : score >= 65 ? _('Good') : score >= 40 ? _('Fair') : _('Poor'); + var grade = score >= 85 ? _('Excellent', 'vantage') : score >= 65 ? _('Good', 'vantage') : score >= 40 ? _('Fair', 'vantage') : _('Poor', 'vantage'); var level = score >= 65 ? 'ok' : score >= 40 ? 'warn' : 'err'; var worst = factors.slice().sort(function(a, b) { return b.cost - a.cost; })[0]; var reason = (worst && worst.cost > 0) ? worst.text diff --git a/luci-app-vantage/htdocs/luci-static/resources/vantage/model.js b/luci-app-vantage/htdocs/luci-static/resources/vantage/model.js index ebeb56131..cf41841e6 100644 --- a/luci-app-vantage/htdocs/luci-static/resources/vantage/model.js +++ b/luci-app-vantage/htdocs/luci-static/resources/vantage/model.js @@ -79,9 +79,9 @@ return baseclass.extend({ total: total, available: avail, used: used, cache: cache, free: free, availPct: avail / total * 100, usedPct: used / total * 100, segments: [ - { key: 'used', label: _('In use'), bytes: used, pct: used / total * 100 }, + { key: 'used', label: _('In use', 'vantage'), bytes: used, pct: used / total * 100 }, { key: 'cache', label: _('Cache (reclaimable)'), bytes: cache, pct: cache / total * 100 }, - { key: 'free', label: _('Free'), bytes: free, pct: free / total * 100 } + { key: 'free', label: _('Free', 'vantage'), bytes: free, pct: free / total * 100 } ] }; }, diff --git a/luci-app-vantage/htdocs/luci-static/resources/vantage/names.js b/luci-app-vantage/htdocs/luci-static/resources/vantage/names.js index 45709493d..8f888ee0d 100644 --- a/luci-app-vantage/htdocs/luci-static/resources/vantage/names.js +++ b/luci-app-vantage/htdocs/luci-static/resources/vantage/names.js @@ -39,7 +39,7 @@ var LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(^|[^\ud800-\udbff])[\u var SPACES = /[\u0020\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]+/g; var SOURCES = { - alias: _('Alias'), dns: _('DNS'), mdns: _('mDNS'), dhcp: _('DHCP'), wps: _('WPS'), + alias: _('Alias', 'vantage'), dns: _('DNS'), mdns: _('mDNS'), dhcp: _('DHCP'), wps: _('WPS'), vendor: _('Vendor'), 'private': _('Private'), mac: _('MAC') }; diff --git a/luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js b/luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js index 1db20f90d..19ebf87f8 100644 --- a/luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js +++ b/luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js @@ -9,9 +9,9 @@ function num(v) { v = +v; return isFinite(v) ? v : 0; } function own(o, k) { return o != null && Object.prototype.hasOwnProperty.call(o, k); } var BANDS = { - '2g': { label: _('2.4 GHz'), short: '2.4', lo: 2400, hi: 2495, base: 2407 }, - '5g': { label: _('5 GHz'), short: '5', lo: 5150, hi: 5895, base: 5000 }, - '6g': { label: _('6 GHz'), short: '6', lo: 5925, hi: 7125, base: 5950 }, + '2g': { label: _('2.4 GHz', 'vantage'), short: '2.4', lo: 2400, hi: 2495, base: 2407 }, + '5g': { label: _('5 GHz', 'vantage'), short: '5', lo: 5150, hi: 5895, base: 5000 }, + '6g': { label: _('6 GHz', 'vantage'), short: '6', lo: 5925, hi: 7125, base: 5950 }, '60g': { label: _('60 GHz'), short: '60', lo: 57240, hi: 70200, base: 56160 } }; @@ -21,13 +21,13 @@ var GEN_RANK = { 'legacy': 0, 'Wi-Fi 4': 4, 'Wi-Fi 5': 5, 'Wi-Fi 6': 6, 'Wi-Fi 6 /* UCI encryption -> friendly label. Suffixes like +ccmp are cipher choices, not a different protocol. */ var SECURITY = [ - [ /^none$/, _('Open'), _('Open network'), 'open' ], - [ /^owe$/, _('OWE'), _('Enhanced Open (OWE)'), 'owe' ], + [ /^none$/, _('Open', 'vantage'), _('Open network'), 'open' ], + [ /^owe$/, _('OWE', 'vantage'), _('Enhanced Open (OWE)'), 'owe' ], [ /^sae-mixed$/, _('WPA2/3'), _('WPA2/WPA3-Personal'), 'ok' ], - [ /^sae(-ext)?$/, _('WPA3'), _('WPA3-Personal'), 'ok' ], - [ /^psk-mixed$/, _('WPA/WPA2'), _('WPA/WPA2-Personal (TKIP allowed)'), 'weak' ], - [ /^psk2$/, _('WPA2'), _('WPA2-Personal'), 'ok' ], - [ /^psk$/, _('WPA'), _('WPA-Personal (legacy)'), 'weak' ], + [ /^sae(-ext)?$/, _('WPA3', 'vantage'), _('WPA3-Personal'), 'ok' ], + [ /^psk-mixed$/, _('WPA/WPA2', 'vantage'), _('WPA/WPA2-Personal (TKIP allowed)'), 'weak' ], + [ /^psk2$/, _('WPA2', 'vantage'), _('WPA2-Personal'), 'ok' ], + [ /^psk$/, _('WPA', 'vantage'), _('WPA-Personal (legacy)'), 'weak' ], [ /^wpa3-mixed$/, _('WPA2/3-Ent'), _('WPA2/WPA3-Enterprise'), 'ok' ], [ /^wpa3(-192)?$/, _('WPA3-Ent'), _('WPA3-Enterprise'), 'ok' ], [ /^wpa-mixed$/, _('WPA/WPA2-Ent'), _('WPA/WPA2-Enterprise'), 'weak' ], @@ -114,9 +114,9 @@ return baseclass.extend({ signal: function(dbm) { dbm = num(dbm); if (!dbm || dbm >= 0) return { bars: 0, level: 'none', word: _('No signal') }; - if (dbm >= -55) return { bars: 4, level: 'ok', word: _('Excellent') }; - if (dbm >= -67) return { bars: 3, level: 'ok', word: _('Good') }; - if (dbm >= -75) return { bars: 2, level: 'warn', word: _('Fair') }; + if (dbm >= -55) return { bars: 4, level: 'ok', word: _('Excellent', 'vantage') }; + if (dbm >= -67) return { bars: 3, level: 'ok', word: _('Good', 'vantage') }; + if (dbm >= -75) return { bars: 2, level: 'warn', word: _('Fair', 'vantage') }; return { bars: 1, level: 'crit', word: _('Weak') }; }, diff --git a/luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js b/luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js index 509164220..6313f98a9 100644 --- a/luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js +++ b/luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js @@ -97,7 +97,7 @@ function what(item) { function from(item) { var f = item.from; - if (!f.anywhere) return _('from %s').format(f.only.join(', ')); + if (!f.anywhere) return _('from %s', 'vantage').format(f.only.join(', ')); return f.except.length ? _('from anywhere except %s').format(f.except.join(', ')) : _('from anywhere'); } @@ -109,16 +109,16 @@ function target(i) { return host + (i.hostPort ? ':' + i.hostPort : ''); } -function zoneName(z) { return z === '*' ? _('any zone') : z; } +function zoneName(z) { return z === '*' ? _('any zone', 'vantage') : z; } function named(item) { if (item.name) return item.name; - return item.kind === 'redirect' ? _('Unnamed port forward') : _('Unnamed rule'); + return item.kind === 'redirect' ? _('Unnamed port forward') : _('Unnamed rule', 'vantage'); } function polBadge(p, risky) { var cls = risky ? 'is-risk' : p === 'ACCEPT' ? 'is-accept' : 'is-stop'; - return E('span', { 'class': 'vt-fg-pol ' + cls }, [ p === 'ACCEPT' ? _('accept') : p === 'DROP' ? _('drop') : _('reject') ]); + return E('span', { 'class': 'vt-fg-pol ' + cls }, [ p === 'ACCEPT' ? _('accept', 'vantage') : p === 'DROP' ? _('drop', 'vantage') : _('reject', 'vantage') ]); } /* a setup fwglance does not follow (its complex) */ @@ -168,7 +168,7 @@ return view.extend({ return E('div', { 'class': 'vt-app vt-fg' }, [ E('div', { 'class': 'vt-body' }, [ this.renderHead(fw), - fw.warnings.length ? E('div', { 'class': 'vt-fg-warns', 'role': 'list', 'aria-label': _('Warnings') }, fw.warnings.map(this.renderWarning)) : '', + fw.warnings.length ? E('div', { 'class': 'vt-fg-warns', 'role': 'list', 'aria-label': _('Warnings', 'vantage') }, fw.warnings.map(this.renderWarning)) : '', /* a setup it does not follow: what it would warn about, as cautions to check */ fw.unconfirmed.length ? E('div', { 'class': 'vt-fg-warns', 'role': 'list', 'aria-label': _('Settings to check') }, fw.unconfirmed.map(function(w) { @@ -251,7 +251,7 @@ return view.extend({ reason = _('Option %s in General Settings has a value firewall4 refuses, so it loads no new rules: what is open is unknown.').format(w.option); } else if (w.code === 'rule-into') { - title = _('%s lets the internet into %s').format(w.name || _('Unnamed rule'), zoneName(w.dest)); + title = _('%s lets the internet into %s').format(w.name || _('Unnamed rule', 'vantage'), zoneName(w.dest)); reason = _('It accepts every port from any address, like a zone forwarding. Limit it to the ports and addresses you need.'); tab = 'rules'; } @@ -333,7 +333,7 @@ return view.extend({ LuCI app, and nftables files the firewall picks up on its own */ if (!fw.upnp && !fw.includes.length) body.push(E('p', { 'class': 'vt-hint vt-fg-foot' }, [ _('From the firewall configuration. Ports opened by UPnP or by custom rule files are not shown.') ])); - return this.tile('globe', _('Open from the internet'), page('rules'), _('Traffic Rules'), body); + return this.tile('globe', _('Open from the internet'), page('rules'), _('Traffic Rules', 'vantage'), body); }, renderZones: function(fw) { @@ -350,7 +350,7 @@ return view.extend({ E('div', { 'class': 'vt-fg-zone-head' }, [ E('span', { 'class': 'vt-fg-zone-name' }, [ name ]), internet ? E('span', { 'class': 'vt-fg-tag' }, [ _('internet') ]) : '', - E('span', { 'class': 'vt-fg-zone-in' }, [ _('Input'), ' ', polBadge(input, internet && input === 'ACCEPT') ]) + E('span', { 'class': 'vt-fg-zone-in' }, [ _('Input', 'vantage'), ' ', polBadge(input, internet && input === 'ACCEPT') ]) ]), E('div', { 'class': 'vt-fg-zone-to' }, [ icon('arrow'), dests(to, internet) ]), hint ? E('div', { 'class': 'vt-hint vt-fg-nets' }, [ hint ]) : '' @@ -374,13 +374,13 @@ return view.extend({ if (i.kind === 'forwarding') return self.row(page('zones'), _('Forwarding %s to %s').format(i.src || '?', i.dest || '?'), [ _('zone forwarding') ].concat(why)); if (i.kind === 'zone') - return self.row(page('zones'), _('Zone %s').format(i.name || '?'), [ _('zone') ].concat(why)); + return self.row(page('zones'), _('Zone %s').format(i.name || '?'), [ _('zone', 'vantage') ].concat(why)); if (i.kind === 'include') return self.row(page('custom'), i.path || '?', [ _('custom rule file') ].concat(why)); return self.row(page(i.kind === 'redirect' ? 'forwards' : 'rules'), named(i), - [ i.kind === 'redirect' ? _('port forward') : _('traffic rule'), ' · ', what(i) ].concat(why)); + [ i.kind === 'redirect' ? _('port forward', 'vantage') : _('traffic rule'), ' · ', what(i) ].concat(why)); }); - return this.tile('off', fw.ignored.length ? _('Not in effect') : _('Switched off'), page('rules'), _('Traffic Rules'), [ + return this.tile('off', fw.ignored.length ? _('Not in effect') : _('Switched off'), page('rules'), _('Traffic Rules', 'vantage'), [ E('div', { 'class': 'vt-fg-rows' }, rows) ]); } diff --git a/luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js b/luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js index b189a58e7..3a93ed664 100644 --- a/luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js +++ b/luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js @@ -365,13 +365,13 @@ function bitsLabel(v) { /* '5 min ago' / '45 s ago' for the left end of a time axis */ function spanAgo(ms) { var s = Math.round(ms / 1000); - return s >= 90 ? _('%d min ago').format(Math.round(s / 60)) : _('%d s ago').format(s); + return s >= 90 ? _('%d min ago', 'vantage').format(Math.round(s / 60)) : _('%d s ago', 'vantage').format(s); } /* '1 min' / '40 s' of collected history */ function spanShort(ms) { var s = Math.max(0, Math.round(ms / 1000)); - return s >= 60 ? _('%d min').format(Math.floor(s / 60)) : _('%d s').format(s); + return s >= 60 ? _('%d min', 'vantage').format(Math.floor(s / 60)) : _('%d s', 'vantage').format(s); } function readStore() { @@ -779,7 +779,7 @@ return view.extend({ var root = E('div', { 'class': 'vt-app', 'id': 'vt-app' }, [ E('div', { 'class': 'vt-body' }, [ this.buildHead(), - this.el.health = E('div', { 'class': 'vt-health', 'role': 'list', 'aria-label': _('Health checks') }), + this.el.health = E('div', { 'class': 'vt-health', 'role': 'list', 'aria-label': _('Health checks', 'vantage') }), this.buildEditBar(), this.el.grid = E('div', { 'class': 'vt-grid vt-grid-wait' }, this.buildTiles()) ]), @@ -882,8 +882,8 @@ return view.extend({ sys: this.buildSystem(), talk: this.buildTalkers(), ssids: this.buildSsids() }; this.tileNames = { - hero: _('Network path'), chart: _('Throughput'), radios: _('Radios'), clients: _('Clients'), - sys: _('System'), talk: _('Top talkers'), ssids: _('Wireless networks') + hero: _('Network path'), chart: _('Throughput', 'vantage'), radios: _('Radios'), clients: _('Clients'), + sys: _('System'), talk: _('Top talkers', 'vantage'), ssids: _('Wireless networks', 'vantage') }; this.ov = {}; this.el.ghost = E('div', { 'class': 'vt-drop-ghost', 'aria-hidden': 'true' }); @@ -1073,7 +1073,7 @@ return view.extend({ self.doLayout(); self.announce(_('Layout reset')); } }, [ _('Reset') ]), - el.editDone = E('button', { 'type': 'button', 'class': 'vt-btn vt-btn-primary', 'click': function() { self.setEditing(false); } }, [ _('Done') ]) + el.editDone = E('button', { 'type': 'button', 'class': 'vt-btn vt-btn-primary', 'click': function() { self.setEditing(false); } }, [ _('Done', 'vantage') ]) ]), el.editSay ]); @@ -1150,7 +1150,7 @@ return view.extend({ o.size.setAttribute('title', _('%d of %d columns').format(e.w, l.cols)); }); var hidden = l.list.filter(function(e) { return e.hide; }); - dom.content(this.el.editHidden, hidden.length ? [ E('span', { 'class': 'vt-label' }, [ _('Hidden') ]) ].concat(hidden.map(function(e) { + dom.content(this.el.editHidden, hidden.length ? [ E('span', { 'class': 'vt-label' }, [ _('Hidden', 'vantage') ]) ].concat(hidden.map(function(e) { var name = self.tileNames[e.id]; return E('button', { 'type': 'button', 'class': 'vt-fchip', 'title': _('Show %s').format(name), 'click': function() { self.editTile(e.id, function(list) { return layout.setHidden(list, e.id, false); }, _('%s shown').format(name)); @@ -1267,7 +1267,7 @@ return view.extend({ if (!this.el || !this.el.live) return; var st = this.st, state, label; if (st.paused || document.hidden) { state = 'paused'; label = _('Paused while hidden'); } - else if (st.lastErr) { state = 'err'; label = _('Reconnecting…'); } + else if (st.lastErr) { state = 'err'; label = _('Reconnecting…', 'vantage'); } else { state = 'ok'; label = _('Live') + ' · ' + fmt.ago(Date.now() - st.lastOk); } this.el.live.className = 'vt-livepill vt-live-' + state; this.el.live.setAttribute('title', label); @@ -1427,7 +1427,7 @@ return view.extend({ updateHero: function() { var self = this, m = this.m, st = this.st, h = this.hero, up = m.uplink, upR = st.uplinkRate, d = m.device; txt(h.gwKicker, up && up.isWan ? _('Internet') : _('Gateway')); - txt(h.gwTitle, up && up.gateway ? up.gateway : up && up.defaultRoute ? up.l3dev : _('No default route')); + txt(h.gwTitle, up && up.gateway ? up.gateway : up && up.defaultRoute ? up.l3dev : _('No default route', 'vantage')); txt(h.gwSub, up && up.dns.length ? _('DNS %s').format(up.dns[0]) : _('Reachability not probed')); txt(h.dnVal, upR ? fmt.bitsParts(upR.rx).v : fmt.DASH); @@ -1472,7 +1472,7 @@ return view.extend({ txt(b.badge, wifi.bandLabel(r.band)); txt(b.gen, r.gen || ''); txt(b.title, r.channel ? _('Ch %d · %d MHz').format(r.channel, r.width || 20) : _('Channel —')); - txt(b.sub, r.disabled ? _('Disabled') : !r.up ? _('Down') : rr ? '↓ ' + fmt.bits(rr.tx) : fmt.DASH); + txt(b.sub, r.disabled ? _('Disabled') : !r.up ? _('Down', 'vantage') : rr ? '↓ ' + fmt.bits(rr.tx) : fmt.DASH); b.active = !!(rr && rr.rx + rr.tx > 50e3); b.rate = rr ? rr.rx + rr.tx : 0; b.ssids.forEach(function(sn) { @@ -1734,7 +1734,7 @@ return view.extend({ tileHead('cpu', _('System'), [ el.sysUptime, this.foldButton('sys', _('Show or hide system details')) ]), E('div', { 'class': 'vt-sys-cpu' }, [ E('div', { 'class': 'vt-sys-cpu-l' }, [ - E('span', { 'class': 'vt-label' }, [ _('CPU') ]), + E('span', { 'class': 'vt-label' }, [ _('CPU', 'vantage') ]), el.cpuVal, el.cpuLoad ]), @@ -1769,7 +1769,7 @@ return view.extend({ var mem = d.memory; if (mem) { patch(el.memHead, [ - E('span', { 'class': 'vt-mem-avail vt-tn' }, [ _('%s available').format(fmt.pct(mem.availPct)) ]), + E('span', { 'class': 'vt-mem-avail vt-tn' }, [ _('%s available', 'vantage').format(fmt.pct(mem.availPct)) ]), E('span', { 'class': 'vt-mem-of vt-tn' }, [ _('%s of %s').format(fmt.bytes(mem.available), fmt.bytes(mem.total)) ]) ]); if (!el.memSegs) { @@ -1792,7 +1792,7 @@ return view.extend({ if (d.swap) { var sb = E('span', { 'class': 'vt-thin-fill' }); setW(sb, d.swap.usedPct); - patch(el.swap, [ E('span', { 'class': 'vt-swap-label' }, [ _('Swap') ]), E('span', { 'class': 'vt-thin' }, [ sb ]), + patch(el.swap, [ E('span', { 'class': 'vt-swap-label' }, [ _('Swap', 'vantage') ]), E('span', { 'class': 'vt-thin' }, [ sb ]), E('span', { 'class': 'vt-tn vt-hint' }, [ _('%s of %s').format(fmt.bytes(d.swap.used), fmt.bytes(d.swap.total)) ]) ]); } else patch(el.swap, []); @@ -1829,9 +1829,9 @@ return view.extend({ plot.addEventListener('blur', function() { self.chartHide(); }); el.plot = plot; el.chartLive = E('p', { 'class': 'vt-sr', 'aria-live': 'polite' }); - return this.foldable('chart', E('section', { 'class': 'vt-tile vt-chart', 'aria-label': _('Throughput') }, [ + return this.foldable('chart', E('section', { 'class': 'vt-tile vt-chart', 'aria-label': _('Throughput', 'vantage') }, [ E('header', { 'class': 'vt-tile-head' }, [ - E('h3', { 'class': 'vt-tile-title' }, [ icon('pulse'), E('span', {}, [ _('Throughput') ]), el.chartSub ]), + E('h3', { 'class': 'vt-tile-title' }, [ icon('pulse'), E('span', {}, [ _('Throughput', 'vantage') ]), el.chartSub ]), E('div', { 'class': 'vt-tile-extra' }, [ E('span', { 'class': 'vt-legend vt-dir-legend' }, [ E('span', { 'class': 'vt-legend-item' }, [ dirKey('dn'), E('span', {}, [ _('Download') ]) ]), @@ -1854,7 +1854,7 @@ return view.extend({ download, a radio sends it. */ chartSeries: function() { var m = this.m; - var list = [ { key: 'uplink', label: m.uplink ? _('Uplink %s').format(m.uplink.dev) : _('Uplink'), dn: 1, up: 2 } ]; + var list = [ { key: 'uplink', label: m.uplink ? _('Uplink %s').format(m.uplink.dev) : _('Uplink', 'vantage'), dn: 1, up: 2 } ]; m.radios.forEach(function(r) { list.push({ key: r.id, label: _('%s radio').format(r.bandLabel), dn: 2, up: 1 }); }); return list; }, @@ -1910,7 +1910,7 @@ return view.extend({ } /* stretches without samples, so a gap reads as missing data */ function gapBand(gp) { - var b = E('span', { 'class': 'vt-mult-gap' }, gp[1] - gp[0] > .12 ? [ E('span', {}, [ _('no data') ]) ] : []); + var b = E('span', { 'class': 'vt-mult-gap' }, gp[1] - gp[0] > .12 ? [ E('span', {}, [ _('no data', 'vantage') ]) ] : []); b.style.left = (gp[0] * 100).toFixed(2) + '%'; b.style.width = ((gp[1] - gp[0]) * 100).toFixed(2) + '%'; return b; @@ -1925,7 +1925,7 @@ return view.extend({ E('span', { 'class': 'vt-mult-now vt-mult-now-up', 'title': _('Upload') }, [ dirKey('up'), E('b', { 'class': 'vt-tn' }, [ dirArrow('up'), ' ', fresh && su.now != null ? fmt.bits(last[s.up]) : fmt.DASH ]) ]), E('span', { 'class': 'vt-mult-stats vt-tn' }, sd.n > 1 ? [ - E('span', {}, [ _('peak'), ' ', E('b', {}, [ dirArrow('dn'), ' ' + fmt.bitsShort(sd.peak) ]), ' ', E('b', {}, [ dirArrow('up'), ' ' + fmt.bitsShort(su.peak) ]) ]) + E('span', {}, [ _('peak', 'vantage'), ' ', E('b', {}, [ dirArrow('dn'), ' ' + fmt.bitsShort(sd.peak) ]), ' ', E('b', {}, [ dirArrow('up'), ' ' + fmt.bitsShort(su.peak) ]) ]) ] : [ E('span', {}, [ _('measuring…') ]) ]) ]), E('div', { 'class': 'vt-mult-plot vt-chart-' + style }, [ @@ -1947,7 +1947,7 @@ return view.extend({ var l = E('span', { 'class': 'vt-mx vt-mx-t' + (k % 2 ? ' vt-mx-odd' : '') }, [ _('−%d min').format(k) ]); l.style.left = ((1 - k * 60000 / dm.span) * 100).toFixed(2) + '%'; return l; - })).concat([ E('span', { 'class': 'vt-mx vt-mx-1' }, [ _('now') ]) ])); + })).concat([ E('span', { 'class': 'vt-mx vt-mx-1' }, [ _('now', 'vantage') ]) ])); var age = dm.age; txt(el.chartSub, dm.full ? _('last 5 min') @@ -2060,7 +2060,7 @@ return view.extend({ bandBadge(r.band), E('span', { 'class': 'vt-rsum-main vt-tn' }, [ r.channel ? _('Ch %d · %d MHz').format(r.channel, r.width || 20) : fmt.DASH ]), E('span', { 'class': 'vt-rsum-sub vt-tn' }, [ r.clients === 1 ? _('1 client') : _('%d clients').format(r.clients) ]), - E('span', { 'class': 'vt-rsum-rate vt-tn' }, [ r.disabled ? _('Disabled') : !r.up ? _('Down') : rr ? '↓ ' + fmt.bits(rr.tx) : fmt.DASH ]), + E('span', { 'class': 'vt-rsum-rate vt-tn' }, [ r.disabled ? _('Disabled') : !r.up ? _('Down', 'vantage') : rr ? '↓ ' + fmt.bits(rr.tx) : fmt.DASH ]), icon('chevron', 'vt-ico-sm') ]); })); @@ -2085,7 +2085,7 @@ return view.extend({ E('header', { 'class': 'vt-radio-head' }, [ bandBadge(r.band), E('span', { 'class': 'vt-radio-name' }, [ r.gen || r.id ]), - E('span', { 'class': 'vt-status vt-status-' + (r.disabled ? 'warn' : r.up ? 'ok' : 'err') }, [ r.disabled ? _('Disabled') : r.up ? _('Up') : _('Down') ]), + E('span', { 'class': 'vt-status vt-status-' + (r.disabled ? 'warn' : r.up ? 'ok' : 'err') }, [ r.disabled ? _('Disabled') : r.up ? _('Up', 'vantage') : _('Down', 'vantage') ]), E('button', { 'type': 'button', 'class': 'vt-icon-action', 'title': _('Radio details'), 'aria-label': _('Radio details'), 'click': function() { self.openDrawer('radio', r.id); } }, [ icon('chevron') ]) ]), @@ -2122,7 +2122,7 @@ return view.extend({ el.filter = E('input', { 'type': 'text', 'class': 'vt-filter-input', 'placeholder': _('Filter by name, address, SSID…'), 'aria-label': _('Filter clients'), 'autocomplete': 'off', 'spellcheck': 'false', 'input': function(ev) { self.ui.filter = ev.target.value; self.paintClients(); } }); - el.filterClear = E('button', { 'type': 'button', 'class': 'vt-filter-clear', 'aria-label': _('Clear filter'), 'hidden': 'hidden', + el.filterClear = E('button', { 'type': 'button', 'class': 'vt-filter-clear', 'aria-label': _('Clear filter', 'vantage'), 'hidden': 'hidden', 'click': function() { self.setFilter(''); el.filter.focus(); } }, [ icon('close') ]); el.chips = E('div', { 'class': 'vt-fchips', 'role': 'group', 'aria-label': _('Quick filters') }); el.count = E('span', { 'class': 'vt-count vt-tn' }); @@ -2188,7 +2188,7 @@ return view.extend({ bands.sort().forEach(function(b) { chipDefs.push([ 'band:' + b, wifi.bandLabel(b), m.clients.filter(function(c) { return c.band === b; }).length ]); }); chipDefs.push([ 'active', _('Active'), m.clients.filter(function(c) { var r = st.staRates[c.mac]; return r && r.rx + r.tx > 8000; }).length ]); chipDefs.push([ 'weak', _('Weak signal'), m.clients.filter(function(c) { return c.signal != null && c.signal < insight.WEAK_DBM; }).length ]); - chipDefs.push([ 'new', _('New'), m.clients.filter(function(c) { return c.isNew; }).length ]); + chipDefs.push([ 'new', _('New', 'vantage'), m.clients.filter(function(c) { return c.isNew; }).length ]); if (!chipDefs.some(function(d) { return d[0] === ui.chip; })) ui.chip = 'all'; /* chips and headings change with the counts or the sort, not with every poll */ var chipSig = JSON.stringify([ ui.chip, chipDefs ]); @@ -2202,7 +2202,7 @@ return view.extend({ el.filterClear.hidden = !ui.filter; /* header with sort buttons */ - var cols = [ [ 'name', _('Device') ], [ 'signal', _('Signal') ], [ 'ssid', _('Network') ], [ 'rate', _('Link rate') ], [ 'live', _('Live') ], [ 'exp', _('Experience') ], [ 'time', _('Online') ] ]; + var cols = [ [ 'name', _('Device') ], [ 'signal', _('Signal') ], [ 'ssid', _('Network') ], [ 'rate', _('Link rate') ], [ 'live', _('Live') ], [ 'exp', _('Experience') ], [ 'time', _('Online', 'vantage') ] ]; var headSig = ui.sort + ' ' + ui.dir; if (this.headSig !== headSig) dom.content(el.thead, [ E('tr', {}, cols.map(function(c) { var on = ui.sort === c[0]; @@ -2283,8 +2283,8 @@ return view.extend({ setW(bar, maxRate > 0 ? total / maxRate * 100 : 0); var tags = []; if (c.gone) tags.push(pill(_('Left %s').format(fmt.ago(Date.now() - c.goneAt)), 'muted')); - else if (c.isNew) tags.push(pill(_('New'), 'accent', _('Connected after you opened this page'))); - if (c.legacyOnRadio && !c.gone) tags.push(pill(c.gen === 'legacy' ? _('Legacy') : c.gen, 'warn', _('Older Wi-Fi generation than the radio; uses more airtime'))); + else if (c.isNew) tags.push(pill(_('New', 'vantage'), 'accent', _('Connected after you opened this page'))); + if (c.legacyOnRadio && !c.gone) tags.push(pill(c.gen === 'legacy' ? _('Legacy', 'vantage') : c.gen, 'warn', _('Older Wi-Fi generation than the radio; uses more airtime'))); var exp = c.exp || { score: 0, level: 'muted', grade: fmt.DASH, reason: '' }; return [ @@ -2329,8 +2329,8 @@ return view.extend({ buildTalkers: function() { this.el.talkers = E('div', { 'class': 'vt-talkers' }); - return E('section', { 'class': 'vt-tile vt-talk', 'aria-label': _('Top talkers') }, [ - tileHead('pulse', _('Top talkers'), [ E('span', { 'class': 'vt-hint' }, [ _('who uses the bandwidth') ]) ]), + return E('section', { 'class': 'vt-tile vt-talk', 'aria-label': _('Top talkers', 'vantage') }, [ + tileHead('pulse', _('Top talkers', 'vantage'), [ E('span', { 'class': 'vt-hint' }, [ _('who uses the bandwidth') ]) ]), this.el.talkers ]); }, @@ -2376,8 +2376,8 @@ return view.extend({ buildSsids: function() { this.el.ssids = E('div', { 'class': 'vt-ssids' }); - return this.foldable('ssids', E('section', { 'class': 'vt-tile vt-ssid-tile', 'aria-label': _('Wireless networks') }, [ - tileHead('wifi', _('Wireless networks'), [ pageLink(insight.PAGES.wireless, _('Edit')), this.foldButton('ssids', _('Show or hide the wireless networks')) ]), + return this.foldable('ssids', E('section', { 'class': 'vt-tile vt-ssid-tile', 'aria-label': _('Wireless networks', 'vantage') }, [ + tileHead('wifi', _('Wireless networks', 'vantage'), [ pageLink(insight.PAGES.wireless, _('Edit')), this.foldButton('ssids', _('Show or hide the wireless networks')) ]), this.el.ssids ])); }, @@ -2386,7 +2386,7 @@ return view.extend({ var self = this, m = this.m, st = this.st; var groups = model.groupSsids(m.ssids); if (!groups.length) { - dom.content(this.el.ssids, [ E('p', { 'class': 'vt-empty-inline' }, [ _('No wireless networks configured.') ]) ]); + dom.content(this.el.ssids, [ E('p', { 'class': 'vt-empty-inline' }, [ _('No wireless networks configured.', 'vantage') ]) ]); return; } patch(this.el.ssids, groups.map(function(g) { @@ -2397,7 +2397,7 @@ return view.extend({ 'click': function() { self.filterSsid(g.ssid); } }, [ E('span', { 'class': 'vt-ssid-ico' + (g.security.tone === 'open' || g.security.tone === 'weak' ? ' vt-t-warn' : '') }, [ icon(g.security.tone === 'open' ? 'unlock' : 'lock') ]), E('span', { 'class': 'vt-ssid-main' }, [ - E('span', { 'class': 'vt-ssid-top' }, [ E('span', { 'class': 'vt-ssid-name' }, [ g.ssid || _('(hidden)') ]), g.hidden ? pill(_('Hidden'), 'muted') : '' ]), + E('span', { 'class': 'vt-ssid-top' }, [ E('span', { 'class': 'vt-ssid-name' }, [ g.ssid || _('(hidden)') ]), g.hidden ? pill(_('Hidden', 'vantage'), 'muted') : '' ]), E('span', { 'class': 'vt-ssid-meta' }, bands.concat([ E('span', { 'class': 'vt-sec', 'title': g.security.long }, [ g.security.short ]) ])) ]), E('span', { 'class': 'vt-ssid-side' }, [ @@ -2492,7 +2492,7 @@ return view.extend({ if (!c) st.gone.forEach(function(g) { if (g.client.mac === mac) gone = g; }); if (!c && gone) c = Object.assign({}, gone.client, { gone: true, goneAt: gone.at }); if (!c) return { - title: [ E('span', { 'class': 'vt-drawer-name' }, [ names.normMac(mac) ? fmt.mac(mac) : _('Unknown device') ]) ], + title: [ E('span', { 'class': 'vt-drawer-name' }, [ names.normMac(mac) ? fmt.mac(mac) : _('Unknown device', 'vantage') ]) ], body: [ E('div', { 'class': 'vt-empty' }, [ icon('wifi'), E('p', { 'class': 'vt-empty-title' }, [ _('Not connected') ]), E('p', {}, [ _('This station is not associated right now.') ]) ]) ] }; @@ -2511,7 +2511,7 @@ return view.extend({ ]) ]; - var rename = this.canWrite ? E('button', { 'type': 'button', 'class': 'vt-btn vt-btn-ghost', 'click': function() { self.startRename(c); } }, [ icon('pencil'), E('span', {}, [ c.alias ? _('Rename') : _('Name this device') ]) ]) : ''; + var rename = this.canWrite ? E('button', { 'type': 'button', 'class': 'vt-btn vt-btn-ghost', 'click': function() { self.startRename(c); } }, [ icon('pencil'), E('span', {}, [ c.alias ? _('Rename', 'vantage') : _('Name this device') ]) ]) : ''; var sigSeries = hist.map(function(p) { return [ p[0], p[1] ]; }).filter(function(p) { return isFinite(p[1]); }); /* dBm as headroom above -95 dBm: -30 dBm at the top, the -75 dBm weak-signal line dashed */ @@ -2541,29 +2541,29 @@ return view.extend({ sigSpark ]), E('div', { 'class': 'vt-dcard' }, [ - E('div', { 'class': 'vt-dcard-label' }, [ _('Throughput') ]), + E('div', { 'class': 'vt-dcard-label' }, [ _('Throughput', 'vantage') ]), E('div', { 'class': 'vt-dcard-value' }, [ r ? fmt.bits(r.tx + r.rx) : fmt.DASH ]), E('div', { 'class': 'vt-dcard-sub vt-tn' }, [ r ? '↓ ' + fmt.bits(r.tx) + ' · ↑ ' + fmt.bits(r.rx) : _('measuring') ]), sparkline(hist, [ 2, 3 ], 0, 'vt-spark-' + (c.band || 'x'), { min: 1000, scale: bitsLabel }) ]) ]), - this.section(_('Link'), [ E('dl', { 'class': 'vt-kvs' }, [ + this.section(_('Link', 'vantage'), [ E('dl', { 'class': 'vt-kvs' }, [ kv(_('To device'), c.tx ? [ c.tx.label, c.tx.detail ? E('small', {}, [ c.tx.detail ]) : '' ] : fmt.DASH), kv(_('From device'), c.rx ? [ c.rx.label, c.rx.detail ? E('small', {}, [ c.rx.detail ]) : '' ] : fmt.DASH), - kv(_('Generation'), c.gen ? [ c.gen, c.legacyOnRadio ? E('small', { 'class': 'vt-t-warn' }, [ _('older than the radio (%s)').format(radio && radio.gen || '') ]) : '' ] : fmt.DASH), - kv(_('Capabilities'), [ c.wmm ? 'WMM' : null, c.mfp ? _('PMF') : null, c.caps && c.caps.rrm ? '802.11k' : null, c.caps && c.caps.mbo ? 'MBO' : null ].filter(Boolean).join(' · ') || fmt.DASH), + kv(_('Generation', 'vantage'), c.gen ? [ c.gen, c.legacyOnRadio ? E('small', { 'class': 'vt-t-warn' }, [ _('older than the radio (%s)').format(radio && radio.gen || '') ]) : '' ] : fmt.DASH), + kv(_('Capabilities', 'vantage'), [ c.wmm ? 'WMM' : null, c.mfp ? _('PMF') : null, c.caps && c.caps.rrm ? '802.11k' : null, c.caps && c.caps.mbo ? 'MBO' : null ].filter(Boolean).join(' · ') || fmt.DASH), kv(_('Noise / SNR'), c.noise != null && c.signal != null ? fmt.dbm(c.noise) + ' · ' + (c.signal - c.noise) + ' dB' : E('span', { 'class': 'vt-muted' }, [ _('not reported by the driver') ])) ]) ]), this.section(_('Connection'), [ E('dl', { 'class': 'vt-kvs' }, [ kv(_('Network'), [ c.ssid, ' ', bandBadge(c.band) ]), - kv(_('Radio'), radio ? _('%s · channel %d · %d MHz').format(radio.id, radio.channel || 0, radio.width || 20) : c.radio), + kv(_('Radio', 'vantage'), radio ? _('%s · channel %d · %d MHz').format(radio.id, radio.channel || 0, radio.width || 20) : c.radio), kv(_('Connected for'), c.gone ? fmt.DASH : fmt.duration(c.connected)), - kv(_('Last activity'), c.inactive == null || c.gone ? fmt.DASH : c.inactive < 1000 ? _('now') : _('%s ago').format(fmt.duration(c.inactive / 1000))), + kv(_('Last activity', 'vantage'), c.inactive == null || c.gone ? fmt.DASH : c.inactive < 1000 ? _('now', 'vantage') : _('%s ago', 'vantage').format(fmt.duration(c.inactive / 1000))), kv(_('Seen since'), c.firstSeen ? (c.isNew ? _('%s (new this session)').format(new Date(c.firstSeen).toLocaleTimeString()) : _('before this page opened')) : fmt.DASH) ]) ]), this.section(_('Traffic'), [ E('dl', { 'class': 'vt-kvs' }, [ - kv(_('Downloaded'), fmt.bytes(c.txBytes)), - kv(_('Uploaded'), fmt.bytes(c.rxBytes)), + kv(_('Downloaded', 'vantage'), fmt.bytes(c.txBytes)), + kv(_('Uploaded', 'vantage'), fmt.bytes(c.rxBytes)), kv(_('Frames to device'), c.txPackets ? _('%d sent · %s retried · %s failed').format(c.txPackets, fmt.pct(c.txRetries / Math.max(1, c.txPackets) * 100, 1), fmt.pct(c.txFailed / Math.max(1, c.txPackets + c.txFailed) * 100, 1)) : fmt.DASH) ]) ]), this.section(_('Identity'), [ E('dl', { 'class': 'vt-kvs' }, [ @@ -2611,7 +2611,7 @@ return view.extend({ this.ui.editing = c.mac; var err = E('p', { 'class': 'vt-form-err', 'role': 'alert', 'hidden': 'hidden' }); var input = E('input', { 'type': 'text', 'class': 'vt-rename-input', 'maxlength': String(names.NAME_MAX * 2), 'autocomplete': 'off', - 'placeholder': _('e.g. Living room TV'), 'aria-label': _('Device name') }); + 'placeholder': _('e.g. Living room TV', 'vantage'), 'aria-label': _('Device name') }); input.value = c.alias || ''; var iconBtns = names.ICONS.map(function(ic) { return E('button', { 'type': 'button', 'class': 'vt-icon-pick', 'title': ic, 'aria-label': ic, 'aria-pressed': String(chosen.icon === ic), @@ -2705,8 +2705,8 @@ return view.extend({ E('div', { 'class': 'vt-dcard' }, [ E('div', { 'class': 'vt-dcard-label' }, [ _('Traffic') ]), E('div', { 'class': 'vt-dcard-value' }, [ rr ? fmt.bits(rr.tx + rr.rx) : fmt.DASH ]), E('div', { 'class': 'vt-dcard-sub vt-tn' }, [ rr ? '↓ ' + fmt.bits(rr.tx) + ' · ↑ ' + fmt.bits(rr.rx) : '' ]), sparkline(this.st.hist.net[r.id] || [], [ 2, 1 ], 0, 'vt-spark-' + (r.band || 'x'), { min: 1000, scale: bitsLabel }) ]) ]), ins.notes.length ? E('ul', { 'class': 'vt-notes' }, ins.notes.map(function(n) { return E('li', { 'class': 'vt-note vt-note-' + n.level }, [ icon(n.level === 'warn' ? 'warn' : 'info', 'vt-ico-sm'), E('span', {}, [ n.text ]) ]); })) : '', - this.section(_('Radio'), [ E('dl', { 'class': 'vt-kvs' }, [ - kv(_('Status'), r.disabled ? _('Disabled') : r.up ? _('Up') : _('Down')), + this.section(_('Radio', 'vantage'), [ E('dl', { 'class': 'vt-kvs' }, [ + kv(_('Status'), r.disabled ? _('Disabled') : r.up ? _('Up', 'vantage') : _('Down', 'vantage')), kv(_('Mode'), [ r.htmode || fmt.DASH, r.hwmodes ? E('small', {}, [ r.hwmodes ]) : '' ]), kv(_('Centre frequency'), r.spectrum ? _('%d MHz (%d–%d MHz)').format(r.spectrum.centre, r.spectrum.lo, r.spectrum.hi) : fmt.DASH), kv(_('Tx power'), r.txpower != null ? _('%s dBm').format(r.txpower) + (r.txpowerCfg != null && r.txpowerCfg !== r.txpower ? ' ' + _('(configured %d dBm, limited by regulatory rules)').format(r.txpowerCfg) : '') : fmt.DASH), @@ -2725,7 +2725,7 @@ return view.extend({ drawerUplink: function() { var up = this.m.uplink, r = this.st.uplinkRate; - if (!up) return { title: [ E('span', { 'class': 'vt-drawer-name' }, [ _('Uplink') ]) ], body: [ E('p', { 'class': 'vt-empty-inline' }, [ _('No interface with a route is up.') ]) ] }; + if (!up) return { title: [ E('span', { 'class': 'vt-drawer-name' }, [ _('Uplink', 'vantage') ]) ], body: [ E('p', { 'class': 'vt-empty-inline' }, [ _('No interface with a route is up.') ]) ] }; var s = up.stats || {}; return { title: [ E('span', { 'class': 'vt-drawer-ico' }, [ icon('globe') ]), E('div', { 'class': 'vt-drawer-titles' }, [ @@ -2739,9 +2739,9 @@ return view.extend({ E('div', { 'class': 'vt-dcard' }, [ E('div', { 'class': 'vt-dcard-label' }, [ _('To network') ]), E('div', { 'class': 'vt-dcard-value' }, [ r ? fmt.bits(r.tx) : fmt.DASH ]), E('div', { 'class': 'vt-dcard-sub' }, [ _('sent on %s').format(up.dev) ]) ]) ]), E('div', { 'class': 'vt-dspark' }, [ sparkline(this.st.hist.net.uplink || [], [ 1, 2 ], 0, 'vt-spark-up', { min: 1000, scale: bitsLabel }) ]), - this.section(_('Link'), [ E('dl', { 'class': 'vt-kvs' }, [ - kv(_('Carrier'), up.carrier === false ? _('No link') : _('Up')), - kv(_('Speed'), model.speedLabel(up.speed)), + this.section(_('Link', 'vantage'), [ E('dl', { 'class': 'vt-kvs' }, [ + kv(_('Carrier'), up.carrier === false ? _('No link') : _('Up', 'vantage')), + kv(_('Speed', 'vantage'), model.speedLabel(up.speed)), kv(_('Protocol'), up.proto || fmt.DASH), kv(_('Interface up for'), up.uptime != null ? fmt.duration(up.uptime) : fmt.DASH) ]) ]), @@ -2752,9 +2752,9 @@ return view.extend({ kv(_('DNS'), up.dns.join('\n') || fmt.DASH, true) ]) ]), this.section(_('Counters since boot'), [ E('dl', { 'class': 'vt-kvs' }, [ - kv(_('Received'), fmt.bytes(s.rx_bytes) + ' · ' + _('%d errors').format(+s.rx_errors || 0)), - kv(_('Sent'), fmt.bytes(s.tx_bytes) + ' · ' + _('%d errors').format(+s.tx_errors || 0)), - kv(_('Dropped'), _('%d in · %d out').format(+s.rx_dropped || 0, +s.tx_dropped || 0)) + kv(_('Received', 'vantage'), fmt.bytes(s.rx_bytes) + ' · ' + _('%d errors').format(+s.rx_errors || 0)), + kv(_('Sent', 'vantage'), fmt.bytes(s.tx_bytes) + ' · ' + _('%d errors').format(+s.tx_errors || 0)), + kv(_('Dropped', 'vantage'), _('%d in · %d out').format(+s.rx_dropped || 0, +s.tx_dropped || 0)) ]) ]) ] }; @@ -2771,15 +2771,15 @@ return view.extend({ E('div', { 'class': 'vt-dactions' }, [ pageLink([ 'admin', 'system', 'system' ], _('System settings')), pageLink(insight.PAGES.processes, _('Processes')) ]), this.section(_('Device'), [ E('dl', { 'class': 'vt-kvs' }, [ kv(_('Model'), d.model), - kv(_('Platform'), [ d.target || fmt.DASH, d.system ? E('small', {}, [ d.system ]) : '' ]), - kv(_('Firmware'), d.firmware || fmt.DASH), + kv(_('Platform', 'vantage'), [ d.target || fmt.DASH, d.system ? E('small', {}, [ d.system ]) : '' ]), + kv(_('Firmware', 'vantage'), d.firmware || fmt.DASH), kv(_('Kernel'), d.kernel || fmt.DASH), kv(_('Uptime'), d.uptime != null ? fmt.duration(d.uptime) : fmt.DASH), kv(_('Local time'), d.localtime != null ? new Date(d.localtime * 1000).toISOString().replace('T', ' ').replace(/\.\d+Z$/, '') : fmt.DASH, true) ]) ]), this.section(_('Load'), [ E('dl', { 'class': 'vt-kvs' }, [ - kv(_('CPU'), cpu ? _('%s over %d cores').format(fmt.pct(cpu.pct), cpu.cores.length || 1) : fmt.DASH), - kv(_('Load average'), d.load.map(fmt.load).join(' · ')), + kv(_('CPU', 'vantage'), cpu ? _('%s over %d cores').format(fmt.pct(cpu.pct), cpu.cores.length || 1) : fmt.DASH), + kv(_('Load average', 'vantage'), d.load.map(fmt.load).join(' · ')), kv(_('Memory in use'), d.memory ? _('%s of %s').format(fmt.bytes(d.memory.used), fmt.bytes(d.memory.total)) : fmt.DASH) ]) ]) ] diff --git a/luci-app-vantage/po/templates/vantage.pot b/luci-app-vantage/po/templates/vantage.pot index 2e3a94f2a..26ab72fc4 100644 --- a/luci-app-vantage/po/templates/vantage.pot +++ b/luci-app-vantage/po/templates/vantage.pot @@ -42,6 +42,7 @@ msgid "%d errors" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:91 +msgctxt "vantage" msgid "%d h ago" msgstr "" @@ -50,11 +51,13 @@ msgid "%d in · %d out" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:374 +msgctxt "vantage" msgid "%d min" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:90 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:368 +msgctxt "vantage" msgid "%d min ago" msgstr "" @@ -87,11 +90,13 @@ msgid "%d radios" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:374 +msgctxt "vantage" msgid "%d s" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:89 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:368 +msgctxt "vantage" msgid "%d s ago" msgstr "" @@ -159,6 +164,7 @@ msgid "%s accepts every connection" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2561 +msgctxt "vantage" msgid "%s ago" msgstr "" @@ -168,6 +174,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:78 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1772 +msgctxt "vantage" msgid "%s available" msgstr "" @@ -377,14 +384,17 @@ msgid "1 thing needs attention" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:12 +msgctxt "vantage" msgid "2.4 GHz" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:13 +msgctxt "vantage" msgid "5 GHz" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:14 +msgctxt "vantage" msgid "6 GHz" msgstr "" @@ -409,6 +419,7 @@ msgid "Airtime" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:42 +msgctxt "vantage" msgid "Alias" msgstr "" @@ -445,6 +456,7 @@ msgid "" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:53 +msgctxt "vantage" msgid "B" msgstr "" @@ -478,6 +490,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:69 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1737 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2781 +msgctxt "vantage" msgid "CPU" msgstr "" @@ -490,6 +503,7 @@ msgid "Cancel" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2554 +msgctxt "vantage" msgid "Capabilities" msgstr "" @@ -519,6 +533,7 @@ msgid "Channel —" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2125 +msgctxt "vantage" msgid "Clear filter" msgstr "" @@ -642,6 +657,7 @@ msgid "Disconnected %s; shown from the last sample." msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1076 +msgctxt "vantage" msgid "Done" msgstr "" @@ -650,6 +666,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2063 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2088 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2709 +msgctxt "vantage" msgid "Down" msgstr "" @@ -664,6 +681,7 @@ msgid "Download (to clients)" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2565 +msgctxt "vantage" msgid "Downloaded" msgstr "" @@ -690,6 +708,7 @@ msgid "Drag to move" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2757 +msgctxt "vantage" msgid "Dropped" msgstr "" @@ -707,6 +726,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:164 #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:117 +msgctxt "vantage" msgid "Excellent" msgstr "" @@ -717,6 +737,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:164 #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:119 +msgctxt "vantage" msgid "Fair" msgstr "" @@ -737,6 +758,7 @@ msgid "Filter clients" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2775 +msgctxt "vantage" msgid "Firmware" msgstr "" @@ -753,6 +775,7 @@ msgid "Frames to device" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/model.js:84 +msgctxt "vantage" msgid "Free" msgstr "" @@ -780,6 +803,7 @@ msgid "Gateway" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:30 +msgctxt "vantage" msgid "Gbit/s" msgstr "" @@ -788,10 +812,12 @@ msgid "General Settings" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2553 +msgctxt "vantage" msgid "Generation" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:53 +msgctxt "vantage" msgid "GiB" msgstr "" @@ -805,6 +831,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:164 #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:118 +msgctxt "vantage" msgid "Good" msgstr "" @@ -821,11 +848,13 @@ msgid "Half duplex" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:782 +msgctxt "vantage" msgid "Health checks" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1153 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2400 +msgctxt "vantage" msgid "Hidden" msgstr "" @@ -854,10 +883,12 @@ msgid "In no zone: the default policies apply." msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/model.js:82 +msgctxt "vantage" msgid "In use" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:353 +msgctxt "vantage" msgid "Input" msgstr "" @@ -908,10 +939,12 @@ msgid "Kernel %s" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:53 +msgctxt "vantage" msgid "KiB" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2561 +msgctxt "vantage" msgid "Last activity" msgstr "" @@ -925,11 +958,13 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:159 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2287 +msgctxt "vantage" msgid "Legacy" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2550 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2742 +msgctxt "vantage" msgid "Link" msgstr "" @@ -957,6 +992,7 @@ msgid "Load %s · %d cores" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2782 +msgctxt "vantage" msgid "Load average" msgstr "" @@ -1026,6 +1062,7 @@ msgid "Memory in use" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:53 +msgctxt "vantage" msgid "MiB" msgstr "" @@ -1118,6 +1155,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2191 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2286 +msgctxt "vantage" msgid "New" msgstr "" @@ -1154,6 +1192,7 @@ msgid "No clients; idle radios are fine" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1430 +msgctxt "vantage" msgid "No default route" msgstr "" @@ -1206,6 +1245,7 @@ msgid "No stations are associated right now" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2389 +msgctxt "vantage" msgid "No wireless networks configured." msgstr "" @@ -1286,6 +1326,7 @@ msgid "Nothing is open in your firewall rules." msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:25 +msgctxt "vantage" msgid "OWE" msgstr "" @@ -1302,6 +1343,7 @@ msgid "On this router" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2205 +msgctxt "vantage" msgid "Online" msgstr "" @@ -1313,6 +1355,7 @@ msgstr[0] "" msgstr[1] "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:24 +msgctxt "vantage" msgid "Open" msgstr "" @@ -1373,10 +1416,12 @@ msgid "" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2774 +msgctxt "vantage" msgid "Platform" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:164 +msgctxt "vantage" msgid "Poor" msgstr "" @@ -1406,6 +1451,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2559 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2708 +msgctxt "vantage" msgid "Radio" msgstr "" @@ -1441,10 +1487,12 @@ msgid "Realtime graphs" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2755 +msgctxt "vantage" msgid "Received" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1270 +msgctxt "vantage" msgid "Reconnecting…" msgstr "" @@ -1453,6 +1501,7 @@ msgid "Remove name" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2514 +msgctxt "vantage" msgid "Rename" msgstr "" @@ -1487,6 +1536,7 @@ msgid "Seen since" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2756 +msgctxt "vantage" msgid "Sent" msgstr "" @@ -1545,6 +1595,7 @@ msgid "Signal not reported" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2744 +msgctxt "vantage" msgid "Speed" msgstr "" @@ -1584,6 +1635,7 @@ msgid "Strong signal, %s" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1795 +msgctxt "vantage" msgid "Swap" msgstr "" @@ -1693,6 +1745,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1832 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1834 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2544 +msgctxt "vantage" msgid "Throughput" msgstr "" @@ -1705,6 +1758,7 @@ msgid "Throughput per link; arrow keys read the values over time" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:53 +msgctxt "vantage" msgid "TiB" msgstr "" @@ -1728,6 +1782,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:886 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2332 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2333 +msgctxt "vantage" msgid "Top talkers" msgstr "" @@ -1738,6 +1793,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:336 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:383 +msgctxt "vantage" msgid "Traffic Rules" msgstr "" @@ -1764,6 +1820,7 @@ msgid "" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2495 +msgctxt "vantage" msgid "Unknown device" msgstr "" @@ -1781,6 +1838,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:116 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:254 +msgctxt "vantage" msgid "Unnamed rule" msgstr "" @@ -1788,6 +1846,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2088 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2709 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2743 +msgctxt "vantage" msgid "Up" msgstr "" @@ -1802,6 +1861,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:57 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1857 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2728 +msgctxt "vantage" msgid "Uplink" msgstr "" @@ -1824,6 +1884,7 @@ msgid "Upload (from clients)" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2566 +msgctxt "vantage" msgid "Uploaded" msgstr "" @@ -1870,6 +1931,7 @@ msgid "WEP (insecure)" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:30 +msgctxt "vantage" msgid "WPA" msgstr "" @@ -1886,6 +1948,7 @@ msgid "WPA-Personal (legacy)" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:28 +msgctxt "vantage" msgid "WPA/WPA2" msgstr "" @@ -1902,6 +1965,7 @@ msgid "WPA/WPA2-Personal (TKIP allowed)" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:29 +msgctxt "vantage" msgid "WPA2" msgstr "" @@ -1934,6 +1998,7 @@ msgid "WPA2/WPA3-Personal" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:27 +msgctxt "vantage" msgid "WPA3" msgstr "" @@ -1958,6 +2023,7 @@ msgid "WPS device name" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:171 +msgctxt "vantage" msgid "Warnings" msgstr "" @@ -1995,6 +2061,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:886 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2379 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2380 +msgctxt "vantage" msgid "Wireless networks" msgstr "" @@ -2015,6 +2082,7 @@ msgid "Zones" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:121 +msgctxt "vantage" msgid "accept" msgstr "" @@ -2035,6 +2103,7 @@ msgid "an unnamed rule" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:112 +msgctxt "vantage" msgid "any zone" msgstr "" @@ -2051,6 +2120,7 @@ msgid "before this page opened" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:30 +msgctxt "vantage" msgid "bit/s" msgstr "" @@ -2076,10 +2146,12 @@ msgid "dBm" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:121 +msgctxt "vantage" msgid "drop" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2614 +msgctxt "vantage" msgid "e.g. Living room TV" msgstr "" @@ -2092,6 +2164,7 @@ msgid "forwards nowhere" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:100 +msgctxt "vantage" msgid "from %s" msgstr "" @@ -2148,10 +2221,12 @@ msgid "internet" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:88 +msgctxt "vantage" msgid "just now" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:30 +msgctxt "vantage" msgid "kbit/s" msgstr "" @@ -2193,6 +2268,7 @@ msgid "no clients" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1913 +msgctxt "vantage" msgid "no data" msgstr "" @@ -2229,6 +2305,7 @@ msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1950 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2561 +msgctxt "vantage" msgid "now" msgstr "" @@ -2241,6 +2318,7 @@ msgid "older than the radio (%s)" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1928 +msgctxt "vantage" msgid "peak" msgstr "" @@ -2249,6 +2327,7 @@ msgid "per core" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:381 +msgctxt "vantage" msgid "port forward" msgstr "" @@ -2261,6 +2340,7 @@ msgid "received on %s" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:121 +msgctxt "vantage" msgid "reject" msgstr "" @@ -2305,6 +2385,7 @@ msgid "who uses the bandwidth" msgstr "" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:377 +msgctxt "vantage" msgid "zone" msgstr "" diff --git a/luci-app-vantage/po/zh_Hans/vantage.po b/luci-app-vantage/po/zh_Hans/vantage.po index 913eae55d..68dc81a72 100644 --- a/luci-app-vantage/po/zh_Hans/vantage.po +++ b/luci-app-vantage/po/zh_Hans/vantage.po @@ -53,6 +53,7 @@ msgid "%d errors" msgstr "%d 个错误" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:91 +msgctxt "vantage" msgid "%d h ago" msgstr "%d 小时前" @@ -61,11 +62,13 @@ msgid "%d in · %d out" msgstr "%d 入 · %d 出" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:374 +msgctxt "vantage" msgid "%d min" -msgstr "%d 分" +msgstr "%d 分钟" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:90 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:368 +msgctxt "vantage" msgid "%d min ago" msgstr "%d 分钟前" @@ -75,7 +78,7 @@ msgstr "%d/%d" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1150 msgid "%d of %d columns" -msgstr "" +msgstr "%d/%d 列" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:103 msgid "%d off" @@ -83,7 +86,7 @@ msgstr "%d 个关闭" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:243 msgid "%d older devices (Wi-Fi 4 or earlier) slow airtime" -msgstr "%d 台旧设备(Wi-Fi 4 或更早)拖慢空口时间" +msgstr "%d 台旧设备(Wi-Fi 4 或更早)占用更多空口时间" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1535 msgid "%d on %s" @@ -98,11 +101,13 @@ msgid "%d radios" msgstr "%d 个射频" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:374 +msgctxt "vantage" msgid "%d s" msgstr "%d 秒" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:89 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:368 +msgctxt "vantage" msgid "%d s ago" msgstr "%d 秒前" @@ -170,6 +175,7 @@ msgid "%s accepts every connection" msgstr "%s 接受所有连接" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2561 +msgctxt "vantage" msgid "%s ago" msgstr "%s 前" @@ -179,6 +185,7 @@ msgstr "%s(%s);低于 %s 时漫游和吞吐量会受影响" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:78 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1772 +msgctxt "vantage" msgid "%s available" msgstr "可用 %s" @@ -213,7 +220,7 @@ msgstr "%s 无载波:网线未插或交换机端口关闭" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1117 msgid "%s hidden" -msgstr "" +msgstr "%s 已隐藏" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:103 msgid "%s is disabled" @@ -229,7 +236,7 @@ msgstr "%s 不属于任何区域,接受所有连接" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1110 msgid "%s is narrower" -msgstr "" +msgstr "%s 已变窄" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:101 msgid "%s is not up" @@ -237,11 +244,11 @@ msgstr "%s 未上线" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1113 msgid "%s is wider" -msgstr "" +msgstr "%s 已加宽" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:254 msgid "%s lets the internet into %s" -msgstr "%s 让互联网进入 %s" +msgstr "%s 允许互联网访问 %s" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:56 msgid "%s link" @@ -249,27 +256,27 @@ msgstr "%s 链路" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:259 msgid "%s may open the router's admin" -msgstr "%s 可能开放了路由器管理" +msgstr "%s 可能暴露了路由器管理界面" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1231 msgid "%s moved" -msgstr "" +msgstr "%s 已移动" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1107 msgid "%s moved down" -msgstr "" +msgstr "%s 已下移" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1098 msgid "%s moved left" -msgstr "" +msgstr "%s 已左移" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1101 msgid "%s moved right" -msgstr "" +msgstr "%s 已右移" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1104 msgid "%s moved up" -msgstr "" +msgstr "%s 已上移" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:50 msgid "%s negotiated half duplex; check the cable and switch port" @@ -277,7 +284,7 @@ msgstr "%s 协商为半双工;请检查网线和交换机端口" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:52 msgid "%s negotiated only %d Mbit/s; a cable pair or port may be faulty" -msgstr "%s 仅协商到 %d Mbit/s;网线对线或端口可能故障" +msgstr "%s 仅协商到 %d Mbit/s;网线线对或端口可能有故障" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:79 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1773 @@ -317,7 +324,7 @@ msgstr "%s 射频" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1156 msgid "%s shown" -msgstr "" +msgstr "%s 已显示" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:59 msgid "%s up" @@ -325,7 +332,7 @@ msgstr "%s 已连接" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:59 msgid "%s up at %s" -msgstr "%s 协商速率为 %s" +msgstr "%s 已连接,速率 %s" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:88 msgid "%s used" @@ -357,7 +364,7 @@ msgstr "%s:无载波" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2712 msgid "(configured %d dBm, limited by regulatory rules)" -msgstr "(配置 %d dBm,受监管规则限制)" +msgstr "(配置 %d dBm,受管制规则限制)" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1491 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2400 @@ -376,7 +383,7 @@ msgstr "已连接 1 个" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:243 msgid "1 older device (Wi-Fi 4 or earlier) slows airtime" -msgstr "1 台旧设备(Wi-Fi 4 或更早)拖慢空口时间" +msgstr "1 台旧设备(Wi-Fi 4 或更早)占用更多空口时间" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2054 msgid "1 radio" @@ -388,14 +395,17 @@ msgid "1 thing needs attention" msgstr "1 项需要关注" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:12 +msgctxt "vantage" msgid "2.4 GHz" msgstr "2.4 GHz" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:13 +msgctxt "vantage" msgid "5 GHz" msgstr "5 GHz" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:14 +msgctxt "vantage" msgid "6 GHz" msgstr "6 GHz" @@ -420,6 +430,7 @@ msgid "Airtime" msgstr "空口时间" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:42 +msgctxt "vantage" msgid "Alias" msgstr "别名" @@ -456,13 +467,14 @@ msgid "" msgstr "互联网上的任何主机都能访问路由器本身。请将此区域的入站数据设为拒绝。" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:53 +msgctxt "vantage" msgid "B" msgstr "B" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1569 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1570 msgid "Busiest network" -msgstr "" +msgstr "最繁忙网络" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1544 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1546 @@ -489,6 +501,7 @@ msgstr "繁忙;负载 %s(%d 核);查看最繁忙进程" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:69 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1737 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2781 +msgctxt "vantage" msgid "CPU" msgstr "CPU" @@ -501,6 +514,7 @@ msgid "Cancel" msgstr "取消" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2554 +msgctxt "vantage" msgid "Capabilities" msgstr "能力" @@ -530,6 +544,7 @@ msgid "Channel —" msgstr "信道 —" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2125 +msgctxt "vantage" msgid "Clear filter" msgstr "清除筛选" @@ -650,19 +665,21 @@ msgstr "已禁用" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2523 msgid "Disconnected %s; shown from the last sample." -msgstr "断开于 %s;显示来自最后一次采样。" +msgstr "断开于 %s;显示最后一次采样的数据。" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1076 +msgctxt "vantage" msgid "Done" -msgstr "" +msgstr "完成" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:45 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1475 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2063 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2088 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2709 +msgctxt "vantage" msgid "Down" -msgstr "断开" +msgstr "已下线" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1837 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1923 @@ -675,6 +692,7 @@ msgid "Download (to clients)" msgstr "下载(到客户端)" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2565 +msgctxt "vantage" msgid "Downloaded" msgstr "已下载" @@ -682,25 +700,26 @@ msgstr "已下载" msgid "" "Drag a tile by its handle or use its buttons. Saved in this browser for " "medium screens." -msgstr "" +msgstr "拖动卡片手柄或使用其按钮移动。中屏布局保存在此浏览器中。" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1138 msgid "" "Drag a tile by its handle or use its buttons. Saved in this browser for " "narrow screens." -msgstr "" +msgstr "拖动卡片手柄或使用其按钮移动。窄屏布局保存在此浏览器中。" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1136 msgid "" "Drag a tile by its handle or use its buttons. Saved in this browser for wide " "screens." -msgstr "" +msgstr "拖动卡片手柄或使用其按钮移动。宽屏布局保存在此浏览器中。" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1089 msgid "Drag to move" -msgstr "" +msgstr "拖动以移动" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2757 +msgctxt "vantage" msgid "Dropped" msgstr "已丢弃" @@ -714,10 +733,11 @@ msgstr "增强开放 (OWE)" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:128 msgid "Everything looks healthy" -msgstr "一切看起来正常" +msgstr "一切正常" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:164 #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:117 +msgctxt "vantage" msgid "Excellent" msgstr "极好" @@ -728,6 +748,7 @@ msgstr "体验" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:164 #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:119 +msgctxt "vantage" msgid "Fair" msgstr "一般" @@ -748,6 +769,7 @@ msgid "Filter clients" msgstr "筛选客户端" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2775 +msgctxt "vantage" msgid "Firmware" msgstr "固件" @@ -764,6 +786,7 @@ msgid "Frames to device" msgstr "发往设备的帧" #: luci-app-vantage/htdocs/luci-static/resources/vantage/model.js:84 +msgctxt "vantage" msgid "Free" msgstr "空闲" @@ -791,6 +814,7 @@ msgid "Gateway" msgstr "网关" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:30 +msgctxt "vantage" msgid "Gbit/s" msgstr "Gbit/s" @@ -799,10 +823,12 @@ msgid "General Settings" msgstr "基本设置" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2553 +msgctxt "vantage" msgid "Generation" msgstr "代际" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:53 +msgctxt "vantage" msgid "GiB" msgstr "GiB" @@ -816,6 +842,7 @@ msgstr "全局唯一 · %s" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:164 #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:118 +msgctxt "vantage" msgid "Good" msgstr "良好" @@ -832,17 +859,19 @@ msgid "Half duplex" msgstr "半双工" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:782 +msgctxt "vantage" msgid "Health checks" msgstr "健康检查" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1153 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2400 +msgctxt "vantage" msgid "Hidden" msgstr "隐藏" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1116 msgid "Hide %s" -msgstr "" +msgstr "隐藏 %s" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2572 msgid "IP addresses" @@ -865,16 +894,18 @@ msgid "In no zone: the default policies apply." msgstr "不属于任何区域:适用默认策略。" #: luci-app-vantage/htdocs/luci-static/resources/vantage/model.js:82 +msgctxt "vantage" msgid "In use" -msgstr "使用中" +msgstr "已用" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:353 +msgctxt "vantage" msgid "Input" msgstr "入站数据" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1454 msgid "Interface" -msgstr "" +msgstr "接口" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2746 msgid "Interface up for" @@ -923,16 +954,18 @@ msgid "Kernel %s" msgstr "内核 %s" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:53 +msgctxt "vantage" msgid "KiB" msgstr "KiB" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2561 +msgctxt "vantage" msgid "Last activity" msgstr "最后活动" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1074 msgid "Layout reset" -msgstr "" +msgstr "布局已重置" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2285 msgid "Left %s" @@ -940,11 +973,13 @@ msgstr "离开于 %s" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:159 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2287 +msgctxt "vantage" msgid "Legacy" msgstr "旧版" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2550 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2742 +msgctxt "vantage" msgid "Link" msgstr "链路" @@ -972,6 +1007,7 @@ msgid "Load %s · %d cores" msgstr "负载 %s · %d 核" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2782 +msgctxt "vantage" msgid "Load average" msgstr "平均负载" @@ -989,11 +1025,11 @@ msgstr "本地时间" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:149 msgid "Low link rate %s despite good signal; interference or an old device" -msgstr "信号良好但链路速率低 %s;可能存在干扰或旧设备" +msgstr "信号良好但链路速率仅 %s;可能存在干扰或设备较旧" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:149 msgid "Low link rate for its signal" -msgstr "相对其信号链路速率偏低" +msgstr "链路速率低于信号预期" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:43 msgid "MAC" @@ -1009,11 +1045,11 @@ msgstr "MCS %d" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1109 msgid "Make %s narrower" -msgstr "" +msgstr "使 %s 变窄" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1112 msgid "Make %s wider" -msgstr "" +msgstr "使 %s 加宽" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2587 msgid "Manufacturer of the network adapter" @@ -1041,6 +1077,7 @@ msgid "Memory in use" msgstr "已用内存" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:53 +msgctxt "vantage" msgid "MiB" msgstr "MiB" @@ -1062,23 +1099,23 @@ msgstr "型号" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1106 msgid "Move %s down" -msgstr "" +msgstr "下移 %s" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1097 msgid "Move %s left" -msgstr "" +msgstr "左移 %s" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1100 msgid "Move %s right" -msgstr "" +msgstr "右移 %s" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1103 msgid "Move %s up" -msgstr "" +msgstr "上移 %s" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1089 msgid "Move %s: drag, or use the arrow keys" -msgstr "" +msgstr "移动 %s:拖动或使用方向键" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2642 msgid "Name" @@ -1125,7 +1162,7 @@ msgstr "网络路径" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1568 msgid "Networks" -msgstr "" +msgstr "网络" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:360 msgid "Networks: %s" @@ -1133,6 +1170,7 @@ msgstr "网络:%s" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2191 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2286 +msgctxt "vantage" msgid "New" msgstr "新增" @@ -1162,19 +1200,20 @@ msgstr "没有匹配“%s”的客户端" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2721 msgid "No clients; an idle radio is fine." -msgstr "无客户端;空闲的射频是正常的。" +msgstr "无客户端;射频空闲属正常。" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:245 msgid "No clients; idle radios are fine" -msgstr "无客户端;空闲的射频是正常的" +msgstr "无客户端;射频空闲属正常" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1430 +msgctxt "vantage" msgid "No default route" msgstr "无默认路由" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:45 msgid "No interface with a route" -msgstr "无带路由的接口" +msgstr "没有带路由的接口" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2728 msgid "No interface with a route is up." @@ -1195,7 +1234,7 @@ msgstr "没有带路由的网络接口处于启用状态" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1383 msgid "No other interfaces are up" -msgstr "" +msgstr "没有其他接口处于启用状态" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:168 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:170 @@ -1221,6 +1260,7 @@ msgid "No stations are associated right now" msgstr "当前没有站点关联" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2389 +msgctxt "vantage" msgid "No wireless networks configured." msgstr "未配置无线网络。" @@ -1259,11 +1299,11 @@ msgstr "噪声 / 信噪比" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2713 msgid "Noise floor" -msgstr "噪声底" +msgstr "底噪" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:244 msgid "Noise floor not reported by the driver" -msgstr "驱动未报告噪声底" +msgstr "驱动未报告底噪" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:100 msgid "None" @@ -1303,6 +1343,7 @@ msgid "Nothing is open in your firewall rules." msgstr "你的防火墙规则中没有开放的内容。" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:25 +msgctxt "vantage" msgid "OWE" msgstr "OWE" @@ -1319,6 +1360,7 @@ msgid "On this router" msgstr "在此路由器上" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2205 +msgctxt "vantage" msgid "Online" msgstr "在线" @@ -1329,6 +1371,7 @@ msgid_plural "" msgstr[0] "只有标准的常规规则(ping、DHCP 续租等):%d 条" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:24 +msgctxt "vantage" msgid "Open" msgstr "开放" @@ -1370,7 +1413,7 @@ msgstr "叠加,对数刻度" #: luci-app-vantage/root/usr/share/luci/menu.d/luci-app-vantage.json:14 msgid "Overview" -msgstr "" +msgstr "概览" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2554 msgid "PMF" @@ -1378,7 +1421,7 @@ msgstr "PMF" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1269 msgid "Paused while hidden" -msgstr "隐藏时暂停" +msgstr "页面隐藏时暂停" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:53 msgid "PiB" @@ -1393,10 +1436,12 @@ msgstr "" "放行的流量。" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2774 +msgctxt "vantage" msgid "Platform" msgstr "平台" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:164 +msgctxt "vantage" msgid "Poor" msgstr "较差" @@ -1426,6 +1471,7 @@ msgstr "快速筛选" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2559 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2708 +msgctxt "vantage" msgid "Radio" msgstr "射频" @@ -1461,10 +1507,12 @@ msgid "Realtime graphs" msgstr "实时图表" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2755 +msgctxt "vantage" msgid "Received" msgstr "已接收" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1270 +msgctxt "vantage" msgid "Reconnecting…" msgstr "重新连接中…" @@ -1473,12 +1521,13 @@ msgid "Remove name" msgstr "移除名称" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2514 +msgctxt "vantage" msgid "Rename" msgstr "重命名" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1075 msgid "Reset" -msgstr "" +msgstr "重置" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2583 msgid "Reverse DNS of its address" @@ -1490,7 +1539,7 @@ msgstr "Root(overlay)" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:264 msgid "Router admin open: %s" -msgstr "路由器管理已开放:%s" +msgstr "路由器管理界面已开放:%s" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:265 msgid "" @@ -1508,6 +1557,7 @@ msgid "Seen since" msgstr "首次出现" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2756 +msgctxt "vantage" msgid "Sent" msgstr "已发送" @@ -1517,11 +1567,11 @@ msgstr "分开显示" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:174 msgid "Settings to check" -msgstr "" +msgstr "待检查的设置" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1155 msgid "Show %s" -msgstr "" +msgstr "显示 %s" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2274 msgid "Show all clients" @@ -1566,6 +1616,7 @@ msgid "Signal not reported" msgstr "未报告信号" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2744 +msgctxt "vantage" msgid "Speed" msgstr "速率" @@ -1604,6 +1655,7 @@ msgid "Strong signal, %s" msgstr "信号强,%s" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1795 +msgctxt "vantage" msgid "Swap" msgstr "交换分区" @@ -1645,7 +1697,7 @@ msgstr "设备无法写入 /etc/config/vantage。" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2100 msgid "" "The driver reports a placeholder value, so no noise floor or SNR is shown" -msgstr "" +msgstr "驱动报告的是占位值,不显示底噪与信噪比" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:250 msgid "The firewall cannot load these settings" @@ -1665,11 +1717,11 @@ msgstr "设备广播的名称(WPS)" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:206 msgid "The settings below apply if traffic reaches them as configured." -msgstr "" +msgstr "以下设置在流量按配置到达时生效。" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:156 msgid "The stored name has an invalid section id" -msgstr "保存的名称包含无效的节 ID" +msgstr "保存的名称包含无效的配置节 ID" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:246 msgid "The uplinks could not be checked" @@ -1685,7 +1737,7 @@ msgstr "此地址族不属于任何区域:适用默认策略。" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1439 msgid "This device" -msgstr "" +msgstr "此设备" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2685 msgid "This device already stores %d names; remove some first." @@ -1703,11 +1755,11 @@ msgstr "本页无法判断开放了哪些内容。请重新加载;如果仍无 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2693 msgid "This radio is not reported any more." -msgstr "此射频不再被报告。" +msgstr "驱动不再报告此射频。" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:203 msgid "This setup is beyond what this overview checks" -msgstr "" +msgstr "此配置超出本概览的检查范围" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2496 msgid "This station is not associated right now." @@ -1717,6 +1769,7 @@ msgstr "此站点当前未关联。" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1832 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1834 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2544 +msgctxt "vantage" msgid "Throughput" msgstr "吞吐量" @@ -1729,6 +1782,7 @@ msgid "Throughput per link; arrow keys read the values over time" msgstr "每条链路的吞吐量;方向键可查看历史值" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:53 +msgctxt "vantage" msgid "TiB" msgstr "TiB" @@ -1752,6 +1806,7 @@ msgstr "发往网络" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:886 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2332 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2333 +msgctxt "vantage" msgid "Top talkers" msgstr "流量大户" @@ -1762,6 +1817,7 @@ msgstr "流量" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:336 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:383 +msgctxt "vantage" msgid "Traffic Rules" msgstr "通信规则" @@ -1791,6 +1847,7 @@ msgstr "" "已安装 UPnP:它运行时,你网络中的设备可以自行开放端口,这些端口不会在此列出。" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2495 +msgctxt "vantage" msgid "Unknown device" msgstr "未知设备" @@ -1808,6 +1865,7 @@ msgstr "未命名的端口转发" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:116 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:254 +msgctxt "vantage" msgid "Unnamed rule" msgstr "未命名的规则" @@ -1815,8 +1873,9 @@ msgstr "未命名的规则" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2088 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2709 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2743 +msgctxt "vantage" msgid "Up" -msgstr "正常" +msgstr "已上线" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1757 msgid "Up %s" @@ -1829,6 +1888,7 @@ msgstr "已运行 %s" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:57 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1857 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2728 +msgctxt "vantage" msgid "Uplink" msgstr "上行链路" @@ -1851,6 +1911,7 @@ msgid "Upload (from clients)" msgstr "上传(来自客户端)" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2566 +msgctxt "vantage" msgid "Uploaded" msgstr "已上传" @@ -1863,18 +1924,24 @@ msgid "" "Vantage dashboard: client names from reverse DNS (lets the holder make the " "device send DNS PTR queries to any DNS server and port it names)" msgstr "" +"Vantage 仪表盘:来自反向 DNS 的客户端名称(持有者可使设备向其指定的任意 DNS " +"服务器和端口发送 DNS PTR 查询)" #: luci-app-vantage/root/usr/share/rpcd/acl.d/luci-app-vantage.json:32 msgid "" "Vantage dashboard: name stations (luci.vantage set_alias: validated names " "in /etc/config/vantage only, at most 512)" msgstr "" +"Vantage 仪表盘:命名站点(luci.vantage set_alias:仅将校验后的名称写入 /etc/" +"config/vantage,至多 512 条)" #: luci-app-vantage/root/usr/share/rpcd/acl.d/luci-app-vantage.json:3 msgid "" "Vantage dashboard: read-only status of the device, uplink, radios and " "stations (wireless configuration without keys, through luci.vantage wireless)" msgstr "" +"Vantage 仪表盘:设备、上行链路、射频与站点的只读状态(经 luci.vantage " +"wireless 读取无线配置,不含密钥)" #: luci-app-vantage/htdocs/luci-static/resources/vantage/names.js:43 msgid "Vendor" @@ -1882,11 +1949,11 @@ msgstr "供应商" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:141 msgid "Very weak signal %s; expect drops" -msgstr "信号极弱 %s;可能出现掉线" +msgstr "信号极弱 %s;可能频繁断连" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:141 msgid "Very weak signal; expect drops" -msgstr "信号极弱;可能出现掉线" +msgstr "信号极弱;可能频繁断连" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:36 msgid "WEP" @@ -1897,6 +1964,7 @@ msgid "WEP (insecure)" msgstr "WEP(不安全)" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:30 +msgctxt "vantage" msgid "WPA" msgstr "WPA" @@ -1913,6 +1981,7 @@ msgid "WPA-Personal (legacy)" msgstr "WPA 个人版(旧版)" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:28 +msgctxt "vantage" msgid "WPA/WPA2" msgstr "WPA/WPA2" @@ -1929,6 +1998,7 @@ msgid "WPA/WPA2-Personal (TKIP allowed)" msgstr "WPA/WPA2 个人版(允许 TKIP)" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:29 +msgctxt "vantage" msgid "WPA2" msgstr "WPA2" @@ -1961,6 +2031,7 @@ msgid "WPA2/WPA3-Personal" msgstr "WPA2/WPA3 个人版" #: luci-app-vantage/htdocs/luci-static/resources/vantage/wifi.js:27 +msgctxt "vantage" msgid "WPA3" msgstr "WPA3" @@ -1985,6 +2056,7 @@ msgid "WPS device name" msgstr "WPS 设备名称" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:171 +msgctxt "vantage" msgid "Warnings" msgstr "警告" @@ -2010,7 +2082,7 @@ msgstr "最弱信号" msgid "" "What is open depends on how firewall4 handles %s, which this page does not " "follow. Check the firewall pages." -msgstr "" +msgstr "开放内容取决于 firewall4 如何处理 %s,本页无法跟踪。请检查防火墙页面。" #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:100 #: luci-app-vantage/htdocs/luci-static/resources/vantage/insight.js:101 @@ -2022,6 +2094,7 @@ msgstr "Wi-Fi" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:886 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2379 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2380 +msgctxt "vantage" msgid "Wireless networks" msgstr "无线网络" @@ -2042,6 +2115,7 @@ msgid "Zones" msgstr "区域" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:121 +msgctxt "vantage" msgid "accept" msgstr "接受" @@ -2055,13 +2129,14 @@ msgstr "所有协议" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1568 msgid "all up" -msgstr "" +msgstr "全部启用" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:128 msgid "an unnamed rule" -msgstr "" +msgstr "一条未命名的规则" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:112 +msgctxt "vantage" msgid "any zone" msgstr "任意区域" @@ -2078,6 +2153,7 @@ msgid "before this page opened" msgstr "打开本页之前" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:30 +msgctxt "vantage" msgid "bit/s" msgstr "bit/s" @@ -2103,10 +2179,12 @@ msgid "dBm" msgstr "dBm" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:121 +msgctxt "vantage" msgid "drop" msgstr "丢弃" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2614 +msgctxt "vantage" msgid "e.g. Living room TV" msgstr "例如:客厅电视" @@ -2119,6 +2197,7 @@ msgid "forwards nowhere" msgstr "不转发到任何区域" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:100 +msgctxt "vantage" msgid "from %s" msgstr "来自 %s" @@ -2175,10 +2254,12 @@ msgid "internet" msgstr "互联网" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:88 +msgctxt "vantage" msgid "just now" msgstr "刚刚" #: luci-app-vantage/htdocs/luci-static/resources/vantage/fmt.js:30 +msgctxt "vantage" msgid "kbit/s" msgstr "kbit/s" @@ -2220,12 +2301,13 @@ msgid "no clients" msgstr "无客户端" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1913 +msgctxt "vantage" msgid "no data" msgstr "无数据" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1570 msgid "no traffic yet" -msgstr "" +msgstr "尚无流量" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1437 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1567 @@ -2238,7 +2320,7 @@ msgstr "无连接" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2572 msgid "not known to this AP" -msgstr "此 AP 未知" +msgstr "此 AP 未识别" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2100 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2101 @@ -2248,7 +2330,7 @@ msgstr "未报告" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2713 msgid "not reported (driver placeholder %s)" -msgstr "未报告(驱动占位 %s)" +msgstr "未报告(驱动占位值 %s)" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2555 msgid "not reported by the driver" @@ -2256,6 +2338,7 @@ msgstr "驱动未报告" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1950 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:2561 +msgctxt "vantage" msgid "now" msgstr "现在" @@ -2268,6 +2351,7 @@ msgid "older than the radio (%s)" msgstr "比射频支持的标准更旧(%s)" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1928 +msgctxt "vantage" msgid "peak" msgstr "峰值" @@ -2276,6 +2360,7 @@ msgid "per core" msgstr "每核" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:381 +msgctxt "vantage" msgid "port forward" msgstr "端口转发" @@ -2288,6 +2373,7 @@ msgid "received on %s" msgstr "在 %s 上接收" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:121 +msgctxt "vantage" msgid "reject" msgstr "拒绝" @@ -2309,11 +2395,11 @@ msgstr "已关闭" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:126 msgid "the covered subnets of zone %s" -msgstr "" +msgstr "区域 %s 覆盖的子网" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:128 msgid "the set in %s" -msgstr "" +msgstr "%s 中的地址集合" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:107 msgid "this router" @@ -2332,6 +2418,7 @@ msgid "who uses the bandwidth" msgstr "带宽使用者" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:377 +msgctxt "vantage" msgid "zone" msgstr "区域" @@ -2341,7 +2428,7 @@ msgstr "区域转发" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/firewall.js:127 msgid "zones %s and %s if they share a device" -msgstr "" +msgstr "区域 %s 和 %s(若两者共用设备)" #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1542 #: luci-app-vantage/htdocs/luci-static/resources/view/vantage/overview.js:1566 @@ -2352,6 +2439,478 @@ msgstr "↑ %s · 经 %s" msgid "−%d min" msgstr "−%d 分钟" +#~ msgctxt "vantage" +#~ msgid "Active" +#~ msgstr "活跃" + +#~ msgctxt "vantage" +#~ msgid "Addresses" +#~ msgstr "地址" + +#~ msgctxt "vantage" +#~ msgid "All" +#~ msgstr "全部" + +#~ msgctxt "vantage" +#~ msgid "Any zone" +#~ msgstr "任意区域" + +#~ msgctxt "vantage" +#~ msgid "Cancel" +#~ msgstr "取消" + +#~ msgctxt "vantage" +#~ msgid "Carrier" +#~ msgstr "链路状态" + +#~ msgctxt "vantage" +#~ msgid "Channel" +#~ msgstr "信道" + +#~ msgctxt "vantage" +#~ msgid "Clients" +#~ msgstr "客户端" + +#~ msgctxt "vantage" +#~ msgid "Close" +#~ msgstr "关闭" + +#~ msgctxt "vantage" +#~ msgid "Connected" +#~ msgstr "已连接" + +#~ msgctxt "vantage" +#~ msgid "Connection" +#~ msgstr "连接" + +#~ msgctxt "vantage" +#~ msgid "Country" +#~ msgstr "国家" + +#~ msgctxt "vantage" +#~ msgid "DHCP" +#~ msgstr "DHCP" + +#~ msgctxt "vantage" +#~ msgid "DNS" +#~ msgstr "DNS" + +#~ msgctxt "vantage" +#~ msgid "Device" +#~ msgstr "设备" + +#~ msgctxt "vantage" +#~ msgid "Device name" +#~ msgstr "设备名" + +#~ msgctxt "vantage" +#~ msgid "Device type" +#~ msgstr "设备类型" + +#~ msgctxt "vantage" +#~ msgid "Disabled" +#~ msgstr "已禁用" + +#~ msgctxt "vantage" +#~ msgid "Download" +#~ msgstr "下载" + +#~ msgctxt "vantage" +#~ msgid "Edit" +#~ msgstr "编辑" + +#~ msgctxt "vantage" +#~ msgid "Gateway" +#~ msgstr "网关" + +#~ msgctxt "vantage" +#~ msgid "General Settings" +#~ msgstr "基本设置" + +#~ msgctxt "vantage" +#~ msgid "IPv4" +#~ msgstr "IPv4" + +#~ msgctxt "vantage" +#~ msgid "IPv6" +#~ msgstr "IPv6" + +#~ msgctxt "vantage" +#~ msgid "Identity" +#~ msgstr "身份" + +#~ msgctxt "vantage" +#~ msgid "Interface" +#~ msgstr "接口" + +#~ msgctxt "vantage" +#~ msgid "Interfaces" +#~ msgstr "接口" + +#~ msgctxt "vantage" +#~ msgid "Internet" +#~ msgstr "互联网" + +#~ msgctxt "vantage" +#~ msgid "Kernel" +#~ msgstr "内核" + +#~ msgctxt "vantage" +#~ msgid "Load" +#~ msgstr "负载" + +#~ msgctxt "vantage" +#~ msgid "MAC" +#~ msgstr "MAC" + +#~ msgctxt "vantage" +#~ msgid "MAC address" +#~ msgstr "MAC 地址" + +#~ msgctxt "vantage" +#~ msgid "Mbit/s" +#~ msgstr "Mbit/s" + +#~ msgctxt "vantage" +#~ msgid "Memory" +#~ msgstr "内存" + +#~ msgctxt "vantage" +#~ msgid "Mode" +#~ msgstr "模式" + +#~ msgctxt "vantage" +#~ msgid "Model" +#~ msgstr "型号" + +#~ msgctxt "vantage" +#~ msgid "Name" +#~ msgstr "名称" + +#~ msgctxt "vantage" +#~ msgid "Network" +#~ msgstr "网络" + +#~ msgctxt "vantage" +#~ msgid "Networks" +#~ msgstr "网络" + +#~ msgctxt "vantage" +#~ msgid "Noise" +#~ msgstr "噪声" + +#~ msgctxt "vantage" +#~ msgid "None" +#~ msgstr "无" + +#~ msgctxt "vantage" +#~ msgid "Not connected" +#~ msgstr "未连接" + +#~ msgctxt "vantage" +#~ msgid "Processes" +#~ msgstr "进程" + +#~ msgctxt "vantage" +#~ msgid "Protocol" +#~ msgstr "协议" + +#~ msgctxt "vantage" +#~ msgid "Reset" +#~ msgstr "重置" + +#~ msgctxt "vantage" +#~ msgid "Save" +#~ msgstr "保存" + +#~ msgctxt "vantage" +#~ msgid "Signal" +#~ msgstr "信号强度" + +#~ msgctxt "vantage" +#~ msgid "Status" +#~ msgstr "状态" + +#~ msgctxt "vantage" +#~ msgid "Storage" +#~ msgstr "存储" + +#~ msgctxt "vantage" +#~ msgid "System" +#~ msgstr "系统" + +#~ msgctxt "vantage" +#~ msgid "Traffic" +#~ msgstr "流量" + +#~ msgctxt "vantage" +#~ msgid "Upload" +#~ msgstr "上传" + +#~ msgctxt "vantage" +#~ msgid "Uptime" +#~ msgstr "运行时间" + +#~ msgctxt "vantage" +#~ msgid "Vendor" +#~ msgstr "供应商" + +#~ msgctxt "vantage" +#~ msgid "Weak" +#~ msgstr "弱" + +#~ msgctxt "vantage" +#~ msgid "Wi-Fi" +#~ msgstr "Wi-Fi" + +#~ msgctxt "vantage" +#~ msgid "auto" +#~ msgstr "自动" + +#~ msgctxt "vantage" +#~ msgid "dBm" +#~ msgstr "dBm" + +#~ msgid "%d h ago" +#~ msgstr "%d 小时前" + +#~ msgid "%d min" +#~ msgstr "%d 分" + +#~ msgid "%d min ago" +#~ msgstr "%d 分钟前" + +#~ msgid "%d s" +#~ msgstr "%d 秒" + +#~ msgid "%d s ago" +#~ msgstr "%d 秒前" + +#~ msgid "%s ago" +#~ msgstr "%s 前" + +#~ msgid "%s available" +#~ msgstr "可用 %s" + +#~ msgid "2.4 GHz" +#~ msgstr "2.4 GHz" + +#~ msgid "5 GHz" +#~ msgstr "5 GHz" + +#~ msgid "6 GHz" +#~ msgstr "6 GHz" + +#~ msgid "Alias" +#~ msgstr "别名" + +#~ msgid "B" +#~ msgstr "B" + +#~ msgid "CPU" +#~ msgstr "CPU" + +#~ msgid "Capabilities" +#~ msgstr "能力" + +#~ msgid "Clear filter" +#~ msgstr "清除筛选" + +#~ msgid "Down" +#~ msgstr "断开" + +#~ msgid "Downloaded" +#~ msgstr "已下载" + +#~ msgid "Dropped" +#~ msgstr "已丢弃" + +#~ msgid "Excellent" +#~ msgstr "极好" + +#~ msgid "Fair" +#~ msgstr "一般" + +#~ msgid "Firmware" +#~ msgstr "固件" + +#~ msgid "Free" +#~ msgstr "空闲" + +#~ msgid "Gbit/s" +#~ msgstr "Gbit/s" + +#~ msgid "Generation" +#~ msgstr "代际" + +#~ msgid "GiB" +#~ msgstr "GiB" + +#~ msgid "Good" +#~ msgstr "良好" + +#~ msgid "Health checks" +#~ msgstr "健康检查" + +#~ msgid "Hidden" +#~ msgstr "隐藏" + +#~ msgid "In use" +#~ msgstr "使用中" + +#~ msgid "Input" +#~ msgstr "入站数据" + +#~ msgid "KiB" +#~ msgstr "KiB" + +#~ msgid "Last activity" +#~ msgstr "最后活动" + +#~ msgid "Legacy" +#~ msgstr "旧版" + +#~ msgid "Link" +#~ msgstr "链路" + +#~ msgid "Load average" +#~ msgstr "平均负载" + +#~ msgid "MiB" +#~ msgstr "MiB" + +#~ msgid "New" +#~ msgstr "新增" + +#~ msgid "No default route" +#~ msgstr "无默认路由" + +#~ msgid "No wireless networks configured." +#~ msgstr "未配置无线网络。" + +#~ msgid "OWE" +#~ msgstr "OWE" + +#~ msgid "Online" +#~ msgstr "在线" + +#~ msgid "Open" +#~ msgstr "开放" + +#~ msgid "Platform" +#~ msgstr "平台" + +#~ msgid "Poor" +#~ msgstr "较差" + +#~ msgid "Radio" +#~ msgstr "射频" + +#~ msgid "Received" +#~ msgstr "已接收" + +#~ msgid "Reconnecting…" +#~ msgstr "重新连接中…" + +#~ msgid "Rename" +#~ msgstr "重命名" + +#~ msgid "Sent" +#~ msgstr "已发送" + +#~ msgid "Speed" +#~ msgstr "速率" + +#~ msgid "Swap" +#~ msgstr "交换分区" + +#~ msgid "Throughput" +#~ msgstr "吞吐量" + +#~ msgid "TiB" +#~ msgstr "TiB" + +#~ msgid "Top talkers" +#~ msgstr "流量大户" + +#~ msgid "Traffic Rules" +#~ msgstr "通信规则" + +#~ msgid "Unknown device" +#~ msgstr "未知设备" + +#~ msgid "Unnamed rule" +#~ msgstr "未命名的规则" + +#~ msgid "Up" +#~ msgstr "正常" + +#~ msgid "Uplink" +#~ msgstr "上行链路" + +#~ msgid "Uploaded" +#~ msgstr "已上传" + +#~ msgid "WPA" +#~ msgstr "WPA" + +#~ msgid "WPA/WPA2" +#~ msgstr "WPA/WPA2" + +#~ msgid "WPA2" +#~ msgstr "WPA2" + +#~ msgid "WPA3" +#~ msgstr "WPA3" + +#~ msgid "Warnings" +#~ msgstr "警告" + +#~ msgid "Wireless networks" +#~ msgstr "无线网络" + +#~ msgid "accept" +#~ msgstr "接受" + +#~ msgid "any zone" +#~ msgstr "任意区域" + +#~ msgid "bit/s" +#~ msgstr "bit/s" + +#~ msgid "drop" +#~ msgstr "丢弃" + +#~ msgid "e.g. Living room TV" +#~ msgstr "例如:客厅电视" + +#~ msgid "from %s" +#~ msgstr "来自 %s" + +#~ msgid "just now" +#~ msgstr "刚刚" + +#~ msgid "kbit/s" +#~ msgstr "kbit/s" + +#~ msgid "no data" +#~ msgstr "无数据" + +#~ msgid "now" +#~ msgstr "现在" + +#~ msgid "peak" +#~ msgstr "峰值" + +#~ msgid "port forward" +#~ msgstr "端口转发" + +#~ msgid "reject" +#~ msgstr "拒绝" + +#~ msgid "zone" +#~ msgstr "区域" + #~ msgid "%s covers only some addresses" #~ msgstr "%s 仅覆盖部分地址" diff --git a/luci-app-wwand/Makefile b/luci-app-wwand/Makefile index a47e4a85b..3c30a5e1e 100644 --- a/luci-app-wwand/Makefile +++ b/luci-app-wwand/Makefile @@ -17,17 +17,20 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-wwand -PKG_VERSION:=1.7.0_pre9 -PKG_RELEASE:=45 +PKG_VERSION:=1.8.0_pre1 +PKG_RELEASE:=46 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git -PKG_SOURCE_VERSION:=ab79811095c81817d55ec9bf82a9fc978f34856d +PKG_SOURCE_VERSION:=9101de373fe07ae2c52f171639cc6e05d689cd59 PKG_MIRROR_HASH:=skip PKG_LICENSE:=GPL-2.0-only PKG_MAINTAINER:= +# po2lmo, for the translation packages below +PKG_BUILD_DEPENDS:=luci-base/host + include $(INCLUDE_DIR)/package.mk define Package/luci-app-wwand @@ -109,5 +112,60 @@ define Package/luci-app-wwand-statistics/install $(1)/www/luci-static/resources/statistics/rrdtool/definitions/ endef +# Translations: one luci-i18n-wwand- per po//wwand.po in the +# source. luci.mk finds the languages by listing po/ next to the Makefile while +# make parses it, and a git source is not there yet at that point — so they are +# named here, and a new translation needs its language (and its name, for +# LuCI's language list) added in the same bump that pins it. Named, like the +# files above: a language listed without its .po fails the build instead of +# shipping an empty package. +# +# Same package names, Kconfig defaults, .lmo names and luci.languages entry +# as luci.mk (feeds/luci luci.mk LuciTranslation, 2026-10): the .po directory +# is LuCI's language tag (pt_BR), the .lmo carries its short code where the two +# differ (LUCI_LC_ALIAS there). The entry matters: the language selector and +# the Accept-Language match read only luci.languages (luci-mod-system +# system.js, luci-base dispatcher.uc, 2026-10), and this package does not pull +# in the luci-i18n-base- that would otherwise write it. +LUCI_WWAND_LANGS:= +LUCI_WWAND_LC.bn_BD:=bn +LUCI_WWAND_LC.nb_NO:=no +LUCI_WWAND_LC.pt_BR:=pt-br +LUCI_WWAND_LC.zh_Hans:=zh-cn +LUCI_WWAND_LC.zh_Hant:=zh-tw +# LUCI_WWAND_NAME.:= in luci.mk>, e.g. +# LUCI_WWAND_NAME.pl:=Polski (Polish) + +# 1: LuCI language code (package and .lmo name), 2: po directory +define LuciWwandTranslation + define Package/luci-i18n-wwand-$(1) + SECTION:=luci + CATEGORY:=LuCI + TITLE:=luci-app-wwand - $(1) translation + HIDDEN:=1 + DEFAULT:=LUCI_LANG_$(2)||(ALL&&m) + DEPENDS:=luci-app-wwand + PKGARCH:=all + endef + + define Package/luci-i18n-wwand-$(1)/description + Translation for luci-app-wwand ($(2)). + endef + + define Package/luci-i18n-wwand-$(1)/install + $$(INSTALL_DIR) $$(1)/etc/uci-defaults + echo "uci set luci.languages.$(subst -,_,$(1))='$(or $(LUCI_WWAND_NAME.$(2)),$(2))'; uci commit luci" \ + > $$(1)/etc/uci-defaults/luci-i18n-wwand-$(1) + $$(INSTALL_DIR) $$(1)/usr/lib/lua/luci/i18n + po2lmo $(PKG_BUILD_DIR)/po/$(2)/wwand.po \ + $$(1)/usr/lib/lua/luci/i18n/wwand.$(1).lmo + endef + + LUCI_WWAND_I18N += luci-i18n-wwand-$(1) +endef + +$(foreach lang,$(LUCI_WWAND_LANGS),$(eval $(call LuciWwandTranslation,$(or $(LUCI_WWAND_LC.$(lang)),$(lang)),$(lang)))) + $(eval $(call BuildPackage,luci-app-wwand)) $(eval $(call BuildPackage,luci-app-wwand-statistics)) +$(foreach pkg,$(LUCI_WWAND_I18N),$(eval $(call BuildPackage,$(pkg)))) diff --git a/luci-theme-argon/Makefile b/luci-theme-argon/Makefile index 79f07b7f2..d09c140a4 100644 --- a/luci-theme-argon/Makefile +++ b/luci-theme-argon/Makefile @@ -11,8 +11,8 @@ LUCI_TITLE:=Argon Theme LUCI_DEPENDS:=+luci-base +USE_APK:wget-any +!USE_APK:wget +jsonfilter +ucode-mod-math +flock +coreutils +coreutils-timeout # Keep existing translation package names and the argon-config message domain. LUCI_BASENAME:=argon-config -PKG_VERSION:=3.1.0 -PKG_RELEASE:=34 +PKG_VERSION:=3.1.1 +PKG_RELEASE:=35 PKG_LICENSE:=Apache-2.0 MIT # LuCI derives translations independently of the theme version. Keep release diff --git a/luci-theme-argon/README.md b/luci-theme-argon/README.md index 288f9428b..371af74e9 100644 --- a/luci-theme-argon/README.md +++ b/luci-theme-argon/README.md @@ -41,7 +41,7 @@ It also supports automatic and manual switching between light and dark modes. - **Layout fixes.** Improve dashboard actions, overview layouts, dynamic lists and custom IP/MAC inputs across narrow screens, light/dark modes and RTL layouts. - **Compatibility improvements.** Improve compatibility with stable OpenWrt firmware. -Users on v3.0.0 or earlier must install v3.1.0 manually once to get online updates. See the [release notes](RELEASE.md). +Users on v3.0.0 or earlier must install v3.1.1 manually once to get online updates. Users on v3.1.0 can upgrade through Theme updates. See the [release notes](RELEASE.md). ## 3.0 highlights @@ -74,7 +74,7 @@ This applies only when the old standalone configuration plugin is installed. Oth The settings page, colors, backgrounds and branding are all included in the theme. The release's `luci-app-argon-config` is an **empty migration package using the old package name**. It preserves existing settings and hands its files over to the theme. -1. Download both `luci-app-argon-config` and `luci-theme-argon` from the **same 3.0 release in the theme repository**, in the same format. +1. Download both `luci-app-argon-config` and `luci-theme-argon` from the **same release in the theme repository**, in the same format. 2. Put both files in one directory on the router and open a terminal there. **Do not uninstall the old plugin first.** Choose the command pair for your firmware; run the second command only after the first succeeds. ```sh diff --git a/luci-theme-argon/README_ZH.md b/luci-theme-argon/README_ZH.md index 8babd00ba..e3a7de665 100644 --- a/luci-theme-argon/README_ZH.md +++ b/luci-theme-argon/README_ZH.md @@ -41,7 +41,7 @@ Argon 是**一款干净整洁的 OpenWrt LuCI 主题**, - **布局修复**:优化 Dashboard 操作区、概览布局、动态列表和自定义 IP/MAC 输入框,兼顾窄屏、亮暗色和 RTL 布局。 - **兼容性改进**:改善稳定版 OpenWrt 固件上的兼容性。 -v3.0.0 及更早版本用户需先手动安装一次 v3.1.0,之后即可使用在线更新。详见[更新日志](RELEASE_ZH.md)。 +v3.0.0 及更早版本用户需先手动安装一次 v3.1.1,之后即可使用在线更新。v3.1.0 用户可直接通过主题更新升级。详见[更新日志](RELEASE_ZH.md)。 ## 3.0 主要特性 @@ -74,7 +74,7 @@ v3.0.0 及更早版本用户需先手动安装一次 v3.1.0,之后即可使用 3.0 的设置页面、配色、背景和 Logo 管理都在主题包内。发行附件中的 `luci-app-argon-config` 是**沿用旧包名的空迁移包**,用于保留旧配置并将文件交给主题,不是另一个配置插件。 -1. 从**主题仓库同一次 3.0 发行**中下载 `luci-app-argon-config` 迁移包和 `luci-theme-argon` 主题包,格式一致。 +1. 从**主题仓库同一次发行**中下载 `luci-app-argon-config` 迁移包和 `luci-theme-argon` 主题包,格式一致。 2. 将两个文件放到路由器同一目录,在该目录打开终端。**无需先卸载旧插件**,按固件选择下面一组命令;第一条成功后,再执行第二条。 ```sh diff --git a/luci-theme-argon/RELEASE.md b/luci-theme-argon/RELEASE.md index 27424d650..bb61129f4 100644 --- a/luci-theme-argon/RELEASE.md +++ b/luci-theme-argon/RELEASE.md @@ -1,5 +1,66 @@ # Update log for master branch +## v3.1.1 [ 2026.10.28 ] + +### Fixed / 修复 + +- Fix mobile sidebar controls that could fail to open, including in Chrome. +- Keep overview port counters and Wi-Fi signal/noise readings readable in narrow layouts and with larger text. +- Prevent wireless client Disconnect menu buttons from wrapping vertically; improve the layout of network names, MAC addresses and host details. +- Refresh stylesheet cache keys when CSS changes, including source rebuilds using the same package version. +- Correct card layouts and spacing on system logs, processes, package management, cron, SSH access, public keys, diagnostics and reboot pages. +- Improve OpenClash update/log pages, Wake on LAN settings/output and Tailscale status/device tables, including both Services and VPN menu paths. + +- 修复手机侧栏菜单可能无法打开的问题,包括 Chrome 浏览器下的点击响应。 +- 改善概览端口收发计数与 Wi-Fi 信号/噪声显示,兼顾窄屏和大字号。 +- 修复无线客户端 Disconnect 菜单按钮文字竖排,优化网络名称、MAC 地址与主机信息布局。 +- CSS 更新时自动变更缓存标识,覆盖使用相同包版本号重新编译的情况。 +- 修正系统日志、进程、软件包管理、计划任务、SSH 管理、公钥、网络诊断及重启页面的卡片布局和间距。 +- 改善 OpenClash 更新/日志、网络唤醒设置/输出及 Tailscale 状态/设备表格,兼容 Services 与 VPN 两种菜单路径。 + +### Upgrade notes / 升级说明 + +Users on v3.1.0 can upgrade through **System → Argon Config → Theme updates**. Users on v3.0.0 or earlier must install v3.1.1 manually once. Online installation updates the theme package and preserves settings; install separate translation packages in the usual way. + +v3.1.0 用户可通过**系统 → Argon 配置 → 主题更新**升级。v3.0.0 及更早版本用户需先手动安装一次 v3.1.1。在线安装更新主题主包并保留设置;独立语言包仍按原方式安装。 + +### Installation / 安装说明 + +| Firmware / 固件 | Package / 安装包 | +| --- | --- | +| Native `opkg`, including OpenWrt 23.05 / 24.10 and GL.iNet firmware based on them / 原生使用 `opkg` 的固件 | `.ipk` → `opkg install` | +| Native APK v3 with `apk-tools 3.x` / 原生使用 APK v3 的固件 | `.apk` → `apk add --allow-untrusted` | + +APK assets use **APK v3** and cannot be read by `apk-tools 2.x`, including `2.14.0`. Having an `apk` command installed on an opkg-based firmware does not make it APK v3 compatible. `--allow-untrusted` does not change format compatibility, and `apk` cannot install IPK files. + +APK 附件采用 **APK v3 格式**,`apk-tools 2.x`(包括 `2.14.0`)无法读取。在原生使用 opkg 的固件上额外安装 `apk`,并不会让固件兼容 APK v3;`--allow-untrusted` 不能解决格式不兼容,`apk` 也不能安装 IPK。 + +APK builds use the OpenWrt **25.12.5 stable SDK**. Theme templates are shipped as source to avoid snapshot-specific ucode bytecode requirements. If an older APK reports `ucode>=2026.02.27`, use a package rebuilt with the [#715 fix](https://github.com/jerrykuku/luci-theme-argon/issues/715); bypassing dependency checks does not make that bytecode compatible. + +APK 使用 OpenWrt **25.12.5 稳定版 SDK** 构建,主题模板保留源码,避免 snapshot 的 ucode 字节码版本限制。若旧 APK 提示需要 `ucode>=2026.02.27`,请使用包含 [#715 修复](https://github.com/jerrykuku/luci-theme-argon/issues/715) 的重新构建包;绕过依赖检查不能解决字节码不兼容。 + +Download the theme and required language packages in the **same format**. Use the exact asset filenames below. For GL.iNet / OpenWrt 23.05, use IPK files with the firmware's existing `opkg`. + +主题和需要的语言包应选择**相同格式**,以本页附件实际文件名为准。GL.iNet / OpenWrt 23.05 请下载 IPK 并使用固件原有的 `opkg` 安装。 + +### Argon 3.0 settings integration / 设置集成 + +Settings are included in `luci-theme-argon`. Fresh installations need only the theme and desired `luci-i18n-argon-config-*` translations. + +设置已内置主题,新安装无需配置插件。已有旧插件时,先单独安装本次发行的 `luci-app-argon-config` 空兼容包,再安装主题;该顺序会保留旧配置并完成文件归属交接。 + +For an existing configuration plugin, install this release's empty `luci-app-argon-config` compatibility package first, then install the theme in a separate command. Do not install both in one transaction or force overwrites. + + +### What's Changed +* fix(wol): correct settings and output spacing by @Aethersailor in https://github.com/jerrykuku/luci-theme-argon/pull/722 +* fix(openclash): align layouts and restore theme accents by @Aethersailor in https://github.com/jerrykuku/luci-theme-argon/pull/723 +* fix(pages): correct native LuCI card layouts by @Aethersailor in https://github.com/jerrykuku/luci-theme-argon/pull/727 +* fix(tailscale): unify status and device table styling by @Aethersailor in https://github.com/jerrykuku/luci-theme-argon/pull/724 + + +**Full Changelog**: https://github.com/jerrykuku/luci-theme-argon/compare/v3.1.0...v3.1.1 + ## v3.1.0 [ 2026.10.22 ] ### Added / 新功能 diff --git a/luci-theme-argon/RELEASE_ZH.md b/luci-theme-argon/RELEASE_ZH.md index 39a057871..63b1f10ae 100644 --- a/luci-theme-argon/RELEASE_ZH.md +++ b/luci-theme-argon/RELEASE_ZH.md @@ -1,5 +1,22 @@ # Master 分支的更新日志 +## v3.1.1 [ 2026.10.28 ] + +### 修复 + +- 修复手机侧栏菜单可能无法打开的问题,包括 Chrome 浏览器下的点击响应。 +- 改善概览端口收发计数与 Wi-Fi 信号/噪声显示,兼顾窄屏和大字号。 +- 修复无线客户端 Disconnect 菜单按钮文字竖排,优化网络名称、MAC 地址与主机信息布局。 +- CSS 更新时自动变更缓存标识,覆盖使用相同包版本号重新编译的情况。 +- 修正系统日志、进程、软件包管理、计划任务、SSH 管理、公钥、网络诊断及重启页面的卡片布局和间距。 +- 改善 OpenClash 更新/日志、网络唤醒设置/输出及 Tailscale 状态/设备表格,兼容 Services 与 VPN 两种菜单路径。 + +### 升级说明 + +v3.1.0 用户可通过**系统 → Argon 配置 → 主题更新**升级。v3.0.0 及更早版本用户需先手动安装一次 v3.1.1。在线安装更新主题主包并保留设置;独立语言包仍按原方式安装。 + +[完整发布说明](https://github.com/jerrykuku/luci-theme-argon/releases/tag/v3.1.1) + ## v3.1.0 [ 2026.10.22 ] ### 新功能 diff --git a/luci-theme-argon/compat/luci-app-argon-config/Makefile b/luci-theme-argon/compat/luci-app-argon-config/Makefile index b86fc1623..47e43d0f1 100644 --- a/luci-theme-argon/compat/luci-app-argon-config/Makefile +++ b/luci-theme-argon/compat/luci-app-argon-config/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-argon-config -PKG_VERSION:=3.1.0 -PKG_RELEASE:=20261022 +PKG_VERSION:=3.1.1 +PKG_RELEASE:=20261028 PKG_LICENSE:=Apache-2.0 LUCI_TITLE:=Argon configuration compatibility package diff --git a/luci-theme-argon/htdocs/luci-static/argon/css/cascade.css b/luci-theme-argon/htdocs/luci-static/argon/css/cascade.css index 9d1102817..463504b1b 100644 --- a/luci-theme-argon/htdocs/luci-static/argon/css/cascade.css +++ b/luci-theme-argon/htdocs/luci-static/argon/css/cascade.css @@ -7947,6 +7947,87 @@ pre.command-output { max-width: 100%; } } +[data-page="admin-services-tailscale"] #service_status_display, +[data-page="admin-vpn-tailscale"] #service_status_display { + display: block; + padding: var(--field-gap) var(--section-inset); + border-radius: var(--radius-card); + background-color: var(--surface-color); + box-shadow: var(--section-shadow); + overflow-x: auto; +} +[data-page="admin-services-tailscale"] #tailscale_devices_display, +[data-page="admin-vpn-tailscale"] #tailscale_devices_display { + display: block; +} +[data-page="admin-services-tailscale"] #tailscale_devices_display:has(> table), +[data-page="admin-vpn-tailscale"] #tailscale_devices_display:has(> table) { + overflow-x: auto; +} +[data-page="admin-services-tailscale"] #service_status_display table, +[data-page="admin-vpn-tailscale"] #service_status_display table, +[data-page="admin-services-tailscale"] #tailscale_devices_display table, +[data-page="admin-vpn-tailscale"] #tailscale_devices_display table { + display: table; + line-height: normal; +} +[data-page="admin-services-tailscale"] #service_status_display table > thead, +[data-page="admin-vpn-tailscale"] #service_status_display table > thead, +[data-page="admin-services-tailscale"] #tailscale_devices_display table > thead, +[data-page="admin-vpn-tailscale"] #tailscale_devices_display table > thead { + display: table-header-group; +} +[data-page="admin-services-tailscale"] #service_status_display table > tbody, +[data-page="admin-vpn-tailscale"] #service_status_display table > tbody, +[data-page="admin-services-tailscale"] #tailscale_devices_display table > tbody, +[data-page="admin-vpn-tailscale"] #tailscale_devices_display table > tbody { + display: table-row-group; +} +[data-page="admin-services-tailscale"] #service_status_display table tr, +[data-page="admin-vpn-tailscale"] #service_status_display table tr, +[data-page="admin-services-tailscale"] #tailscale_devices_display table tr, +[data-page="admin-vpn-tailscale"] #tailscale_devices_display table tr { + display: table-row; +} +[data-page="admin-services-tailscale"] #service_status_display table th, +[data-page="admin-vpn-tailscale"] #service_status_display table th, +[data-page="admin-services-tailscale"] #tailscale_devices_display table th, +[data-page="admin-vpn-tailscale"] #tailscale_devices_display table th, +[data-page="admin-services-tailscale"] #service_status_display table td, +[data-page="admin-vpn-tailscale"] #service_status_display table td, +[data-page="admin-services-tailscale"] #tailscale_devices_display table td, +[data-page="admin-vpn-tailscale"] #tailscale_devices_display table td { + display: table-cell; + padding: 1.1em 1.25rem; + border-top: var(--table-border); + line-height: normal; + text-align: center; + white-space: nowrap; +} +[data-page="admin-services-tailscale"] #service_status_display table th, +[data-page="admin-vpn-tailscale"] #service_status_display table th, +[data-page="admin-services-tailscale"] #tailscale_devices_display table th, +[data-page="admin-vpn-tailscale"] #tailscale_devices_display table th { + text-align: center !important; +} +[data-page="admin-services-tailscale"] #service_status_display tr + tr, +[data-page="admin-vpn-tailscale"] #service_status_display tr + tr, +[data-page="admin-services-tailscale"] #tailscale_devices_display tr:has(> td), +[data-page="admin-vpn-tailscale"] #tailscale_devices_display tr:has(> td) { + background-color: var(--row-color); +} +[data-page="admin-services-tailscale"] #tailscale_devices_display tr:nth-child(2n of :has(> td)), +[data-page="admin-vpn-tailscale"] #tailscale_devices_display tr:nth-child(2n of :has(> td)) { + background-color: var(--row-alt); +} +[data-page="admin-services-tailscale"] #service_status_display tr:first-child > td, +[data-page="admin-vpn-tailscale"] #service_status_display tr:first-child > td, +[data-page="admin-services-tailscale"] #tailscale_devices_display :is(.cbi-table-header, .cbi-section-table-titles) > th, +[data-page="admin-vpn-tailscale"] #tailscale_devices_display :is(.cbi-table-header, .cbi-section-table-titles) > th { + border-top: 0; + background-color: var(--surface-muted); + line-height: 1.3rem; +} [data-page="admin-vpn-passwall"] h4 { background: transparent; } @@ -8440,11 +8521,25 @@ pre.command-output { unicode-bidi: isolate; } @media screen and (min-width: 1153px) { + #wifi_assoclist_table .td > .cbi-dropdown:is(.btn, .cbi-button):not([multiple]) { + min-width: max-content; + } + #wifi_assoclist_table .td > .cbi-dropdown:is(.btn, .cbi-button):not([multiple]) > ul:not(.dropdown) > li { + white-space: nowrap; + overflow-wrap: normal; + } + #wifi_assoclist_table > .tr > :is(.th, .td) { + padding-inline: var(--action-gap); + overflow-wrap: break-word; + } #wifi_assoclist_table > .tr > :is(.th, .td):first-child { width: 22%; - min-width: 14rem; + min-width: 10rem; } } +#wifi_assoclist_table > .tr > .td > span.hide-xs { + display: block; +} @media screen and (max-width: 600px) { #wifi_assoclist_table .td:has(> .ifacebadge[data-ssid]) { flex-basis: 100%; diff --git a/luci-theme-argon/less/page-fix.less b/luci-theme-argon/less/page-fix.less index 302ffcfa9..422263a0d 100644 --- a/luci-theme-argon/less/page-fix.less +++ b/luci-theme-argon/less/page-fix.less @@ -57,6 +57,7 @@ @import "pages/openclash.less"; // VPN +@import "pages/tailscale.less"; @import "pages/passwall.less"; @import "pages/passwall2.less"; @import "pages/openvpn.less"; diff --git a/luci-theme-argon/less/pages/tailscale.less b/luci-theme-argon/less/pages/tailscale.less new file mode 100644 index 000000000..268e05d36 --- /dev/null +++ b/luci-theme-argon/less/pages/tailscale.less @@ -0,0 +1,75 @@ +// out: false + +[data-page="admin-services-tailscale"], +[data-page="admin-vpn-tailscale"] { + // The community app renders its status outside the normal CBI section card. + #service_status_display { + display: block; + padding: var(--field-gap) var(--section-inset); + border-radius: var(--radius-card); + background-color: var(--surface-color); + box-shadow: var(--section-shadow); + overflow-x: auto; + } + + #tailscale_devices_display { + display: block; + + // Older app versions render the table without a scrolling wrapper. + &:has(> table) { + overflow-x: auto; + } + } + + // Both renderers use native rows and cells rather than LuCI's .tr/.td pair. + #service_status_display, + #tailscale_devices_display { + table { + display: table; + line-height: normal; + + > thead { + display: table-header-group; + } + + > tbody { + display: table-row-group; + } + + tr { + display: table-row; + } + + th, + td { + display: table-cell; + padding: 1.1em 1.25rem; + border-top: var(--table-border); + line-height: normal; + text-align: center; + white-space: nowrap; + } + + // The app sets header alignment inline. + th { + text-align: center !important; + } + } + } + + #service_status_display tr + tr, + #tailscale_devices_display tr:has(> td) { + background-color: var(--row-color); + } + + #tailscale_devices_display tr:nth-child(2n of :has(> td)) { + background-color: var(--row-alt); + } + + #service_status_display tr:first-child > td, + #tailscale_devices_display :is(.cbi-table-header, .cbi-section-table-titles) > th { + border-top: 0; + background-color: var(--surface-muted); + line-height: 1.3rem; + } +} diff --git a/luci-theme-argon/less/pages/wireless.less b/luci-theme-argon/less/pages/wireless.less index feac02b58..9db99972c 100644 --- a/luci-theme-argon/less/pages/wireless.less +++ b/luci-theme-argon/less/pages/wireless.less @@ -56,12 +56,33 @@ } @media screen and (min-width: 1153px) { + // A table action column shrinks to its controls' minimum content width. + // Preserve the ComboButton label when MAC filtering adds its menu. + .td > .cbi-dropdown:is(.btn, .cbi-button):not([multiple]) { + min-width: max-content; + + > ul:not(.dropdown) > li { + white-space: nowrap; + overflow-wrap: normal; + } + } + + > .tr > :is(.th, .td) { + padding-inline: var(--action-gap); + overflow-wrap: break-word; + } + > .tr > :is(.th, .td):first-child { width: 22%; - min-width: 14rem; + min-width: 10rem; } } + // Keep host names separate from the optional address details. + > .tr > .td > span.hide-xs { + display: block; + } + @media screen and (max-width: 600px) { .td:has(> .ifacebadge[data-ssid]) { flex-basis: 100%; diff --git a/luci-theme-argon/tests/helpers/render-templates.js b/luci-theme-argon/tests/helpers/render-templates.js index 247fdf8b9..693050c0d 100644 --- a/luci-theme-argon/tests/helpers/render-templates.js +++ b/luci-theme-argon/tests/helpers/render-templates.js @@ -13,10 +13,11 @@ module.exports = function createRenderer(sourceDir) { fs.mkdirSync(target, { recursive: true }); for (const name of fs.readdirSync(sourceDir).filter(name => name.endsWith('.ut'))) { const source = fs.readFileSync(path.join(sourceDir, name), 'utf8') - .replace("import { access, lsdir, lstat } from 'fs';", ` + .replace("import { access, lsdir, lstat, stat } from 'fs';", ` function access(path) { return fixture.configExists ?? true; } function lsdir(path) { return fixture.icons ?? ['default.svg', 'status.svg']; } -function lstat(path) { return { type: 'file' }; }`) +function lstat(path) { return { type: 'file' }; } +function stat(path) { return path == '/www/luci-static/argon/css/cascade.css' ? fixture.stylesheetStat : null; }`) .replace("import { access } from 'fs';", 'function access(path) { return fixture.configExists ?? true; }') .replace("import * as fs from 'fs';", ` let fs = { access: path => fixture.configExists ?? true, diff --git a/luci-theme-argon/tests/template-composition.test.js b/luci-theme-argon/tests/template-composition.test.js index d366dada5..1db7f7dac 100644 --- a/luci-theme-argon/tests/template-composition.test.js +++ b/luci-theme-argon/tests/template-composition.test.js @@ -57,6 +57,24 @@ test('first-child entry routes identify the resolved page and retain wildcard re } finally { renderer.close(); } }); +test('stylesheet URLs track source rebuilds and preserved timestamps on application and login pages', () => { + const renderer = createRenderer(templates); + try { + for (const entry of ['application', 'sysauth']) { + const stylesheet = stylesheetStat => renderer.render(entry, { stylesheetStat }).html + .match(/href="([^"]*\/css\/cascade\.css[^"]*)"/)[1]; + const original = { mtime: 1792993553, ctime: 1792993553, size: 282004 }; + const first = stylesheet(original); + assert.equal(first, '/luci-static/argon/css/cascade.css?v=1792993553-1792993553-282004'); + assert.equal(stylesheet(original), first); + assert.notEqual(stylesheet({ ...original, mtime: original.mtime + 1 }), first); + assert.notEqual(stylesheet({ ...original, ctime: original.ctime + 1 }), first); + assert.notEqual(stylesheet({ ...original, size: original.size + 1 }), first); + assert.equal(stylesheet(null), '/luci-static/argon/css/cascade.css?v=0'); + } + } finally { renderer.close(); } +}); + test('login partials retain image/video, branding and authentication scope and script order', () => { const renderer = createRenderer(templates); try { diff --git a/luci-theme-argon/ucode/template/themes/argon/head.ut b/luci-theme-argon/ucode/template/themes/argon/head.ut index 158076099..67841ba3a 100644 --- a/luci-theme-argon/ucode/template/themes/argon/head.ut +++ b/luci-theme-argon/ucode/template/themes/argon/head.ut @@ -18,7 +18,7 @@ {# Shared document head. Callers supply hostname and is_login explicitly. -#} {% - import { access, lsdir, lstat } from 'fs'; + import { access, lsdir, lstat, stat } from 'fs'; import { cursor } from 'uci'; import { dark_palette, appearance_modes } from 'luci.argon-palette'; @@ -80,7 +80,15 @@ {% endif %} {% include("themes/" + theme + "/head_meta", { hostname: hostname, node: node, bar_color: bar_color, media: media, mode: mode, primary: primary, dark_primary: dark_primary }) %} - + {% + // Source builds can change CSS without changing the package version. + // ctime also catches replacements which preserve mtime and file size. + const stylesheet_stat = stat('/www' + media + '/css/cascade.css'); + const stylesheet_version = stylesheet_stat + ? sprintf('%d-%d-%d', stylesheet_stat.mtime, stylesheet_stat.ctime, stylesheet_stat.size) + : '0'; + %} + + +
证书剩余超过 30 天时不会重复签发,避免触发 Let's Encrypt 频率限制;到期前 30 天内会自动续期。
+ + ]] end diff --git a/minigate/luasrc/model/cbi/minigate/ddns.lua b/minigate/luasrc/model/cbi/minigate/ddns.lua index 3e2d47c30..72361fcb9 100644 --- a/minigate/luasrc/model/cbi/minigate/ddns.lua +++ b/minigate/luasrc/model/cbi/minigate/ddns.lua @@ -1,5 +1,5 @@ local m,s,o -local sys=require"luci.sys" +local ctl=require"luci.controller.minigate" local function esc(v) v=tostring(v or "") @@ -8,23 +8,47 @@ local function esc(v) return v end -m=Map("minigate","MiniGate - 动态DNS","支持多域名、IPv4/IPv6双栈。保存后立即同步。需先启用全局开关。") - --- 只重新同步 DDNS,不重启反向代理和登录防护 -m.on_after_commit=function(self) - sys.call("/bin/sh /usr/lib/minigate/after_commit.sh ddns >/dev/null 2>&1") +-- 域名:可带 *. 前缀,每段 1-63 位字母数字或连字符,不以连字符开头或结尾 +local function valid_domain(value) + local name=tostring(value or ""):gsub("^%*%.","") + if #name==0 or #name>253 then return false end + local count=0 + for label in (name.."."):gmatch("([^.]*)%.") do + if #label<1 or #label>63 or not label:match("^[%w%-]+$") or label:match("^%-") or label:match("%-$") then return false end + count=count+1 + end + return count>=2 end +-- 保存并应用后由 procd 的配置触发器只重新同步 DDNS,不重启反向代理和登录防护 +m=Map("minigate","MiniGate - 动态DNS","支持多域名、IPv4/IPv6双栈。保存并应用后立即同步。需先在「总览」启用 MiniGate。") + +s=m:section(SimpleSection); s.template="minigate/style" + s=m:section(TypedSection,"ddns","DDNS 记录") s.anonymous=true; s.addremove=true; s.sortable=true o=s:option(Flag,"enabled","启用"); o.rmempty=false o=s:option(Value,"domain","域名"); o.rmempty=false; o.placeholder="*.example.com 或 home.example.com" +o.validate=function(self,value) + if valid_domain(value) then return value end + return nil,"域名格式不正确,例如 home.example.com 或 *.example.com" +end o=s:option(Value,"cf_zone_id","Zone ID"); o.rmempty=false; o.placeholder="Cloudflare 区域 ID" +o.description="在 Cloudflare 域名概览页右下角「API」区域可以找到。" +o.validate=function(self,value) + if tostring(value or ""):match("^%x+$") and #value==32 then return value end + return nil,"Zone ID 应为 32 位十六进制字符" +end o=s:option(Value,"cf_api_token","API 令牌"); o.rmempty=false; o.password=true +o.description="在 Cloudflare「我的个人资料 → API 令牌」创建,权限选「区域 → DNS → 编辑」,区域资源选本域名。" +o.validate=function(self,value) + if tostring(value or ""):match("^[%w%-_]+$") then return value end + return nil,"API 令牌只包含字母、数字、- 和 _,请检查是否复制了多余的空格" +end -- ====== 协议版本 ====== o=s:option(ListValue,"ip_version","协议版本") @@ -36,6 +60,7 @@ o.description="选择更新哪种 DNS 记录。双栈模式同时更新 A 和 AA o=s:option(ListValue,"ip_source","IP 来源") o:value("interface","从网卡获取"); o:value("url","从外部URL获取"); o.default="interface" +o.description="软路由在上级路由后面(网卡是 192.168.x.x 等内网地址)时,请选「从外部URL获取」。内网地址不会被推送到 Cloudflare。" -- IPv4 网卡 o=s:option(ListValue,"interface","IPv4 网络接口"); o.default="wan" @@ -45,17 +70,17 @@ o:depends("ip_source","interface") -- IPv4 URL o=s:option(ListValue,"ip_url","IPv4 获取地址") -o:value("http://ip.3322.net","ip.3322.net") -o:value("http://members.3322.org/dyndns/getip","members.3322.org") -o:value("http://ns1.dnspod.net:6666","ns1.dnspod.net (腾讯)") -o:value("http://ip.tool.chinaz.com/getip","chinaz.com (站长工具)") -o.default="http://ip.3322.net" -o.description="旁路由/代理环境自动绕过代理直连获取真实公网IP" +o:value("https://ddns.oray.com/checkip","ddns.oray.com(HTTPS,推荐)") +o:value("http://ip.3322.net","ip.3322.net(HTTP)") +o:value("http://members.3322.org/dyndns/getip","members.3322.org(HTTP)") +o:value("http://ns1.dnspod.net:6666","ns1.dnspod.net(HTTP)") +o.default="https://ddns.oray.com/checkip" +o.description="所选地址失败时自动尝试其他地址;旁路由/代理环境会绕过代理直连获取真实公网 IP。" o:depends("ip_source","url") -- IPv6 网卡 o=s:option(ListValue,"interface6","IPv6 网络接口"); o.default="wan6" -o.description="用于获取 IPv6 地址的接口(通常为 wan6)" +o.description="用于获取 IPv6 地址的接口(通常为 wan6);只使用全球单播地址。" uci:foreach("network","interface",function(sec) if sec[".name"]~="loopback"then o:value(sec[".name"],sec[".name"])end end) o:depends({ip_source="interface",ip_version="ipv6"}) o:depends({ip_source="interface",ip_version="dual"}) @@ -66,45 +91,37 @@ o:value("https://api6.ipify.org","api6.ipify.org") o:value("https://6.ipw.cn","6.ipw.cn") o:value("https://v6.ident.me","v6.ident.me") o:value("https://ifconfig.co","ifconfig.co (v6)") -o:value("http://v6.66666.host:66/ip","v6.66666.host") +o:value("http://v6.66666.host:66/ip","v6.66666.host(HTTP)") o.default="https://api6.ipify.org" o.description="仅通过 IPv6 连接获取地址" 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(Value,"check_interval","检查间隔(秒)"); o.datatype="uinteger"; o.default="300" +o:value("300","5 分钟"); o:value("600","10 分钟"); o:value("1800","30 分钟"); o:value("3600","1 小时") +o.description="最少 300 秒。定时任务每 5 分钟检查一次,实际间隔按 5 分钟向上取整。" +o.validate=function(self,value) + local n=tonumber(value) + if n and n>=300 then return value end + return nil,"检查间隔不能少于 300 秒" +end --- 状态+手动同步 +-- 状态+手动同步(状态来自 /var/run,不写入配置文件) +local STATE={ok={"✓ 正常","mgx-ok"},partial={"⚠ 部分成功","mgx-warn"},error={"✗ 异常","mgx-err"},stopped={"已停止","mgx-muted"}} o=s:option(DummyValue,"_info","运行状态"); o.rawhtml=true o.cfgvalue=function(self,section) - local st=m.uci:get("minigate",section,"status")or"unknown" - local msg=m.uci:get("minigate",section,"status_msg")or"" - local ip=m.uci:get("minigate",section,"last_ip")or"" - local ip6=m.uci:get("minigate",section,"last_ip6")or"" - local lu=m.uci:get("minigate",section,"last_update")or"" - local ns=m.uci:get("minigate",section,"next_sync")or"" - local ver=m.uci:get("minigate",section,"ip_version")or"ipv4" - - local h="" - if st=="ok"then - h='✓ 正常' - elseif st=="partial"then - h='⚠ 部分成功' - elseif st=="error"then - h='✗ '..esc(msg~=""and msg or"异常")..'' - else h='未同步' end - - -- 显示 IP 地址 - 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..'
' - h=h..'' + local domain=m.uci:get("minigate",section,"domain")or"" + local st=ctl.ddns_state(domain) + local label=STATE[st.status or ""] or {"未同步","mgx-muted"} + local h=''..label[1]..'' + if st.status=="error" and (st.message or "")~="" then h=h..' '..esc(st.message)..'' end + if (st.ip or "")~="" then h=h..'A: '..esc(st.ip)..'' end + if (st.ip6 or "")~="" then h=h..'AAAA: '..esc(st.ip6)..'' end + if (st.message or "")~="" and st.status~="error" then h=h..''..esc(st.message)..'' end + if (st.updated or "")~="" then h=h..'更新: '..esc(st.updated)..'' end + if (st.next or "")~="" then h=h..'下次: '..esc(st.next)..'' end + h=h..'
' + h=h..'' return h end @@ -117,24 +134,53 @@ 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") + local job=luci.dispatcher.build_url("admin/services/minigate/ddns_job") return[[]] end diff --git a/minigate/luasrc/model/cbi/minigate/general.lua b/minigate/luasrc/model/cbi/minigate/general.lua index c7fce427e..a593378c4 100644 --- a/minigate/luasrc/model/cbi/minigate/general.lua +++ b/minigate/luasrc/model/cbi/minigate/general.lua @@ -1,20 +1,19 @@ local m, s, o -local sys = require "luci.sys" -m = Map("minigate", "minigate", "轻量级网关管理:动态域名解析(IPv4/IPv6双栈)、SSL 证书签发、反向代理。") +-- 保存并应用后由 procd 的配置触发器重载服务(见 /etc/init.d/minigate) +m = Map("minigate", "MiniGate", "轻量级网关管理:动态域名解析(IPv4/IPv6双栈)、SSL 证书签发、反向代理和登录防护。") -m.on_after_commit = function(self) - local en = self.uci:get("minigate", "global", "enabled") - 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", "全局设置") +s.anonymous = true +o = s:option(Flag, "enabled", "启用 MiniGate") +o.description = "总开关。关闭后停止 DDNS 定时任务、证书续期和反向代理;登录防护单独控制。点击「保存并应用」后生效。" +o.rmempty = false s = m:section(NamedSection, "global", "global", "服务状态") s.anonymous = true -s:tab("status", "总览") -local su = luci.dispatcher.build_url("admin/services/minigate/status") - -o = s:taboption("status", DummyValue, "_status") +o = s:option(DummyValue, "_status") o.rawhtml = true o.cfgvalue = function() local su = luci.dispatcher.build_url("admin/services/minigate/status") @@ -29,11 +28,14 @@ o.cfgvalue = function() return [[
- +
动态 DNS
@@ -177,15 +177,28 @@ o.cfgvalue = function()
加载中...
+
+
危险操作
+
+
+
卸载 MiniGate
+
停止所有服务并移除程序;卸载时可选择是否保留配置、证书和封禁记录。
+
+
+ +
+
+
+ '; + p2.innerHTML=h; +} + +function mgRenderStatus(d){ + var notice=document.getElementById('mg-disabled'); + if(notice)notice.hidden=(d.enabled=='1'); + mgRenderDdns(d); + mgRenderCerts(d); + mgRenderProxy(d); } // 页面在后台时暂停轮询,切回前台立即刷新 @@ -653,15 +695,4 @@ mgAutoCheckUpdate(); ]] end -s = m:section(NamedSection, "global", "global", "全局设置") -s.anonymous = true -o = s:option(Flag, "enabled", "启用 MiniGate") -o.description = "总开关。关闭后停止 DDNS 定时任务、ACME 续期和反向代理。保存后立即生效。" -o.rmempty = false - -o = s:option(Flag, "ipv6_listen", "反向代理监听 IPv6") -o.description = "开启后,Nginx 反向代理将同时监听 IPv4 和 IPv6 地址(listen [::]:port)。" -o.rmempty = false -o.default = "0" - return m diff --git a/minigate/luasrc/model/cbi/minigate/login_guard.lua b/minigate/luasrc/model/cbi/minigate/login_guard.lua index 13a73567d..224644147 100644 --- a/minigate/luasrc/model/cbi/minigate/login_guard.lua +++ b/minigate/luasrc/model/cbi/minigate/login_guard.lua @@ -2,13 +2,10 @@ local m, s, o local sys = require "luci.sys" local fs = require "nixio.fs" -m = Map("minigate", "登录防护 (Login Guard)", - "监控 SSH(dropbear)和 LuCI 网页登录失败,达到阈值自动封禁源 IP。" .. - "适用于 SSH 端口被映射到公网、防爆破。LAN 私有 IP 自动豁免。") - -m.on_after_commit = function(self) - sys.call("/bin/sh /usr/lib/minigate/after_commit.sh reload >/dev/null 2>&1") -end +-- 保存并应用后由 procd 按新配置重启登录防护 +m = Map("minigate", "MiniGate - 登录防护", + "监控 SSH(dropbear)和 LuCI 网页登录失败,达到阈值自动封禁源 IP,支持 IPv4 和 IPv6。" .. + "适用于 SSH 端口被映射到公网、防爆破。LAN 私有地址自动豁免。") -- ============== 实时状态 ============== s = m:section(NamedSection, "login_guard", "login_guard", "实时状态") @@ -106,6 +103,9 @@ o.cfgvalue = function() .lg-btn{height:30px;border:0;border-radius:6px;padding:0 12px;font-size:12px;color:#fff;cursor:pointer} .lg-btn-primary{background:var(--lg-primary)} .lg-btn-danger{background:var(--lg-danger)} +.lg-panel-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:12px 16px;border-top:1px solid var(--lg-border-soft)} +.lg-foot-note{color:var(--lg-text-muted);font-size:12px} +.lg-btn-outline-danger{height:30px;border:1px solid var(--lg-danger);border-radius:6px;padding:0 12px;background:transparent;color:var(--lg-danger);font-size:12px;cursor:pointer} .lg-table-wrap{overflow-x:auto} .lg-table{width:100%;border-collapse:collapse;min-width:720px} .lg-table th{padding:10px 12px;color:var(--lg-text-muted);font-size:11px;font-weight:600;text-align:left;border-bottom:1px solid var(--lg-border);background:var(--lg-surface-muted)} @@ -166,12 +166,15 @@ o.cfgvalue = function() - - - + +
加载中...
+
+ 清空后所有已封禁 IP 立即恢复访问,无法撤销。 + +
@@ -293,7 +296,7 @@ function lgUnban(ip,btn){ 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;} + if(!ip.match(/^\d+\.\d+\.\d+\.\d+$/)&&!(ip.match(/^[0-9a-fA-F:]+$/)&&ip.split(':').length>2)){alert('IP 格式不对');return;} lgRequest('post',']] .. bu .. [[',{ip:ip},function(x,d){ if(d&&d.success){ document.getElementById('lg-ban-input').value=''; @@ -470,12 +473,12 @@ o.description = "开启后将自动监控 SSH/LuCI 失败登录。LAN IP 永远 o.rmempty = false o = s:option(Value, "threshold", "失败次数阈值") -o.description = "在「失败窗口」时间内累积达到此次数则封禁。" -o.datatype = "uinteger" +o.description = "在「失败窗口」时间内累积达到此次数则封禁,范围 1-100。" +o.datatype = "range(1,100)" o.default = "3" o.placeholder = "3" -o = s:option(ListValue, "window", "失败窗口(秒)") +o = s:option(ListValue, "window", "失败窗口") o.description = "在多长时间内累计失败次数。超过此时间未再失败则计数清零。" o:value("300", "5 分钟") o:value("600", "10 分钟") @@ -494,8 +497,22 @@ o:value("2592000", "30 天") o.default = "43200" o = s:option(DynamicList, "whitelist", "白名单(额外)") -o.description = "除了 LAN 私有段(192.168.x、10.x、127.x、172.16-31.x)默认豁免外,再加这些 IP/CIDR。" .. - "支持单 IP(如 1.2.3.4)或 /8 /16 /24 /32 子网。" -o.placeholder = "如 8.8.8.8 或 203.0.113.0/24" +o.description = "LAN 私有地址(192.168.x、10.x、172.16-31.x、IPv6 fd/fe80)和 LAN 分配的 IPv6 子网默认豁免," .. + "这里再加其他 IP 或子网,支持 IPv4/IPv6 和任意前缀长度。" +o.placeholder = "如 8.8.8.8、203.0.113.0/24 或 2001:db8::/48" +-- 只接受地址或「地址/前缀长度」,与 login_guard.sh 的匹配方式一致 +o.validate = function(self, value) + local dt = require "luci.cbi.datatypes" + local values = type(value) == "table" and value or {value} + for _, item in ipairs(values) do + item = tostring(item or "") + local addr, bits = item:match("^([^/]+)/(%d+)$") + addr = addr or item + bits = tonumber(bits) + local ok = (dt.ip4addr(addr) and (not bits or bits <= 32)) or (dt.ip6addr(addr) and (not bits or bits <= 128)) + if not ok then return nil, "白名单格式不正确:" .. item end + end + return value +end return m diff --git a/minigate/luasrc/model/cbi/minigate/proxy.lua b/minigate/luasrc/model/cbi/minigate/proxy.lua index fc47baa9b..18073a55c 100644 --- a/minigate/luasrc/model/cbi/minigate/proxy.lua +++ b/minigate/luasrc/model/cbi/minigate/proxy.lua @@ -1,23 +1,19 @@ local m, s, o -local sys = require "luci.sys" local uc = require "luci.model.uci".cursor() -m = Map("minigate", "MiniGate - 反向代理", "保存后自动生效。") - -m.on_after_commit = function(self) - sys.call("/bin/sh /usr/lib/minigate/after_commit.sh proxy >/dev/null 2>&1") -end +-- 保存并应用后由 procd 的配置触发器重新生成并重载反向代理 +m = Map("minigate", "MiniGate - 反向代理", "先配置代理规则,页面下方是 HTTP 跳转、IPv6、局域网解析和访问地区等高级设置。保存并应用后自动生效。") local ddns_domains = {} local ddns_ok = {} local wildcard_domains = {} local normal_domains = {} +local ctl = require "luci.controller.minigate" uc:foreach("minigate", "ddns", function(sec) local d = sec.domain or "" - local st = sec.status or "" if d ~= "" then ddns_domains[#ddns_domains + 1] = d - if st == "ok" then ddns_ok[d] = true end + if (ctl.ddns_state(d).status or "") == "ok" then ddns_ok[d] = true end if d:match("^%*%.") then wildcard_domains[#wildcard_domains + 1] = d else @@ -26,68 +22,15 @@ uc:foreach("minigate", "ddns", function(sec) end end) -s = m:section(NamedSection, "global", "global", "HTTP 自动跳转", - "让每个已启用 HTTPS 的监听端口同时接受 HTTP;明文请求会以 308 跳转到同域名、同端口的 HTTPS。") -s.anonymous = true - -o = s:option(Flag, "http_redirect", "启用 HTTP 跳转") -o.default = "0" -o.rmempty = false - -local lan_ip_auto = uc:get("network", "lan", "ipaddr") -if type(lan_ip_auto) == "table" then lan_ip_auto = lan_ip_auto[1] end -lan_ip_auto = lan_ip_auto and lan_ip_auto:match("^(%d+%.%d+%.%d+%.%d+)") - -s = m:section(NamedSection, "global", "global", "局域网直连解析", - "让局域网设备把本页的代理域名直接解析到本机 LAN 地址:访问不经过公网 IP、主路由回流和上游 DNS 缓存,也不进入代理;公网解析不变。" .. - "仅对使用本机 DNS(dnsmasq)的设备生效,且要求公网端口映射没有改端口号。") -s.anonymous = true - -o = s:option(Flag, "lan_dns", "启用局域网直连解析") -o.default = "0" -o.rmempty = false - -o = s:option(Value, "lan_ip", "解析到的 LAN 地址", "留空自动使用 LAN 口 IPv4 地址。") -o.datatype = "ip4addr" -o.placeholder = lan_ip_auto and ("自动(" .. lan_ip_auto .. ")") or "自动" -o:depends("lan_dns", "1") - -s = m:section(NamedSection, "global", "global", "公网访问范围", - "启用后,仅允许所选地区的公网 IP 访问本插件的所有反代站点(含 HTTP 跳转和 WebSocket);其他地区及归属地未知的 IP 直接断开。" .. - "局域网私有地址和回环地址继续可用,不影响 SSH、RustDesk 等其他服务。IPv4/IPv6 均检查,按实际连接来源判断。" .. - "使用随版本附带的离线地址库,归属地可能存在误差;外出或使用外省代理出口也会被拒绝。") -s.anonymous = true - -o = s:option(Flag, "access_enabled", "限制公网访问地区") -o.default = "0" -o.rmempty = false - -local access_regions = {} -local regions_file = io.open("/usr/lib/minigate/geo/regions.txt", "r") -if regions_file then - for line in regions_file:lines() do - local code, name = line:match("^(%d+) (.+)$") - if code then access_regions[#access_regions + 1] = {code, name} end - end - regions_file:close() +s = m:section(SimpleSection); s.template = "minigate/style" +if #ddns_domains == 0 then + s = m:section(SimpleSection); s.template = "minigate/proxy_empty" end -o = s:option(DynamicList, "access_regions", "允许的省级地区", "可选择多个地区;只允许四川时,仅保留“四川省”。关闭地区限制即恢复所有地区访问。") -o.default = "510000" -o.rmempty = false -o:depends("access_enabled", "1") -local valid_regions = {} -for _, region in ipairs(access_regions) do - o:value(region[1], region[2]) - valid_regions[region[1]] = true -end -o.validate = function(self, value, section) - local values = type(value) == "table" and value or {value} - if #values == 0 then return nil, "请选择至少一个允许的地区" end - for _, code in ipairs(values) do - if not valid_regions[code] then return nil, "请从列表中选择有效的省级地区" end - end - return value +-- 子域名前缀:一段字母、数字或连字符 +local function valid_prefix(value) + value = tostring(value or "") + return #value >= 1 and #value <= 63 and value:match("^[%w%-]+$") and not value:match("^%-") and not value:match("%-$") end -- ================================ @@ -96,15 +39,12 @@ end if #wildcard_domains > 0 then s = m:section(TypedSection, "proxy_wildcard", "通配符域名", - "配置监听端口和HTTPS,具体转发由「子域名」控制。") -s.anonymous = false + "配置监听端口和HTTPS,具体转发由「子域名」控制。禁用后,它下面的子域名一起停用。") +s.anonymous = true s.addremove = true s.template = "cbi/tblsection" -s.sectionhead = "名称" -o = s:option(ListValue, "enabled", "启用") -o:value("1", "✓ 启用") -o:value("0", "✗ 禁用") +o = s:option(Flag, "enabled", "启用") o.default = "1" o.rmempty = false @@ -131,12 +71,16 @@ o.rmempty = false -- ================================ -- 子域名规则 -- ================================ -s = m:section(TypedSection, "subproxy", "子域名规则") +s = m:section(TypedSection, "subproxy", "子域名规则", "所有站点都自动支持 WebSocket。") s.anonymous = true s.addremove = true s.sortable = true s.template = "cbi/tblsection" +o = s:option(Flag, "enabled", "启用") +o.default = "1" +o.rmempty = false + o = s:option(ListValue, "parent_domain", "主域名") o:value("", "--") for _, d in ipairs(wildcard_domains) do @@ -148,8 +92,13 @@ o.rmempty = false o = s:option(Value, "prefix", "前缀") o.rmempty = false o.placeholder = "app" +o.validate = function(self, value) + if valid_prefix(value) then return value end + return nil, "前缀只能包含字母、数字和连字符,例如 app" +end o = s:option(Value, "target_addr", "目标地址") +o.datatype = "host" o.rmempty = false o.placeholder = "192.168.1.100" @@ -164,9 +113,9 @@ o.cfgvalue = function(self, section) local base = m.uci:get("minigate", section, "parent_domain") or "" local prefix = m.uci:get("minigate", section, "prefix") or "" if prefix ~= "" and base ~= "" then - return '' .. prefix .. '.' .. base .. '' + return '' .. (prefix .. "." .. base):gsub("[<>&\"']", "") .. '' end - return '--' + return '--' end end -- wildcard_domains @@ -177,16 +126,13 @@ end -- wildcard_domains if #normal_domains > 0 or #wildcard_domains == 0 then s = m:section(TypedSection, "proxy", "代理规则", - "非通配符域名的反向代理。") -s.anonymous = false + "非通配符域名的反向代理。同一端口只能用于一种协议(HTTP 或 HTTPS),冲突的规则不会生效并在总览页提示。所有站点都自动支持 WebSocket。") +s.anonymous = true s.addremove = true s.template = "cbi/tblsection" s.sortable = true -s.sectionhead = "名称" -o = s:option(ListValue, "enabled", "启用") -o:value("1", "✓ 启用") -o:value("0", "✗ 禁用") +o = s:option(Flag, "enabled", "启用") o.default = "1" o.rmempty = false @@ -205,6 +151,7 @@ o.default = "443" o.rmempty = false o = s:option(Value, "target_addr", "目标地址") +o.datatype = "host" o.rmempty = false o.placeholder = "192.168.1.100" @@ -219,12 +166,88 @@ o:value("0", "✗ 关闭") o.default = "1" o.rmempty = false -o = s:option(ListValue, "websocket", "WS") -o:value("0", "✗ 关闭") -o:value("1", "✓ 开启") +end -- normal_domains + +s = m:section(NamedSection, "global", "global", "高级设置:HTTP 跳转与 IPv6", + "HTTP 跳转让每个已启用 HTTPS 的监听端口同时接受 HTTP,明文请求以 308 跳转到同域名、同端口的 HTTPS。") +s.anonymous = true + +o = s:option(Flag, "http_redirect", "启用 HTTP 跳转") o.default = "0" o.rmempty = false -end -- normal_domains +o = s:option(Flag, "ipv6_listen", "监听 IPv6") +o.description = "开启后,反向代理同时监听 IPv4 和 IPv6 地址(listen [::]:端口)。" +o.default = "0" +o.rmempty = false + +local lan_ip_auto = uc:get("network", "lan", "ipaddr") +if type(lan_ip_auto) == "table" then lan_ip_auto = lan_ip_auto[1] end +lan_ip_auto = lan_ip_auto and lan_ip_auto:match("^(%d+%.%d+%.%d+%.%d+)") + +s = m:section(NamedSection, "global", "global", "高级设置:局域网直连解析", + "让局域网设备把本页的代理域名直接解析到本机 LAN 地址:访问不经过公网 IP、主路由回流和上游 DNS 缓存,也不进入代理;公网解析不变。" .. + "仅对使用本机 DNS(dnsmasq)的设备生效,且要求公网端口映射没有改端口号。") +s.anonymous = true + +o = s:option(Flag, "lan_dns", "启用局域网直连解析") +o.default = "0" +o.rmempty = false + +o = s:option(Value, "lan_ip", "解析到的 LAN 地址", "留空自动使用 LAN 口 IPv4 地址。") +o.datatype = "ip4addr" +o.placeholder = lan_ip_auto and ("自动(" .. lan_ip_auto .. ")") or "自动" +o:depends("lan_dns", "1") + +s = m:section(NamedSection, "global", "global", "高级设置:公网访问范围", + "启用后,仅允许所选地区的公网 IP 访问本插件的所有反代站点(含 HTTP 跳转和 WebSocket);其他地区及归属地未知的 IP 直接断开。" .. + "局域网私有地址和回环地址继续可用,不影响 SSH、RustDesk 等其他服务。IPv4/IPv6 均检查,按实际连接来源判断。" .. + "使用随版本附带的离线地址库,归属地可能存在误差;外出或使用外省代理出口也会被拒绝。") +s.anonymous = true + +o = s:option(Flag, "access_enabled", "限制公网访问地区") +o.default = "0" +o.rmempty = false + +local access_regions = {} +local regions_file = io.open("/usr/lib/minigate/geo/regions.txt", "r") +if regions_file then + for line in regions_file:lines() do + local code, name = line:match("^(%d+) (.+)$") + if code then access_regions[#access_regions + 1] = {code, name} end + end + regions_file:close() +end + +o = s:option(MultiValue, "access_regions", "允许的省级地区", "勾选允许访问的地区,可多选。关闭地区限制即恢复所有地区访问。") +o.widget = "checkbox" +o.size = 6 +o.default = "510000" +o.rmempty = false +o:depends("access_enabled", "1") +-- 旧版本以 list 保存;统一转成以空格分隔的字符串供复选框回显 +o.cfgvalue = function(self, section) + local value = m.uci:get("minigate", section, "access_regions") + if type(value) == "table" then return table.concat(value, " ") end + return value +end +local valid_regions = {} +for _, region in ipairs(access_regions) do + o:value(region[1], region[2]) + valid_regions[region[1]] = true +end +o.validate = function(self, value, section) + local values = {} + if type(value) == "table" then + values = value + else + for code in tostring(value or ""):gmatch("%S+") do values[#values + 1] = code end + end + if #values == 0 then return nil, "请选择至少一个允许的地区" end + for _, code in ipairs(values) do + if not valid_regions[code] then return nil, "请从列表中选择有效的省级地区" end + end + return value +end return m diff --git a/minigate/luasrc/view/minigate/log.htm b/minigate/luasrc/view/minigate/log.htm index a772704a0..9db2e68a2 100644 --- a/minigate/luasrc/view/minigate/log.htm +++ b/minigate/luasrc/view/minigate/log.htm @@ -1,22 +1,46 @@ <%+header%> +<%+minigate/style%> +

MiniGate - 日志

-
- + - - + + +
-
加载中...
+
加载中...
<%+footer%> diff --git a/minigate/luasrc/view/minigate/proxy_empty.htm b/minigate/luasrc/view/minigate/proxy_empty.htm new file mode 100644 index 000000000..9e6cdd836 --- /dev/null +++ b/minigate/luasrc/view/minigate/proxy_empty.htm @@ -0,0 +1 @@ +
还没有可用的域名。请先在">「动态DNS」添加域名,再回到这里配置反向代理。
diff --git a/minigate/luasrc/view/minigate/style.htm b/minigate/luasrc/view/minigate/style.htm new file mode 100644 index 000000000..c156315c3 --- /dev/null +++ b/minigate/luasrc/view/minigate/style.htm @@ -0,0 +1,26 @@ + diff --git a/minigate/releases/v2026.10.28.md b/minigate/releases/v2026.10.28.md new file mode 100644 index 000000000..1db4c3ccf --- /dev/null +++ b/minigate/releases/v2026.10.28.md @@ -0,0 +1,64 @@ +## 审查问题与界面优化 + +这一版集中修复代码审查中发现的问题,并调整各页面界面。界面名称统一为 **MiniGate**;OpenWrt 包名仍为 `minigate`。 + +### 修复 + +- 通配符主域名停用或删除后,其子域名不再退回成 443 端口的明文 HTTP 站点。之前在同一端口还有 HTTPS 规则时,这还会让 Nginx 因两个默认站点而无法加载。现在子域名随主域名一起停用,也可以单独停用。 +- 同一端口同时配置了 HTTP 和 HTTPS 规则时,冲突的规则会被跳过,并在总览页说明原因,不再生成 Nginx 无法加载的配置。 +- 配置检查失败时,自动恢复上一份可用配置,正在运行的站点不受影响。生成过程加了锁,保存设置、证书续期和软件更新同时触发时不会互相覆盖。 +- 登录防护支持 IPv6 来源;白名单支持 IPv4/IPv6 和任意前缀长度。防火墙没有接受的封禁会如实记录为失败,不再误记为已封禁。 +- 封禁集合和拦截规则随防火墙每次重载自动加载;重载清空的封禁会在 30 秒内恢复,之前最多要等 5 分钟。 +- DDNS 只向 Cloudflare 推送公网地址。软路由在上级路由 NAT 之后时,网卡上的内网地址会被拒绝,并提示改用外部 URL。 +- 日志页「全部」改为按时间合并各来源。之前只是把各文件首尾相接后取最后 200 行,实际几乎看不到 DDNS 和证书日志。 + +### 可靠性与安全 + +- 服务改由 procd 管理:Nginx 和登录防护异常退出后自动拉起,停止时结束所有子进程。LuCI「保存并应用」后只重载受影响的部分,不再轮询等待配置写入。 +- DDNS 与证书的运行状态移到 `/var/run/minigate`,定时检查不再反复改写 `/etc/config/minigate`。升级时会清除旧版写在配置文件里的状态字段。 +- acme.sh 固定为 3.1.6,下载后先校验 SHA-256 再安装。证书剩余超过 30 天时,手动签发不会重复申请,避免触发 Let's Encrypt 频率限制。 +- 签发证书和手动同步 DDNS 改在后台执行,页面轮询结果,不受网页请求超时影响。 +- 外部 IP 查询默认改用 HTTPS 地址,失败时依次回退。 +- 所有站点自动支持 WebSocket。 +- 访客归属地优先查随包的离线地址库;境外地址才用 HTTPS 接口查询,缓存有数量上限。 +- 定时任务只管理本插件的脚本,不会再误删用户自己写的、名字里含 minigate 的任务。 +- 移除旧版地区日志扫描。已启用旧版限制的配置,升级时迁移为反代请求前准入,同时移除旧的全局防火墙集合。 + +### 界面 + +- 总开关移到总览页顶部,未启用时会显示提示。 +- DDNS 卡片列出全部记录,按最差的状态着色。 +- 证书卡片和证书页显示到期日期、剩余天数,并标出测试证书。 +- 反代地址可以直接点击,配置问题会显示在反代卡片里。 +- 卸载移到总览底部的「危险操作」区。 +- 日志页新增登录防护和软件更新来源;往上翻看旧日志时,不再被自动拉回底部。 +- DDNS 手动同步在原位置更新状态,不再刷新整页,表单里未保存的修改不会丢;有未保存修改时会先提示。 +- 反代页改为规则在前、高级设置在后,「监听 IPv6」也移到这里;地区改用复选框,启用开关统一为勾选框;没有域名时会引导到动态 DNS 页。 +- 登录防护页的「清空全部封禁」移到列表底部;手动封禁支持 IPv6。 +- DDNS、反代、证书和日志页跟随 LuCI 深色主题。 +- 表单增加校验:域名、Zone ID、API 令牌、检查间隔、失败阈值和白名单。 + +## 升级说明 + +- 升级后服务改由 procd 管理。LuCI 的「保存并应用」会自动重载服务,不需要再手动重启。 +- 防火墙下次重载时才会从随包文件加载新的封禁集合;在此之前,登录防护会自行创建同名集合和规则,封禁照常生效。 +- 旧版写在配置文件里的 DDNS 和证书状态字段会被清除。升级后首次同步前,状态显示为「未同步」。 +- 子域名所属的通配符主域名未启用时,该子域名不再生效。如果之前依赖这种回退的明文访问,请启用主域名,或改为普通代理规则。 + +## 验证 + +本地 127 项测试全部通过,无跳过。其中包括 LuaJIT 下的 LuCI 页面加载与表单校验、真实 Nginx 的转发与拦截、子域名跳过、端口冲突、配置回滚、登录防护 IPv6 解析与 procd 停止,以及 DDNS 公网地址校验。 + +另外两项检查: + +- 用 `nft -c` 在 OpenWrt 25.12 上校验了封禁 include 文件,校验结果不写入防火墙。 +- 用 `sh -n` 检查了 12 个 Shell 脚本的语法。 + +本次不自动更新路由器。 + +## 安装包 + +- opkg 安装包:`minigate_2026.10.28_all.ipk`。 +- 源码安装包:`minigate-v2026.10.28-src.tar.gz`。 +- 兼容旧更新器:`luci-app-minigate-v2026.10.28-src.tar.gz`。 +- 使用随附的 `SHA256SUMS` 校验文件完整性。 diff --git a/minigate/root/etc/init.d/minigate b/minigate/root/etc/init.d/minigate index 264ff6bdf..ada7bfa2a 100644 --- a/minigate/root/etc/init.d/minigate +++ b/minigate/root/etc/init.d/minigate @@ -1,93 +1,124 @@ #!/bin/sh /etc/rc.common START=99 STOP=10 -LOGIN_GUARD=/usr/lib/minigate/login_guard.sh +USE_PROCD=1 -# === 启动登录防护 watcher(独立子进程) === -start_login_guard() { - local lg_en=$(uci -q get minigate.login_guard.enabled) - [ "$lg_en" = "1" ] || return 0 - # 已在跑则跳过 - 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 "登录防护已启动" +PROG_DIR="${MINIGATE_LIB_DIR:-/usr/lib/minigate}" +PROXY="$PROG_DIR/proxy.sh" +LOGIN_GUARD="$PROG_DIR/login_guard.sh" +NGINX_CONF="${MINIGATE_NGINX_CONF:-/etc/minigate/nginx/minigate.conf}" +STATE_DIR="${MINIGATE_STATE_DIR:-/var/run/minigate}" +CRON_FILE="${MINIGATE_CRON_FILE:-/etc/crontabs/root}" +MG_RELOADING=0 + +# 只匹配本插件脚本路径,不会删除用户自己写的、名字里恰好含 minigate 的任务 +update_cron() { + local enabled="$1" tmp + mkdir -p "$(dirname "$CRON_FILE")" + tmp="${CRON_FILE}.minigate.$$" + if [ -f "$CRON_FILE" ]; then + grep -v '/usr/lib/minigate/' "$CRON_FILE" > "$tmp" + else + : > "$tmp" + fi + if [ "$enabled" = "1" ]; then + cat >> "$tmp" <<'EOF' +*/5 * * * * /bin/sh /usr/lib/minigate/ddns.sh +0 3 * * * /bin/sh /usr/lib/minigate/acme.sh renew +*/10 * * * * /bin/sh /usr/lib/minigate/proxy.sh trim-logs +EOF + fi + if [ -f "$CRON_FILE" ] && [ "$(cat "$tmp")" = "$(cat "$CRON_FILE")" ]; then + rm -f "$tmp" + return 0 + fi + cat "$tmp" > "$CRON_FILE" + rm -f "$tmp" + # crond 是常驻进程,不能继承 procd 的服务锁 + ( close_procd_lock; /etc/init.d/cron restart ) >/dev/null 2>&1 || true } -# 先结束子进程: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 +# 按配置类型计算摘要:保存某一页时只重载受影响的部分 +config_digest() { + uci -q show minigate 2>/dev/null | awk -v types=" $1 " ' + { + key = $0; sub(/=.*/, "", key) + n = split(key, part, ".") + } + n == 2 { value = $0; sub(/^[^=]*=/, "", value); type[part[2]] = value } + index(types, " " type[part[2]] " ") { print } + ' | md5sum | cut -d' ' -f1 } -# 只匹配实际启动形式;调用方命令行里恰好含有这段文本时(BusyBox pgrep 不排除祖先),不能结束调用方 -stop_login_guard() { - 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 "^/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}') - [ -n "$handle" ] && nft delete rule inet fw4 input handle "$handle" 2>/dev/null - logger -t minigate "登录防护已停止" +# procd 在 fd 1000 上持有服务锁;常驻或长时间运行的子进程继承它会挡住下一次服务操作。 +# 用 eval 关闭,避免只支持个位数描述符的 shell 在解析本文件时出错。 +close_procd_lock() { + [ -e /proc/self/fd/1000 ] && eval 'exec 1000>&-' + return 0 } -start() { - # === 登录防护可独立启用,与 minigate 主开关无关 === - start_login_guard - - local en=$(uci -q get minigate.global.enabled) - [ "$en" = "1" ] || { logger -t minigate "主功能未启用(仅登录防护可能已运行)"; return 0; } - logger -t minigate "启动..." - /bin/sh /usr/lib/minigate/proxy.sh start - /bin/sh /usr/lib/minigate/ddns.sh force & - # 设置 cron - sed -i '/minigate/d' /etc/crontabs/root 2>/dev/null - 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 "已启动" +run_background() { + ( close_procd_lock; "$@" ) /dev/null 2>&1 & } -stop() { - logger -t minigate "停止所有服务..." - # 停止反向代理 - /bin/sh /usr/lib/minigate/proxy.sh stop - sleep 1 - # 移除所有 cron 定时任务(DDNS + ACME) - sed -i '/minigate/d' /etc/crontabs/root 2>/dev/null - /etc/init.d/cron restart 2>/dev/null - # 清除 DDNS 运行状态 - local sections=$(uci -q show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1) - for sec in $sections; do - uci -q set minigate.${sec}.status="stopped" - uci -q set minigate.${sec}.status_msg="已停止" - uci -q set minigate.${sec}.next_sync="" - done - uci commit minigate - # 停止登录防护 - stop_login_guard - logger -t minigate "已停止(DDNS定时/ACME续期/反代/登录防护 全部关闭)" +start_service() { + mkdir -p "$STATE_DIR" + + # 登录防护可独立启用,与 MiniGate 主开关无关;配置摘要变化时 procd 自动重启它 + if [ "$(uci -q get minigate.login_guard.enabled)" = "1" ]; then + procd_open_instance login_guard + procd_set_param command /bin/sh "$LOGIN_GUARD" run + procd_set_param env MINIGATE_LG_DIGEST="$(config_digest login_guard)" + procd_set_param respawn + procd_close_instance + fi + + if [ "$(uci -q get minigate.global.enabled)" != "1" ]; then + update_cron 0 + /bin/sh "$PROXY" stop + /bin/sh "$PROG_DIR/ddns.sh" mark-stopped + return 0 + fi + + [ "${MINIGATE_SKIP_PREPARE:-0}" = "1" ] || /bin/sh "$PROXY" prepare + # 生成失败时 proxy.sh 已恢复上一份配置,继续用它运行,不因一次保存错误中断全部站点 + if [ -s "$NGINX_CONF" ]; then + procd_open_instance nginx + procd_set_param command /bin/sh "$PROXY" run + procd_set_param env MINIGATE_STREAM="$(grep -c '^stream {' "$NGINX_CONF")" + procd_set_param respawn + procd_close_instance + /bin/sh "$PROXY" lan-dns + fi + + update_cron 1 + config_digest "global proxy proxy_wildcard subproxy" > "$STATE_DIR/digest.proxy" + config_digest "ddns" > "$STATE_DIR/digest.ddns" + [ "$MG_RELOADING" = "1" ] || run_background /bin/sh "$PROG_DIR/ddns.sh" force } -restart() { - stop - sleep 1 +# LuCI「保存并应用」后由 procd 调用:重新声明进程,只重载配置有变化的部分 +reload_service() { + local old_proxy old_ddns + old_proxy=$(cat "$STATE_DIR/digest.proxy" 2>/dev/null) + old_ddns=$(cat "$STATE_DIR/digest.ddns" 2>/dev/null) + + MG_RELOADING=1 start + MG_RELOADING=0 + + [ "$(uci -q get minigate.global.enabled)" = "1" ] || return 0 + [ "$old_proxy" = "$(cat "$STATE_DIR/digest.proxy" 2>/dev/null)" ] || /bin/sh "$PROXY" hup + [ "$old_ddns" = "$(cat "$STATE_DIR/digest.ddns" 2>/dev/null)" ] || run_background /bin/sh "$PROG_DIR/ddns.sh" force } -reload() { - /bin/sh /usr/lib/minigate/proxy.sh reload - /bin/sh /usr/lib/minigate/ddns.sh force - # 登录防护配置改了也需要重启 watcher - stop_login_guard - start_login_guard +stop_service() { + update_cron 0 + /bin/sh "$PROXY" stop + /bin/sh "$PROG_DIR/ddns.sh" mark-stopped + rm -f "$STATE_DIR/digest.proxy" "$STATE_DIR/digest.ddns" +} + +service_triggers() { + procd_add_reload_trigger "minigate" } diff --git a/minigate/root/usr/lib/minigate/acme.sh b/minigate/root/usr/lib/minigate/acme.sh index d695c87d5..8627d4832 100644 --- a/minigate/root/usr/lib/minigate/acme.sh +++ b/minigate/root/usr/lib/minigate/acme.sh @@ -4,9 +4,30 @@ ACME_BIN="${ACME_HOME}/acme.sh" 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}" +# 最近一次签发结果只放内存;证书本身的有效期由页面直接读取证书文件 +STATE_FILE="${MINIGATE_ACME_STATE:-/var/run/minigate/acme.state}" +# 固定 acme.sh 版本并校验 GitHub 标签归档的 SHA-256,不再以 root 执行未校验的 master 分支 +ACME_VERSION="3.1.6" +ACME_SHA256="0d3f9000ac44a6331314742a88c475f79134e24fc991997883652adc59efc486" +ACME_URL="https://github.com/acmesh-official/acme.sh/archive/refs/tags/${ACME_VERSION}.tar.gz" +# 证书剩余天数少于此值才强制重签,避免反复点击触发 Let's Encrypt 频率限制 +RENEW_DAYS=30 log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [ACME] $*" >> "$LOGFILE"; } +# write_state 结果 域名 说明 +write_state() { + local tmp + mkdir -p "$(dirname "$STATE_FILE")" 2>/dev/null || return 0 + tmp="${STATE_FILE}.$$" + { + printf 'result=%s\n' "$1" + printf 'domain=%s\n' "$2" + printf 'message=%s\n' "$3" + printf 'time=%s\n' "$(date '+%Y-%m-%d %H:%M:%S')" + } > "$tmp" && mv -f "$tmp" "$STATE_FILE" +} + safe_dir() { echo "$1" | sed 's/\*/_wildcard_/g'; } cert_path_for_domain() { @@ -22,23 +43,24 @@ cert_is_valid() { [ -f "$cert" ] && openssl x509 -checkend 0 -noout -in "$cert" >/dev/null 2>&1 } +cert_valid_for_days() { + local cert="$1" days="$2" + [ -f "$cert" ] && openssl x509 -checkend $((days * 86400)) -noout -in "$cert" >/dev/null 2>&1 +} + keep_existing_cert_ok() { local domain="$1" reason="$2" local cert="$(cert_path_for_domain "$domain")" if cert_is_valid "$cert"; then - local expiry=$(cert_expiry "$cert") - log "失败: $reason;现有证书仍有效: $domain (过期: $expiry)" - uci -q set minigate.acme.status="ok" - uci -q set minigate.acme.last_domain="$domain" - uci -q set minigate.acme.cert_expiry="$expiry" - uci -q set minigate.acme.last_error="$reason" - uci commit minigate + log "失败: $reason;现有证书仍有效: $domain (过期: $(cert_expiry "$cert"))" + write_state partial "$domain" "$reason;现有证书仍有效,已继续使用" [ -f "$PROXY_SCRIPT" ] && /bin/sh "$PROXY_SCRIPT" reload 2>/dev/null return 0 fi return 1 } +# 只使用同一域名或其上级域名所在 DDNS 记录的令牌,不拿其他区域的令牌碰运气 find_token_for_domain() { local target="$1" local sections=$(uci -q show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1) @@ -49,43 +71,50 @@ find_token_for_domain() { return 0 fi done - local parent=$(echo "$target" | sed 's/^[^.]*\.//') + local parent=$(echo "$target" | sed 's/^\*\.//; s/^[^.]*\.//') + local base=$(echo "$target" | sed 's/^\*\.//') for sec in $sections; do local d=$(uci -q get minigate.${sec}.domain) local db=$(echo "$d" | sed 's/^\*\.//') - if [ "$db" = "$parent" ] || [ "$d" = "*.$parent" ]; then + if [ "$db" = "$parent" ] || [ "$db" = "$base" ] || [ "$d" = "*.$parent" ]; then echo "$(uci -q get minigate.${sec}.cf_api_token)|$(uci -q get minigate.${sec}.cf_zone_id)" return 0 fi done - for sec in $sections; do - local t=$(uci -q get minigate.${sec}.cf_api_token) - local z=$(uci -q get minigate.${sec}.cf_zone_id) - [ -n "$t" ] && echo "${t}|${z}" && return 0 - done return 1 } +download_acme() { + local output="$1" + if command -v curl >/dev/null 2>&1; then + curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 10 --max-time 120 -o "$output" "$ACME_URL" 2>>"$LOGFILE" && return 0 + fi + command -v wget >/dev/null 2>&1 && wget -qO "$output" "$ACME_URL" 2>>"$LOGFILE" +} + do_install() { if [ -x "$ACME_BIN" ] && [ -d "${ACME_HOME}/dnsapi" ]; then log "acme.sh 已安装"; return 0 fi - log "下载 acme.sh ..." + log "下载 acme.sh ${ACME_VERSION} ..." mkdir -p "$ACME_HOME" "$CERT_DIR" - cd /tmp; rm -rf acme.sh-master acme-master.tar.gz - local ok=0 - for url in "https://github.com/acmesh-official/acme.sh/archive/master.tar.gz"; do - wget -qO /tmp/acme-master.tar.gz "$url" 2>/dev/null && ok=1 && break - curl -sLo /tmp/acme-master.tar.gz "$url" 2>/dev/null && ok=1 && break - done - [ "$ok" != "1" ] && { log "下载失败"; return 1; } - tar xzf /tmp/acme-master.tar.gz -C /tmp/ 2>/dev/null - cd /tmp/acme.sh-master - ./acme.sh --install --home "$ACME_HOME" --no-cron --no-profile 2>>"$LOGFILE" - cd /tmp; rm -rf /tmp/acme-master.tar.gz /tmp/acme.sh-master + local work archive actual + work=$(mktemp -d /tmp/minigate-acme.XXXXXX) || { log "无法创建临时目录"; return 1; } + archive="${work}/acme.tar.gz" + if ! download_acme "$archive"; then + log "下载失败"; rm -rf "$work"; return 1 + fi + actual=$(sha256sum "$archive" | awk '{print $1}') + if [ "$actual" != "$ACME_SHA256" ]; then + log "acme.sh 校验失败(SHA-256 不一致),已拒绝安装" + rm -rf "$work"; return 1 + fi + tar xzf "$archive" -C "$work" 2>>"$LOGFILE" || { log "解压失败"; rm -rf "$work"; return 1; } + ( cd "${work}/acme.sh-${ACME_VERSION}" && ./acme.sh --install --home "$ACME_HOME" --no-cron --no-profile ) >> "$LOGFILE" 2>&1 + rm -rf "$work" if [ -x "$ACME_BIN" ] && [ -d "${ACME_HOME}/dnsapi" ]; then - "$ACME_BIN" --home "$ACME_HOME" --set-default-ca --server letsencrypt 2>>"$LOGFILE" - log "安装成功"; uci -q set minigate.acme.status="installed"; uci commit minigate; return 0 + "$ACME_BIN" --home "$ACME_HOME" --set-default-ca --server letsencrypt >> "$LOGFILE" 2>&1 + log "安装成功"; return 0 else log "安装失败"; return 1 fi @@ -101,20 +130,30 @@ do_issue() { local fs=$(uci -q show minigate | grep '=ddns$' | head -1 | cut -d. -f2 | cut -d= -f1) [ -n "$fs" ] && domain=$(uci -q get minigate.${fs}.domain) fi - [ -z "$domain" ] && { log "未指定域名"; uci -q set minigate.acme.status="error"; uci commit minigate; return 1; } + [ -z "$domain" ] && { log "未指定域名"; write_state error "" "未指定域名"; return 1; } local ti=$(find_token_for_domain "$domain") local token=$(echo "$ti" | cut -d'|' -f1) local zone_id=$(echo "$ti" | cut -d'|' -f2) - [ -z "$token" ] && { log "无API令牌"; uci -q set minigate.acme.status="error"; uci commit minigate; return 1; } + [ -z "$token" ] && { log "无API令牌"; write_state error "$domain" "没有找到该域名对应的 Cloudflare 令牌"; return 1; } local safe=$(safe_dir "$domain") + local cert="${CERT_DIR}/${safe}/fullchain.pem" mkdir -p "${CERT_DIR}/${safe}" if ! do_install; then keep_existing_cert_ok "$domain" "acme.sh 安装失败" && return 0 - uci -q set minigate.acme.status="error"; uci commit minigate + write_state error "$domain" "acme.sh 安装失败" return 1 fi + + local force="" + if cert_valid_for_days "$cert" "$RENEW_DAYS" && [ "$staging" != "1" ] && ! cert_is_staging "$cert"; then + log "跳过: $domain 证书剩余超过 ${RENEW_DAYS} 天 (过期: $(cert_expiry "$cert"))" + write_state ok "$domain" "证书剩余超过 ${RENEW_DAYS} 天,未重复签发" + return 0 + fi + [ -f "$cert" ] && force="--force" + export CF_Token="$token" [ -n "$zone_id" ] && export CF_Zone_ID="$zone_id" @@ -124,23 +163,21 @@ do_issue() { [ "$staging" = "1" ] && sf="--staging --server letsencrypt" log "签发: $domain (staging=$staging)" - uci -q set minigate.acme.status="issuing"; uci commit minigate + write_state issuing "$domain" "正在签发" "$ACME_BIN" --home "$ACME_HOME" --issue --dns dns_cf $sf -d "$domain" --keylength "$kl" \ ${email:+--accountemail "$email"} \ --cert-file "${CERT_DIR}/${safe}/cert.pem" --key-file "${CERT_DIR}/${safe}/key.pem" \ - --fullchain-file "${CERT_DIR}/${safe}/fullchain.pem" --force >> "$LOGFILE" 2>&1 + --fullchain-file "$cert" $force >> "$LOGFILE" 2>&1 local ret=$? - if [ $ret -eq 0 ] && [ -f "${CERT_DIR}/${safe}/fullchain.pem" ]; then - local expiry=$(cert_expiry "${CERT_DIR}/${safe}/fullchain.pem") - log "成功: $domain (过期: $expiry)" - uci -q set minigate.acme.status="ok" - uci -q set minigate.acme.last_domain="$domain" - uci -q set minigate.acme.last_issue="$(date '+%Y-%m-%d %H:%M:%S')" - uci -q set minigate.acme.cert_expiry="$expiry" - uci -q delete minigate.acme.last_error - uci commit minigate + if [ $ret -eq 0 ] && [ -f "$cert" ]; then + log "成功: $domain (过期: $(cert_expiry "$cert"))" + if [ "$staging" = "1" ]; then + write_state ok "$domain" "已签发测试证书(浏览器不信任),正式使用请关闭测试模式后重新签发" + else + write_state ok "$domain" "签发成功" + fi if echo "$domain" | grep -q '^\*\.'; then local base=$(echo "$domain" | sed 's/^\*\.//') [ ! -e "${CERT_DIR}/${base}" ] && ln -sf "${safe}" "${CERT_DIR}/${base}" @@ -149,16 +186,22 @@ do_issue() { return 0 else keep_existing_cert_ok "$domain" "签发失败 (exit=$ret)" && return 0 - log "失败 (exit=$ret)"; uci -q set minigate.acme.status="error"; uci -q set minigate.acme.last_error="签发失败 (exit=$ret)"; uci commit minigate; return 1 + log "失败 (exit=$ret)" + write_state error "$domain" "签发失败 (exit=$ret),请查看 ACME 日志" + return 1 fi } +cert_is_staging() { + openssl x509 -in "$1" -noout -issuer 2>/dev/null | grep -qi 'staging\|fake' +} + certs_fingerprint() { cat "$CERT_DIR"/*/fullchain.pem 2>/dev/null | md5sum | cut -d' ' -f1 } do_renew() { - local before domain cert + local before do_install || return 1 before=$(certs_fingerprint) log "续期..." @@ -167,14 +210,12 @@ do_renew() { # 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 } +if [ "${MINIGATE_ACME_LIBRARY_ONLY:-0}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi + 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 deleted file mode 100755 index 853529868..000000000 --- a/minigate/root/usr/lib/minigate/after_commit.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/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 6e2754d0b..0833986e5 100644 --- a/minigate/root/usr/lib/minigate/ddns.sh +++ b/minigate/root/usr/lib/minigate/ddns.sh @@ -6,6 +6,8 @@ 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}" +# 运行状态只放内存,不写 /etc/config:避免每次检查都提交配置、备份里混入运行数据 +STATE_DIR="${MINIGATE_DDNS_STATE_DIR:-/var/run/minigate/ddns}" 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"; }; } @@ -24,6 +26,71 @@ lock_ddns() { fi } +# 状态文件与 IP 历史一样按域名命名;LuCI 侧用相同规则读取 +state_key() { + printf '%s' "$1" | tr -c 'A-Za-z0-9_-' '_' +} + +state_value() { + sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1 +} + +# write_state 域名 状态 说明 IPv4 IPv6 更新时间 下次检查;IP 为空时保留上次结果 +write_state() { + local key file tmp ip ip6 + key=$(state_key "$1") + [ -n "$key" ] || return 0 + mkdir -p "$STATE_DIR" 2>/dev/null || return 0 + file="${STATE_DIR}/${key}.state" + tmp="${file}.$$" + ip="$4"; ip6="$5" + [ -n "$ip" ] || ip=$(state_value "$file" ip) + [ -n "$ip6" ] || ip6=$(state_value "$file" ip6) + { + printf 'status=%s\n' "$2" + printf 'message=%s\n' "$3" + printf 'ip=%s\n' "$ip" + printf 'ip6=%s\n' "$ip6" + printf 'updated=%s\n' "$6" + printf 'next=%s\n' "$7" + } > "$tmp" && mv -f "$tmp" "$file" +} + +mark_stopped() { + local sec domain file + for sec in $(uci -q -X show minigate | grep '=ddns$' | cut -d. -f2 | cut -d= -f1); do + domain=$(uci -q get minigate.${sec}.domain) + [ -n "$domain" ] || continue + file="${STATE_DIR}/$(state_key "$domain").state" + write_state "$domain" stopped "已停止" "" "" "$(state_value "$file" updated)" "" + done +} + +# 只接受公网地址:软路由在上级路由 NAT 之后时,网卡上是内网地址,不能推到 Cloudflare +is_public_ipv4() { + printf '%s\n' "$1" | awk -F. ' + NF != 4 { exit 1 } + { + for (i = 1; i <= 4; i++) if ($i !~ /^[0-9]+$/ || $i + 0 > 255) exit 1 + a = $1 + 0; b = $2 + 0 + if (a == 0 || a == 10 || a == 127 || a >= 224) exit 1 + if (a == 100 && b >= 64 && b <= 127) exit 1 + if (a == 169 && b == 254) exit 1 + if (a == 172 && b >= 16 && b <= 31) exit 1 + if (a == 192 && b == 168) exit 1 + if (a == 198 && (b == 18 || b == 19)) exit 1 + }' +} + +# 全球单播 2000::/3;排除 ULA、链路本地等 +is_public_ipv6() { + case "$1" in + *[!0-9a-fA-F:]*) return 1 ;; + [23][0-9a-fA-F]*:*) return 0 ;; + esac + return 1 +} + history_now() { if [ -n "${MINIGATE_HISTORY_NOW:-}" ]; then printf '%s\n' "$MINIGATE_HISTORY_NOW" @@ -158,49 +225,60 @@ get_ip4_iface() { echo "$ip" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' } +# 依次请求查询地址,取第一个公网 IPv4;HTTPS 优先,HTTP 仅作兜底 +IP4_FALLBACK_URLS="https://ddns.oray.com/checkip http://ip.3322.net http://members.3322.org/dyndns/getip http://ns1.dnspod.net:6666" + get_ip4_url() { - local ip="" - # 备用 URL 列表(主选 + 自动回退) - local fallback_urls="http://ip.3322.net http://members.3322.org/dyndns/getip http://ns1.dnspod.net:6666 http://ip.tool.chinaz.com/getip" - local urls="$1 $fallback_urls" - - for url in $urls; do - local host=$(echo "$url" | sed -E 's|https?://||;s|/.*||;s|:.*||') - local port=$(echo "$url" | grep -oE ':[0-9]+' | head -1 | tr -d ':') - local path=$(echo "$url" | sed -E 's|https?://[^/]*||') - [ -z "$path" ] && path="/" - [ -z "$port" ] && port="80" - - # 方法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 --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; } + local ip="" url + for url in $1 $IP4_FALLBACK_URLS; do + ip=$(fetch_ip4 "$url") + if [ -n "$ip" ]; then + echo "$ip" + return 0 fi done echo "" } +fetch_ip4() { + local url="$1" ip candidate host port scheme resolved + host=$(echo "$url" | sed -E 's|https?://||;s|/.*||;s|:.*||') + port=$(echo "$url" | sed -E 's|https?://[^/:]*||' | grep -oE '^:[0-9]+' | tr -d ':') + scheme="http" + echo "$url" | grep -q '^https' && scheme="https" + [ -z "$port" ] && { [ "$scheme" = "https" ] && port="443" || port="80"; } + + # 方法1: 直连 + for candidate in $(curl -4 -s --connect-timeout 3 --max-time 8 "$url" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -3); do + is_public_ipv4 "$candidate" && { echo "$candidate"; return 0; } + done + + # 方法2: 用公共 DNS 解析后直连(绕过透明代理的 fake-ip 解析) + resolved=$(nslookup "$host" 223.5.5.5 2>/dev/null | grep -A1 'Name:' | grep 'Address:' | head -1 | awk '{print $2}') + is_public_ipv4 "$resolved" || return 0 + for candidate in $(curl -4 -s --connect-timeout 3 --max-time 8 --resolve "${host}:${port}:${resolved}" "$url" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -3); do + is_public_ipv4 "$candidate" && { echo "$candidate"; return 0; } + done + return 0 +} + # ====== IPv6 获取 ====== get_ip6_iface() { - local ip="" iface="${1:-wan6}" - # 优先取全局 IPv6 地址(排除 link-local fe80::) - ip=$(ubus call network.interface.${iface} status 2>/dev/null | jsonfilter -e '@["ipv6-address"][0].address' 2>/dev/null) - [ -z "$ip" ] && ip=$(ifstatus "${iface}" 2>/dev/null | jsonfilter -e '@["ipv6-address"][0].address' 2>/dev/null) - # 也尝试从 ipv6-prefix-assignment 获取 - [ -z "$ip" ] && ip=$(ubus call network.interface.${iface} status 2>/dev/null | jsonfilter -e '@["ipv6-prefix-assignment"][0]["local-address"].address' 2>/dev/null) - # 验证是有效的 IPv6(排除 link-local) - echo "$ip" | grep -v '^fe80' | grep -oE '^[0-9a-fA-F:]+$' | head -1 + local status ip iface="${1:-wan6}" + status=$(ubus call network.interface.${iface} status 2>/dev/null) + [ -n "$status" ] || status=$(ifstatus "${iface}" 2>/dev/null) + # 取第一个全球单播地址(跳过 ULA、链路本地),没有时再看前缀分配给本机的地址 + for ip in $(printf '%s' "$status" | jsonfilter -e '@["ipv6-address"][*].address' 2>/dev/null) \ + $(printf '%s' "$status" | jsonfilter -e '@["ipv6-prefix-assignment"][*]["local-address"].address' 2>/dev/null); do + is_public_ipv6 "$ip" && { echo "$ip"; return 0; } + done + echo "" } get_ip6_url() { local url="$1" ip="" local host=$(echo "$url" | sed -E 's|https?://||;s|/.*||;s|:.*||') - local port=$(echo "$url" | grep -oE ':[0-9]+' | head -1 | tr -d ':') + local port=$(echo "$url" | sed -E 's|https?://[^/:]*||' | grep -oE '^:[0-9]+' | tr -d ':') # 先尝试直连 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) @@ -215,7 +293,7 @@ get_ip6_url() { 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" + is_public_ipv6 "$ip" && echo "$ip" } # Token 经标准输入交给 curl,不出现在进程参数中。 @@ -315,7 +393,7 @@ update_one() { [ -z "$domain" ] || [ -z "$zone_id" ] || [ -z "$token" ] && { log "$domain: 配置不完整" - uci -q set minigate.${sec}.status="error"; uci -q set minigate.${sec}.status_msg="配置不完整" + write_state "$domain" error "配置不完整" "" "" "$(date '+%Y-%m-%d %H:%M:%S')" "" return 1 } @@ -333,7 +411,12 @@ update_one() { local ip4="" case "$ip_source" in url) ip4=$(get_ip4_url "$ip_url");; *) ip4=$(get_ip4_iface "$iface");; esac - if [ -z "$ip4" ]; then + if [ -n "$ip4" ] && ! is_public_ipv4 "$ip4"; then + log "$domain: 网卡地址 $ip4 不是公网地址(可能在上级路由 NAT 之后),请改用「从外部URL获取」" + msgs="${msgs}IPv4 ${ip4} 不是公网地址; " + any_fail=1 + ip4="" + elif [ -z "$ip4" ]; then log "$domain: 获取IPv4失败" msgs="${msgs}IPv4获取失败; " any_fail=1 @@ -406,18 +489,13 @@ update_one() { fi # ====== 更新状态 ====== + local status="error" if [ "$any_ok" = "1" ] && [ "$any_fail" = "0" ]; then - uci -q set minigate.${sec}.status="ok" + status="ok" elif [ "$any_ok" = "1" ] && [ "$any_fail" = "1" ]; then - uci -q set minigate.${sec}.status="partial" - else - uci -q set minigate.${sec}.status="error" + status="partial" fi - uci -q set minigate.${sec}.status_msg="$msgs" - [ -n "$result_ip" ] && uci -q set minigate.${sec}.last_ip="$result_ip" - [ -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" + write_state "$domain" "$status" "$msgs" "$result_ip" "$result_ip6" "$(date '+%Y-%m-%d %H:%M:%S')" "$next_time" [ "$refresh_dns" = "1" ] && refresh_local_dns_caches "$domain" || true } @@ -448,7 +526,6 @@ main() { local c=0 for sec in $due; do update_one "$sec" "$force"; c=$((c + 1)); done [ "$c" = "0" ] && log "无 DDNS 记录" - uci commit minigate log "--- 同步完成 ($c 条) ---" trim_log } @@ -462,9 +539,13 @@ if [ "$1" = "single" ] && [ -n "$2" ]; then lock_ddns wait log "手动同步: $2" update_one "$2" "1" - uci commit minigate trim_log exit 0 fi +if [ "$1" = "mark-stopped" ]; then + mark_stopped + exit 0 +fi + main "$@" diff --git a/minigate/root/usr/lib/minigate/geo_lookup.sh b/minigate/root/usr/lib/minigate/geo_lookup.sh new file mode 100755 index 000000000..8bf2e781c --- /dev/null +++ b/minigate/root/usr/lib/minigate/geo_lookup.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# 用随包的离线地址库查询省级归属地,命中时输出地区名;未命中输出空。 +GEO_DIR="${MINIGATE_GEO_DIR:-/usr/lib/minigate/geo}" +ip="$1" + +case "$ip" in + ''|*[!0-9a-fA-F:.]*) exit 1 ;; +esac + +code=$(awk -v ip="$ip" ' + function v4(a, p, n, i, v) { + n = split(a, p, ".") + if (n != 4) return -1 + v = 0 + for (i = 1; i <= 4; i++) { + if (p[i] !~ /^[0-9]+$/ || p[i] + 0 > 255) return -1 + v = v * 256 + p[i] + } + return v + } + function pad(g) { while (length(g) < 4) g = "0" g; return g } + function v6(a, halves, left, right, l, r, i, out, missing) { + a = tolower(a) + if (a !~ /^[0-9a-f:]+$/) return "" + if (split(a, halves, "::") > 2) return "" + l = (halves[1] == "") ? 0 : split(halves[1], left, ":") + r = (index(a, "::") && halves[2] != "") ? split(halves[2], right, ":") : 0 + if (!index(a, "::") && l != 8) return "" + missing = 8 - l - r + if (missing < 0) return "" + out = "" + for (i = 1; i <= l; i++) { if (length(left[i]) > 4 || left[i] == "") return ""; out = out pad(left[i]) } + for (i = 0; i < missing; i++) out = out "0000" + for (i = 1; i <= r; i++) { if (length(right[i]) > 4 || right[i] == "") return ""; out = out pad(right[i]) } + return out + } + function hexval(c) { return index("0123456789abcdef", c) - 1 } + BEGIN { + family = index(ip, ":") ? 6 : 4 + if (family == 4) { want4 = v4(ip); if (want4 < 0) exit } + else { want6 = v6(ip); if (want6 == "") exit } + } + /^[0-9a-fA-F.:]+\/[0-9]+ 1;$/ { + net = $1; sub(/ .*/, "", net) + addr = net; sub(/\/.*/, "", addr) + bits = net; sub(/^[^\/]*\//, "", bits); bits = bits + 0 + if (family == 4) { + if (index(addr, ":")) next + d = 2 ^ (32 - bits) + if (int(want4 / d) == int(v4(addr) / d)) { found = FILENAME; exit } + } else { + if (!index(addr, ":")) next + b = v6(addr) + full = int(bits / 4) + if (substr(want6, 1, full) != substr(b, 1, full)) next + rest = bits - full * 4 + if (rest == 0) { found = FILENAME; exit } + d = 2 ^ (4 - rest) + if (int(hexval(substr(want6, full + 1, 1)) / d) == int(hexval(substr(b, full + 1, 1)) / d)) { found = FILENAME; exit } + } + } + END { + if (found != "") { sub(/^.*\//, "", found); sub(/\.conf$/, "", found); print found } + } +' "$GEO_DIR"/[0-9]*.conf 2>/dev/null) + +[ -n "$code" ] || exit 0 +awk -v code="$code" '$1 == code { $1 = ""; sub(/^ /, ""); print; exit }' "$GEO_DIR/regions.txt" diff --git a/minigate/root/usr/lib/minigate/geofence.sh b/minigate/root/usr/lib/minigate/geofence.sh deleted file mode 100644 index 939914aa8..000000000 --- a/minigate/root/usr/lib/minigate/geofence.sh +++ /dev/null @@ -1,198 +0,0 @@ -#!/bin/sh -# MiniGate GeoFence - 地区访问控制 -# 检查反代访问日志,非允许地区的 IP 用防火墙封禁 -LOGFILE="${MINIGATE_GEOFENCE_LOG:-/var/log/minigate-geofence.log}" -CACHE_DIR="${MINIGATE_GEO_CACHE_DIR:-/tmp/minigate_geo}" -BLOCK_SET="minigate_blocked" -ACCESS_LOG="${MINIGATE_ACCESS_LOG:-/var/log/minigate-access.log}" - -log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [GEOFENCE] $*" >> "$LOGFILE"; } - -# 查询 IP 归属地,返回省份名 -lookup_geo() { - local ip="$1" - local cache="$CACHE_DIR/${ip}" - - # 缓存命中 - [ -f "$cache" ] && cat "$cache" && return 0 - - local geo="" - - # API 1: pconline - if [ -z "$geo" ]; then - local raw=$(curl -s --connect-timeout 3 --max-time 5 "https://whois.pconline.com.cn/ipJson.jsp?ip=${ip}&json=true" 2>/dev/null) - geo=$(echo "$raw" | jsonfilter -e '$.pro' 2>/dev/null) - fi - - # API 2: ip9.com.cn - if [ -z "$geo" ]; then - local raw=$(curl -s --connect-timeout 3 --max-time 5 "https://ip9.com.cn/get?ip=${ip}" 2>/dev/null) - geo=$(echo "$raw" | jsonfilter -e '$.data.prov' 2>/dev/null) - fi - - # API 3: ip-api.com - if [ -z "$geo" ]; then - local raw=$(curl -s --connect-timeout 3 --max-time 5 "http://ip-api.com/json/${ip}?fields=regionName" 2>/dev/null) - geo=$(echo "$raw" | jsonfilter -e '$.regionName' 2>/dev/null) - fi - - [ -z "$geo" ] && geo="unknown" - - # 写入缓存 - mkdir -p "$CACHE_DIR" - echo "$geo" > "$cache" - echo "$geo" -} - -# 检查 IP 是否在允许的地区列表中 -is_allowed() { - local geo="$1" - local allowed=$(uci -q get minigate.global.geo_allowed) - - [ -z "$allowed" ] && return 0 # 未配置地区限制,全部放行 - - # allowed 是逗号分隔的省份列表,如 "四川,重庆,贵州" - echo "$allowed" | tr ',' '\n' | while read prov; do - [ -z "$prov" ] && continue - echo "$geo" | grep -q "$prov" && echo "yes" && return 0 - done | grep -q "yes" -} - -# 初始化 ipset(nftables 或 iptables) -init_block_set() { - if command -v nft >/dev/null 2>&1; then - # nftables (OpenWrt 22+) - nft list set inet fw4 "$BLOCK_SET" >/dev/null 2>&1 || { - nft add set inet fw4 "$BLOCK_SET" '{ type ipv4_addr; flags timeout; }' - nft add rule inet fw4 input ip saddr @"$BLOCK_SET" drop 2>/dev/null - log "nftables 封禁集合已创建" - } - else - # iptables fallback - ipset list "$BLOCK_SET" >/dev/null 2>&1 || { - ipset create "$BLOCK_SET" hash:ip timeout 43200 2>/dev/null # 12小时超时 - iptables -I INPUT -m set --match-set "$BLOCK_SET" src -j DROP 2>/dev/null - log "ipset 封禁集合已创建" - } - fi -} - -# 封禁 IP -block_ip() { - local ip="$1" reason="$2" - if command -v nft >/dev/null 2>&1; then - nft add element inet fw4 "$BLOCK_SET" "{ $ip timeout 12h }" 2>/dev/null - else - ipset add "$BLOCK_SET" "$ip" timeout 43200 2>/dev/null - fi - log "封禁: $ip ($reason) 12小时" - - # 记录到 UCI - local ts=$(date '+%Y-%m-%d %H:%M:%S') - uci -q add_list minigate.global.blocked_ips="${ip}|${reason}|${ts}" - uci commit minigate 2>/dev/null -} - -# 清理过期缓存(超过24小时的) -clean_cache() { - [ -d "$CACHE_DIR" ] && find "$CACHE_DIR" -type f -mmin +1440 -delete 2>/dev/null -} - -# 局域网、回环、链路本地和运营商 NAT 地址没有归属地,局域网直连时访客就是内网 IP,不参与地区判断 -is_private_ip() { - case "$1" in - 0.*|10.*|127.*|169.254.*|192.168.*) return 0 ;; - 172.1[6-9].*|172.2[0-9].*|172.3[01].*) return 0 ;; - 100.6[4-9].*|100.[7-9][0-9].*|100.1[01][0-9].*|100.12[0-7].*) return 0 ;; - esac - return 1 -} - -# BusyBox grep 不支持 -P,用 sed 取 IPv4 访客地址 -extract_public_ips() { - local ip - sed -n 's/.*"client":"\([0-9]\{1,3\}\(\.[0-9]\{1,3\}\)\{3\}\)".*/\1/p' "$ACCESS_LOG" 2>/dev/null | \ - sort -u | while read -r ip; do - is_private_ip "$ip" || echo "$ip" - done -} - -# 主逻辑:扫描 access log 中的新 IP -scan_and_block() { - # 请求前准入已覆盖公网反代,避免旧扫描器再把来源全局封禁到其他服务。 - [ "$(uci -q get minigate.global.access_enabled)" = "1" ] && return 0 - local enabled=$(uci -q get minigate.global.geo_enabled) - [ "$enabled" != "1" ] && return 0 - - local allowed=$(uci -q get minigate.global.geo_allowed) - [ -z "$allowed" ] && { log "未配置允许地区,跳过"; return 0; } - - init_block_set - - local ips=$(extract_public_ips) - - local checked=0 blocked=0 - for ip in $ips; do - # 已经封禁的跳过 - if command -v nft >/dev/null 2>&1; then - nft list set inet fw4 "$BLOCK_SET" 2>/dev/null | grep -q "$ip" && continue - else - ipset test "$BLOCK_SET" "$ip" 2>/dev/null && continue - fi - - local geo=$(lookup_geo "$ip") - checked=$((checked + 1)) - - if [ "$geo" = "unknown" ]; then - log "警告: $ip 归属地未知,暂时放行" - continue - fi - - if ! is_allowed "$geo"; then - block_ip "$ip" "$geo" - blocked=$((blocked + 1)) - else - log "放行: $ip ($geo)" - fi - - # 限速,避免 API 过载 - sleep 1 - done - - [ "$checked" -gt 0 ] && log "扫描完成: 检查 $checked 个IP,封禁 $blocked 个" - clean_cache -} - -# 手动解封 -unblock_ip() { - local ip="$1" - if command -v nft >/dev/null 2>&1; then - nft delete element inet fw4 "$BLOCK_SET" "{ $ip }" 2>/dev/null - else - ipset del "$BLOCK_SET" "$ip" 2>/dev/null - fi - log "解封: $ip" -} - -# 清除所有封禁 -flush_blocks() { - if command -v nft >/dev/null 2>&1; then - nft flush set inet fw4 "$BLOCK_SET" 2>/dev/null - else - ipset flush "$BLOCK_SET" 2>/dev/null - fi - uci -q delete minigate.global.blocked_ips - uci commit minigate 2>/dev/null - log "已清除所有封禁" -} - -if [ "${MINIGATE_GEOFENCE_LIBRARY_ONLY:-0}" = "1" ]; then - return 0 2>/dev/null || exit 0 -fi - -case "${1:-scan}" in - scan) scan_and_block ;; - unblock) unblock_ip "$2" ;; - flush) flush_blocks ;; - *) echo "用法: $0 {scan|unblock |flush}" ;; -esac diff --git a/minigate/root/usr/lib/minigate/job.sh b/minigate/root/usr/lib/minigate/job.sh new file mode 100755 index 000000000..e2326d9e8 --- /dev/null +++ b/minigate/root/usr/lib/minigate/job.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# 后台执行耗时操作(签发证书、手动同步 DDNS),LuCI 轮询状态文件,不再卡住网页请求。 +# 用法: job.sh <任务名> <命令...> +JOB_DIR="${MINIGATE_JOB_DIR:-/var/run/minigate/jobs}" + +name="$1" +shift +case "$name" in ''|*[!A-Za-z0-9_-]*) echo "无效任务名" >&2; exit 2 ;; esac +[ "$#" -gt 0 ] || { echo "用法: $0 <任务名> <命令...>" >&2; exit 2; } + +mkdir -p "$JOB_DIR" || exit 1 +file="${JOB_DIR}/${name}.state" + +write_job() { + printf 'status=%s\nstarted=%s\nfinished=%s\ncode=%s\n' "$1" "$2" "$3" "$4" > "${file}.$$" && + mv -f "${file}.$$" "$file" +} + +started=$(date +%s) +write_job running "$started" "" "" +"$@" /dev/null 2>&1 +code=$? +write_job done "$started" "$(date +%s)" "$code" +exit "$code" diff --git a/minigate/root/usr/lib/minigate/login_guard.sh b/minigate/root/usr/lib/minigate/login_guard.sh index fe074b4b1..fe4e9aea9 100755 --- a/minigate/root/usr/lib/minigate/login_guard.sh +++ b/minigate/root/usr/lib/minigate/login_guard.sh @@ -1,9 +1,9 @@ #!/bin/sh # MiniGate · 登录防护(Login Guard) -# 监控 SSH (dropbear) 和 LuCI 网页登录失败,达到阈值自动封禁源 IP +# 监控 SSH (dropbear) 和 LuCI 网页登录失败,达到阈值自动封禁源 IP(IPv4 / IPv6) # # 用法: -# login_guard.sh run 后台监控(init.d 调用) +# login_guard.sh run 后台监控(由 procd 启动) # login_guard.sh list 列出当前已封禁的 IP # login_guard.sh status 状态总览 # login_guard.sh ban 手动封禁 @@ -16,15 +16,21 @@ ENABLED=$(uci -q get minigate.login_guard.enabled || echo "0") 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") +case "$THRESHOLD" in ''|*[!0-9]*|0) THRESHOLD=3 ;; esac +case "$BANTIME" in ''|*[!0-9]*|0) BANTIME=43200 ;; esac +case "$WINDOW" in ''|*[!0-9]*|0) WINDOW=600 ;; esac 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="${MINIGATE_LG_LOG:-/var/log/minigate-login-guard.log}" -ACCESS_LOG=/var/log/minigate-access.log -SET="login_banned_v4" +ACCESS_LOG="${MINIGATE_ACCESS_LOG:-/var/log/minigate-access.log}" +SET4="login_banned_v4" +SET6="login_banned_v6" TABLE="inet fw4" +WATCHDOG_INTERVAL="${MINIGATE_LG_WATCHDOG:-30}" +LAN6_NETS="" mkdir -p "$DATA_DIR" "$COUNTER_DIR" touch "$BANS_FILE" @@ -34,62 +40,212 @@ log() { logger -t minigate-lg "$*" } -# === 白名单:从 uci 读 list whitelist + 默认私有段 === +fmt_duration() { + local s=$1 + if [ "$s" -ge 86400 ] && [ $((s % 86400)) -eq 0 ]; then + printf "%d天" $((s / 86400)) + elif [ "$s" -ge 3600 ]; then + if [ $((s % 3600)) -eq 0 ]; then + printf "%d小时" $((s / 3600)) + else + printf "%d小时%d分" $((s / 3600)) $(((s % 3600) / 60)) + fi + elif [ "$s" -ge 60 ]; then + printf "%d分%d秒" $((s / 60)) $((s % 60)) + else + printf "%d秒" "$s" + fi +} + +# 输出 4 / 6,无效地址输出空。IPv6 只接受纯十六进制写法,地址会原样进入 nft 命令。 +ip_family() { + case "$1" in + *[!0-9.]*) ;; + *.*.*.*) + printf '%s\n' "$1" | awk -F. 'NF == 4 { for (i = 1; i <= 4; i++) if ($i !~ /^[0-9]+$/ || $i > 255) exit 1; ok = 1 } END { exit !ok }' && echo 4 + return 0 + ;; + esac + case "$1" in + *:*:*) + case "$1" in *[!0-9a-fA-F:]*) return 0 ;; esac + [ "${#1}" -le 39 ] && echo 6 + ;; + esac + return 0 +} + +set_for_ip() { + case "$(ip_family "$1")" in + 4) echo "$SET4" ;; + 6) echo "$SET6" ;; + esac +} + +# 判断 IP 是否属于 CIDR(IPv4 任意前缀;IPv6 展开后逐位比较) +cidr_match() { + awk -v ip="$1" -v net="$2" ' + function v4(a, p, n, i, v) { + n = split(a, p, ".") + if (n != 4) return -1 + v = 0 + for (i = 1; i <= 4; i++) { + if (p[i] !~ /^[0-9]+$/ || p[i] + 0 > 255) return -1 + v = v * 256 + p[i] + } + return v + } + function pad(g) { while (length(g) < 4) g = "0" g; return g } + function v6(a, halves, left, right, l, r, i, out, missing, g) { + a = tolower(a) + if (a !~ /^[0-9a-f:]+$/) return "" + if (split(a, halves, "::") > 2) return "" + l = (halves[1] == "") ? 0 : split(halves[1], left, ":") + r = (index(a, "::") && halves[2] != "") ? split(halves[2], right, ":") : 0 + if (!index(a, "::") && l != 8) return "" + missing = 8 - l - r + if (missing < 0) return "" + out = "" + for (i = 1; i <= l; i++) { if (length(left[i]) > 4 || left[i] == "") return ""; out = out pad(left[i]) } + for (i = 0; i < missing; i++) out = out "0000" + for (i = 1; i <= r; i++) { if (length(right[i]) > 4 || right[i] == "") return ""; out = out pad(right[i]) } + return out + } + function hexval(c) { return index("0123456789abcdef", c) - 1 } + BEGIN { + addr = net; bits = -1 + if (index(net, "/")) { addr = substr(net, 1, index(net, "/") - 1); bits = substr(net, index(net, "/") + 1) } + if (bits != -1 && bits !~ /^[0-9]+$/) exit 1 + bits = bits + 0 + if (index(ip, ":")) { + a = v6(ip); b = v6(addr) + if (a == "" || b == "") exit 1 + if (bits == -1) bits = 128 + if (bits > 128) exit 1 + full = int(bits / 4) + if (substr(a, 1, full) != substr(b, 1, full)) exit 1 + rest = bits - full * 4 + if (rest == 0) exit 0 + d = 2 ^ (4 - rest) + exit !(int(hexval(substr(a, full + 1, 1)) / d) == int(hexval(substr(b, full + 1, 1)) / d)) + } + a = v4(ip); b = v4(addr) + if (a < 0 || b < 0) exit 1 + if (bits == -1) bits = 32 + if (bits > 32) exit 1 + d = 2 ^ (32 - bits) + exit !(int(a / d) == int(b / d)) + }' +} + +# LAN 接口实际分配到的全球 IPv6 子网也属于内网 +load_lan6_nets() { + local status address mask i=0 nets="" + status=$(ubus call network.interface.lan status 2>/dev/null) + if [ -n "$status" ]; then + while [ "$i" -lt 32 ]; do + address=$(printf '%s' "$status" | jsonfilter -e "@[\"ipv6-prefix-assignment\"][${i}].address" 2>/dev/null) + mask=$(printf '%s' "$status" | jsonfilter -e "@[\"ipv6-prefix-assignment\"][${i}].mask" 2>/dev/null) + [ -n "$address" ] || break + i=$((i + 1)) + case "$mask" in ''|*[!0-9]*) continue ;; esac + [ "$mask" -ge 48 ] && [ "$mask" -le 128 ] || continue + nets="$nets ${address}/${mask}" + done + fi + LAN6_NETS="$nets" +} + +# === 白名单:默认私有段 + LAN 的 IPv6 子网 + uci list whitelist === is_whitelisted() { - local ip="$1" + local ip="$1" net case "$ip" in 192.168.*|10.*|127.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*) return 0 ;; + ::1|[fF][cCdD]*:*|[fF][eE][89aAbB]*:*) return 0 ;; esac - local wl - wl=$(uci -q get minigate.login_guard.whitelist 2>/dev/null) - [ -z "$wl" ] && return 1 - for net in $wl; do - case "$net" in - "$ip") return 0 ;; - */[0-9]*) - # 简单 CIDR 匹配(前缀 byte 比较) - local prefix="${net%/*}" - local mask="${net#*/}" - # 只比 /8 /16 /24 /32 这种整字节边界(够用) - case "$mask" in - 8) [ "${ip%%.*}" = "${prefix%%.*}" ] && return 0 ;; - 16) [ "${ip%.*.*}" = "${prefix%.*.*}" ] && return 0 ;; - 24) [ "${ip%.*}" = "${prefix%.*}" ] && return 0 ;; - 32) [ "$ip" = "$prefix" ] && return 0 ;; - esac - ;; - esac + for net in $LAN6_NETS $(uci -q get minigate.login_guard.whitelist 2>/dev/null); do + cidr_match "$ip" "$net" && return 0 done return 1 } -# === 确保 nft set + drop rule 存在(fw4 reload 后自动重建) === -# 返回 0 表示刚重建过,调用方需要从持久化文件恢复封禁。 +nft_set_spec() { + case "$1" in + "$SET6") echo '{ type ipv6_addr; flags timeout; }' ;; + *) echo '{ type ipv4_addr; flags timeout; }' ;; + esac +} + +nft_rule_match() { + case "$1" in + "$SET6") echo "ip6 saddr" ;; + *) echo "ip saddr" ;; + esac +} + +# === 确保 nft set + drop rule 存在 === +# 规则和集合随包放在 /usr/share/nftables.d,防火墙每次重载都会带上;这里兜底处理 +# 安装后防火墙尚未重载的情况。返回 0 表示刚重建过,调用方需要从持久化文件恢复封禁。 ensure_nft() { - local recreated=0 - if ! nft list set $TABLE $SET >/dev/null 2>&1; then - nft "add set $TABLE $SET { type ipv4_addr; flags timeout; timeout ${BANTIME}s; }" 2>/dev/null - recreated=1 - fi - if ! nft list chain $TABLE input 2>/dev/null | grep -q "@$SET"; then - nft "insert rule $TABLE input ip saddr @$SET counter drop comment \"minigate-login-guard\"" 2>/dev/null - recreated=1 - fi + local recreated=0 set chain + for set in "$SET4" "$SET6"; do + if ! nft list set $TABLE $set >/dev/null 2>&1; then + nft "add set $TABLE $set $(nft_set_spec "$set")" 2>/dev/null + recreated=1 + fi + done + chain=$(nft list chain $TABLE input 2>/dev/null) + for set in "$SET4" "$SET6"; do + if ! printf '%s\n' "$chain" | grep -q "@$set"; then + nft "insert rule $TABLE input $(nft_rule_match "$set") @$set counter drop comment \"minigate-login-guard\"" 2>/dev/null + recreated=1 + fi + done [ $recreated -eq 1 ] || return 1 log "已创建 nft set + drop 规则" return 0 } +# 防火墙重载后,随包的集合会重新出现但内容为空;持久化文件里仍有未过期封禁时恢复 +sets_lost_bans() { + local now set + now=$(date +%s) + for set in "$SET4" "$SET6"; do + nft list set $TABLE $set 2>/dev/null | grep -q 'elements' && continue + while read -r ip exp; do + [ -n "$ip" ] || continue + case "$exp" in ''|*[!0-9]*) continue ;; esac + [ "$exp" -gt "$now" ] || continue + [ "$(set_for_ip "$ip")" = "$set" ] && return 0 + done < "$BANS_FILE" + done + return 1 +} + +add_ban_element() { + local ip="$1" seconds="$2" set + set=$(set_for_ip "$ip") + [ -n "$set" ] || return 1 + nft add element $TABLE $set "{ $ip timeout ${seconds}s }" 2>/dev/null +} + +persist_ban() { + local ip="$1" exp="$2" + grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true + echo "$ip $exp" >> "${BANS_FILE}.tmp" + mv "${BANS_FILE}.tmp" "$BANS_FILE" +} + # === 把 IP 加入封禁集合 + 持久化 === ban_ip() { local ip="$1" local exp=$(($(date +%s) + BANTIME)) - nft add element $TABLE $SET "{ $ip timeout ${BANTIME}s }" 2>/dev/null - # 去重写入持久化文件 - grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true - echo "$ip $exp" >> "${BANS_FILE}.tmp" - mv "${BANS_FILE}.tmp" "$BANS_FILE" - log "BAN $ip 12小时(达到 $THRESHOLD 次失败)" + if ! add_ban_element "$ip" "$BANTIME"; then + log "封禁 $ip 失败:防火墙未接受该地址" + return 1 + fi + persist_ban "$ip" "$exp" + log "BAN $ip $(fmt_duration "$BANTIME")(达到 $THRESHOLD 次失败)" rm -f "$COUNTER_DIR/$ip" } @@ -102,9 +258,9 @@ restore_bans() { local count=0 while read -r ip exp; do [ -z "$ip" ] && continue - if [ "$exp" -gt "$now" ]; then - local remaining=$((exp - now)) - nft add element $TABLE $SET "{ $ip timeout ${remaining}s }" 2>/dev/null + case "$exp" in ''|*[!0-9]*) continue ;; esac + if [ "$exp" -gt "$now" ] && [ -n "$(set_for_ip "$ip")" ]; then + add_ban_element "$ip" $((exp - now)) echo "$ip $exp" >> "$tmp" count=$((count + 1)) fi @@ -117,6 +273,7 @@ restore_bans() { handle_failure() { local ip="$1" [ -z "$ip" ] && return + [ -n "$(ip_family "$ip")" ] || return is_whitelisted "$ip" && return # 滑动窗口:超过 window 秒的旧失败丢弃 @@ -139,14 +296,24 @@ handle_failure() { if [ "$count" -ge "$THRESHOLD" ]; then # 加 ban 前再确认 nft 还在(fw4 可能重载) - local ban_target="$ip" repair_nft - ban_ip "$ban_target" + ban_ip "$ip" fi } +# 取日志行里最后一个 " from " 后的地址:用户名由访客提供,可能伪造前面的 from。 +# dropbear 以 "地址:端口" 记录(IPv6 不加方括号),LuCI 只记录地址。 extract_ip() { - echo "$1" | grep -oE "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" | head -1 + local line="$1" with_port="$2" addr + addr=${line##* from } + addr=${addr%% *} + case "$addr" in + \[*\]:*) addr=${addr#\[}; addr=${addr%%\]*} ;; + *) + [ "$with_port" = "1" ] && case "$addr" in *:[0-9]*) addr=${addr%:*} ;; esac + ;; + esac + [ -n "$(ip_family "$addr")" ] && echo "$addr" } extract_json_field() { @@ -171,8 +338,24 @@ access_log_loop() { done } +syslog_loop() { + logread -f 2>/dev/null | while read -r line; do + case "$line" in + *"Bad password attempt"*"from"*|\ + *"Login attempt for nonexistent user"*"from"*) + handle_failure "$(extract_ip "$line" 1)" + ;; + *"luci: failed login"*"from"*) + handle_failure "$(extract_ip "$line" 0)" + ;; + esac + done +} + repair_nft() { - ensure_nft && restore_bans + if ensure_nft || sets_lost_bans; then + restore_bans + fi return 0 } @@ -183,15 +366,42 @@ trim_log() { tail -n 500 "$LOG_FILE" > "${LOG_FILE}.tmp" && mv -f "${LOG_FILE}.tmp" "$LOG_FILE" } -# === 后台 watchdog:每 5 分钟检查 nft 资源 === +# === 后台 watchdog:防火墙重载后尽快恢复封禁 === watchdog_loop() { + local tick=0 while true; do - sleep 300 + sleep "$WATCHDOG_INTERVAL" repair_nft - trim_log + tick=$((tick + 1)) + if [ $((tick % 10)) -eq 0 ]; then + load_lan6_nets + trim_log + fi done } +stop_process_tree() { + local child + for child in $(pgrep -P "$1" 2>/dev/null); do + stop_process_tree "$child" + done + kill "$1" 2>/dev/null +} + +# procd 停止时只向本进程发 TERM;logread -f / tail -f 忽略 SIGPIPE 不会自行退出,需逐个结束。 +# 停止后不再拦截(与旧版移除规则一致),持久化文件保留,下次启动恢复。 +stop_watcher() { + local child + trap - TERM INT + for child in $(pgrep -P $$ 2>/dev/null); do + stop_process_tree "$child" + done + nft flush set $TABLE $SET4 2>/dev/null + nft flush set $TABLE $SET6 2>/dev/null + log "登录防护已停止" + exit 0 +} + # === 主监控循环 === run_watcher() { [ "$ENABLED" = "1" ] || { @@ -199,28 +409,19 @@ run_watcher() { exit 0 } + load_lan6_nets ensure_nft restore_bans - log "登录防护启动 阈值=$THRESHOLD 窗口=${WINDOW}s 封禁=${BANTIME}s" + log "登录防护启动 阈值=$THRESHOLD 窗口=${WINDOW}s 封禁=$(fmt_duration "$BANTIME")" - # 后台 watchdog + trap stop_watcher TERM INT watchdog_loop & - # MiniGate 反代访问 LuCI 时,LuCI 日志只会看到反代内网 IP。 # 这里补充监听 nginx access log,用 JSON client 字段记录真实公网 IP。 access_log_loop & - - # 主循环:监控系统日志 - logread -f 2>/dev/null | while read -r line; do - case "$line" in - *"Bad password attempt"*"from"*|\ - *"Login attempt for nonexistent user"*"from"*|\ - *"luci: failed login"*"from"*) - ip=$(extract_ip "$line") - handle_failure "$ip" - ;; - esac - done + syslog_loop & + # 在 wait 中才能及时响应 TERM;任一循环全部退出后进程结束,由 procd 重新拉起 + wait } # === CLI 命令 === @@ -228,37 +429,26 @@ run_watcher() { # 用 nft -j 拿封禁列表,输出每行: <剩余秒数> # 注意 nft -j 输出可能有空格、字段顺序也不固定(有的 elem 多个 timeout 字段) parse_set_json() { - nft -j list set $TABLE $SET 2>/dev/null | awk ' - { - # 在每条 elem 块({...})内匹配 val + expires - # [^{}]* 限制不跨越 brace 边界 - while (match($0, /"val":[ ]*"[0-9.]+"[^{}]*"expires":[ ]*[0-9]+/)) { - block = substr($0, RSTART, RLENGTH) - $0 = substr($0, RSTART + RLENGTH) - # 提取 IP - if (match(block, /[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/)) { - ip = substr(block, RSTART, RLENGTH) + local set + for set in "$SET4" "$SET6"; do + nft -j list set $TABLE $set 2>/dev/null | awk ' + { + # 在每条 elem 块({...})内匹配 val + expires;[^{}]* 限制不跨越 brace 边界 + while (match($0, /"val":[ ]*"[0-9a-fA-F:.]+"[^{}]*"expires":[ ]*[0-9]+/)) { + block = substr($0, RSTART, RLENGTH) + $0 = substr($0, RSTART + RLENGTH) + if (match(block, /"val":[ ]*"[0-9a-fA-F:.]+"/)) { + ip = substr(block, RSTART, RLENGTH) + sub(/^"val":[ ]*"/, "", ip); sub(/"$/, "", ip) + } + if (match(block, /"expires":[ ]*[0-9]+/)) { + exp_str = substr(block, RSTART, RLENGTH) + gsub(/[^0-9]/, "", exp_str) + print ip, exp_str + } } - # 提取 expires - if (match(block, /"expires":[ ]*[0-9]+/)) { - exp_str = substr(block, RSTART, RLENGTH) - gsub(/[^0-9]/, "", exp_str) - print ip, exp_str - } - } - }' -} - -# 把秒数转人类可读 -fmt_duration() { - local s=$1 - if [ "$s" -ge 3600 ]; then - printf "%dh%dm" $((s/3600)) $(((s%3600)/60)) - elif [ "$s" -ge 60 ]; then - printf "%dm%ds" $((s/60)) $((s%60)) - else - printf "%ds" "$s" - fi + }' + done } cmd_list() { @@ -270,7 +460,7 @@ cmd_list() { return fi echo "$out" | while read -r ip secs; do - printf " %-18s 剩余 %s\n" "$ip" "$(fmt_duration "$secs")" + printf " %-40s 剩余 %s\n" "$ip" "$(fmt_duration "$secs")" done } @@ -293,15 +483,16 @@ cmd_status() { read -r first count < "$f" now=$(date +%s) age=$((now - first)) - printf " %-18s %d/%d (距首次失败 %ds)\n" "$ip" "$count" "$THRESHOLD" "$age" + printf " %-40s %d/%d (距首次失败 %ds)\n" "$ip" "$count" "$THRESHOLD" "$age" done fi } cmd_unban() { - local ip="$1" - [ -z "$ip" ] && { echo "用法: $0 unban "; exit 1; } - nft delete element $TABLE $SET "{ $ip }" 2>&1 + local ip="$1" set + set=$(set_for_ip "$ip") + [ -n "$set" ] || { echo "用法: $0 unban "; exit 1; } + nft delete element $TABLE $set "{ $ip }" 2>&1 grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true mv "${BANS_FILE}.tmp" "$BANS_FILE" rm -f "$COUNTER_DIR/$ip" @@ -311,19 +502,20 @@ cmd_unban() { cmd_ban() { local ip="$1" - [ -z "$ip" ] && { echo "用法: $0 ban "; exit 1; } + [ -n "$(ip_family "$ip")" ] || { echo "用法: $0 ban "; exit 1; } ensure_nft - local exp=$(($(date +%s) + BANTIME)) - nft add element $TABLE $SET "{ $ip timeout ${BANTIME}s }" 2>&1 - grep -v "^$ip " "$BANS_FILE" > "${BANS_FILE}.tmp" 2>/dev/null || true - echo "$ip $exp" >> "${BANS_FILE}.tmp" - mv "${BANS_FILE}.tmp" "$BANS_FILE" + if ! add_ban_element "$ip" "$BANTIME"; then + echo "✗ 封禁 $ip 失败" + exit 1 + fi + persist_ban "$ip" $(($(date +%s) + BANTIME)) echo "✓ 已封禁 $ip" log "MANUAL ban $ip" } cmd_flush() { - nft flush set $TABLE $SET 2>&1 || true + nft flush set $TABLE $SET4 2>&1 || true + nft flush set $TABLE $SET6 2>&1 || true > "$BANS_FILE" rm -rf "$COUNTER_DIR"/* echo "✓ 已清空所有封禁" @@ -356,7 +548,7 @@ case "${1:-help}" in cat < 手动封禁 diff --git a/minigate/root/usr/lib/minigate/proxy.sh b/minigate/root/usr/lib/minigate/proxy.sh index ca8781cce..b0d82c900 100644 --- a/minigate/root/usr/lib/minigate/proxy.sh +++ b/minigate/root/usr/lib/minigate/proxy.sh @@ -15,9 +15,40 @@ ACCESS_LOG_MAX="${MINIGATE_ACCESS_LOG_MAX:-1048576}" LAN_HOSTS_FILE="${MINIGATE_LAN_HOSTS_FILE:-/tmp/hosts/minigate}" GEO_DIR="${MINIGATE_GEO_DIR:-/usr/lib/minigate/geo}" MIME_TYPES="${MINIGATE_MIME_TYPES:-/etc/nginx/mime.types}" +LOCK_FILE="${MINIGATE_PROXY_LOCK:-/var/lock/minigate-proxy.lock}" +STATUS_FILE="${MINIGATE_PROXY_STATUS:-/var/run/minigate/proxy.status}" +INIT_SCRIPT="${MINIGATE_INIT:-/etc/init.d/minigate}" +# 每次生成使用独立临时目录记录端口、默认站点和通配符设置,结束后删除 +GEN_TMP="" log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [PROXY] $*" >> "$LOGFILE"; } +# 生成配置时的提示同时写日志,并在总览页显示 +warn() { + log "$*" + [ -n "$GEN_TMP" ] && printf '%s\n' "$*" >> "$GEN_TMP/warnings" +} + +# LuCI 保存、证书续期和更新可能同时触发生成;串行化,避免互相删除刚生成的文件。 +lock_proxy() { + command -v flock >/dev/null 2>&1 || return 0 + mkdir -p "$(dirname "$LOCK_FILE")" 2>/dev/null + exec 6>"$LOCK_FILE" || return 0 + flock -x 6 +} + +write_status() { + local result="$1" message="$2" tmp + mkdir -p "$(dirname "$STATUS_FILE")" 2>/dev/null || return 0 + tmp="${STATUS_FILE}.$$" + { + printf 'result=%s\n' "$result" + printf 'time=%s\n' "$(date '+%Y-%m-%d %H:%M:%S')" + printf 'message=%s\n' "$message" + [ -n "$GEN_TMP" ] && [ -f "$GEN_TMP/warnings" ] && sed 's/^/warning=/' "$GEN_TMP/warnings" + } > "$tmp" && mv -f "$tmp" "$STATUS_FILE" +} + generate_main_conf() { local http_redirect=$(get_http_redirect) local use_stream=0 @@ -27,7 +58,7 @@ generate_main_conf() { use_stream=1 fi if [ "$use_stream" = "1" ] && [ ! -f "$STREAM_MODULE" ]; then - log "同端口HTTP跳转需要 nginx-mod-stream" + warn "同端口HTTP跳转需要 nginx-mod-stream,请先安装" return 1 fi @@ -62,6 +93,11 @@ EOF default $proxy_protocol_addr; "" $remote_addr; } + # 所有站点都支持 WebSocket;普通请求仍按 nginx 默认关闭上游连接 + map $http_upgrade $minigate_connection_upgrade { + default upgrade; + "" close; + } log_format minigate_json escape=json '{"time":"$time_iso8601",' '"domain":"$server_name",' @@ -97,9 +133,19 @@ find_cert() { return 1 } +# nginx 1.25 起用独立的 http2 指令;同时兼容以后的主版本号 check_h2() { - local m=$(nginx -v 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1 | cut -d. -f2) - [ "${m:-0}" -ge 25 ] && echo "new" || echo "old" + local v major minor + v=$(nginx -v 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1) + major=${v%%.*} + minor=${v#*.} + case "$major" in ''|*[!0-9]*) major=0 ;; esac + case "$minor" in ''|*[!0-9]*) minor=0 ;; esac + if [ "$major" -gt 1 ] || { [ "$major" = "1" ] && [ "$minor" -ge 25 ]; }; then + echo "new" + else + echo "old" + fi } # 读取全局 IPv6 监听设置 @@ -229,7 +275,7 @@ SEOF ensure_default_server() { local lport="$1" ssl="$2" h2s="$3" ipv6_listen="$4" mux="$5" local key=$(echo "${lport}_${ssl}_${mux}" | tr -c 'A-Za-z0-9_' '_') - local mark="/tmp/minigate_default_${key}.tmp" + local mark="${GEN_TMP}/default_${key}" [ -f "$mark" ] && return 0 : > "$mark" write_default_server "${SITES_DIR}/000_default_${key}.conf" "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux" @@ -254,7 +300,7 @@ SEOF ensure_stream_server() { local lport="$1" ipv6_listen="$2" - local mark="/tmp/minigate_stream_${lport}.tmp" + local mark="${GEN_TMP}/stream_${lport}" local conf="${STREAMS_DIR}/stream_${lport}.conf" [ -f "$mark" ] && return 0 : > "$mark" @@ -320,11 +366,15 @@ SEOF write_geo_guard "$conf" if [ "$ssl" = "1" ]; then local cp=$(find_cert "$domain") - [ -n "$cp" ] && cat >> "$conf" <> "$conf" <> "$conf" <> "$conf"; echo "}" >> "$conf" +} + +# 同一端口只能用于一种协议:明文 HTTP、HTTPS,或 HTTPS + HTTP 跳转(stream 分流)。 +# 先占用的规则生效,冲突的规则跳过并提示,避免生成 nginx 无法加载的配置。 +claim_port() { + local lport="$1" mode="$2" domain="$3" file used + file="${GEN_TMP}/port_${lport}" + if [ -f "$file" ]; then + used=$(cat "$file") + [ "$used" = "$mode" ] && return 0 + warn "跳过 ${domain}:端口 ${lport} 已用于$(port_mode_label "$used"),不能同时用于$(port_mode_label "$mode")" + return 1 + fi + echo "$mode" > "$file" +} + +port_mode_label() { + case "$1" in + http) echo "HTTP" ;; + https) echo "HTTPS" ;; + *) echo "HTTPS(含 HTTP 跳转)" ;; + esac +} + +# 生成一条站点规则;返回非 0 表示因端口冲突被跳过 +add_site() { + local domain="$1" lport="$2" taddr="$3" tport="$4" ssl="$5" h2s="$6" ipv6_listen="$7" http_redirect="$8" + local mux=0 mode=http conf + if [ "$ssl" = "1" ]; then + mode=https + if [ "$http_redirect" = "1" ]; then + mux=1 + mode=mux + fi + fi + claim_port "$lport" "$mode" "$domain" || return 1 + if [ "$mux" = "1" ]; then + ensure_stream_server "$lport" "$ipv6_listen" + ensure_default_server "$lport" "0" "$h2s" "$ipv6_listen" "1" + fi + ensure_default_server "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux" + SITE_INDEX=$((SITE_INDEX + 1)) + conf="${SITES_DIR}/site_${SITE_INDEX}.conf" + : > "$conf" + write_server "$conf" "$domain" "$lport" "$taddr" "$tport" "$ssl" "1" "1" "$h2s" "$ipv6_listen" "$mux" + [ "$mux" = "1" ] && write_redirect_server "$conf" "$domain" "$lport" + return 0 +} + +wildcard_key() { + printf '%s' "$1" | tr -c 'A-Za-z0-9.-' '_' } generate_sites() { + local own_tmp=0 + if [ -z "$GEN_TMP" ]; then + GEN_TMP=$(mktemp -d "${TMPDIR:-/tmp}/minigate-gen.XXXXXX") || return 1 + own_tmp=1 + fi mkdir -p "$SITES_DIR" "$STREAMS_DIR" "$SOCKET_DIR" rm -f "$SITES_DIR"/*.conf rm -f "$STREAMS_DIR"/*.conf local h2s=$(check_h2) - local idx=0 local ipv6_listen=$(get_ipv6_listen) local http_redirect=$(get_http_redirect) + SITE_INDEX=0 # === 通配符域名设置 (proxy_wildcard sections) === local wc_sections=$(uci -q show minigate | grep '=proxy_wildcard$' | cut -d. -f2 | cut -d= -f1) @@ -372,7 +477,7 @@ generate_sites() { local lport=$(uci -q get minigate.${sec}.listen_port); lport=${lport:-443} local ssl=$(uci -q get minigate.${sec}.ssl); ssl=${ssl:-0} local base=$(echo "$domain" | sed 's/^\*\.//') - echo "${lport}|${ssl}" > "/tmp/minigate_proxy_${base}.tmp" + echo "${lport}|${ssl}" > "${GEN_TMP}/wildcard_$(wildcard_key "$base")" log "通配符: $domain (端口:$lport https:$ssl ipv6:$ipv6_listen)" done @@ -386,58 +491,38 @@ generate_sites() { local taddr=$(uci -q get minigate.${sec}.target_addr) local tport=$(uci -q get minigate.${sec}.target_port); tport=${tport:-80} local ssl=$(uci -q get minigate.${sec}.ssl); ssl=${ssl:-1} - local h2=$(uci -q get minigate.${sec}.http2); h2=${h2:-1} - local ws=$(uci -q get minigate.${sec}.websocket); ws=${ws:-0} [ -z "$domain" ] || [ -z "$taddr" ] && continue - local mux=0 - if [ "$http_redirect" = "1" ] && [ "$ssl" = "1" ]; then - mux=1 - ensure_stream_server "$lport" "$ipv6_listen" - ensure_default_server "$lport" "0" "$h2s" "$ipv6_listen" "1" - fi - ensure_default_server "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux" - idx=$((idx + 1)) - local conf="${SITES_DIR}/site_${idx}.conf"; > "$conf" - write_server "$conf" "$domain" "$lport" "$taddr" "$tport" "$ssl" "$h2" "$ws" "$h2s" "$ipv6_listen" "$mux" - [ "$mux" = "1" ] && write_redirect_server "$conf" "$domain" "$lport" + add_site "$domain" "$lport" "$taddr" "$tport" "$ssl" "$h2s" "$ipv6_listen" "$http_redirect" done - # === 子域名规则:继承通配符主域名设置 === + # === 子域名规则:继承通配符主域名设置;主域名未启用时整组不生效 === local subs=$(uci -q show minigate | grep '=subproxy$' | cut -d. -f2 | cut -d= -f1) for sec in $subs; do + local enabled=$(uci -q get minigate.${sec}.enabled) + [ "${enabled:-1}" = "1" ] || continue local parent=$(uci -q get minigate.${sec}.parent_domain) local prefix=$(uci -q get minigate.${sec}.prefix) local taddr=$(uci -q get minigate.${sec}.target_addr) local tport=$(uci -q get minigate.${sec}.target_port); tport=${tport:-80} - local ws=$(uci -q get minigate.${sec}.websocket); ws=${ws:-0} [ -z "$parent" ] || [ -z "$prefix" ] || [ -z "$taddr" ] && continue local domain="${prefix}.${parent}" - - local parent_conf="/tmp/minigate_proxy_${parent}.tmp" - local lport="443" ssl="0" - if [ -f "$parent_conf" ]; then - local settings=$(cat "$parent_conf") - lport=$(echo "$settings" | cut -d'|' -f1) - ssl=$(echo "$settings" | cut -d'|' -f2) + local parent_conf="${GEN_TMP}/wildcard_$(wildcard_key "$parent")" + if [ ! -f "$parent_conf" ]; then + warn "跳过 ${domain}:通配符主域名 *.${parent} 未启用或不存在" + continue fi - - idx=$((idx + 1)) - local mux=0 - if [ "$http_redirect" = "1" ] && [ "$ssl" = "1" ]; then - mux=1 - ensure_stream_server "$lport" "$ipv6_listen" - ensure_default_server "$lport" "0" "$h2s" "$ipv6_listen" "1" - fi - ensure_default_server "$lport" "$ssl" "$h2s" "$ipv6_listen" "$mux" - local conf="${SITES_DIR}/site_${idx}.conf"; > "$conf" - write_server "$conf" "$domain" "$lport" "$taddr" "$tport" "$ssl" "1" "$ws" "$h2s" "$ipv6_listen" "$mux" - [ "$mux" = "1" ] && write_redirect_server "$conf" "$domain" "$lport" + local settings=$(cat "$parent_conf") + local lport=${settings%%|*} + local ssl=${settings#*|} + add_site "$domain" "$lport" "$taddr" "$tport" "$ssl" "$h2s" "$ipv6_listen" "$http_redirect" done - rm -f /tmp/minigate_proxy_*.tmp - rm -f /tmp/minigate_default_*.tmp - rm -f /tmp/minigate_stream_*.tmp + if [ "$own_tmp" = "1" ]; then + rm -rf "$GEN_TMP" + GEN_TMP="" + fi + return 0 } # === 局域网直连解析 === @@ -523,62 +608,109 @@ ${ip} ${domain}" log "局域网直连解析 -> ${ip}: $(echo $domains)" } -do_stop() { - if [ -f "$PID_FILE" ]; then - local pid=$(cat "$PID_FILE" 2>/dev/null) - if [ -n "$pid" ]; then - kill "$pid" 2>/dev/null - local i=0 - while [ $i -lt 10 ] && kill -0 "$pid" 2>/dev/null; do - sleep 1; i=$((i + 1)) - done - kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null - fi - rm -f "$PID_FILE" - rm -f "$SOCKET_DIR"/*.sock - log "已停止" +# 保存当前配置,生成或检查失败时恢复,磁盘上始终保留最后一份可用配置 +snapshot_config() { + local dir="$1" + mkdir -p "$dir/sites" "$dir/streams" || return 1 + cp -p "$SITES_DIR"/*.conf "$dir/sites/" 2>/dev/null + cp -p "$STREAMS_DIR"/*.conf "$dir/streams/" 2>/dev/null + [ -f "$NGINX_CONF" ] && cp -p "$NGINX_CONF" "$dir/main.conf" + return 0 +} + +restore_config() { + local dir="$1" + rm -f "$SITES_DIR"/*.conf "$STREAMS_DIR"/*.conf + cp -p "$dir/sites/"*.conf "$SITES_DIR/" 2>/dev/null + cp -p "$dir/streams/"*.conf "$STREAMS_DIR/" 2>/dev/null + if [ -f "$dir/main.conf" ]; then + cp -p "$dir/main.conf" "$NGINX_CONF" + else + rm -f "$NGINX_CONF" fi } +# 生成并检查配置;失败时恢复上一份配置,不影响正在运行的 nginx prepare_config() { - generate_sites || return 1 - generate_main_conf || return 1 - nginx -t -c "$NGINX_CONF" >> "$LOGFILE" 2>&1 || { log "配置错误"; return 1; } + local backup output rc=0 message="" + GEN_TMP=$(mktemp -d "${TMPDIR:-/tmp}/minigate-gen.XXXXXX") || { log "无法创建临时目录"; return 1; } + backup="${GEN_TMP}/backup" + output="${GEN_TMP}/nginx-test.log" + snapshot_config "$backup" + + if ! generate_sites || ! generate_main_conf; then + rc=1 + message="生成配置失败,已保留上一份配置" + elif ! nginx -t -c "$NGINX_CONF" > "$output" 2>&1; then + rc=1 + cat "$output" >> "$LOGFILE" + message="配置检查失败,已保留上一份配置:$(grep -m1 -E 'emerg|error' "$output" | sed 's/^nginx: //')" + log "配置错误,已恢复上一份配置" + fi + + if [ "$rc" = "0" ]; then + write_status ok "配置检查通过" + else + restore_config "$backup" + write_status error "$message" + fi + rm -rf "$GEN_TMP" + GEN_TMP="" + return "$rc" } -start_prepared() { - do_stop 2>/dev/null - sleep 1 +nginx_pid() { + local pid + pid=$(cat "$PID_FILE" 2>/dev/null) + case "$pid" in ''|*[!0-9]*) return 1 ;; esac + kill -0 "$pid" 2>/dev/null || return 1 + echo "$pid" +} + +# 由 procd 启动(前台运行,崩溃后自动拉起)。启动前清掉上次异常退出留下的 socket。 +do_run() { mkdir -p "$SOCKET_DIR" rm -f "$SOCKET_DIR"/*.sock - nginx -c "$NGINX_CONF" >> "$LOGFILE" 2>&1 && log "已启动" || { log "启动失败"; return 1; } + exec nginx -c "$NGINX_CONF" -g 'daemon off;' } -do_start() { - [ -z "$(which nginx 2>/dev/null)" ] && { log "nginx未安装"; return 1; } - prepare_config || return 1 - start_prepared || return 1 - sync_lan_hosts +# 让运行中的 nginx 加载新配置。stream 模块增减需要重启进程,交给 procd 处理。 +apply_config() { + local pid old_stream="$1" new_stream=0 + grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && new_stream=1 + pid=$(nginx_pid) + if [ -n "$pid" ] && [ "$old_stream" = "$new_stream" ]; then + kill -HUP "$pid" || return 1 + log "已重载" + else + MINIGATE_SKIP_PREPARE=1 "$INIT_SCRIPT" start 6>&- >/dev/null 2>&1 || return 1 + log "已交由 procd 启动" + fi } do_reload() { - local old_mux=0 new_mux=0 - grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && old_mux=1 + local old_stream=0 + [ "$(uci -q get minigate.global.enabled)" = "1" ] || return 0 + lock_proxy + grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && old_stream=1 prepare_config || return 1 - grep -q '^stream {' "$NGINX_CONF" 2>/dev/null && new_mux=1 - if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE" 2>/dev/null) 2>/dev/null; then - if [ "$old_mux" != "$new_mux" ]; then - start_prepared || return 1 - else - kill -HUP $(cat "$PID_FILE") || return 1 - log "已重载" - fi - else - start_prepared || return 1 - fi + apply_config "$old_stream" || return 1 sync_lan_hosts } +# 仅向已加载新配置的 nginx 发送重载信号(init 已完成生成和检查) +do_hup() { + local pid + pid=$(nginx_pid) || return 0 + kill -HUP "$pid" && log "已重载" +} + +do_stop() { + remove_lan_hosts + rm -f "$SOCKET_DIR"/*.sock 2>/dev/null + write_status stopped "反向代理已停止" +} + # /var/log 在内存中且 nginx 不会自行轮转。访问日志保留最近记录到 .1 后原地清空: # nginx 以追加方式继续写入,登录防护的 tail -f 从头读取新内容,不会重放旧记录。 trim_logs() { @@ -599,11 +731,12 @@ if [ "${MINIGATE_PROXY_LIBRARY_ONLY:-0}" = "1" ]; then fi case "$1" in - start) do_start ;; - stop) do_stop; remove_lan_hosts ;; + prepare|test) lock_proxy; prepare_config ;; + run) do_run ;; reload) do_reload ;; - test) prepare_config ;; + hup) do_hup ;; + stop) do_stop ;; trim-logs) trim_logs ;; lan-dns) sync_lan_hosts ;; - *) echo "用法: $0 {start|stop|reload|test|trim-logs|lan-dns}" ;; + *) echo "用法: $0 {prepare|run|reload|hup|stop|trim-logs|lan-dns}" ;; esac diff --git a/minigate/root/usr/lib/minigate/uninstall.sh b/minigate/root/usr/lib/minigate/uninstall.sh index b65a7a045..617a4f69f 100755 --- a/minigate/root/usr/lib/minigate/uninstall.sh +++ b/minigate/root/usr/lib/minigate/uninstall.sh @@ -158,6 +158,7 @@ remove_firewall_state() { [ -z "$ROOT" ] || return 0 remove_nft_rule_for_set login_banned_v4 + remove_nft_rule_for_set login_banned_v6 remove_nft_rule_for_set minigate_blocked if command -v iptables >/dev/null 2>&1; then while iptables -D INPUT -m set --match-set minigate_blocked src -j DROP >/dev/null 2>&1; do :; done @@ -170,7 +171,8 @@ remove_cron() { cron_file="$(root_path /etc/crontabs/root)" if [ -f "$cron_file" ]; then cron_tmp="${cron_file}.minigate.$$" - if sed '/minigate/d' "$cron_file" > "$cron_tmp"; then + # 只删除本插件脚本的任务,保留用户自己写的其他任务 + if sed '\#/usr/lib/minigate/#d' "$cron_file" > "$cron_tmp"; then cat "$cron_tmp" > "$cron_file" fi rm -f "$cron_tmp" @@ -205,7 +207,9 @@ remove_program_files() { rm -f \ "$(root_path /etc/init.d/minigate)" \ "$(root_path /usr/lib/lua/luci/controller/minigate.lua)" \ - "$(root_path /usr/lib/lua/luci/i18n/minigate.zh-cn.lmo)" + "$(root_path /usr/lib/lua/luci/i18n/minigate.zh-cn.lmo)" \ + "$(root_path /usr/share/nftables.d/table-pre/20-minigate-login-guard.nft)" \ + "$(root_path /usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft)" rm -rf \ "$(root_path /usr/lib/minigate)" \ "$(root_path /usr/lib/lua/luci/model/cbi/minigate)" \ diff --git a/minigate/root/usr/lib/minigate/update.sh b/minigate/root/usr/lib/minigate/update.sh index edfddf589..d8e34fa91 100755 --- a/minigate/root/usr/lib/minigate/update.sh +++ b/minigate/root/usr/lib/minigate/update.sh @@ -3,7 +3,7 @@ PATH=/usr/sbin:/usr/bin:/sbin:/bin export PATH -CURRENT_VERSION="2026.10.26" +CURRENT_VERSION="2026.10.28" REPOSITORY="tpxcer/minigate" API_URL="https://api.github.com/repos/${REPOSITORY}/releases/latest" DOWNLOAD_ROOT="https://github.com/${REPOSITORY}/releases/download" @@ -159,7 +159,9 @@ backup_program() { usr/lib/minigate \ usr/lib/lua/luci/controller/minigate.lua \ usr/lib/lua/luci/model/cbi/minigate \ - usr/lib/lua/luci/view/minigate; do + usr/lib/lua/luci/view/minigate \ + usr/share/nftables.d/table-pre/20-minigate-login-guard.nft \ + usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft; do [ -e "/$path" ] && paths="$paths $path" done diff --git a/minigate/root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft b/minigate/root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft new file mode 100644 index 000000000..73476e291 --- /dev/null +++ b/minigate/root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft @@ -0,0 +1,3 @@ +# MiniGate 登录防护:放在 input 链最前,已建立的连接也会被断开 +ip saddr @login_banned_v4 counter drop comment "minigate-login-guard" +ip6 saddr @login_banned_v6 counter drop comment "minigate-login-guard" diff --git a/minigate/root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft b/minigate/root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft new file mode 100644 index 000000000..85cf41885 --- /dev/null +++ b/minigate/root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft @@ -0,0 +1,10 @@ +# MiniGate 登录防护:封禁集合随防火墙每次重载重建,封禁记录由 login_guard.sh 恢复 +set login_banned_v4 { + type ipv4_addr + flags timeout +} + +set login_banned_v6 { + type ipv6_addr + flags timeout +} diff --git a/minigate/scripts/build-ipk.sh b/minigate/scripts/build-ipk.sh index 3a7f1c9bf..6be099569 100755 --- a/minigate/scripts/build-ipk.sh +++ b/minigate/scripts/build-ipk.sh @@ -5,7 +5,7 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" OUT_DIR="${1:-$ROOT_DIR/dist}" PKG_NAME="minigate" -PKG_VERSION="2026.10.26" +PKG_VERSION="2026.10.28" PKG_ARCH="all" PKG_FILE="${PKG_NAME}_${PKG_VERSION}_${PKG_ARCH}.ipk" DEPS="libc, luci-base, luci-compat, nginx-ssl, nginx-mod-stream, openssl-util, wget, curl, jsonfilter, coreutils-stat, nftables" @@ -29,7 +29,9 @@ mkdir -p \ "$DATA_DIR/etc/minigate/certs" \ "$DATA_DIR/etc/minigate/nginx/sites" \ "$DATA_DIR/etc/minigate/nginx/streams" \ - "$DATA_DIR/etc/minigate/login-guard" + "$DATA_DIR/etc/minigate/login-guard" \ + "$DATA_DIR/usr/share/nftables.d/table-pre" \ + "$DATA_DIR/usr/share/nftables.d/chain-pre/input" install -m 0644 "$ROOT_DIR/luasrc/controller/minigate.lua" "$DATA_DIR/usr/lib/lua/luci/controller/minigate.lua" install -m 0644 "$ROOT_DIR"/luasrc/model/cbi/minigate/*.lua "$DATA_DIR/usr/lib/lua/luci/model/cbi/minigate/" @@ -38,6 +40,8 @@ install -m 0755 "$ROOT_DIR"/root/usr/lib/minigate/*.sh "$DATA_DIR/usr/lib/miniga cp -R "$ROOT_DIR/root/usr/lib/minigate/geo" "$DATA_DIR/usr/lib/minigate/" install -m 0644 "$ROOT_DIR/root/etc/config/minigate" "$DATA_DIR/etc/config/minigate" install -m 0755 "$ROOT_DIR/root/etc/init.d/minigate" "$DATA_DIR/etc/init.d/minigate" +install -m 0644 "$ROOT_DIR/root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft" "$DATA_DIR/usr/share/nftables.d/table-pre/" +install -m 0644 "$ROOT_DIR/root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft" "$DATA_DIR/usr/share/nftables.d/chain-pre/input/" if [ -f "$ROOT_DIR/po/zh-cn/minigate.po" ] && command -v po2lmo >/dev/null 2>&1; then mkdir -p "$DATA_DIR/usr/lib/lua/luci/i18n" diff --git a/minigate/tests/access-ui.test.cjs b/minigate/tests/access-ui.test.cjs index 2b2bfcaae..eb713b69e 100644 --- a/minigate/tests/access-ui.test.cjs +++ b/minigate/tests/access-ui.test.cjs @@ -10,11 +10,12 @@ test('LuCI access settings offer all provinces, default off, validate multiple c const result = spawnSync(lua, ['-'], { encoding: 'utf8', input: ` local options = {} package.preload['luci.sys'] = function() return {} end + package.preload['luci.controller.minigate'] = function() return {ddns_state=function() return {} end} end package.preload['luci.model.uci'] = function() return {cursor=function() return {foreach=function() end, get=function() end} end} end - Flag, Value, DynamicList, ListValue, DummyValue = 'Flag', 'Value', 'DynamicList', 'ListValue', 'DummyValue' - NamedSection, TypedSection = 'NamedSection', 'TypedSection' + Flag, Value, DynamicList, ListValue, DummyValue, MultiValue = 'Flag', 'Value', 'DynamicList', 'ListValue', 'DummyValue', 'MultiValue' + NamedSection, TypedSection, SimpleSection = 'NamedSection', 'TypedSection', 'SimpleSection' function Map() return {section=function() return {option=function(self, kind, name) @@ -34,7 +35,7 @@ test('LuCI access settings offer all provinces, default off, validate multiple c assert(dofile(${JSON.stringify(path.join(root, 'luasrc/model/cbi/minigate/proxy.lua'))})) assert(options.access_enabled.default == '0') local regions = options.access_regions - assert(regions.kind == 'DynamicList') + assert(regions.kind == 'MultiValue' and regions.widget == 'checkbox') assert(regions.default == '510000') assert(regions.dependency[1] == 'access_enabled' and regions.dependency[2] == '1') assert(regions.choices['510000'] == '四川省') @@ -42,6 +43,8 @@ test('LuCI access settings offer all provinces, default off, validate multiple c assert(count == 34) assert(regions:validate({'510000', '500000'}, 'global')) assert(regions:validate('510000', 'global')) + assert(regions:validate('510000 500000', 'global')) + assert(not regions:validate('510000 ../x', 'global')) assert(not regions:validate({}, 'global')) assert(not regions:validate({''}, 'global')) assert(not regions:validate({'990000'}, 'global')) diff --git a/minigate/tests/access.test.cjs b/minigate/tests/access.test.cjs index cd139d633..01ea7d164 100644 --- a/minigate/tests/access.test.cjs +++ b/minigate/tests/access.test.cjs @@ -64,17 +64,36 @@ test('HTTP redirect rejects unapproved source before sending a redirect', () => assert.ok(result.conf.indexOf('return 444;') < result.conf.indexOf('return 308')); }); -test('the legacy scanner cannot add global firewall bans in request access mode', () => { - const scanner = path.join(root, 'root/usr/lib/minigate/geofence.sh'); +test('the installer removes the legacy scanner and its global firewall set', () => { + const installer = fs.readFileSync(path.join(root, 'install.sh'), 'utf8'); + const init = fs.readFileSync(path.join(root, 'root/etc/init.d/minigate'), 'utf8'); + assert.equal(fs.existsSync(path.join(root, 'root/usr/lib/minigate/geofence.sh')), false); + assert.doesNotMatch(init, /geofence/); + assert.match(installer, /rm -f \/usr\/lib\/minigate\/after_commit\.sh \/usr\/lib\/minigate\/geofence\.sh/); + assert.match(installer, /nft delete set inet fw4 minigate_blocked/); + assert.match(installer, /uci -q delete minigate\.global\.blocked_ips/); +}); + +test('legacy province names migrate to request access region codes', () => { + const installer = fs.readFileSync(path.join(root, 'install.sh'), 'utf8'); + const body = installer.slice(installer.indexOf('migrate_legacy_geofence() {'), installer.indexOf('\nmigrate_legacy_geofence\n')); const result = spawnSync('/bin/sh', ['-c', ` - export MINIGATE_GEOFENCE_LIBRARY_ONLY=1 - . '${scanner}' - uci() { [ "$3" = minigate.global.access_enabled ] && echo 1; } - init_block_set() { echo unexpected >&2; return 1; } - scan_and_block + D=${JSON.stringify(root)} + uci() { + case "$*" in + '-q get minigate.global.geo_enabled') echo 1 ;; + '-q get minigate.global.access_enabled') echo 0 ;; + '-q get minigate.global.geo_allowed') echo '四川,重庆' ;; + *) echo "uci $*" ;; + esac + } + ${body} + migrate_legacy_geofence `], { encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr); - assert.equal(result.stderr, ''); + assert.match(result.stdout, /add_list minigate\.global\.access_regions=510000/); + assert.match(result.stdout, /add_list minigate\.global\.access_regions=500000/); + assert.match(result.stdout, /set minigate\.global\.access_enabled=1/); }); test('bundled regions all cover IPv4 and IPv6 and data follows every install path', () => { diff --git a/minigate/tests/after-commit.test.cjs b/minigate/tests/after-commit.test.cjs deleted file mode 100644 index 279a551e1..000000000 --- a/minigate/tests/after-commit.test.cjs +++ /dev/null @@ -1,92 +0,0 @@ -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 ef9dc3a0b..8e035687a 100644 --- a/minigate/tests/ddns-history.test.cjs +++ b/minigate/tests/ddns-history.test.cjs @@ -31,11 +31,12 @@ function readRows(file) { return fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean); } -function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) { +function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0, currentIp = '198.51.100.20' }) { 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'); + const stateDir = path.join(tempDir, 'state'); if (cachedIp) fs.writeFileSync(cacheFile, `${cachedIp}\n`); const command = ` @@ -44,6 +45,7 @@ function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) { 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'))} + export MINIGATE_DDNS_STATE_DIR=${JSON.stringify(stateDir)} . ${JSON.stringify(ddnsScript)} uci() { [ "\${1:-}" = "-q" ] && shift @@ -57,11 +59,11 @@ function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) { 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)} ;; + set:*|commit:*) printf '%s\\n' "\$*" >> ${JSON.stringify(uciLog)} ;; esac return 0 } - get_ip4_url() { printf '198.51.100.20\\n'; } + get_ip4_url() { printf '%s\\n' ${JSON.stringify(currentIp)}; } record_ip_history() { return 0; } calc_next() { printf '12:00:00\\n'; } cf_update() { @@ -78,7 +80,15 @@ function runUpdateScenario({ cfResult, cachedIp = '', refreshResult = 0 }) { 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 }; + const stateFile = path.join(stateDir, '__example_com.state'); + const state = {}; + if (fs.existsSync(stateFile)) { + for (const line of readRows(stateFile)) { + const at = line.indexOf('='); + state[line.slice(0, at)] = line.slice(at + 1); + } + } + return { result, refreshes, uciWrites, cache, state }; } function runCloudflareScenario(mode) { @@ -251,14 +261,14 @@ function runScheduled({ force = false, checked }) { 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']); + assert.deepEqual(due.calls, ['cfg01 0'], 'scheduled runs never commit /etc/config'); 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']); + assert.deepEqual(forced.calls, ['cfg01 1', 'cfg02 1']); }); test('DDNS interval is at least five minutes and the next run matches the cron grid', () => { @@ -280,7 +290,9 @@ test('successful Cloudflare changes refresh local DNS caches once', () => { 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')); + assert.equal(scenario.state.status, 'ok'); + assert.equal(scenario.state.ip, '198.51.100.20'); + assert.deepEqual(scenario.uciWrites, [], 'runtime state must not be written to /etc/config'); }); test('unchanged forced sync does not flush caches, but a stale local IP does', () => { @@ -299,14 +311,40 @@ test('Cloudflare failure never flushes local caches', () => { 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')); + assert.equal(scenario.state.status, 'error'); + assert.deepEqual(scenario.uciWrites, []); }); 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')); + assert.equal(scenario.state.status, 'ok'); +}); + +test('a private interface address is never pushed to Cloudflare', () => { + for (const ip of ['192.168.2.2', '10.0.0.5', '100.64.1.1', '172.20.0.1']) { + const scenario = runUpdateScenario({ cfResult: 'changed', currentIp: ip }); + assert.equal(scenario.result.status, 0, scenario.result.stderr || scenario.result.stdout); + assert.equal(scenario.state.status, 'error', ip); + assert.match(scenario.state.message, /不是公网地址/); + assert.equal(scenario.cache, '', 'cf_update must not run for ' + ip); + assert.deepEqual(scenario.refreshes, []); + } +}); + +test('public address checks reject private, carrier NAT, ULA and link-local ranges', () => { + const { output } = runHistory(` + for ip in 1.2.3.4 182.147.210.9 10.1.1.1 172.16.0.1 172.32.0.1 192.168.1.1 100.64.0.1 100.128.0.1 127.0.0.1 169.254.1.1 0.1.2.3 224.0.0.1 256.1.1.1 1.2.3; do + is_public_ipv4 "$ip" && printf '%s ' "$ip" + done + printf '| ' + for ip in 2001:db8::1 240e:1::5 fd00::1 fe80::1 ::1 'x:y'; do + is_public_ipv6 "$ip" && printf '%s ' "$ip" + done + true + `); + assert.equal(output, '1.2.3.4 182.147.210.9 172.32.0.1 100.128.0.1 | 2001:db8::1 240e:1::5'); }); test('local cache refresh uses sing-box API and dnsmasq SIGHUP without service restart', () => { diff --git a/minigate/tests/geofence.test.cjs b/minigate/tests/geofence.test.cjs deleted file mode 100644 index d83a1089e..000000000 --- a/minigate/tests/geofence.test.cjs +++ /dev/null @@ -1,81 +0,0 @@ -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/geofence.sh'); - -function runLibrary(body, accessLog = '') { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-geofence-test-')); - const log = path.join(dir, 'access.log'); - fs.writeFileSync(log, accessLog); - const command = ` - set -u - export MINIGATE_GEOFENCE_LIBRARY_ONLY=1 - export MINIGATE_ACCESS_LOG=${JSON.stringify(log)} - export MINIGATE_GEOFENCE_LOG=${JSON.stringify(path.join(dir, 'geofence.log'))} - export MINIGATE_GEO_CACHE_DIR=${JSON.stringify(path.join(dir, 'cache'))} - . ${JSON.stringify(script)} - ${body} - `; - return spawnSync('/bin/sh', ['-c', command], { encoding: 'utf8' }); -} - -const entry = client => JSON.stringify({ time: '2026-10-17T07:22:40+08:00', domain: 'box.example.com', client, status: 200 }); -const ACCESS_LOG = [ - entry('192.168.2.1'), - entry('198.51.100.7'), - entry('172.20.0.5'), - entry('203.0.113.9'), - entry('198.51.100.7'), - entry('2001:db8::1'), - entry('100.100.1.1'), - entry('192.168.2.15') -].join('\n') + '\n'; - -test('private, loopback, link-local and carrier NAT addresses are recognised', () => { - const privateIps = ['0.0.0.0', '10.1.2.3', '127.0.0.1', '169.254.1.1', '172.16.0.1', '172.31.255.255', - '192.168.2.15', '100.64.0.1', '100.99.1.1', '100.127.255.255']; - const publicIps = ['172.15.0.1', '172.32.0.1', '100.63.255.255', '100.128.0.1', '198.51.100.7', '1.10.0.1', '192.169.0.1']; - const check = ips => ips.map(ip => `is_private_ip ${ip} && echo "${ip} private" || echo "${ip} public"`).join('\n'); - const result = runLibrary(check([...privateIps, ...publicIps])); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.stdout.trim().split('\n'), [ - ...privateIps.map(ip => `${ip} private`), - ...publicIps.map(ip => `${ip} public`) - ]); -}); - -test('client extraction works without grep -P and keeps only unique public IPv4 visitors', () => { - const result = runLibrary(` - grep() { [ "\${1:-}" = "-oP" ] && { echo "grep: unrecognized option: P" >&2; return 2; }; command grep "$@"; } - extract_public_ips - `, ACCESS_LOG); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.stdout.trim().split('\n'), ['198.51.100.7', '203.0.113.9']); -}); - -test('scan never looks up or blocks LAN visitors', () => { - const result = runLibrary(` - uci() { - [ "\${1:-}" = "-q" ] && shift - case "\${2:-}" in - minigate.global.geo_enabled) echo 1 ;; - minigate.global.geo_allowed) echo 四川 ;; - esac - } - init_block_set() { :; } - nft() { :; } - sleep() { :; } - lookup_geo() { echo "lookup $1" >&2; echo 广东; } - block_ip() { echo "block $1"; } - mkdir -p "$CACHE_DIR" - scan_and_block - `, ACCESS_LOG); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.stderr.trim().split('\n'), ['lookup 198.51.100.7', 'lookup 203.0.113.9']); - assert.deepEqual(result.stdout.trim().split('\n'), ['block 198.51.100.7', 'block 203.0.113.9']); -}); diff --git a/minigate/tests/init.test.cjs b/minigate/tests/init.test.cjs new file mode 100644 index 000000000..3203ed679 --- /dev/null +++ b/minigate/tests/init.test.cjs @@ -0,0 +1,77 @@ +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 init = path.join(root, 'root/etc/init.d/minigate'); + +function runInit(body, env = {}) { + return spawnSync('/bin/sh', ['-c', `. ${JSON.stringify(init)}\n${body}`], { encoding: 'utf8', env: { ...process.env, ...env } }); +} + +test('cron entries are managed by script path and user jobs mentioning minigate survive', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-cron-')); + const cron = path.join(dir, 'root'); + fs.writeFileSync(cron, '0 1 * * * /root/backup-minigate-certs.sh\n*/2 * * * * /bin/sh /usr/lib/minigate/geofence.sh scan\n'); + let result = runInit('update_cron 1', { MINIGATE_CRON_FILE: cron }); + assert.equal(result.status, 0, result.stderr); + const enabled = fs.readFileSync(cron, 'utf8'); + assert.match(enabled, /^0 1 \* \* \* \/root\/backup-minigate-certs\.sh$/m); + assert.doesNotMatch(enabled, /geofence/); + assert.match(enabled, /\/usr\/lib\/minigate\/ddns\.sh$/m); + assert.match(enabled, /\/usr\/lib\/minigate\/acme\.sh renew$/m); + + result = runInit('update_cron 0', { MINIGATE_CRON_FILE: cron }); + assert.equal(result.status, 0, result.stderr); + assert.equal(fs.readFileSync(cron, 'utf8'), '0 1 * * * /root/backup-minigate-certs.sh\n'); +}); + +test('reload digests only include the settings of the page that changed', () => { + const result = runInit(` + md5sum() { cat; } + uci() { + cat <<'UCI' +minigate.global=global +minigate.global.enabled='1' +minigate.@ddns[0]=ddns +minigate.@ddns[0].domain='a.example.com' +minigate.web=proxy +minigate.web.domain='b.example.com' +minigate.login_guard=login_guard +minigate.login_guard.threshold='3' +UCI + } + echo "== ddns"; config_digest ddns + echo "== proxy"; config_digest "global proxy proxy_wildcard subproxy" + `); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, [ + '== ddns', + 'minigate.@ddns[0]=ddns', + "minigate.@ddns[0].domain='a.example.com'", + '== proxy', + 'minigate.global=global', + "minigate.global.enabled='1'", + 'minigate.web=proxy', + "minigate.web.domain='b.example.com'", + '' + ].join('\n')); +}); + +test('services run under procd, background work drops the procd lock and LuCI pages no longer poll for commits', () => { + const source = fs.readFileSync(init, 'utf8'); + assert.match(source, /procd_open_instance login_guard/); + assert.match(source, /procd_set_param env MINIGATE_LG_DIGEST=/); + assert.match(source, /run_background\(\) \{\n \( close_procd_lock; "\$@" \) <\/dev\/null >\/dev\/null 2>&1 &/); + assert.match(source, /\( close_procd_lock; \/etc\/init\.d\/cron restart \)/); + // 只有 eval 字符串里出现多位数描述符,dash 等 shell 才能解析本文件 + assert.deepEqual(source.split('\n').filter(line => /\d{2,}>&/.test(line)), [" [ -e /proc/self/fd/1000 ] && eval 'exec 1000>&-'"]); + for (const page of ['general', 'ddns', 'proxy', 'login_guard']) { + const lua = fs.readFileSync(path.join(root, `luasrc/model/cbi/minigate/${page}.lua`), 'utf8'); + assert.doesNotMatch(lua, /on_after_commit|after_commit\.sh/, page); + } + assert.equal(fs.existsSync(path.join(root, 'root/usr/lib/minigate/after_commit.sh')), false); +}); diff --git a/minigate/tests/lan-dns.test.cjs b/minigate/tests/lan-dns.test.cjs index fa41db502..59d53832d 100644 --- a/minigate/tests/lan-dns.test.cjs +++ b/minigate/tests/lan-dns.test.cjs @@ -127,9 +127,10 @@ test('no generated sites means no hosts file', () => { test('reload syncs LAN hosts only after nginx accepted the new configuration', () => { const box = sandbox(); const body = ok => ` - PID_FILE=${JSON.stringify(path.join(box.dir, 'missing.pid'))} - prepare_config() { return 0; } - start_prepared() { return ${ok ? 0 : 1}; } + uci() { echo 1; } + lock_proxy() { :; } + prepare_config() { return ${ok ? 0 : 1}; } + apply_config() { return 0; } sync_lan_hosts() { echo synced; } do_reload `; diff --git a/minigate/tests/login-guard.test.cjs b/minigate/tests/login-guard.test.cjs index 70ee1b920..0a11dd84d 100644 --- a/minigate/tests/login-guard.test.cjs +++ b/minigate/tests/login-guard.test.cjs @@ -8,24 +8,29 @@ 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 }) { +// firewallReady: 集合和规则都在;setsEmpty: 防火墙重载后随包集合存在但内容为空 +function runRepair({ firewallReady, setsEmpty = false }) { 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'); + fs.writeFileSync(bans, '198.51.100.4 9999999999\n198.51.100.5 1\n2001:db8::9 9999999999\n'); const before = fs.statSync(bans).ino; const command = ` set -u logger() { :; } uci() { return 1; } + ubus() { 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 ;; + 'list set inet fw4 login_banned_v4'|'list set inet fw4 login_banned_v6') + [ ${firewallReady ? 1 : 0} = 1 ] || return 1 + [ ${setsEmpty ? 1 : 0} = 1 ] || printf 'elements = { 198.51.100.4 }\\n' + return 0 ;; + 'list chain inet fw4 input') [ ${firewallReady ? 1 : 0} = 1 ] && printf 'ip saddr @login_banned_v4 counter drop\\nip6 saddr @login_banned_v6 counter drop\\n'; return 0 ;; esac return 0 } @@ -52,56 +57,118 @@ test('periodic nft check does not rewrite the persisted ban list when rules are 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', () => { +test('a firewall reload that drops the set restores persisted IPv4 and IPv6 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, /add set inet fw4 login_banned_v4 \{ type ipv4_addr; flags timeout; \}/); + assert.match(scenario.calls, /add set inet fw4 login_banned_v6 \{ type ipv6_addr; flags timeout; \}/); assert.match(scenario.calls, /insert rule inet fw4 input ip saddr @login_banned_v4/); + assert.match(scenario.calls, /insert rule inet fw4 input ip6 saddr @login_banned_v6/); assert.match(scenario.calls, /add element inet fw4 login_banned_v4 \{ 198\.51\.100\.4 timeout \d+s \}/); + assert.match(scenario.calls, /add element inet fw4 login_banned_v6 \{ 2001:db8::9 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'); + assert.equal(scenario.bans, '198.51.100.4 9999999999\n2001:db8::9 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('packaged firewall sets that come back empty after a reload are refilled', () => { + const scenario = runRepair({ firewallReady: true, setsEmpty: true }); + assert.equal(scenario.result.status, 0, scenario.result.stderr); + assert.doesNotMatch(scenario.calls, /add set|insert rule/); + assert.match(scenario.calls, /add element inet fw4 login_banned_v4 \{ 198\.51\.100\.4 timeout \d+s \}/); +}); + +test('fw4 includes declare both ban sets before the input rules that use them', () => { + const tablePre = fs.readFileSync(path.join(root, 'root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft'), 'utf8'); + const chainPre = fs.readFileSync(path.join(root, 'root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft'), 'utf8'); + assert.match(tablePre, /set login_banned_v4 \{\n\ttype ipv4_addr\n\tflags timeout\n\}/); + assert.match(tablePre, /set login_banned_v6 \{\n\ttype ipv6_addr\n\tflags timeout\n\}/); + assert.match(chainPre, /^ip saddr @login_banned_v4 counter drop comment "minigate-login-guard"$/m); + assert.match(chainPre, /^ip6 saddr @login_banned_v6 counter drop comment "minigate-login-guard"$/m); + for (const file of ['Makefile', 'install.sh', 'scripts/build-ipk.sh', 'root/usr/lib/minigate/uninstall.sh']) { + assert.match(fs.readFileSync(path.join(root, file), 'utf8'), /nftables\.d\/chain-pre\/input\/20-minigate-login-guard\.nft/, file); + } +}); + +function library(body) { + return spawnSync('/bin/sh', ['-c', ` + export MINIGATE_LG_LIBRARY_ONLY=1 + export MINIGATE_LG_DATA_DIR=${JSON.stringify(fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-lg-lib-')))} + export MINIGATE_LG_RUN_DIR=${JSON.stringify(fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-lg-run-')))} + export MINIGATE_LG_LOG=/dev/null + logger() { :; } + uci() { [ "$3" = minigate.login_guard.whitelist ] && echo "203.0.113.0/20 2001:db8:abcd::/48"; return 0; } + . ${JSON.stringify(script)} + ${body} + `], { encoding: 'utf8' }); } -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'); +test('addresses from dropbear and LuCI logs are parsed for both IP families and spoofed usernames', () => { + const result = library(` + extract_ip "authpriv.warn dropbear[1]: Bad password attempt for 'root' from 198.51.100.7:52144" 1 + extract_ip "dropbear[1]: Login attempt for nonexistent user 'x from 9.9.9.9:1' from 2001:db8::7:52144" 1 + extract_ip "luci: failed login on / for root from 2001:db8::8" 0 + extract_ip "luci: failed login on / for root from bad;rm" 0 || echo rejected + `); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), '198.51.100.7\n2001:db8::7\n2001:db8::8\nrejected'); +}); + +test('whitelist matches any IPv4 prefix length and IPv6 subnets, private ranges stay exempt', () => { + const result = library(` + for ip in 203.0.120.9 203.0.128.1 2001:db8:abcd:1::5 2001:db8:abce::5 192.168.2.5 fd00::5 fe80::1 198.51.100.7 2400:da00::1; do + is_whitelisted "$ip" && printf 'white:%s ' "$ip" || printf 'watch:%s ' "$ip" + done + `); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), 'white:203.0.120.9 watch:203.0.128.1 white:2001:db8:abcd:1::5 watch:2001:db8:abce::5 white:192.168.2.5 white:fd00::5 white:fe80::1 watch:198.51.100.7 watch:2400:da00::1'); +}); + +test('a ban the firewall rejects is reported instead of being logged as banned', () => { + const result = library(` + nft() { return 1; } + ban_ip 198.51.100.9 && echo banned || echo failed + cat "$BANS_FILE" + `); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), 'failed'); +}); + +test('procd stopping the login guard ends logread and tail children and lifts enforcement', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-login-guard-stop-')); + const bin = path.join(dir, 'bin'); const followed = path.join(dir, 'access.log'); - const guardScript = path.join(dir, 'login_guard.sh'); - const marker = path.join(dir, 'caller-survived'); + const nftLog = path.join(dir, 'nft.log'); + fs.mkdirSync(bin); 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 shim = (name, body) => fs.writeFileSync(path.join(bin, name), `#!/bin/sh\n${body}\n`, { mode: 0o755 }); + shim('uci', 'case "$3" in minigate.login_guard.enabled) echo 1 ;; *) exit 1 ;; esac'); + shim('nft', `printf '%s\\n' "$*" >> ${JSON.stringify(nftLog)}; case "$*" in 'list chain'*) echo '@login_banned_v4 @login_banned_v6' ;; 'list set'*) echo elements ;; esac; exit 0`); + shim('logger', 'exit 0'); + shim('ubus', 'exit 1'); + // logread -f 与 tail -f 一样会一直阻塞,用 tail 模拟 + shim('logread', `exec tail -n 0 -f ${JSON.stringify(followed)}`); + const guard = spawn('/bin/sh', [script, 'run'], { + stdio: 'ignore', + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + MINIGATE_LG_DATA_DIR: path.join(dir, 'data'), + MINIGATE_LG_RUN_DIR: path.join(dir, 'run'), + MINIGATE_LG_LOG: path.join(dir, 'lg.log'), + MINIGATE_ACCESS_LOG: followed + } + }); 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'); + while (pgrep(`tail -n 0 -f ${followed}`).split('\n').filter(Boolean).length < 2 && Date.now() - started < 4000) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + assert.equal(pgrep(`tail -n 0 -f ${followed}`).split('\n').filter(Boolean).length, 2, 'both followers should run'); + guard.kill('SIGTERM'); + await new Promise(resolve => guard.once('exit', resolve)); 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`), ''); + assert.equal(pgrep(`tail -n 0 -f ${followed}`), '', 'tail -f / logread -f must not be left behind'); + assert.match(fs.readFileSync(nftLog, 'utf8'), /flush set inet fw4 login_banned_v4[\s\S]*flush set inet fw4 login_banned_v6/); }); test('upgrades stop the old login guard tree before running the previous init script', () => { diff --git a/minigate/tests/lua.test.cjs b/minigate/tests/lua.test.cjs new file mode 100644 index 000000000..0457d4703 --- /dev/null +++ b/minigate/tests/lua.test.cjs @@ -0,0 +1,210 @@ +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 lua = process.env.MINIGATE_TEST_LUA; +const root = path.resolve(__dirname, '..'); +const controller = path.join(root, 'luasrc/controller/minigate.lua'); + +function runLua(source) { + const result = spawnSync(lua, ['-'], { encoding: 'utf8', input: source }); + assert.equal(result.status, 0, result.stderr || result.stdout); + return result.stdout.trim(); +} + +function luaFiles(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = path.join(dir, entry.name); + return entry.isDirectory() ? luaFiles(full) : (entry.name.endsWith('.lua') ? [full] : []); + }); +} + +test('every LuCI Lua file compiles', { skip: !lua }, () => { + const files = luaFiles(path.join(root, 'luasrc')); + assert.ok(files.length >= 6); + const checks = files.map(file => `assert(loadfile(${JSON.stringify(file)}))`).join('\n'); + runLua(checks + '\nprint("ok")'); +}); + +// 加载控制器时只需要 luci.http;其他模块在函数内部按需 require +const controllerPrelude = ` + local response, status_code, calls, form = nil, nil, {}, {} + luci = {http={ + formvalue=function(name) return form[name] end, + prepare_content=function() end, + write_json=function(v) response=v end, + status=function(code) status_code=code end + }} + package.preload['luci.sys'] = function() return { + call=function(cmd) calls[#calls + 1] = cmd; return 0 end, + exec=function() return '' end + } end + package.preload['luci.model.uci'] = function() return {cursor=function() return { + get=function(_, _, sec) if sec == 'cfg01' then return 'ddns' end end, + foreach=function() end + } end} end + dofile(${JSON.stringify(controller)}) + local c = luci.controller.minigate +`; + +test('certificate expiry parsing is timezone independent', { skip: !lua }, () => { + const expected = Date.UTC(2026, 11, 16, 12, 0, 0) / 1000; + const out = runLua(controllerPrelude + ` + print(c.parse_cert_time('Dec 16 12:00:00 2026 GMT')) + print(c.parse_cert_time('Feb 1 00:00:00 2028 GMT')) + print(tostring(c.parse_cert_time('garbage'))) + `); + assert.deepEqual(out.split('\n'), [String(expected), String(Date.UTC(2028, 1, 1) / 1000), 'nil']); +}); + +test('log view merges sources by time and keeps untimed lines with their entry', { skip: !lua }, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-logs-')); + fs.writeFileSync(path.join(dir, 'ddns.log'), '2026-10-28 10:00:01 [DDNS] a\n2026-10-28 10:00:05 [DDNS] c\n'); + fs.writeFileSync(path.join(dir, 'nginx.log'), '2026/10/28 10:00:03 [emerg] b\n'); + fs.writeFileSync(path.join(dir, 'update.log'), '2026-10-28 10:00:04 start\ncurl output without time\n'); + const out = runLua(controllerPrelude + ` + print(c.merge_logs({{'DDNS', ${JSON.stringify(path.join(dir, 'ddns.log'))}}, {'Nginx', ${JSON.stringify(path.join(dir, 'nginx.log'))}}, + {'更新', ${JSON.stringify(path.join(dir, 'update.log'))}}, {'缺失', '/missing.log'}}, 300)) + print('--') + print(c.merge_logs({{'DDNS', ${JSON.stringify(path.join(dir, 'ddns.log'))}}}, 1)) + `); + assert.equal(out, [ + '[DDNS] 2026-10-28 10:00:01 [DDNS] a', + '[Nginx] 2026/10/28 10:00:03 [emerg] b', + '[更新] 2026-10-28 10:00:04 start', + '[更新] curl output without time', + '[DDNS] 2026-10-28 10:00:05 [DDNS] c', + '--', + '[DDNS] 2026-10-28 10:00:05 [DDNS] c' + ].join('\n')); +}); + +test('state files are read by the same domain key the shell scripts write', { skip: !lua }, () => { + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-state-')), 'proxy.status'); + fs.writeFileSync(file, 'result=ok\nmessage=配置检查通过\nwarning=one\nwarning=two=2\n'); + const out = runLua(controllerPrelude + ` + local st = c.read_state(${JSON.stringify(file)}) + print(st.result, st.message, #st.warnings, st.warnings[2]) + print(c.state_key('*.example.com'), c.state_key('home.example.com')) + print(tostring(c.read_state('/missing/state'))) + `); + assert.equal(out, 'ok\t配置检查通过\t2\ttwo=2\n__example_com\thome_example_com\nnil'); +}); + +test('login guard endpoints accept IPv6, quote the address and reject shell text', { skip: !lua }, () => { + const out = runLua(controllerPrelude + ` + for _, ip in ipairs({'2001:db8::1', '198.51.100.7', "1.2.3.4'; reboot; '", 'abc'}) do + form.ip = ip; calls = {}; response = nil + c.action_lg_ban() + print(response.success and calls[1] or 'rejected') + end + `); + assert.equal(out, [ + "/bin/sh /usr/lib/minigate/login_guard.sh ban '2001:db8::1' >/dev/null 2>&1", + "/bin/sh /usr/lib/minigate/login_guard.sh ban '198.51.100.7' >/dev/null 2>&1", + 'rejected', + 'rejected' + ].join('\n')); +}); + +test('manual DDNS sync starts a background job only for a real DDNS section', { skip: !lua }, () => { + const out = runLua(controllerPrelude + ` + form.section = 'cfg01'; c.action_ddns_sync() + print(response.success, calls[1]) + form.section = 'cfg01;reboot'; calls = {}; c.action_ddns_sync() + print(response.success, status_code, #calls) + `); + const [started, rejected] = out.split('\n'); + assert.match(started, /^true\t\( \/bin\/sh \/usr\/lib\/minigate\/job\.sh ddns-cfg01 \/bin\/sh \/usr\/lib\/minigate\/ddns\.sh single 'cfg01' \) <\/dev\/null >\/dev\/null 2>&1 &$/); + assert.equal(rejected, 'false\t400\t0'); +}); + +// 用最小 CBI 桩加载每个页面,并执行表单渲染时会调用的 cfgvalue +const cbiPrelude = ` + local options = {} + local function node() + local n = {} + function n:option(kind, name, title) + local o = {kind=kind, name=name, choices={}} + function o:value(k, v) self.choices[k] = v or k end + function o:depends() end + options[name] = o + return o + end + function n:tab() end + function n:taboption(_, ...) return n:option(...) end + return n + end + function Map(config, title) + local m = node() + m.title = title + m.uci = {get=function(_, _, sec, opt) local v; if opt == 'domain' then v = 'home.example.com' end; return v end} + function m:section() return node() end + return m + end + for _, name in ipairs({'Flag','Value','DynamicList','ListValue','DummyValue','MultiValue','NamedSection','TypedSection','SimpleSection'}) do _G[name] = name end + luci = {dispatcher={build_url=function(...) return '/cgi-bin/luci/' .. table.concat({...}, '/') end}} + package.preload['luci.sys'] = function() return {exec=function() return '' end, call=function() return 0 end} end + package.preload['nixio.fs'] = function() return {access=function() return false end, dir=function() return nil end, stat=function() return nil end} end + package.preload['luci.model.uci'] = function() return {cursor=function() return { + foreach=function(_, config, kind, fn) if kind == 'ddns' then fn({domain='home.example.com'}); fn({domain='*.example.com'}) end end, + get=function(_, _, _, opt) local v; if opt == 'staging' then v = '1' end; return v end + } end} end + package.preload['luci.controller.minigate'] = function() return { + ddns_state=function() return {status='error', message='IPv4 192.168.2.2 不是公网地址', ip='198.51.100.1'} end, + cert_list=function() return {{domain='*.example.com', expires_date='2026-11-02', days_left=5, staging=true}} end + } end + package.preload['luci.cbi.datatypes'] = function() return { + ip4addr=function(v) return v:match('^%d+%.%d+%.%d+%.%d+$') ~= nil end, + ip6addr=function(v) return v:match('^[%x:]+$') ~= nil and v:find(':') ~= nil end + } end + local function page(name) return assert(dofile(${JSON.stringify(path.join(root, 'luasrc/model/cbi/minigate'))} .. '/' .. name .. '.lua')) end +`; + +test('every LuCI page loads and renders its status blocks', { skip: !lua }, () => { + const out = runLua(cbiPrelude + ` + for _, name in ipairs({'general', 'ddns', 'acme', 'proxy', 'login_guard'}) do + options = {} + local m = page(name) + assert(m.title:match('^MiniGate'), name .. ' title') + for key, o in pairs(options) do + if o.kind == 'DummyValue' and type(o.cfgvalue) == 'function' then + assert(type(o.cfgvalue(o, 'cfg01')) == 'string', name .. '.' .. key) + end + end + end + options = {}; page('ddns') + print(options._info.cfgvalue(options._info, 'cfg01'):match('不是公网地址') ~= nil) + options = {}; page('acme') + local certs = options._certs.cfgvalue(options._certs) + print(certs:match('剩 5 天') ~= nil, certs:match('测试证书') ~= nil, certs:match('测试模式已开启') ~= nil) + options = {}; page('general') + print(options.enabled ~= nil, options.ipv6_listen == nil) + `); + assert.equal(out, 'true\ntrue\ttrue\ttrue\ntrue\ttrue'); +}); + +test('LuCI forms validate domains, Cloudflare IDs, intervals, thresholds and whitelists', { skip: !lua }, () => { + const out = runLua(cbiPrelude + ` + local function ok(o, v) return (o.validate(o, v, 'cfg01')) ~= nil end + options = {}; page('ddns') + print(ok(options.domain, 'home.example.com'), ok(options.domain, '*.example.com'), ok(options.domain, 'bad domain.com'), + ok(options.domain, '-a.example.com'), ok(options.domain, 'localhost')) + print(ok(options.cf_zone_id, string.rep('a1', 16)), ok(options.cf_zone_id, 'zone'), ok(options.cf_api_token, 'abc_DEF-123'), ok(options.cf_api_token, 'abc def')) + print(ok(options.check_interval, '300'), ok(options.check_interval, '60')) + options = {}; page('proxy') + print(ok(options.prefix, 'app'), ok(options.prefix, 'a.b'), ok(options.prefix, '-x'), options.target_addr.datatype) + options = {}; page('login_guard') + print(options.threshold.datatype, ok(options.whitelist, {'203.0.113.0/20', '2001:db8::/48', '8.8.8.8'}), ok(options.whitelist, {'1.2.3.0/255.255.255.0'}), ok(options.whitelist, '10.0.0.0/33')) + `); + assert.equal(out, [ + 'true\ttrue\tfalse\tfalse\tfalse', + 'true\tfalse\ttrue\tfalse', + 'true\tfalse', + 'true\tfalse\tfalse\thost', + 'range(1,100)\ttrue\tfalse\tfalse' + ].join('\n')); +}); diff --git a/minigate/tests/proxy.test.cjs b/minigate/tests/proxy.test.cjs index 5a1cab1a0..17be18968 100644 --- a/minigate/tests/proxy.test.cjs +++ b/minigate/tests/proxy.test.cjs @@ -65,3 +65,130 @@ test('log trimming is scheduled and the visitor list reads the rotated access lo 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/); }); + +// 用假的 uci / nginx 生成完整配置,检查跳过规则、回滚和状态文件 +function generate(db, { nginxFails = false } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'minigate-proxy-gen-')); + const bin = path.join(dir, 'bin'); + fs.mkdirSync(bin); + fs.mkdirSync(path.join(dir, 'sites')); + fs.writeFileSync(path.join(dir, 'uci.db'), db.trim() + '\n'); + fs.writeFileSync(path.join(bin, 'uci'), `#!/bin/sh +DB=${JSON.stringify(path.join(dir, 'uci.db'))} +[ "$1" = "-q" ] && shift +case "$1" in + show) grep "^$2" "$DB" ;; + get) v=$(grep "^$2=" "$DB" | head -1 | cut -d= -f2-); [ -n "$v" ] && echo "$v" || exit 1 ;; +esac +`, { mode: 0o755 }); + fs.writeFileSync(path.join(bin, 'nginx'), `#!/bin/sh +[ "$1" = "-v" ] && { echo "nginx version: nginx/1.26.3" >&2; exit 0; } +${nginxFails ? 'echo "nginx: [emerg] unknown directive in site_1.conf:3" >&2; exit 1' : 'exit 0'} +`, { mode: 0o755 }); + fs.writeFileSync(path.join(bin, 'openssl'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + fs.writeFileSync(path.join(dir, 'sites/site_1.conf'), '# previous good site\n'); + fs.writeFileSync(path.join(dir, 'main.conf'), '# previous good main\n'); + const result = spawnSync('/bin/sh', [script, 'prepare'], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + TMPDIR: dir, + MINIGATE_PROXY_LOCK: path.join(dir, 'proxy.lock'), + MINIGATE_PROXY_STATUS: path.join(dir, 'proxy.status'), + MINIGATE_NGINX_CONF: path.join(dir, 'main.conf'), + MINIGATE_MIME_TYPES: '/dev/null', + MINIGATE_SITES_DIR: path.join(dir, 'sites'), + MINIGATE_STREAMS_DIR: path.join(dir, 'streams'), + MINIGATE_CERT_DIR: path.join(dir, 'certs'), + MINIGATE_LOGFILE: path.join(dir, 'proxy.log'), + MINIGATE_SOCKET_DIR: path.join(dir, 'sock') + } + }); + const sites = fs.readdirSync(path.join(dir, 'sites')).sort(); + const conf = sites.map(name => fs.readFileSync(path.join(dir, 'sites', name), 'utf8')).join('\n'); + const status = fs.existsSync(path.join(dir, 'proxy.status')) ? fs.readFileSync(path.join(dir, 'proxy.status'), 'utf8') : ''; + const leftovers = fs.readdirSync(dir).filter(name => name.startsWith('minigate-gen.')); + return { result, dir, sites, conf, status, leftovers, main: fs.readFileSync(path.join(dir, 'main.conf'), 'utf8') }; +} + +const BASE = ` +minigate.global=global +minigate.global.enabled=1 +minigate.global.http_redirect=0 +minigate.wc=proxy_wildcard +minigate.wc.enabled=WC_ENABLED +minigate.wc.domain=*.example.com +minigate.wc.listen_port=443 +minigate.wc.ssl=1 +minigate.sub1=subproxy +minigate.sub1.parent_domain=example.com +minigate.sub1.prefix=nas +minigate.sub1.target_addr=192.168.2.10 +minigate.sub1.target_port=5000 +`; + +test('subdomains are skipped when their wildcard parent is disabled instead of falling back to plain HTTP', () => { + const disabled = generate(BASE.replace('WC_ENABLED', '0')); + assert.equal(disabled.result.status, 0, disabled.result.stderr); + assert.doesNotMatch(disabled.conf, /nas\.example\.com/); + assert.match(disabled.status, /warning=跳过 nas\.example\.com:通配符主域名 \*\.example\.com 未启用或不存在/); + + const enabled = generate(BASE.replace('WC_ENABLED', '1')); + assert.equal(enabled.result.status, 0, enabled.result.stderr); + assert.match(enabled.conf, /listen 443 ssl;\n server_name nas\.example\.com;/); + assert.doesNotMatch(enabled.conf, /listen 443;\n server_name nas/); +}); + +test('a disabled subdomain rule is not generated', () => { + const result = generate(BASE.replace('WC_ENABLED', '1') + 'minigate.sub1.enabled=0\n'); + assert.equal(result.result.status, 0, result.result.stderr); + assert.doesNotMatch(result.conf, /nas\.example\.com/); +}); + +test('HTTP and HTTPS rules on one port never produce two default servers', () => { + const result = generate(BASE.replace('WC_ENABLED', '1') + ` +minigate.plain=proxy +minigate.plain.enabled=1 +minigate.plain.domain=plain.example.org +minigate.plain.listen_port=443 +minigate.plain.target_addr=192.168.2.30 +minigate.plain.ssl=0 +`); + assert.equal(result.result.status, 0, result.result.stderr); + // 普通规则先生成并占用端口,之后的子域名 HTTPS 规则被跳过 + assert.equal((result.conf.match(/default_server/g) || []).length, 1); + assert.match(result.conf, /server_name plain\.example\.org;/); + assert.doesNotMatch(result.conf, /nas\.example\.com/); + assert.match(result.status, /warning=跳过 nas\.example\.com:端口 443 已用于HTTP,不能同时用于HTTPS/); +}); + +test('missing certificates and every site get WebSocket support and a visible warning', () => { + const result = generate(BASE.replace('WC_ENABLED', '1')); + assert.match(result.status, /warning=nas\.example\.com 没有可用证书/); + assert.match(result.conf, /proxy_set_header Connection \$minigate_connection_upgrade;/); + assert.match(result.main, /map \$http_upgrade \$minigate_connection_upgrade/); + assert.match(result.status, /^result=ok$/m); + assert.deepEqual(result.leftovers, [], 'temporary generation directory is removed'); +}); + +test('a configuration nginx rejects is rolled back so the last working files stay on disk', () => { + const result = generate(BASE.replace('WC_ENABLED', '1'), { nginxFails: true }); + assert.equal(result.result.status, 1); + assert.deepEqual(result.sites, ['site_1.conf']); + assert.equal(fs.readFileSync(path.join(result.dir, 'sites/site_1.conf'), 'utf8'), '# previous good site\n'); + assert.equal(result.main, '# previous good main\n'); + assert.match(result.status, /^result=error$/m); + assert.match(result.status, /配置检查失败,已保留上一份配置:\[emerg\] unknown directive/); + assert.deepEqual(result.leftovers, []); +}); + +test('nginx runs in the foreground under procd and clears stale sockets first', () => { + const source = fs.readFileSync(script, 'utf8'); + assert.match(source, /do_run\(\) \{\n mkdir -p "\$SOCKET_DIR"\n rm -f "\$SOCKET_DIR"\/\*\.sock\n exec nginx -c "\$NGINX_CONF" -g 'daemon off;'/); + const init = fs.readFileSync(path.join(root, 'root/etc/init.d/minigate'), 'utf8'); + assert.match(init, /USE_PROCD=1/); + assert.match(init, /procd_set_param command \/bin\/sh "\$PROXY" run/); + assert.match(init, /procd_add_reload_trigger "minigate"/); + assert.match(source, /MINIGATE_SKIP_PREPARE=1 "\$INIT_SCRIPT" start 6>&-/); +}); diff --git a/minigate/tests/ui.test.cjs b/minigate/tests/ui.test.cjs index aaba9e7b5..9e5fb0598 100644 --- a/minigate/tests/ui.test.cjs +++ b/minigate/tests/ui.test.cjs @@ -131,6 +131,95 @@ test('overview renders legacy visitor responses as unknown and escapes log conte assert.doesNotMatch(html, / ({ + enabled: '1', ddns_list: [], proxy_rules: [], certs: [], acme: {}, proxy_warnings: [], ...extra +}); + +test('overview shows a notice only while MiniGate is disabled', () => { + const page = fakePage(overviewScript); + take(page, 'su').callback(null, statusData({ enabled: '0' })); + assert.equal(page.element('mg-disabled').hidden, false); + page.context.mgLoadStatus(); + take(page, 'su').callback(null, statusData({ enabled: '1' })); + assert.equal(page.element('mg-disabled').hidden, true); +}); + +test('overview DDNS card lists every record and takes the worst status', () => { + const page = fakePage(overviewScript); + take(page, 'su').callback(null, statusData({ + ddns_status: 'error', + ddns_list: [ + { domain: 'a.example.com', enabled: '1', status: 'ok', last_ip: '198.51.100.1', last_update: '2026-10-28 10:00:00' }, + { domain: 'b.example.com', enabled: '1', status: 'error', status_msg: 'IPv4 192.168.2.2 不是公网地址' } + ] + })); + assert.match(page.element('mg-d1').innerHTML, /异常/); + const body = page.element('mg-d2').innerHTML; + assert.match(body, /a\.example\.com[\s\S]*A 198\.51\.100\.1/); + assert.match(body, /b\.example\.com/); + assert.match(body, /不是公网地址<b>/); + assert.doesNotMatch(body, //); +}); + +test('overview certificate card shows expiry dates, days left and test certificates', () => { + const page = fakePage(overviewScript); + take(page, 'su').callback(null, statusData({ + acme: { enabled: '1', result: 'error', message: '签发失败 (exit=1)' }, + certs: [ + { domain: '*.example.com', expires_date: '2026-11-02', days_left: 5, staging: false }, + { domain: 'test.example.com', expires_date: '2027-01-20', days_left: 84, staging: true } + ] + })); + assert.match(page.element('mg-a1').innerHTML, /即将到期/); + const body = page.element('mg-a2').innerHTML; + assert.match(body, /到期 2026-11-02(剩 5 天)/); + assert.match(body, /测试证书,浏览器不信任/); + assert.match(body, /最近一次签发:签发失败/); + + const staging = fakePage(overviewScript); + take(staging, 'su').callback(null, statusData({ certs: [{ domain: 'x.example.com', expires_date: '2027-01-20', days_left: 84, staging: true }] })); + assert.match(staging.element('mg-a1').innerHTML, /测试证书/); +}); + +test('overview proxy card links sites safely and surfaces configuration problems', () => { + const page = fakePage(overviewScript); + take(page, 'su').callback(null, statusData({ + proxy_running: true, + proxy_check: 'error', + proxy_message: '配置检查失败,已保留上一份配置:[emerg] x', + proxy_warnings: ['跳过 nas.example.com:通配符主域名 *.example.com 未启用或不存在'], + proxy_rules: [{ scheme: 'https', domain: 'box.example.com', listen_port: '2000', target: '192.168.2.2:8080' }, + { scheme: 'https', domain: '">', listen_port: '443', target: 'x' }] + })); + assert.match(page.element('mg-p1').innerHTML, /最近一次配置未生效/); + const body = page.element('mg-p2').innerHTML; + assert.match(body, //); + assert.match(body, /已保留上一份配置/); + assert.match(body, /跳过 nas\.example\.com/); + assert.doesNotMatch(body, /">/); +}); + +function ddnsPageScript() { + const source = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/ddns.lua'), 'utf8'); + const start = source.indexOf('', start); + return source.slice(start + '