mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-02 20:33:56 +08:00
🎁 Sync 2026-10-28 19:35:15
This commit is contained in:
1 parent
85209f310d
commit
390801c844
90 files changed
+5821
-1967
No files matched your search
File renamed without changes.
+2
-2
@@ -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)?
|
||||
|
||||
+2
-2
@@ -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)?
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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<n do
|
||||
local data=self:recv(n-#result)
|
||||
if not data or data=="" then return nil, -1 end
|
||||
result=result..data
|
||||
end
|
||||
return result
|
||||
end
|
||||
local source=chunksource(sock, wire:sub(1,split))
|
||||
local output=""
|
||||
for i=1,100 do
|
||||
local data, err=source()
|
||||
assert(not err, "chunk parse failed at split " .. split)
|
||||
if not data then break end
|
||||
output=output..data
|
||||
end
|
||||
assert(output=="helloworld", "chunk boundary corrupted body at " .. split)
|
||||
end
|
||||
local source=chunksource({recv=function() return "" end}, "incomplete")
|
||||
assert(source()==nil, "EOF not handled")
|
||||
source=chunksource({recv=function() error("must not read zero bytes") end}, string.rep("z",1024))
|
||||
assert(source()==nil, "oversize chunk header accepted")
|
||||
print("PASS: package validation, full proxy writes, all chunk boundaries, EOF and header limit")
|
||||
@@ -10,8 +10,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-vantage
|
||||
PKG_VERSION:=1.0.9
|
||||
PKG_RELEASE:=16
|
||||
PKG_VERSION:=1.0.10
|
||||
PKG_RELEASE:=17
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
|
||||
LUCI_TITLE:=Vantage dashboard
|
||||
|
||||
@@ -27,7 +27,7 @@ return baseclass.extend({
|
||||
bitsParts: function(bps) {
|
||||
bps = num(bps);
|
||||
if (bps == null || bps < 0) return { v: DASH, u: '' };
|
||||
var u = [ _('bit/s'), _('kbit/s'), _('Mbit/s'), _('Gbit/s'), _('Tbit/s') ], i = 0;
|
||||
var u = [ _('bit/s', 'vantage'), _('kbit/s', 'vantage'), _('Mbit/s'), _('Gbit/s', 'vantage'), _('Tbit/s') ], i = 0;
|
||||
while (bps >= 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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
};
|
||||
|
||||
|
||||
@@ -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') };
|
||||
},
|
||||
|
||||
|
||||
@@ -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)
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
]) ])
|
||||
]
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+61
-3
@@ -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-<lang> per po/<lang>/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-<lang> 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.<lang>:=<name as LUCI_LANG.<lang> 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))))
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 / 新功能
|
||||
|
||||
@@ -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 ]
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }) %}
|
||||
|
||||
<link rel="stylesheet" href="{{ media }}/css/cascade.css{# ?v=PKG_VERSION #}">
|
||||
{%
|
||||
// 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';
|
||||
%}
|
||||
<link rel="stylesheet" href="{{ media }}/css/cascade.css?v={{ stylesheet_version }}">
|
||||
<style title="text/css">
|
||||
:root {
|
||||
--light-primary: {{ primary }};
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-theme-vantage
|
||||
PKG_VERSION:=1.0.9
|
||||
PKG_RELEASE:=22
|
||||
PKG_VERSION:=1.0.10
|
||||
PKG_RELEASE:=23
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
|
||||
LUCI_TITLE:=Vantage theme
|
||||
|
||||
@@ -286,31 +286,31 @@ function enhanceMemory() {
|
||||
const usedPct = pctOf(inuse, total);
|
||||
|
||||
const summary = '%s: %s %s, %s %s (%s), %s %s, %s %s'.format(_('Memory'),
|
||||
_('In use'), fmtBytes(inuse), _('Cache'), fmtBytes(reclaim), _('reclaimable'),
|
||||
_('Free'), fmtBytes(free), _('Total'), fmtBytes(total));
|
||||
_('In use', 'vantage-theme'), fmtBytes(inuse), _('Cache'), fmtBytes(reclaim), _('reclaimable'),
|
||||
_('Free', 'vantage-theme'), fmtBytes(free), _('Total', 'vantage-theme'), fmtBytes(total));
|
||||
|
||||
const children = [
|
||||
E('div', { 'class': 'v-meter-head' }, [
|
||||
E('span', { 'class': 'v-meter-big' }, [ pctOf(available, total) + '%', E('small', {}, [ _('available') ]) ]),
|
||||
E('span', { 'class': 'v-meter-big' }, [ pctOf(available, total) + '%', E('small', {}, [ _('available', 'vantage-theme') ]) ]),
|
||||
E('span', { 'class': 'v-meter-sub' }, [ '%s / %s'.format(fmtBytes(available), fmtBytes(total)) ])
|
||||
]),
|
||||
E('div', { 'class': 'v-meter-bar', 'role': 'img', 'aria-label': summary }, [
|
||||
segment('v-seg-used' + (usedPct >= 90 ? ' is-crit' : usedPct >= 75 ? ' is-warn' : ''), inuse, total, _('In use')),
|
||||
segment('v-seg-used' + (usedPct >= 90 ? ' is-crit' : usedPct >= 75 ? ' is-warn' : ''), inuse, total, _('In use', 'vantage-theme')),
|
||||
segment('v-seg-cache', reclaim, total, _('Cache') + ' (' + _('reclaimable') + ')')
|
||||
]),
|
||||
E('ul', { 'class': 'v-meter-legend', 'aria-hidden': 'true' }, [
|
||||
legendKey('v-seg-used', _('In use'), inuse),
|
||||
legendKey('v-seg-used', _('In use', 'vantage-theme'), inuse),
|
||||
legendKey('v-seg-cache', _('Cache'), reclaim, _('reclaimable')),
|
||||
legendKey('v-seg-free', _('Free'), free)
|
||||
legendKey('v-seg-free', _('Free', 'vantage-theme'), free)
|
||||
])
|
||||
];
|
||||
|
||||
if (swap) {
|
||||
const swapUsed = swap.total - swap.value;
|
||||
children.push(E('div', { 'class': 'v-meter-rows v-meter-swap' }, E('div', { 'class': 'v-meter-row' }, [
|
||||
E('span', { 'class': 'v-meter-label' }, [ _('Swap') ]),
|
||||
E('span', { 'class': 'v-meter-label' }, [ _('Swap', 'vantage-theme') ]),
|
||||
E('div', { 'class': 'v-meter-bar is-thin', 'role': 'img',
|
||||
'aria-label': '%s: %s / %s'.format(_('Swap'), fmtBytes(swapUsed), fmtBytes(swap.total)) },
|
||||
'aria-label': '%s: %s / %s'.format(_('Swap', 'vantage-theme'), fmtBytes(swapUsed), fmtBytes(swap.total)) },
|
||||
segment('v-seg-used', swapUsed, swap.total)),
|
||||
E('span', { 'class': 'v-meter-val' }, [ E('b', {}, [ fmtBytes(swapUsed) ]), ' / ' + fmtBytes(swap.total) ])
|
||||
])));
|
||||
@@ -367,6 +367,19 @@ function decorateRadios() {
|
||||
});
|
||||
}
|
||||
|
||||
/* Port status: the ▲ / ▼ before each counter in a span of its own, for the
|
||||
dashboard's upload and download colours */
|
||||
function decoratePorts() {
|
||||
document.querySelectorAll('#view .ifacebox > .ifacebox-head.cbi-tooltip-container + .ifacebox-body > .cbi-tooltip-container').forEach(stats => {
|
||||
Array.prototype.slice.call(stats.childNodes).forEach(n => {
|
||||
const dir = n.nodeType === 3 && { '▲': 'tx', '▼': 'rx' }[n.data.charAt(0)];
|
||||
if (!dir) return;
|
||||
stats.insertBefore(E('span', { 'class': 'vt-port-dir', 'data-dir': dir }, [ n.data.charAt(0) ]), n);
|
||||
n.data = n.data.slice(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupStockEnhancers() {
|
||||
const view = document.getElementById('view');
|
||||
if (!view || !window.MutationObserver) return;
|
||||
@@ -375,7 +388,7 @@ function setupStockEnhancers() {
|
||||
const run = () => {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try { enhanceMemory(); enhanceStorage(); decorateRadios(); }
|
||||
try { enhanceMemory(); enhanceStorage(); decorateRadios(); decoratePorts(); }
|
||||
catch (e) { if (window.console) console.warn('vantage: status enhancer', e); }
|
||||
busy = false;
|
||||
};
|
||||
@@ -891,7 +904,7 @@ return baseclass.extend({
|
||||
ul.appendChild(item);
|
||||
}
|
||||
fly.appendChild(ul);
|
||||
fly.appendChild(E('div', { 'class': 'vt-fly-foot' }, [ _('Press'), ' ', E('kbd', {}, [ 'Ctrl K' ]), ' ', _('to search all pages') ]));
|
||||
fly.appendChild(E('div', { 'class': 'vt-fly-foot' }, [ _('Press', 'vantage-theme'), ' ', E('kbd', {}, [ 'Ctrl K' ]), ' ', _('to search all pages') ]));
|
||||
fly.setAttribute('data-cat', cat.name);
|
||||
fly.setAttribute('role', 'region');
|
||||
fly.setAttribute('aria-labelledby', 'vt-fly-title');
|
||||
@@ -1087,9 +1100,9 @@ return baseclass.extend({
|
||||
actions() {
|
||||
const pref = this.themePref();
|
||||
const list = [];
|
||||
if (pref !== 'dark') list.push({ title: _('Use dark theme'), path: _('Appearance'), icon: 'moon', run: () => this.setTheme('dark') });
|
||||
if (pref !== 'light') list.push({ title: _('Use light theme'), path: _('Appearance'), icon: 'sun', run: () => this.setTheme('light') });
|
||||
if (pref !== 'auto') list.push({ title: _('Follow system theme'), path: _('Appearance'), icon: 'auto', run: () => this.setTheme('auto') });
|
||||
if (pref !== 'dark') list.push({ title: _('Use dark theme'), path: _('Appearance', 'vantage-theme'), icon: 'moon', run: () => this.setTheme('dark') });
|
||||
if (pref !== 'light') list.push({ title: _('Use light theme'), path: _('Appearance', 'vantage-theme'), icon: 'sun', run: () => this.setTheme('light') });
|
||||
if (pref !== 'auto') list.push({ title: _('Follow system theme'), path: _('Appearance', 'vantage-theme'), icon: 'auto', run: () => this.setTheme('auto') });
|
||||
list.push({ title: _('Log out'), path: _('Session'), icon: 'logout', segs: [ 'admin', 'logout' ] });
|
||||
list.forEach(a => { a.hay = a.path + ' › ' + a.title; a.group = _('Actions'); });
|
||||
return list;
|
||||
@@ -1110,14 +1123,14 @@ return baseclass.extend({
|
||||
'aria-controls': 'vt-pal-list', 'aria-autocomplete': 'list', 'autocomplete': 'off', 'spellcheck': 'false',
|
||||
'placeholder': _('Jump to a page or action…'), 'aria-label': _('Search pages')
|
||||
});
|
||||
const list = E('ul', { 'class': 'vt-pal-list', 'id': 'vt-pal-list', 'role': 'listbox', 'aria-label': _('Results') });
|
||||
const list = E('ul', { 'class': 'vt-pal-list', 'id': 'vt-pal-list', 'role': 'listbox', 'aria-label': _('Results', 'vantage-theme') });
|
||||
const count = E('span', { 'class': 'vt-pal-count' });
|
||||
const box = E('div', { 'class': 'vt-pal', 'role': 'dialog', 'aria-modal': 'true', 'aria-label': _('Command palette') }, [
|
||||
E('div', { 'class': 'vt-pal-field' }, [ svg('search', 18), input, E('kbd', {}, [ 'Esc' ]) ]),
|
||||
list,
|
||||
E('div', { 'class': 'vt-pal-foot' }, [
|
||||
E('span', {}, [ E('kbd', {}, [ '↑' ]), E('kbd', {}, [ '↓' ]), _('navigate') ]),
|
||||
E('span', {}, [ E('kbd', {}, [ '↵' ]), _('open') ]),
|
||||
E('span', {}, [ E('kbd', {}, [ '↵' ]), _('open', 'vantage-theme') ]),
|
||||
E('span', {}, [ E('kbd', {}, [ 'Esc' ]), _('close') ]),
|
||||
count
|
||||
])
|
||||
@@ -1185,7 +1198,7 @@ return baseclass.extend({
|
||||
if (!results.length)
|
||||
pal.list.appendChild(E('li', { 'class': 'vt-pal-empty', 'role': 'presentation' }, [ _('No matching pages') ]));
|
||||
|
||||
pal.count.textContent = q ? '%d / %d'.format(results.length, pool.length) : '%d %s'.format(pool.length, _('items'));
|
||||
pal.count.textContent = q ? '%d / %d'.format(results.length, pool.length) : '%d %s'.format(pool.length, _('items', 'vantage-theme'));
|
||||
this.setPaletteActive(0, true);
|
||||
},
|
||||
|
||||
@@ -1270,7 +1283,7 @@ return baseclass.extend({
|
||||
|
||||
const btn = document.getElementById('vt-theme');
|
||||
if (btn) {
|
||||
const names = { auto: _('Auto'), light: _('Light'), dark: _('Dark') };
|
||||
const names = { auto: _('Auto', 'vantage-theme'), light: _('Light', 'vantage-theme'), dark: _('Dark', 'vantage-theme') };
|
||||
const label = '%s: %s'.format(_('Colour theme'), names[pref]) + (pref === 'auto' ? ' (%s)'.format(dark ? names.dark : names.light) : '');
|
||||
btn.textContent = '';
|
||||
btn.appendChild(svg(pref === 'auto' ? 'auto' : (pref === 'dark' ? 'moon' : 'sun'), 18));
|
||||
@@ -1499,7 +1512,7 @@ return baseclass.extend({
|
||||
const btn = E('button', {
|
||||
'type': 'button', 'class': 'cbi-button cbi-button-neutral vt-acts-more',
|
||||
'aria-haspopup': 'menu', 'aria-expanded': 'false', 'aria-controls': 'vt-actmenu',
|
||||
'aria-label': _('More actions'), 'title': _('More actions')
|
||||
'aria-label': _('More actions', 'vantage-theme'), 'title': _('More actions', 'vantage-theme')
|
||||
}, [ svg('more', 18) ]);
|
||||
btn.addEventListener('click', ev => {
|
||||
ev.preventDefault();
|
||||
@@ -1516,7 +1529,7 @@ return baseclass.extend({
|
||||
|
||||
actMenuEl() {
|
||||
if (this.actMenu) return this.actMenu;
|
||||
const menu = E('div', { 'id': 'vt-actmenu', 'class': 'vt-actmenu', 'role': 'menu', 'aria-label': _('More actions'), 'hidden': 'hidden' });
|
||||
const menu = E('div', { 'id': 'vt-actmenu', 'class': 'vt-actmenu', 'role': 'menu', 'aria-label': _('More actions', 'vantage-theme'), 'hidden': 'hidden' });
|
||||
menu.addEventListener('keydown', ev => {
|
||||
const items = Array.prototype.filter.call(menu.querySelectorAll('[role="menuitem"]'), n => !n.disabled);
|
||||
const i = items.indexOf(document.activeElement);
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
const MODE_KEY = 'vantage.nft.mode';
|
||||
const MAX_DEPTH = 8;
|
||||
const MAX_STEPS = 200; /* rules drawn per chain before "Show all" */
|
||||
const CTX = 'Status -> Firewall flow chart';
|
||||
|
||||
function storageGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
|
||||
function storageSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
|
||||
@@ -35,21 +34,21 @@ function reducedMotion() { return !!(window.matchMedia && window.matchMedia('(pr
|
||||
|
||||
function hookName(h) {
|
||||
switch (h) {
|
||||
case 'prerouting': return _('Prerouting', CTX);
|
||||
case 'input': return _('Input', CTX);
|
||||
case 'forward': return _('Forward', CTX);
|
||||
case 'output': return _('Output', CTX);
|
||||
case 'postrouting': return _('Postrouting', CTX);
|
||||
case 'ingress': return _('Ingress', CTX);
|
||||
case 'egress': return _('Egress', CTX);
|
||||
case 'prerouting': return _('Prerouting', 'vantage-theme');
|
||||
case 'input': return _('Input', 'vantage-theme');
|
||||
case 'forward': return _('Forward', 'vantage-theme');
|
||||
case 'output': return _('Output', 'vantage-theme');
|
||||
case 'postrouting': return _('Postrouting', 'vantage-theme');
|
||||
case 'ingress': return _('Ingress');
|
||||
case 'egress': return _('Egress');
|
||||
default: return h;
|
||||
}
|
||||
}
|
||||
|
||||
function stageName(s) {
|
||||
switch (s.hook) {
|
||||
case 'bridgeIn': case 'bridgeOut': return _('Bridge', CTX);
|
||||
case 'bridged': return _('Bridged between ports', CTX);
|
||||
case 'bridgeIn': case 'bridgeOut': return _('Bridge');
|
||||
case 'bridged': return _('Bridged between ports', 'vantage-theme');
|
||||
default: return hookName(s.hook);
|
||||
}
|
||||
}
|
||||
@@ -153,14 +152,14 @@ function chip(kind, label, title) {
|
||||
|
||||
function verdictLabel(v) {
|
||||
switch (v.kind) {
|
||||
case 'accept': return _('Accept', CTX);
|
||||
case 'drop': return _('Drop', CTX);
|
||||
case 'reject': return _('Reject', CTX);
|
||||
case 'queue': return _('Queue to a program', CTX);
|
||||
case 'accept': return _('Accept', 'vantage-theme');
|
||||
case 'drop': return _('Drop', 'vantage-theme');
|
||||
case 'reject': return _('Reject', 'vantage-theme');
|
||||
case 'queue': return _('Queue to a program', 'vantage-theme');
|
||||
case 'nat':
|
||||
return v.nat === 'masquerade' ? _('Masquerade', CTX) : v.nat === 'snat' ? _('Rewrite source', CTX)
|
||||
: v.nat === 'dnat' ? _('Rewrite destination', CTX) : _('Redirect', CTX);
|
||||
default: return _('Next rule', CTX);
|
||||
return v.nat === 'masquerade' ? _('Masquerade', 'vantage-theme') : v.nat === 'snat' ? _('Rewrite source', 'vantage-theme')
|
||||
: v.nat === 'dnat' ? _('Rewrite destination', 'vantage-theme') : _('Redirect', 'vantage-theme');
|
||||
default: return _('Next rule', 'vantage-theme');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,11 +201,11 @@ return baseclass.extend({
|
||||
Object.keys(m.chains).forEach((k, i) => { this.ids[k] = 'v-nft-c' + i; });
|
||||
this.cards = Object.create(null);
|
||||
|
||||
const tabs = E('ul', { 'class': 'cbi-tabmenu v-nft-tabs', 'role': 'group', 'aria-label': _('View', CTX) });
|
||||
const tabs = E('ul', { 'class': 'cbi-tabmenu v-nft-tabs', 'role': 'group', 'aria-label': _('View', 'vantage-theme') });
|
||||
const panel = E('div', { 'class': 'v-nft-panel' }, [
|
||||
E('h3', {}, [ _('Packet path', CTX) ]),
|
||||
E('h3', {}, [ _('Packet path', 'vantage-theme') ]),
|
||||
this.chart(),
|
||||
E('h3', {}, [ _('Chains', CTX) ]),
|
||||
E('h3', {}, [ _('Chains', 'vantage-theme') ]),
|
||||
E('div', { 'class': 'v-nft-chains' }, this.cardsOf(m.bases))
|
||||
]);
|
||||
m.others.forEach((t) => {
|
||||
@@ -214,7 +213,7 @@ return baseclass.extend({
|
||||
panel.appendChild(E('div', { 'class': 'v-nft-chains' }, this.cardsOf(t.chains)));
|
||||
});
|
||||
if (m.unused.length) {
|
||||
panel.appendChild(E('h3', {}, [ m.namedMaps ? _('Chains no rule jumps to directly', CTX) : _('Chains no rule jumps to', CTX) ]));
|
||||
panel.appendChild(E('h3', {}, [ m.namedMaps ? _('Chains no rule jumps to directly', 'vantage-theme') : _('Chains no rule jumps to', 'vantage-theme') ]));
|
||||
panel.appendChild(E('div', { 'class': 'v-nft-chains' }, this.cardsOf(m.unused, true)));
|
||||
}
|
||||
|
||||
@@ -229,7 +228,7 @@ return baseclass.extend({
|
||||
if (mode === 'flow') this.fit();
|
||||
};
|
||||
/* LuCI's tab markup for the look; buttons for what they do */
|
||||
[ [ 'flow', _('Flow', CTX) ], [ 'table', _('Rules table', CTX) ] ].forEach((t) => {
|
||||
[ [ 'flow', _('Flow', 'vantage-theme') ], [ 'table', _('Rules table', 'vantage-theme') ] ].forEach((t) => {
|
||||
const pick = (ev) => { ev.preventDefault(); storageSet(MODE_KEY, t[0]); set(t[0]); };
|
||||
tabs.appendChild(E('li', { 'data-mode': t[0], 'role': 'presentation' }, [
|
||||
E('a', { 'href': '#', 'role': 'button', 'click': pick,
|
||||
@@ -247,7 +246,7 @@ return baseclass.extend({
|
||||
const st = this.m.stages;
|
||||
const arrow = () => E('span', { 'class': 'v-nft-arrow', 'aria-hidden': 'true' });
|
||||
const pass = () => E('span', { 'class': 'v-nft-pass', 'aria-hidden': 'true' });
|
||||
const end = (where) => E('span', { 'class': 'v-nft-endpoint', 'data-where': where }, [ where === 'device' ? _('This device', CTX) : _('Network', CTX) ]);
|
||||
const end = (where) => E('span', { 'class': 'v-nft-endpoint', 'data-where': where }, [ where === 'device' ? _('This device', 'vantage-theme') : _('Network') ]);
|
||||
const row = [ end('net') ];
|
||||
[ st.ingress, st.bridgeIn, st.prerouting ].filter(Boolean).forEach((s) => { row.push(arrow(), this.stage(s)); });
|
||||
row.push(arrow(), E('div', { 'class': 'v-nft-fork' }, [
|
||||
@@ -263,9 +262,9 @@ return baseclass.extend({
|
||||
|
||||
const notes = [];
|
||||
if (!st.bridged.chains.length)
|
||||
notes.push(E('p', { 'class': 'v-nft-note' }, [ _('Frames bridged between ports of one bridge, such as Wi-Fi and LAN in br-lan on an access point, never reach the input, forward or output chains, and no bridge chain filters them here. (The kernel can hand them to the IP chains if br_netfilter is enabled; the ruleset doesn\'t show that.)', CTX) ]));
|
||||
notes.push(E('p', { 'class': 'v-nft-note' }, [ _('Frames bridged between ports of one bridge, such as Wi-Fi and LAN in br-lan on an access point, never reach the input, forward or output chains, and no bridge chain filters them here. (The kernel can hand them to the IP chains if br_netfilter is enabled; the ruleset doesn\'t show that.)', 'vantage-theme') ]));
|
||||
if (this.m.offload)
|
||||
notes.push(E('p', { 'class': 'v-nft-note' }, [ _('Flow offloading is on: once a forwarded connection is established, its packets can skip prerouting, forward and postrouting, so these chains and their counters see only its first packets.', CTX) ]));
|
||||
notes.push(E('p', { 'class': 'v-nft-note' }, [ _('Flow offloading is on: once a forwarded connection is established, its packets can skip prerouting, forward and postrouting, so these chains and their counters see only its first packets.', 'vantage-theme') ]));
|
||||
|
||||
this.el = E('div', { 'class': 'v-nft-chart' }, [
|
||||
E('div', { 'class': 'v-nft-chart-row' }, row),
|
||||
@@ -291,11 +290,11 @@ return baseclass.extend({
|
||||
const none = s.hook === 'bridged' && !s.chains.length;
|
||||
const name = [ stageName(s) ];
|
||||
if (s.drops === 'all')
|
||||
name.push(E('small', {}, [ s.only ? _('drops unmatched %s', CTX).format(familyName(s.only)) : _('drops unmatched', CTX) ]));
|
||||
name.push(E('small', {}, [ s.only ? _('drops unmatched %s', 'vantage-theme').format(familyName(s.only)) : _('drops unmatched', 'vantage-theme') ]));
|
||||
else if (s.drops === 'some')
|
||||
name.push(E('small', {}, [ _('rules drop some', CTX) ]));
|
||||
name.push(E('small', {}, [ _('rules drop some', 'vantage-theme') ]));
|
||||
else if (none)
|
||||
name.push(E('small', {}, [ _('no bridge chains', CTX) ]));
|
||||
name.push(E('small', {}, [ _('no bridge chains', 'vantage-theme') ]));
|
||||
/* a name twice at one hook (docker, mwan3): say whose */
|
||||
const seen = Object.create(null);
|
||||
s.chains.forEach((k) => { seen[m.chains[k].name] = (seen[m.chains[k].name] || 0) + 1; });
|
||||
@@ -304,13 +303,13 @@ return baseclass.extend({
|
||||
E('div', { 'class': 'v-nft-stage-chains' }, list.length ? list.map((k) => {
|
||||
const c = m.chains[k];
|
||||
const b = E('button', { 'type': 'button', 'class': 'v-nft-link', 'click': () => this.show(k),
|
||||
'title': _('%s: table %s (%s), priority %d', CTX).format(c.name, c.table, familyName(c.family), c.prio) }, [ c.name ]);
|
||||
'title': _('%s: table %s (%s), priority %d', 'vantage-theme').format(c.name, c.table, familyName(c.family), c.prio) }, [ c.name ]);
|
||||
if (seen[c.name] > 1 || (nftmodel.HOOKS.indexOf(s.hook) < 0 && c.family !== 'netdev' && c.family !== 'bridge'))
|
||||
b.appendChild(E('small', {}, [ c.table + ' · ' + familyName(c.family) ]));
|
||||
else if (c.type === 'nat')
|
||||
b.appendChild(E('small', {}, [ _('NAT', CTX) ]));
|
||||
b.appendChild(E('small', {}, [ _('NAT') ]));
|
||||
return b;
|
||||
}) : none ? [] : [ E('span', { 'class': 'v-nft-none' }, [ _('no rules', CTX) ]) ])
|
||||
}) : none ? [] : [ E('span', { 'class': 'v-nft-none' }, [ _('no rules', 'vantage-theme') ]) ])
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -330,7 +329,7 @@ return baseclass.extend({
|
||||
const out = keys.filter((k) => regular ? c(k).rules.length : live(c(k))).map((k) => this.card(k));
|
||||
const empty = keys.filter((k) => regular ? !c(k).rules.length : !live(c(k)));
|
||||
if (empty.length)
|
||||
out.push(E('p', { 'class': 'v-nft-empty' }, [ _('No rules: %s', CTX).format(empty.map((k) => c(k).name).join(', ')) ]));
|
||||
out.push(E('p', { 'class': 'v-nft-empty' }, [ _('No rules: %s', 'vantage-theme').format(empty.map((k) => c(k).name).join(', ')) ]));
|
||||
return out;
|
||||
},
|
||||
|
||||
@@ -338,19 +337,19 @@ return baseclass.extend({
|
||||
const m = this.m, c = m.chains[k];
|
||||
const meta = [];
|
||||
if (c.base) {
|
||||
meta.push(hookName(c.hook), _('priority %d', CTX).format(c.prio));
|
||||
meta.push(hookName(c.hook), _('priority %d', 'vantage-theme').format(c.prio));
|
||||
if (c.family !== 'inet') meta.push(familyName(c.family));
|
||||
if (c.type === 'nat') meta.push(_('NAT', CTX));
|
||||
if (c.dev.length) meta.push(_('on %s', CTX).format(c.dev.join(', ')));
|
||||
if (c.type === 'nat') meta.push(_('NAT'));
|
||||
if (c.dev.length) meta.push(_('on %s', 'vantage-theme').format(c.dev.join(', ')));
|
||||
}
|
||||
meta.push(N_(c.rules.length, '%d rule', '%d rules').format(c.rules.length));
|
||||
meta.push(N_(c.rules.length, '%d rule', '%d rules', 'vantage-theme').format(c.rules.length));
|
||||
/* other chains at the same hook (and family group) still see what
|
||||
this one accepts */
|
||||
if (c.base) {
|
||||
const group = (f) => f === 'bridge' ? 'bridge' : f === 'netdev' ? 'netdev' : 'ip';
|
||||
const more = Object.keys(m.chains).filter((o) => o !== k && m.chains[o].base && m.chains[o].hook === c.hook
|
||||
&& group(m.chains[o].family) === group(c.family) && live(m.chains[o])).length;
|
||||
if (more) meta.push(N_(more, '%d more chain at this hook', '%d more chains at this hook').format(more));
|
||||
if (more) meta.push(N_(more, '%d more chain at this hook', '%d more chains at this hook', 'vantage-theme').format(more));
|
||||
}
|
||||
const card = E('details', { 'class': 'v-nft-card', 'id': this.ids[k], 'data-chain': c.name, 'data-family': c.family }, [
|
||||
E('summary', {}, [ E('strong', {}, [ c.name ]), E('span', { 'class': 'v-nft-meta' }, [ meta.join(' · ') ]) ].concat(c.base ? [ this.policyChip(c) ] : []))
|
||||
@@ -366,9 +365,9 @@ return baseclass.extend({
|
||||
},
|
||||
|
||||
policyChip(c) {
|
||||
if (c.policy === 'drop') return chip('drop', _('Drops unmatched', CTX));
|
||||
if (c.type === 'filter') return chip('accept', _('Passes unmatched', CTX));
|
||||
return chip('none', _('Leaves unmatched unchanged', CTX));
|
||||
if (c.policy === 'drop') return chip('drop', _('Drops unmatched', 'vantage-theme'));
|
||||
if (c.type === 'filter') return chip('accept', _('Passes unmatched', 'vantage-theme'));
|
||||
return chip('none', _('Leaves unmatched unchanged', 'vantage-theme'));
|
||||
},
|
||||
|
||||
/* where a packet goes when it leaves the chain without a verdict:
|
||||
@@ -376,12 +375,12 @@ return baseclass.extend({
|
||||
with none, the base chain's policy */
|
||||
away(path, stack) {
|
||||
const up = stack.length ? this.m.chains[stack[stack.length - 1]] : null;
|
||||
if (up) return chip('return', _('Back to %s', CTX).format(up.name));
|
||||
if (up) return chip('return', _('Back to %s', 'vantage-theme').format(up.name));
|
||||
const b = this.m.chains[path[0]];
|
||||
if (!b.base) return chip('return', _('Back to the calling chain', CTX));
|
||||
if (b.policy === 'drop') return chip('drop', _('Drop: %s policy', CTX).format(b.name));
|
||||
if (b.type === 'filter') return chip('accept', _('Pass: %s policy', CTX).format(b.name));
|
||||
return chip('none', _('Unchanged', CTX));
|
||||
if (!b.base) return chip('return', _('Back to the calling chain', 'vantage-theme'));
|
||||
if (b.policy === 'drop') return chip('drop', _('Drop: %s policy', 'vantage-theme').format(b.name));
|
||||
if (b.type === 'filter') return chip('accept', _('Pass: %s policy', 'vantage-theme').format(b.name));
|
||||
return chip('none', _('Unchanged', 'vantage-theme'));
|
||||
},
|
||||
|
||||
/* a chain's rules as steps. path: the chains opened above it (to stop
|
||||
@@ -397,7 +396,7 @@ return baseclass.extend({
|
||||
const also = row && r.verdict.kind !== 'vmap' && (nat || r.parts.acts > (r.parts.last >= 0 ? 1 : 0)) ? sideActions(row, r.parts, nat) : null;
|
||||
/* a rule with conditions LuCI's rows can't supply never reads
|
||||
as "Any packet" */
|
||||
const label = r.comment || (cond ? '' : r.parts.conds ? _('Conditions in the rules table', CTX) : _('Any packet', 'nft match any traffic'));
|
||||
const label = r.comment || (cond ? '' : r.parts.conds ? _('Conditions in the rules table', 'vantage-theme') : _('Any packet', 'nft match any traffic'));
|
||||
const what = [ E('div', { 'class': 'v-nft-what-name', 'data-muted': String(!r.comment && !cond && !!r.parts.conds) }, [ label ]) ];
|
||||
if (!r.comment && cond) what[0] = cond;
|
||||
else if (cond) what.push(cond);
|
||||
@@ -405,7 +404,7 @@ return baseclass.extend({
|
||||
const then = this.outcome(r.verdict, c, path, stack);
|
||||
if (r.counter)
|
||||
then.push(E('span', { 'class': 'v-nft-count', 'title': _('Traffic matched by rule: %.1000mPackets, %.1024mBytes', 'nft counter').format(r.counter.packets, r.counter.bytes) },
|
||||
[ _('%s matched', CTX).format('%.1024mB'.format(r.counter.bytes)) ]));
|
||||
[ _('%s matched', 'vantage-theme').format('%.1024mB'.format(r.counter.bytes)) ]));
|
||||
return E('li', { 'class': 'v-nft-step' }, [
|
||||
E('span', { 'class': 'v-nft-n' }, [ String(r.index + 1) ]),
|
||||
E('div', { 'class': 'v-nft-what' }, what),
|
||||
@@ -419,7 +418,7 @@ return baseclass.extend({
|
||||
E('button', { 'type': 'button', 'class': 'v-nft-verdict v-nft-jump', 'click': () => {
|
||||
c.rules.slice(MAX_STEPS).forEach((r) => list.insertBefore(step(r), more));
|
||||
more.remove();
|
||||
} }, [ N_(c.rules.length - MAX_STEPS, 'Show %d more rule', 'Show %d more rules').format(c.rules.length - MAX_STEPS) ])
|
||||
} }, [ N_(c.rules.length - MAX_STEPS, 'Show %d more rule', 'Show %d more rules', 'vantage-theme').format(c.rules.length - MAX_STEPS) ])
|
||||
]);
|
||||
list.appendChild(more);
|
||||
}
|
||||
@@ -433,10 +432,10 @@ return baseclass.extend({
|
||||
const tk = nftmodel.keyOf(c.family, c.table, target);
|
||||
const label = (key != null ? key + ' → ' : '→ ') + target;
|
||||
if (!this.m.chains[tk]) return chip(kind, label);
|
||||
if (path.indexOf(tk) >= 0) return chip(kind, label + ' ' + _('(open above)', CTX));
|
||||
if (path.length >= MAX_DEPTH) return chip(kind, label + ' ' + _('(too deep to open here)', CTX));
|
||||
if (path.indexOf(tk) >= 0) return chip(kind, label + ' ' + _('(open above)', 'vantage-theme'));
|
||||
if (path.length >= MAX_DEPTH) return chip(kind, label + ' ' + _('(too deep to open here)', 'vantage-theme'));
|
||||
const b = E('button', { 'type': 'button', 'class': 'v-nft-verdict v-nft-jump', 'data-kind': kind, 'aria-expanded': 'false',
|
||||
'title': kind === 'goto' ? _('Continues in %s and does not come back here', CTX).format(target) : _('Continues in %s, then comes back here', CTX).format(target) }, [ label ]);
|
||||
'title': kind === 'goto' ? _('Continues in %s and does not come back here', 'vantage-theme').format(target) : _('Continues in %s, then comes back here', 'vantage-theme').format(target) }, [ label ]);
|
||||
let sub = null;
|
||||
b.addEventListener('click', () => {
|
||||
if (!sub) {
|
||||
@@ -452,7 +451,7 @@ return baseclass.extend({
|
||||
};
|
||||
if (v.kind === 'return') return [ this.away(path, stack) ];
|
||||
if (v.kind === 'jump' || v.kind === 'goto') return [ jump(v.kind, v.target) ];
|
||||
if (v.kind === 'vmap' && v.named) return [ chip('none', _('Verdict map %s', CTX).format(v.named)) ];
|
||||
if (v.kind === 'vmap' && v.named) return [ chip('none', _('Verdict map %s', 'vantage-theme').format(v.named)) ];
|
||||
if (v.kind === 'vmap') {
|
||||
/* keys with the same verdict share a chip: "established, related → Accept" */
|
||||
const out = [], same = Object.create(null);
|
||||
@@ -473,7 +472,7 @@ return baseclass.extend({
|
||||
end(path, stack) {
|
||||
return E('li', { 'class': 'v-nft-step v-nft-step-end' }, [
|
||||
E('span', { 'class': 'v-nft-n', 'aria-hidden': 'true' }),
|
||||
E('div', { 'class': 'v-nft-what' }, [ _('No rule matched', CTX) ]),
|
||||
E('div', { 'class': 'v-nft-what' }, [ _('No rule matched', 'vantage-theme') ]),
|
||||
E('div', { 'class': 'v-nft-then' }, [ this.away(path, stack) ])
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -785,6 +785,8 @@ a.vt-crumb:hover { color: var(--text); }
|
||||
padding: 10px !important;
|
||||
border: none !important;
|
||||
}
|
||||
/* Card padding supplies the space after the final paragraph. */
|
||||
.cbi-section > p:last-child { margin-bottom: 0; }
|
||||
/* card header row */
|
||||
.cbi-section > h3:first-child, .cbi-section > legend:first-child, .cbi-section > .cbi-title:first-child,
|
||||
.cbi-section > .cbi-section-remove:first-child + .cbi-title {
|
||||
@@ -1299,6 +1301,19 @@ input[type="button"], input[type="reset"], input[type="submit"]) {
|
||||
:is(.btn, .cbi-button, .cbi-dropdown).cbi-button-save.important:hover {
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
/* LuCI's emphasis without .important (Bootstrap outlines these, apps mark
|
||||
Open / Restart / Refresh with them): the signal as text and line on the
|
||||
plain fill. In table rows only Edit keeps it: a row of Start / Restart /
|
||||
Reload / Stop on every line (System -> Startup) stays plain */
|
||||
:is(.btn, .cbi-button):is(.cbi-button-action, .cbi-button-reload, .cbi-button-find, .cbi-button-link, .cbi-button-download):not(.important, .cbi-dropdown, .td *),
|
||||
:is(.btn, .cbi-button).cbi-button-edit:not(.important, .cbi-dropdown) {
|
||||
border-color: var(--accent-line);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
:is(.btn, .cbi-button):is(.cbi-button-action, .cbi-button-reload, .cbi-button-find, .cbi-button-link, .cbi-button-download):not(.important, .cbi-dropdown, .td *):hover,
|
||||
:is(.btn, .cbi-button).cbi-button-edit:not(.important, .cbi-dropdown):hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.cbi-value-field :is(.btn, .cbi-button).cbi-button-apply,
|
||||
.td :is(.btn, .cbi-button).cbi-button-apply {
|
||||
background: var(--accent-soft); border-color: var(--accent-line); color: var(--accent-text); font-weight: 500;
|
||||
@@ -1422,6 +1437,9 @@ table { border-collapse: separate; border-spacing: 0; width: 100%; }
|
||||
:is(.cbi-section, .cbi-value) .table .tr > :is(.th, .td):first-child { padding-left: var(--card-px); }
|
||||
:is(.cbi-section, .cbi-value) .table .tr > :is(.th, .td):last-child { padding-right: var(--card-px); }
|
||||
.cbi-section > .table:last-child, .cbi-section > div:last-child > .table:last-child { margin-bottom: calc(-1 * var(--card-py) + 4px); }
|
||||
/* Nested sections have no bottom padding for the table to bleed into. */
|
||||
.cbi-section .cbi-section > .table:last-child,
|
||||
.cbi-section .cbi-section > div:last-child > .table:last-child { margin-bottom: 0; }
|
||||
.table .tr.table-titles > .th, .table .cbi-section-table-titles > .th, thead .th {
|
||||
position: sticky; top: var(--stick-top); z-index: 20;
|
||||
}
|
||||
@@ -1550,7 +1568,9 @@ div.right > .btn, div.right > .cbi-button, div.right > button { margin-left: 0;
|
||||
}
|
||||
:is(.cbi-tabmenu > li > a, .vt-pagetabs > .vt-tab):hover { color: var(--text); }
|
||||
:is(.cbi-tabmenu > li > a, .vt-pagetabs > .vt-tab):focus-visible { outline-offset: -1px; border-radius: calc(var(--r) + 3px); }
|
||||
.cbi-tabmenu > li.cbi-tab > a, .vt-pagetabs > .vt-tab[aria-current] { background-color: var(--seg-on); color: var(--text); font-weight: 600; box-shadow: var(--seg-on-ring); }
|
||||
/* the selected segment carries the signal (text and ring): white on the
|
||||
grey track alone was hard to pick out; hover keeps it */
|
||||
.cbi-tabmenu > li.cbi-tab > a, .cbi-tabmenu > li.cbi-tab > a:hover, .vt-pagetabs > .vt-tab[aria-current] { background-color: var(--seg-on); color: var(--accent-text); font-weight: 600; box-shadow: inset 0 0 0 1px var(--accent-line); }
|
||||
.cbi-tabmenu > li[data-errors] > a::after {
|
||||
content: attr(data-errors);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
@@ -1685,6 +1705,45 @@ div:has(> .ifacebox > .ifacebox-head.cbi-tooltip-container) > .ifacebox { width:
|
||||
.ifacebox-head.active { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.ifacebox-body { padding: 8px 10px; font-size: var(--fs-sm); color: var(--text-2); }
|
||||
.ifacebox-body img { width: 18px; height: 18px; }
|
||||
/* Status -> Port status, each box: a link dot by the name (as on the
|
||||
upstream and radio cards), the speed as its figure, the zone as a bare
|
||||
bar and the counters' arrows in the dashboard's upload and download
|
||||
colours (menu-vantage.js puts them in spans). LuCI's port picture only
|
||||
repeats the link state: hidden. A port without link is muted throughout */
|
||||
.ifacebox:has(> .ifacebox-head.cbi-tooltip-container) > .ifacebox-head:first-child {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 10px 2px;
|
||||
background: none; border-bottom: 0;
|
||||
font-family: var(--mono); font-weight: 650 !important; text-align: left;
|
||||
}
|
||||
.ifacebox:has(> .ifacebox-head.cbi-tooltip-container) > .ifacebox-head:first-child::before {
|
||||
content: ""; flex: none;
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 1.5px var(--text-3);
|
||||
}
|
||||
.ifacebox:has(> .ifacebox-body > img[src$="_down.svg"]) > .ifacebox-head:first-child { color: var(--text-2); }
|
||||
.ifacebox:has(> .ifacebox-body > img[src$="_up.svg"]) > .ifacebox-head:first-child::before { background: var(--v-chart-1); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.ifacebox:has(> .ifacebox-head.cbi-tooltip-container) > .ifacebox-head:first-child + .ifacebox-body { padding: 2px 10px 6px; color: var(--text-3); }
|
||||
.ifacebox:has(> .ifacebox-body > img[src$="_up.svg"]) > .ifacebox-head:first-child + .ifacebox-body { color: var(--text-2); }
|
||||
.ifacebox:has(> .ifacebox-head.cbi-tooltip-container) > .ifacebox-head:first-child + .ifacebox-body > :is(img, br) { display: none; }
|
||||
.ifacebox:has(> .ifacebox-head.cbi-tooltip-container) > .ifacebox-head:first-child + .ifacebox-body > span {
|
||||
color: var(--text);
|
||||
font: 600 var(--fs-md)/1.4 var(--font); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ifacebox-head.cbi-tooltip-container:has(> .zonebadge) { gap: 3px; padding: 4px 10px; background: none; border-bottom: 0; }
|
||||
.ifacebox-head.cbi-tooltip-container > .zonebadge[style*="--zone-color-rgb"] {
|
||||
height: 3px !important; padding: 0;
|
||||
border: 0; border-radius: 2px; box-shadow: none;
|
||||
background-color: rgb(var(--zone-color-rgb)) !important;
|
||||
}
|
||||
.ifacebox-head.cbi-tooltip-container > .zonebadge:is([style*="--zone-color-rgb: 238, 238, 238"], [style*="--zone-color-rgb:238, 238, 238"], [style*="--zone-color-rgb:238,238,238"]) { background-color: var(--text-3) !important; }
|
||||
.ifacebox-head.cbi-tooltip-container + .ifacebox-body { padding: 4px 10px 10px; color: var(--text-2); font-variant-numeric: tabular-nums; }
|
||||
/* the PoE power line comes inline as black: black on the dark card */
|
||||
.ifacebox-head.cbi-tooltip-container + .ifacebox-body > .cbi-tooltip-container > span[style*="color:#000"] { color: inherit !important; }
|
||||
.ifacebox:has(> .ifacebox-body > img[src$="_down.svg"]) > .ifacebox-head.cbi-tooltip-container + .ifacebox-body { color: var(--text-3); }
|
||||
.vt-port-dir[data-dir="tx"] { color: var(--v-chart-5); }
|
||||
.vt-port-dir[data-dir="rx"] { color: var(--v-chart-1); }
|
||||
.ifacebox:has(> .ifacebox-body > img[src$="_down.svg"]) .vt-port-dir { color: inherit; }
|
||||
|
||||
.network-status-table > .ifacebox { display: flex; }
|
||||
.network-status-table .ifacebox-head {
|
||||
|
||||
@@ -1,59 +1,67 @@
|
||||
msgid ""
|
||||
msgstr "Content-Type: text/plain; charset=UTF-8"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:70
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:69
|
||||
msgctxt "Yet unknown nftables table family (\"family\" table \"name\")"
|
||||
msgid "\"%h\" table \"%h\""
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:353
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:352
|
||||
msgctxt "vantage-theme"
|
||||
msgid "%d more chain at this hook"
|
||||
msgid_plural "%d more chains at this hook"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:346
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:345
|
||||
msgctxt "vantage-theme"
|
||||
msgid "%d rule"
|
||||
msgid_plural "%d rules"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:408
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:407
|
||||
msgctxt "vantage-theme"
|
||||
msgid "%s matched"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:307
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:306
|
||||
msgctxt "vantage-theme"
|
||||
msgid "%s: table %s (%s), priority %d"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:436
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:435
|
||||
msgctxt "vantage-theme"
|
||||
msgid "(open above)"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:437
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:436
|
||||
msgctxt "vantage-theme"
|
||||
msgid "(too deep to open here)"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:67
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:66
|
||||
msgid "ARP traffic table \"%h\""
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:156
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:155
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Accept"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1094
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1107
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:400
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:399
|
||||
msgctxt "nft match any traffic"
|
||||
msgid "Any packet"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1090
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1091
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1092
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1103
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1104
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1105
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Appearance"
|
||||
msgstr ""
|
||||
|
||||
@@ -61,31 +69,36 @@ msgstr ""
|
||||
msgid "Authorization Required"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1273
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1286
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:379
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:378
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Back to %s"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:381
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:380
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Back to the calling chain"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:69
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Breadcrumb"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:51
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:50
|
||||
msgid "Bridge"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:68
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:67
|
||||
msgid "Bridge traffic table \"%h\""
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:52
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:51
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Bridged between ports"
|
||||
msgstr ""
|
||||
|
||||
@@ -103,15 +116,18 @@ msgstr ""
|
||||
msgid "Cached"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:209
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:208
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Chains"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:217
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:216
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Chains no rule jumps to"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:217
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:216
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Chains no rule jumps to directly"
|
||||
msgstr ""
|
||||
|
||||
@@ -119,76 +135,87 @@ msgstr ""
|
||||
msgid "Channel"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:745
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:758
|
||||
msgid "Collapse navigation"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1274
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1287
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:81
|
||||
msgid "Colour theme"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1115
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1128
|
||||
msgid "Command palette"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:400
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:399
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Conditions in the rules table"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:439
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:438
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Continues in %s and does not come back here"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:439
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:438
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Continues in %s, then comes back here"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1273
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1286
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Dark"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:157
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:156
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Drop"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:382
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:381
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Drop: %s policy"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:369
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:368
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Drops unmatched"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:44
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:43
|
||||
msgid "Egress"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:745
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:758
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:54
|
||||
msgid "Expand navigation"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:232
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:231
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Flow"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:268
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:267
|
||||
msgctxt "vantage-theme"
|
||||
msgid ""
|
||||
"Flow offloading is on: once a forwarded connection is established, its "
|
||||
"packets can skip prerouting, forward and postrouting, so these chains and "
|
||||
"their counters see only its first packets."
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1092
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1105
|
||||
msgid "Follow system theme"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:40
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:39
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Forward"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:266
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:265
|
||||
msgctxt "vantage-theme"
|
||||
msgid ""
|
||||
"Frames bridged between ports of one bridge, such as Wi-Fi and LAN in br-lan "
|
||||
"on an access point, never reach the input, forward or output chains, and no "
|
||||
@@ -198,36 +225,40 @@ msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:290
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:304
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Free"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:48
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Home"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:64
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:63
|
||||
msgid "IPv4 traffic table \"%h\""
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:66
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:65
|
||||
msgid "IPv4/IPv6 traffic table \"%h\""
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:65
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:64
|
||||
msgid "IPv6 traffic table \"%h\""
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:289
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:298
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:302
|
||||
msgctxt "vantage-theme"
|
||||
msgid "In use"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:43
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:42
|
||||
msgid "Ingress"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:39
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:38
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Input"
|
||||
msgstr ""
|
||||
|
||||
@@ -235,15 +266,17 @@ msgstr ""
|
||||
msgid "Invalid username and/or password! Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1111
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1124
|
||||
msgid "Jump to a page or action…"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:371
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:370
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Leaves unmatched unchanged"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1273
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1286
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Light"
|
||||
msgstr ""
|
||||
|
||||
@@ -252,16 +285,18 @@ msgstr ""
|
||||
msgid "Log in"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1093
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1106
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:84
|
||||
msgid "Log out"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:47
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Main navigation"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:161
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:160
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Masquerade"
|
||||
msgstr ""
|
||||
|
||||
@@ -270,49 +305,55 @@ msgstr ""
|
||||
msgid "Memory"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1502
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1519
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1515
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1532
|
||||
msgctxt "vantage-theme"
|
||||
msgid "More actions"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:311
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:343
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:310
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:342
|
||||
msgid "NAT"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:250
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:249
|
||||
msgid "Network"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:69
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:68
|
||||
msgid "Network device table \"%h\""
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:163
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:162
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Next rule"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1186
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1199
|
||||
msgid "No matching pages"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:476
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:475
|
||||
msgctxt "vantage-theme"
|
||||
msgid "No rule matched"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:333
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:332
|
||||
msgctxt "vantage-theme"
|
||||
msgid "No rules: %s"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1020
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1033
|
||||
msgid "Other pages in %s"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:41
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:40
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Output"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:207
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:206
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Packet path"
|
||||
msgstr ""
|
||||
|
||||
@@ -320,11 +361,13 @@ msgstr ""
|
||||
msgid "Page tabs"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:383
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:382
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Pass: %s policy"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:370
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:369
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Passes unmatched"
|
||||
msgstr ""
|
||||
|
||||
@@ -332,71 +375,82 @@ msgstr ""
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:42
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:41
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Postrouting"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:38
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:37
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Prerouting"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:894
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:907
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Press"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:159
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:158
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Queue to a program"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:162
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:161
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Redirect"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:158
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:157
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Reject"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1113
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1126
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Results"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:162
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:161
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Rewrite destination"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:161
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:160
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Rewrite source"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:232
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:231
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Rules table"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1111
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1124
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:72
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:74
|
||||
msgid "Search pages"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1093
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1106
|
||||
msgid "Session"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:422
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:421
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Show %d more rule"
|
||||
msgid_plural "Show %d more rules"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1656
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1669
|
||||
msgid "Show full description"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1645
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1658
|
||||
msgid "Show full help"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1645
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1658
|
||||
msgid "Show full help: %s"
|
||||
msgstr ""
|
||||
|
||||
@@ -410,6 +464,7 @@ msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:311
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:313
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Swap"
|
||||
msgstr ""
|
||||
|
||||
@@ -417,11 +472,13 @@ msgstr ""
|
||||
msgid "Swap free"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:250
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:249
|
||||
msgctxt "vantage-theme"
|
||||
msgid "This device"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:290
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Total"
|
||||
msgstr ""
|
||||
|
||||
@@ -429,20 +486,21 @@ msgstr ""
|
||||
msgid "Total Available"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:407
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:406
|
||||
msgctxt "nft counter"
|
||||
msgid "Traffic matched by rule: %.1000mPackets, %.1024mBytes"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:384
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:383
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Unchanged"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1090
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1103
|
||||
msgid "Use dark theme"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1091
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1104
|
||||
msgid "Use light theme"
|
||||
msgstr ""
|
||||
|
||||
@@ -454,11 +512,13 @@ msgstr ""
|
||||
msgid "Username"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:455
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:454
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Verdict map %s"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:205
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:204
|
||||
msgctxt "vantage-theme"
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
@@ -467,18 +527,21 @@ msgid "Wireless"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:294
|
||||
msgctxt "vantage-theme"
|
||||
msgid "available"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1121
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1134
|
||||
msgid "close"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:294
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:293
|
||||
msgctxt "vantage-theme"
|
||||
msgid "drops unmatched"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:294
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:293
|
||||
msgctxt "vantage-theme"
|
||||
msgid "drops unmatched %s"
|
||||
msgstr ""
|
||||
|
||||
@@ -487,41 +550,47 @@ msgstr ""
|
||||
msgid "free"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1188
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1201
|
||||
msgctxt "vantage-theme"
|
||||
msgid "items"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1119
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1132
|
||||
msgid "navigate"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:298
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:297
|
||||
msgctxt "vantage-theme"
|
||||
msgid "no bridge chains"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:313
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:312
|
||||
msgctxt "vantage-theme"
|
||||
msgid "no rules"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:344
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:343
|
||||
msgctxt "vantage-theme"
|
||||
msgid "on %s"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1120
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1133
|
||||
msgctxt "vantage-theme"
|
||||
msgid "open"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:874
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:948
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:887
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:961
|
||||
msgid "page"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:874
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:948
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:887
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:961
|
||||
msgid "pages"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:341
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:340
|
||||
msgctxt "vantage-theme"
|
||||
msgid "priority %d"
|
||||
msgstr ""
|
||||
|
||||
@@ -531,11 +600,12 @@ msgstr ""
|
||||
msgid "reclaimable"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:296
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:295
|
||||
msgctxt "vantage-theme"
|
||||
msgid "rules drop some"
|
||||
msgstr ""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:894
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:907
|
||||
msgid "to search all pages"
|
||||
msgstr ""
|
||||
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
zh_Hans
|
||||
@@ -0,0 +1,838 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: luci-theme-vantage\n"
|
||||
"PO-Revision-Date: \n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: zh_Hans\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:69
|
||||
msgctxt "Yet unknown nftables table family (\"family\" table \"name\")"
|
||||
msgid "\"%h\" table \"%h\""
|
||||
msgstr "\"%h\" 表 \"%h\""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:352
|
||||
msgctxt "vantage-theme"
|
||||
msgid "%d more chain at this hook"
|
||||
msgid_plural "%d more chains at this hook"
|
||||
msgstr[0] "此钩子还有 %d 条链"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:345
|
||||
msgctxt "vantage-theme"
|
||||
msgid "%d rule"
|
||||
msgid_plural "%d rules"
|
||||
msgstr[0] "%d 条规则"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:407
|
||||
msgctxt "vantage-theme"
|
||||
msgid "%s matched"
|
||||
msgstr "已匹配 %s"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:306
|
||||
msgctxt "vantage-theme"
|
||||
msgid "%s: table %s (%s), priority %d"
|
||||
msgstr "%s:表 %s(%s),优先级 %d"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:435
|
||||
msgctxt "vantage-theme"
|
||||
msgid "(open above)"
|
||||
msgstr "(上方已展开)"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:436
|
||||
msgctxt "vantage-theme"
|
||||
msgid "(too deep to open here)"
|
||||
msgstr "(层级过深,不在此展开)"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:66
|
||||
msgid "ARP traffic table \"%h\""
|
||||
msgstr "ARP 流量表 \"%h\""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:155
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Accept"
|
||||
msgstr "接受"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1107
|
||||
msgid "Actions"
|
||||
msgstr "操作"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:399
|
||||
msgctxt "nft match any traffic"
|
||||
msgid "Any packet"
|
||||
msgstr "任意数据包"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1103
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1104
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1105
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Appearance"
|
||||
msgstr "外观"
|
||||
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:43
|
||||
msgid "Authorization Required"
|
||||
msgstr "需要授权"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1286
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Auto"
|
||||
msgstr "自动"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:378
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Back to %s"
|
||||
msgstr "返回 %s"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:380
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Back to the calling chain"
|
||||
msgstr "返回调用链"
|
||||
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:69
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Breadcrumb"
|
||||
msgstr "面包屑"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:50
|
||||
msgid "Bridge"
|
||||
msgstr "桥接"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:67
|
||||
msgid "Bridge traffic table \"%h\""
|
||||
msgstr "桥接流量表 \"%h\""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:51
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Bridged between ports"
|
||||
msgstr "端口间桥接"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:278
|
||||
msgid "Buffered"
|
||||
msgstr "已缓冲"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:289
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:299
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:303
|
||||
msgid "Cache"
|
||||
msgstr "缓存"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:278
|
||||
msgid "Cached"
|
||||
msgstr "已缓存"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:208
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Chains"
|
||||
msgstr "链"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:216
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Chains no rule jumps to"
|
||||
msgstr "没有规则跳转到的链"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:216
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Chains no rule jumps to directly"
|
||||
msgstr "没有规则直接跳转到的链"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:359
|
||||
msgid "Channel"
|
||||
msgstr "信道"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:758
|
||||
msgid "Collapse navigation"
|
||||
msgstr "收起导航"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1287
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:81
|
||||
msgid "Colour theme"
|
||||
msgstr "颜色主题"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1128
|
||||
msgid "Command palette"
|
||||
msgstr "命令面板"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:399
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Conditions in the rules table"
|
||||
msgstr "条件见规则表"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:438
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Continues in %s and does not come back here"
|
||||
msgstr "转入 %s,不再返回此处"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:438
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Continues in %s, then comes back here"
|
||||
msgstr "转入 %s,随后返回此处"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1286
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Dark"
|
||||
msgstr "深色"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:156
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Drop"
|
||||
msgstr "丢弃"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:381
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Drop: %s policy"
|
||||
msgstr "丢弃:%s 策略"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:368
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Drops unmatched"
|
||||
msgstr "丢弃未匹配流量"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:43
|
||||
msgid "Egress"
|
||||
msgstr "出口"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:758
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:54
|
||||
msgid "Expand navigation"
|
||||
msgstr "展开导航"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:231
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Flow"
|
||||
msgstr "流向"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:267
|
||||
msgctxt "vantage-theme"
|
||||
msgid ""
|
||||
"Flow offloading is on: once a forwarded connection is established, its "
|
||||
"packets can skip prerouting, forward and postrouting, so these chains and "
|
||||
"their counters see only its first packets."
|
||||
msgstr ""
|
||||
"流量卸载已开启:一条转发连接建立后,它的数据包可以跳过 prerouting、forward "
|
||||
"和 postrouting,因此这些链和它们的计数器只会看到该连接最初的数据包。"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1105
|
||||
msgid "Follow system theme"
|
||||
msgstr "跟随系统主题"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:39
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Forward"
|
||||
msgstr "转发"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:265
|
||||
msgctxt "vantage-theme"
|
||||
msgid ""
|
||||
"Frames bridged between ports of one bridge, such as Wi-Fi and LAN in br-lan "
|
||||
"on an access point, never reach the input, forward or output chains, and no "
|
||||
"bridge chain filters them here. (The kernel can hand them to the IP chains "
|
||||
"if br_netfilter is enabled; the ruleset doesn't show that.)"
|
||||
msgstr ""
|
||||
"同一网桥的端口之间桥接的帧(例如接入点上 br-lan 里的 Wi-Fi 和 LAN)永远不会到"
|
||||
"达 input、forward 或 output 链,这里也没有桥接链会过滤它们。(启用 "
|
||||
"br_netfilter 时内核可以把它们交给 IP 链;规则集不体现这一点。)"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:290
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:304
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Free"
|
||||
msgstr "空闲"
|
||||
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:48
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Home"
|
||||
msgstr "首页"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:63
|
||||
msgid "IPv4 traffic table \"%h\""
|
||||
msgstr "IPv4 流量表 \"%h\""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:65
|
||||
msgid "IPv4/IPv6 traffic table \"%h\""
|
||||
msgstr "IPv4/IPv6 流量表 \"%h\""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:64
|
||||
msgid "IPv6 traffic table \"%h\""
|
||||
msgstr "IPv6 流量表 \"%h\""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:289
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:298
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:302
|
||||
msgctxt "vantage-theme"
|
||||
msgid "In use"
|
||||
msgstr "已用"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:42
|
||||
msgid "Ingress"
|
||||
msgstr "入口"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:38
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Input"
|
||||
msgstr "输入"
|
||||
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:48
|
||||
msgid "Invalid username and/or password! Please try again."
|
||||
msgstr "用户名或密码错误,请重试。"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1124
|
||||
msgid "Jump to a page or action…"
|
||||
msgstr "跳转到页面或操作…"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:370
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Leaves unmatched unchanged"
|
||||
msgstr "未匹配流量保持原样"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1286
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Light"
|
||||
msgstr "浅色"
|
||||
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:23
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:60
|
||||
msgid "Log in"
|
||||
msgstr "登录"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1106
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:84
|
||||
msgid "Log out"
|
||||
msgstr "退出登录"
|
||||
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:47
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Main navigation"
|
||||
msgstr "主导航"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:160
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Masquerade"
|
||||
msgstr "伪装(NAT)"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:273
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:288
|
||||
msgid "Memory"
|
||||
msgstr "内存"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1515
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1532
|
||||
msgctxt "vantage-theme"
|
||||
msgid "More actions"
|
||||
msgstr "更多操作"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:310
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:342
|
||||
msgid "NAT"
|
||||
msgstr "NAT"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:249
|
||||
msgid "Network"
|
||||
msgstr "网络"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:68
|
||||
msgid "Network device table \"%h\""
|
||||
msgstr "网络设备表 \"%h\""
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:162
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Next rule"
|
||||
msgstr "下一条规则"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1199
|
||||
msgid "No matching pages"
|
||||
msgstr "没有匹配的页面"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:475
|
||||
msgctxt "vantage-theme"
|
||||
msgid "No rule matched"
|
||||
msgstr "没有规则匹配"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:332
|
||||
msgctxt "vantage-theme"
|
||||
msgid "No rules: %s"
|
||||
msgstr "无规则:%s"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1033
|
||||
msgid "Other pages in %s"
|
||||
msgstr "%s 中的其他页面"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:40
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Output"
|
||||
msgstr "输出"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:206
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Packet path"
|
||||
msgstr "数据包路径"
|
||||
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:90
|
||||
msgid "Page tabs"
|
||||
msgstr "页面标签页"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:382
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Pass: %s policy"
|
||||
msgstr "放行:%s 策略"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:369
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Passes unmatched"
|
||||
msgstr "放行未匹配流量"
|
||||
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:57
|
||||
msgid "Password"
|
||||
msgstr "密码"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:41
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Postrouting"
|
||||
msgstr "路由后"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:37
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Prerouting"
|
||||
msgstr "路由前"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:907
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Press"
|
||||
msgstr "按"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:158
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Queue to a program"
|
||||
msgstr "排队交给程序"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:161
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Redirect"
|
||||
msgstr "重定向"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:157
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Reject"
|
||||
msgstr "拒绝"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1126
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Results"
|
||||
msgstr "结果"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:161
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Rewrite destination"
|
||||
msgstr "改写目的地址"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:160
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Rewrite source"
|
||||
msgstr "改写源地址"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:231
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Rules table"
|
||||
msgstr "规则表"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1124
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:72
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:74
|
||||
msgid "Search pages"
|
||||
msgstr "搜索页面"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1106
|
||||
msgid "Session"
|
||||
msgstr "会话"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:421
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Show %d more rule"
|
||||
msgid_plural "Show %d more rules"
|
||||
msgstr[0] "再显示 %d 条规则"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1669
|
||||
msgid "Show full description"
|
||||
msgstr "显示完整描述"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1658
|
||||
msgid "Show full help"
|
||||
msgstr "显示完整帮助"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1658
|
||||
msgid "Show full help: %s"
|
||||
msgstr "显示完整帮助:%s"
|
||||
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/header.ut:44
|
||||
msgid "Skip to content"
|
||||
msgstr "跳至正文"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:325
|
||||
msgid "Storage"
|
||||
msgstr "存储"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:311
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:313
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Swap"
|
||||
msgstr "交换空间"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:278
|
||||
msgid "Swap free"
|
||||
msgstr "空闲交换空间"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:249
|
||||
msgctxt "vantage-theme"
|
||||
msgid "This device"
|
||||
msgstr "本设备"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:290
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Total"
|
||||
msgstr "总计"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:277
|
||||
msgid "Total Available"
|
||||
msgstr "总可用"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:406
|
||||
msgctxt "nft counter"
|
||||
msgid "Traffic matched by rule: %.1000mPackets, %.1024mBytes"
|
||||
msgstr "规则已匹配流量:%.1000m个数据包、%.1024m字节"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:383
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Unchanged"
|
||||
msgstr "不变"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1103
|
||||
msgid "Use dark theme"
|
||||
msgstr "使用深色主题"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1104
|
||||
msgid "Use light theme"
|
||||
msgstr "使用浅色主题"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:277
|
||||
msgid "Used"
|
||||
msgstr "已用"
|
||||
|
||||
#: luci-theme-vantage/ucode/template/themes/vantage/sysauth.ut:53
|
||||
msgid "Username"
|
||||
msgstr "用户名"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:454
|
||||
msgctxt "vantage-theme"
|
||||
msgid "Verdict map %s"
|
||||
msgstr "判定映射 %s"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:204
|
||||
msgctxt "vantage-theme"
|
||||
msgid "View"
|
||||
msgstr "视图"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:352
|
||||
msgid "Wireless"
|
||||
msgstr "无线"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:294
|
||||
msgctxt "vantage-theme"
|
||||
msgid "available"
|
||||
msgstr "可用"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1134
|
||||
msgid "close"
|
||||
msgstr "关闭"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:293
|
||||
msgctxt "vantage-theme"
|
||||
msgid "drops unmatched"
|
||||
msgstr "丢弃未匹配流量"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:293
|
||||
msgctxt "vantage-theme"
|
||||
msgid "drops unmatched %s"
|
||||
msgstr "丢弃未匹配的 %s"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:340
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:343
|
||||
msgid "free"
|
||||
msgstr "空闲"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1201
|
||||
msgctxt "vantage-theme"
|
||||
msgid "items"
|
||||
msgstr "条"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1132
|
||||
msgid "navigate"
|
||||
msgstr "移动"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:297
|
||||
msgctxt "vantage-theme"
|
||||
msgid "no bridge chains"
|
||||
msgstr "无桥接链"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:312
|
||||
msgctxt "vantage-theme"
|
||||
msgid "no rules"
|
||||
msgstr "无规则"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:343
|
||||
msgctxt "vantage-theme"
|
||||
msgid "on %s"
|
||||
msgstr "接口 %s"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:1133
|
||||
msgctxt "vantage-theme"
|
||||
msgid "open"
|
||||
msgstr "打开"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:887
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:961
|
||||
msgid "page"
|
||||
msgstr "页"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:887
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:961
|
||||
msgid "pages"
|
||||
msgstr "页"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:340
|
||||
msgctxt "vantage-theme"
|
||||
msgid "priority %d"
|
||||
msgstr "优先级 %d"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:289
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:299
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:303
|
||||
msgid "reclaimable"
|
||||
msgstr "可回收"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/vantage-theme/nftflow.js:295
|
||||
msgctxt "vantage-theme"
|
||||
msgid "rules drop some"
|
||||
msgstr "部分规则会丢弃"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:907
|
||||
msgid "to search all pages"
|
||||
msgstr "搜索所有页面"
|
||||
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:340
|
||||
#: luci-theme-vantage/htdocs/luci-static/resources/menu-vantage.js:343
|
||||
msgid "used"
|
||||
msgstr "已用"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "ARP traffic table \"%h\""
|
||||
#~ msgstr "ARP 流量表 \"%h\""
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Actions"
|
||||
#~ msgstr "操作"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Authorization Required"
|
||||
#~ msgstr "需要授权"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Bridge"
|
||||
#~ msgstr "桥接"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Bridge traffic table \"%h\""
|
||||
#~ msgstr "桥接流量表 \"%h\""
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Cache"
|
||||
#~ msgstr "缓存"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Egress"
|
||||
#~ msgstr "出口"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "IPv4 traffic table \"%h\""
|
||||
#~ msgstr "IPv4 流量表 \"%h\""
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "IPv4/IPv6 traffic table \"%h\""
|
||||
#~ msgstr "IPv4/IPv6 流量表 \"%h\""
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "IPv6 traffic table \"%h\""
|
||||
#~ msgstr "IPv6 流量表 \"%h\""
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Ingress"
|
||||
#~ msgstr "入口"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Invalid username and/or password! Please try again."
|
||||
#~ msgstr "用户名或密码错误,请重试。"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Log in"
|
||||
#~ msgstr "登录"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Log out"
|
||||
#~ msgstr "退出登录"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "NAT"
|
||||
#~ msgstr "NAT"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Network"
|
||||
#~ msgstr "网络"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Network device table \"%h\""
|
||||
#~ msgstr "网络设备表 \"%h\""
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Password"
|
||||
#~ msgstr "密码"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Skip to content"
|
||||
#~ msgstr "跳至正文"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Username"
|
||||
#~ msgstr "用户名"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Buffered"
|
||||
#~ msgstr "已缓冲"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Cached"
|
||||
#~ msgstr "已缓存"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Channel"
|
||||
#~ msgstr "信道"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Collapse navigation"
|
||||
#~ msgstr "收起导航"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Colour theme"
|
||||
#~ msgstr "颜色主题"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Command palette"
|
||||
#~ msgstr "命令面板"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Expand navigation"
|
||||
#~ msgstr "展开导航"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Follow system theme"
|
||||
#~ msgstr "跟随系统主题"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Jump to a page or action…"
|
||||
#~ msgstr "跳转到页面或操作…"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Memory"
|
||||
#~ msgstr "内存"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "No matching pages"
|
||||
#~ msgstr "没有匹配的页面"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Other pages in %s"
|
||||
#~ msgstr "%s 中的其他页面"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Page tabs"
|
||||
#~ msgstr "页面标签页"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Search pages"
|
||||
#~ msgstr "搜索页面"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Session"
|
||||
#~ msgstr "会话"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Show full description"
|
||||
#~ msgstr "显示完整描述"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Show full help"
|
||||
#~ msgstr "显示完整帮助"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Show full help: %s"
|
||||
#~ msgstr "显示完整帮助:%s"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Storage"
|
||||
#~ msgstr "存储"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Swap free"
|
||||
#~ msgstr "空闲交换空间"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Total Available"
|
||||
#~ msgstr "总可用"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Use dark theme"
|
||||
#~ msgstr "使用深色主题"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Use light theme"
|
||||
#~ msgstr "使用浅色主题"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Used"
|
||||
#~ msgstr "已用"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "Wireless"
|
||||
#~ msgstr "无线"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "close"
|
||||
#~ msgstr "关闭"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "free"
|
||||
#~ msgstr "空闲"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "navigate"
|
||||
#~ msgstr "移动"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "page"
|
||||
#~ msgstr "页"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "pages"
|
||||
#~ msgstr "页"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "reclaimable"
|
||||
#~ msgstr "可回收"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "to search all pages"
|
||||
#~ msgstr "搜索所有页面"
|
||||
|
||||
#~ msgctxt "vantage-theme"
|
||||
#~ msgid "used"
|
||||
#~ msgstr "已用"
|
||||
@@ -46,8 +46,8 @@
|
||||
<a class="vt-skip" href="#maincontent">{{ entityencode(_('Skip to content'), true) }}</a>
|
||||
<div class="vt-shell">
|
||||
{% if (authed): %}
|
||||
<nav class="vt-rail" id="vt-rail" aria-label="{{ entityencode(_('Main navigation'), true) }}">
|
||||
<a class="vt-brand" href="{{ dispatcher.build_url('admin') }}" aria-label="{{ entityencode(_('Home'), true) }}">
|
||||
<nav class="vt-rail" id="vt-rail" aria-label="{{ entityencode(_('Main navigation', 'vantage-theme'), true) }}">
|
||||
<a class="vt-brand" href="{{ dispatcher.build_url('admin') }}" aria-label="{{ entityencode(_('Home', 'vantage-theme'), true) }}">
|
||||
<svg viewBox="0 0 32 32" width="32" height="32" aria-hidden="true">
|
||||
<rect x="1" y="1" width="30" height="30" rx="7" class="vt-brand-tile"></rect>
|
||||
<path d="M10 10.5 10 21.5" class="vt-brand-l"></path>
|
||||
@@ -75,7 +75,7 @@
|
||||
<svg viewBox="0 0 32 32" width="28" height="28"><rect x="1" y="1" width="30" height="30" rx="7" class="vt-brand-tile"/><path d="M9 10.5 16 22" class="vt-brand-l"/><path d="M16 22 23 10.5" class="vt-brand-r"/><circle cx="23" cy="10.5" r="1.9" class="vt-brand-dot"/></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
<nav class="vt-crumbs" id="vt-crumbs" aria-label="{{ entityencode(_('Breadcrumb'), true) }}"><span class="vt-crumb vt-crumb-current">{{ entityencode(page_title, true) }}</span></nav>
|
||||
<nav class="vt-crumbs" id="vt-crumbs" aria-label="{{ entityencode(_('Breadcrumb', 'vantage-theme'), true) }}"><span class="vt-crumb vt-crumb-current">{{ entityencode(page_title, true) }}</span></nav>
|
||||
<div class="vt-top-actions">
|
||||
{% if (authed): %}
|
||||
<button type="button" class="vt-search" id="vt-search-btn" aria-haspopup="dialog" aria-keyshortcuts="Control+K /" aria-label="{{ entityencode(_('Search pages'), true) }}">
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=meow
|
||||
PKG_VERSION:=0.26.1
|
||||
PKG_RELEASE:=16
|
||||
PKG_VERSION:=0.27.0
|
||||
PKG_RELEASE:=17
|
||||
|
||||
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
|
||||
PKG_SOURCE_URL:=https://codeload.github.com/meow-rs/meow-rs/tar.gz/v$(PKG_VERSION)?
|
||||
|
||||
@@ -137,7 +137,12 @@ gen_rules() {
|
||||
meta mark $ROUTING_MARK return
|
||||
ether saddr @bypass_src return
|
||||
NFT
|
||||
[ "$dns_hijack" -eq 1 ] && echo " meta l4proto udp th dport 53 return"
|
||||
# UDP/53 never enters TPROXY: with dns_hijack on, the dstnat
|
||||
# redirect above handles it; with it off, DNS is left alone and
|
||||
# follows normal routing, so a query to the router's own dnsmasq
|
||||
# is not stolen by a transparent reply socket that cannot bind
|
||||
# :53 (issue #743).
|
||||
echo " meta l4proto udp th dport 53 return"
|
||||
[ "$ipv6" -eq 1 ] || echo " meta nfproto ipv6 return"
|
||||
cat <<-NFT
|
||||
fib daddr type local return
|
||||
|
||||
+9
-10
@@ -1,8 +1,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=minigate
|
||||
PKG_VERSION:=2026.10.26
|
||||
PKG_RELEASE:=7
|
||||
PKG_VERSION:=2026.10.28
|
||||
PKG_RELEASE:=8
|
||||
PKG_LICENSE:=MIT
|
||||
PKG_MAINTAINER:=MiniGate
|
||||
|
||||
@@ -43,16 +43,15 @@ define Package/$(PKG_NAME)/install
|
||||
|
||||
# Backend scripts
|
||||
$(INSTALL_DIR) $(1)/usr/lib/minigate
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/ddns.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/acme.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/proxy.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/geofence.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/login_guard.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/update.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/uninstall.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/after_commit.sh $(1)/usr/lib/minigate/
|
||||
$(INSTALL_BIN) ./root/usr/lib/minigate/*.sh $(1)/usr/lib/minigate/
|
||||
$(CP) ./root/usr/lib/minigate/geo $(1)/usr/lib/minigate/
|
||||
|
||||
# Login guard nftables sets and rules, loaded by fw4 on every reload
|
||||
$(INSTALL_DIR) $(1)/usr/share/nftables.d/table-pre
|
||||
$(INSTALL_DIR) $(1)/usr/share/nftables.d/chain-pre/input
|
||||
$(INSTALL_DATA) ./root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft $(1)/usr/share/nftables.d/table-pre/
|
||||
$(INSTALL_DATA) ./root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft $(1)/usr/share/nftables.d/chain-pre/input/
|
||||
|
||||
# Config
|
||||
$(INSTALL_DIR) $(1)/etc/config
|
||||
$(CP) ./root/etc/config/minigate $(1)/etc/config/
|
||||
|
||||
+79
-48
@@ -1,14 +1,14 @@
|
||||
# minigate - OpenWrt 轻量网关管理
|
||||
# MiniGate - OpenWrt 轻量网关管理
|
||||
|
||||
一个类似 Lucky 的轻量级 OpenWrt 应用,提供四大核心功能:DDNS、SSL 证书、反向代理、登录防护。
|
||||
|
||||
当前版本:**v2026.10.26**。项目名称、仓库名及 OpenWrt 内部包名均为 **`minigate`**。
|
||||
当前版本:**v2026.10.28**。界面名称为 **MiniGate**,仓库名及 OpenWrt 内部包名均为 **`minigate`**。
|
||||
|
||||
> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.26_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。
|
||||
> OpenWrt 用户请从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载 `minigate_2026.10.28_all.ipk`。不要把 GitHub 自动生成的 `Source code (zip)` / `Source code (tar.gz)` 当安装包上传到 LuCI。
|
||||
|
||||
## 项目定位
|
||||
|
||||
minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户,目标是在 LuCI 中集中完成域名解析、证书签发、反向代理和登录防护配置,减少在路由器上手动维护多组脚本、Nginx 配置和防火墙规则的成本。
|
||||
MiniGate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户,目标是在 LuCI 中集中完成域名解析、证书签发、反向代理和登录防护配置,减少在路由器上手动维护多组脚本、Nginx 配置和防火墙规则的成本。
|
||||
|
||||
项目重点关注以下场景:
|
||||
|
||||
@@ -36,54 +36,63 @@ minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户
|
||||
- 自动检测 WAN IP 变化并更新 DNS 记录
|
||||
- 公网记录变化成功后无重启刷新 sing-box 与 dnsmasq DNS 缓存,减少局域网继续命中旧地址
|
||||
- 同名同类型记录重复时,保留匹配当前 IP 的记录并清理其余旧记录,避免旧 IP 继续参与解析
|
||||
- 支持从网络接口或外部 URL 获取 IP
|
||||
- 支持从网络接口或外部 URL 获取 IP;外部查询默认用 HTTPS,失败时依次回退
|
||||
- 只向 Cloudflare 推送公网地址:软路由在上级路由 NAT 之后时,网卡上的 192.168.x 等内网地址会被拒绝并提示改用外部 URL
|
||||
- 运行状态保存在 `/var/run/minigate`,定时检查不再改写 `/etc/config/minigate`
|
||||
- 每条记录可配置检查间隔(最小 5 分钟,按 5 分钟取整)
|
||||
- 一键手动触发更新
|
||||
- 一键手动触发更新,在后台执行并原地刷新状态,不会丢掉表单里未保存的修改
|
||||
- 最近一天 IP 变化明细,显示 IPv4/IPv6 地址、开始时间、结束时间和持续时长
|
||||
|
||||
### 🔒 SSL/TLS 证书 (ACME)
|
||||
- **Let's Encrypt** 自动证书签发
|
||||
- 使用 **DNS-01** 挑战方式(Cloudflare)
|
||||
- 支持 ECC / RSA 多种密钥类型
|
||||
- 自动续期(通过 cron 每天检查)
|
||||
- 自动续期(通过 cron 每天检查);剩余超过 30 天时手动签发不会重复申请,避免触发频率限制
|
||||
- 证书列表显示到期日期、剩余天数,并区分测试证书和正式证书
|
||||
- acme.sh 固定为 3.1.6 版本,下载后校验 SHA-256 再安装
|
||||
- 签发在后台执行,页面轮询结果,不受网页请求超时影响
|
||||
- Staging 模式用于测试
|
||||
|
||||
### 🔄 反向代理
|
||||
- 基于 **Nginx** 的轻量反向代理
|
||||
- **IPv6 监听**支持(listen [::]:port 双栈)
|
||||
- HTTP/2、WebSocket 支持
|
||||
- HTTP/2;所有站点自动支持 WebSocket,无需逐条开启
|
||||
- 自动 SSL 证书关联
|
||||
- 已启用 HTTPS 的任意监听端口可自动识别明文 HTTP,并以 308 跳转到同域名、同端口的 HTTPS
|
||||
- 安全 Headers(HSTS 等)
|
||||
- 多站点管理
|
||||
- 多站点管理;子域名可单独停用,通配符主域名停用时其子域名一并停用
|
||||
- 同一端口只能用于一种协议(HTTP 或 HTTPS),冲突的规则会被跳过,并在总览页提示原因
|
||||
- 配置检查失败时自动恢复上一份可用配置,正在运行的站点不受影响;缺少证书的站点会在总览页提示
|
||||
- Nginx 由 procd 管理,异常退出后自动拉起
|
||||
- **直接 IP 访问拒绝**:只允许域名访问,扫描器直接断开
|
||||
- **未知 Host 拒绝**:同一监听端口下,未配置的域名/前缀不会落到第一个反代站点
|
||||
- **局域网直连解析**(可选):内网设备把代理域名直接解析到本机 LAN 地址,公网 IP 变化、上游 DNS 缓存过期或主路由不支持回流时,内网访问不受影响,流量也不进入代理
|
||||
- **公网访问范围**(可选):选择允许访问的省级地区,IPv4/IPv6 在首次转发前检查,未命中白名单直接断开;局域网私有地址继续可用
|
||||
|
||||
### 🛡 登录防护 (Login Guard)
|
||||
- 监控 **SSH(dropbear)** 和 **LuCI 网页** 登录失败
|
||||
- 监控 **SSH(dropbear)** 和 **LuCI 网页** 登录失败,支持 **IPv4 和 IPv6** 来源
|
||||
- 在「失败窗口」内累积达到阈值(默认 3 次/10 分钟)→ 自动用 nftables 封禁源 IP
|
||||
- 可配置封禁时长(1 小时 / 6 小时 / **12 小时(默认)** / 24 小时 / 1 周 / 30 天)
|
||||
- **LAN 私有段自动豁免**(192.168.x / 10.x / 127.x / 172.16-31.x),可加额外白名单
|
||||
- **LAN 私有段自动豁免**(192.168.x / 10.x / 127.x / 172.16-31.x、IPv6 ULA / 链路本地,以及 LAN 分配的 IPv6 子网),可加额外白名单(IPv4/IPv6,任意前缀长度)
|
||||
- **滑动时间窗口**:超过窗口未再失败则计数自动清零
|
||||
- 持久化存储 `/etc/minigate/login-guard/bans.txt`,重启/固件升级后已生效封禁自动恢复
|
||||
- LuCI 页面实时显示封禁列表、剩余时间、归属地(结合归属地查询 API)
|
||||
- 失败计数列表默认显示 5 条,支持切换 5 / 20 / 30 条,并复用访客追踪归属地查询;显示最近访问时间并按最新时间排序
|
||||
- 支持网页**一键解封**、**手动封禁**、**清空全部**
|
||||
- 后台 watchdog 每 5 分钟自检 nft set,防 fw4 reload 后规则丢失
|
||||
- 支持网页**一键解封**、**手动封禁**、**清空全部**(清空按钮在列表底部,避免误点)
|
||||
- 封禁集合与拦截规则随包放在 `/usr/share/nftables.d`,防火墙每次重载自动加载;重载清空的封禁 30 秒内从持久化文件恢复
|
||||
- 由 procd 管理,异常退出后自动拉起;停止时结束所有子进程
|
||||
|
||||
### 👁 访客追踪
|
||||
- 总览页面显示来源 IP、归属地、最近请求结果、HTTP 状态及转发/拦截次数;被拒绝的扫描也保留在记录中
|
||||
- 「近5分钟有请求」仅表示近期出现请求,不代表访客在线;已转发也不代表已登录。旧日志缺少上游响应信息时,只能标记为已响应、请求失败或结果未知,不推断为准入放行
|
||||
- IP 归属地后端查询(ip9.com.cn → ip-api.com → pconline 多源回退)
|
||||
- IP 归属地优先查随包的离线省级地址库,命中时不把访客 IP 发给外部服务;境外地址再用 HTTPS 接口(ip9.com.cn → pconline)查询,缓存最多保留 500 条
|
||||
|
||||
### 软件更新
|
||||
- 登录 LuCI 后打开 **服务 → minigate → 总览**,自动检查 GitHub 最新正式版;不会自动下载或安装
|
||||
- 登录 LuCI 后打开 **服务 → MiniGate → 总览**,自动检查 GitHub 最新正式版;不会自动下载或安装
|
||||
- 10 分钟内复用成功检查结果;「检查更新」可随时手动重新检查
|
||||
- 点击「一键更新」先显示本次 Release 更新内容,确认后才开始安装
|
||||
- 安装前校验 SHA-256 并备份程序和配置,安装或服务验证失败时尝试回滚
|
||||
- 总览标题区提供「卸载」入口;二次确认默认保留配置、证书、封禁记录和日志,也可主动选择彻底清理
|
||||
- 总览页底部「危险操作」区提供「卸载」入口;二次确认默认保留配置、证书、封禁记录和日志,也可主动选择彻底清理
|
||||
|
||||
---
|
||||
|
||||
@@ -93,20 +102,20 @@ minigate 面向需要自托管轻量网关能力的 OpenWrt / ImmortalWrt 用户
|
||||
|
||||
从 [Releases](https://github.com/tpxcer/minigate/releases/latest) 下载:
|
||||
|
||||
`minigate_2026.10.26_all.ipk`
|
||||
`minigate_2026.10.28_all.ipk`
|
||||
|
||||
**通过 LuCI 界面安装:**
|
||||
1. 打开 LuCI → **系统** → **软件包**
|
||||
2. 点击 **上传软件包**
|
||||
3. 选择 `minigate_2026.10.26_all.ipk`,点击安装
|
||||
3. 选择 `minigate_2026.10.28_all.ipk`,点击安装
|
||||
|
||||
**通过命令行安装:**
|
||||
|
||||
```bash
|
||||
cd /tmp
|
||||
wget -O minigate_2026.10.26_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate_2026.10.26_all.ipk
|
||||
wget -O minigate_2026.10.28_all.ipk https://github.com/tpxcer/minigate/releases/download/v2026.10.28/minigate_2026.10.28_all.ipk
|
||||
opkg update
|
||||
opkg install /tmp/minigate_2026.10.26_all.ipk
|
||||
opkg install /tmp/minigate_2026.10.28_all.ipk
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
|
||||
/etc/init.d/uhttpd restart
|
||||
/etc/init.d/minigate restart
|
||||
@@ -125,7 +134,7 @@ Release 用的 `.ipk` 请用仓库脚本生成,避免生成 Debian 风格 ar
|
||||
脚本会输出:
|
||||
|
||||
```text
|
||||
dist/minigate_2026.10.26_all.ipk
|
||||
dist/minigate_2026.10.28_all.ipk
|
||||
```
|
||||
|
||||
该文件外层是 OpenWrt/ImmortalWrt 24.10 兼容的 `tar.gz`,内部成员顺序为 `debian-binary`、`control.tar.gz`、`data.tar.gz`。
|
||||
@@ -153,7 +162,7 @@ wget -qO- https://raw.githubusercontent.com/tpxcer/minigate/main/scripts/install
|
||||
需要指定并核对具体版本时,执行:
|
||||
|
||||
```bash
|
||||
d="$(mktemp -d /tmp/minigate.XXXXXX)" && cd "$d" && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate-v2026.10.26-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.26/SHA256SUMS && grep 'minigate-v2026.10.26-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.10.26-src.tar.gz && sh install.sh && /etc/init.d/uhttpd restart && /etc/init.d/minigate restart
|
||||
d="$(mktemp -d /tmp/minigate.XXXXXX)" && cd "$d" && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.28/minigate-v2026.10.28-src.tar.gz && wget -q https://github.com/tpxcer/minigate/releases/download/v2026.10.28/SHA256SUMS && grep 'minigate-v2026.10.28-src.tar.gz$' SHA256SUMS | sha256sum -c - && tar xzf minigate-v2026.10.28-src.tar.gz && sh install.sh && /etc/init.d/uhttpd restart && /etc/init.d/minigate restart
|
||||
```
|
||||
|
||||
其中 `minigate.XXXXXX` 是临时目录模板,系统会自动把六个 `X` 替换为随机字符,避免与已有目录重名。该命令会校验源码包的 SHA-256、保留现有 minigate 配置并重启 LuCI 与 minigate 服务。
|
||||
@@ -168,20 +177,20 @@ ssh root@192.168.1.1
|
||||
|
||||
# 2. 在路由器上直接下载 Release 源码包
|
||||
cd /tmp
|
||||
wget -O minigate-src.tar.gz https://github.com/tpxcer/minigate/releases/download/v2026.10.26/minigate-v2026.10.26-src.tar.gz
|
||||
wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.10.26/SHA256SUMS
|
||||
awk '$2 == "minigate-v2026.10.26-src.tar.gz" {print $1 " minigate-src.tar.gz"}' minigate-SHA256SUMS | sha256sum -c - || exit 1
|
||||
wget -O minigate-src.tar.gz https://github.com/tpxcer/minigate/releases/download/v2026.10.28/minigate-v2026.10.28-src.tar.gz
|
||||
wget -O minigate-SHA256SUMS https://github.com/tpxcer/minigate/releases/download/v2026.10.28/SHA256SUMS
|
||||
awk '$2 == "minigate-v2026.10.28-src.tar.gz" {print $1 " minigate-src.tar.gz"}' minigate-SHA256SUMS | sha256sum -c - || exit 1
|
||||
|
||||
# 3. 解压到独立目录并安装
|
||||
mkdir -p /tmp/minigate-v2026.10.26
|
||||
tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.10.26
|
||||
cd /tmp/minigate-v2026.10.26
|
||||
mkdir -p /tmp/minigate-v2026.10.28
|
||||
tar xzf minigate-src.tar.gz -C /tmp/minigate-v2026.10.28
|
||||
cd /tmp/minigate-v2026.10.28
|
||||
sh install.sh
|
||||
|
||||
# 4. 启动服务
|
||||
/etc/init.d/minigate restart
|
||||
|
||||
# 5. 访问 LuCI → 服务 → minigate
|
||||
# 5. 访问 LuCI → 服务 → MiniGate
|
||||
```
|
||||
|
||||
### 方法 4:OpenWrt SDK 编译(适用所有版本,含 APK)
|
||||
@@ -207,7 +216,7 @@ APK 的内部版本格式要求将同日序号 `-N` 写为 `-rN`,SDK 会自动
|
||||
|
||||
### LuCI 一键更新
|
||||
|
||||
进入 **服务 → minigate → 总览 → 软件更新**:
|
||||
进入 **服务 → MiniGate → 总览 → 软件更新**:
|
||||
|
||||
1. 打开总览时自动检查,也可以点击「检查更新」立即重新检查
|
||||
2. 有新版本时点击「一键更新」,弹出该版本的更新内容
|
||||
@@ -225,7 +234,7 @@ SSH 登录后执行上方「方法 2」的一条命令,保留原有配置并
|
||||
### IPK 升级
|
||||
|
||||
```bash
|
||||
opkg install --force-reinstall /tmp/minigate_2026.10.26_all.ipk
|
||||
opkg install --force-reinstall /tmp/minigate_2026.10.28_all.ipk
|
||||
rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
|
||||
/etc/init.d/minigate restart
|
||||
```
|
||||
@@ -244,7 +253,7 @@ rm -f /tmp/luci-indexcache /tmp/luci-modulecache 2>/dev/null
|
||||
|
||||
### 方式 1:LuCI 一键卸载(推荐)
|
||||
|
||||
进入 **服务 → minigate → 总览**,点击标题右侧的「卸载」:
|
||||
进入 **服务 → MiniGate → 总览**,点击页面底部「危险操作」区的「卸载」:
|
||||
|
||||
1. 阅读中断提示并确认卸载范围
|
||||
2. 默认不勾选数据清理,保留 `/etc/config/minigate`、证书、封禁记录和日志
|
||||
@@ -287,28 +296,29 @@ opkg remove minigate 2>/dev/null || apk del minigate 2>/dev/null
|
||||
## 使用指南
|
||||
|
||||
### 第一步:基础配置
|
||||
1. 进入 **LuCI → 服务 → minigate**
|
||||
2. 在「总览」页面开启 minigate
|
||||
3. 如需 IPv6 反代监听,开启「反向代理监听 IPv6」
|
||||
1. 进入 **LuCI → 服务 → MiniGate**
|
||||
2. 在「总览」页面顶部勾选「启用 MiniGate」,点击「保存并应用」
|
||||
3. 如需 IPv6 反代监听,在「反向代理 → 高级设置」开启「监听 IPv6」
|
||||
|
||||
### 第二步:配置 DDNS
|
||||
1. 切换到 **DDNS** 标签页
|
||||
2. 启用 DDNS,填入 Cloudflare Zone ID 和 API Token
|
||||
3. 设置域名,选择协议版本(IPv4 / IPv6 / 双栈)
|
||||
4. 保存并应用
|
||||
4. 软路由在上级路由后面时,「IP 来源」选「从外部URL获取」
|
||||
5. 保存并应用
|
||||
|
||||
### 第三步:签发 SSL 证书
|
||||
1. 切换到 **SSL 证书** 标签页,启用 ACME
|
||||
2. 先用 Staging 模式测试
|
||||
3. 点击「立即签发 / 续期」
|
||||
4. 成功后关闭 Staging 重新签发正式证书
|
||||
2. 先用测试模式(Staging)验证
|
||||
3. 点击「立即签发」,签发在后台进行,可以离开页面
|
||||
4. 成功后关闭测试模式并保存,再签发正式证书;证书列表会标出「测试证书」和「正式证书」
|
||||
|
||||
### 第四步:配置反向代理
|
||||
1. 切换到 **反向代理** 标签页
|
||||
2. 添加规则,填写域名、目标地址和端口
|
||||
3. 启用 SSL(自动关联 ACME 证书)
|
||||
4. 如需输入 `http://域名:监听端口` 后自动进入 HTTPS,开启「HTTP 自动跳转」
|
||||
5. 保存并应用;minigate 会自动跟随每条 HTTPS 规则的监听端口,无需额外指定固定跳转端口
|
||||
4. 如需输入 `http://域名:监听端口` 后自动进入 HTTPS,在页面下方「高级设置」开启「HTTP 跳转」
|
||||
5. 保存并应用;MiniGate 会自动跟随每条 HTTPS 规则的监听端口,无需额外指定固定跳转端口
|
||||
6. 如需内网访问不绕公网,开启「局域网直连解析」(见下方说明)
|
||||
|
||||
### 局域网直连解析
|
||||
@@ -326,11 +336,11 @@ opkg remove minigate 2>/dev/null || apk del minigate 2>/dev/null
|
||||
- 关闭开关或停止 minigate 时会移除该文件;也可以手动执行 `/usr/lib/minigate/proxy.sh lan-dns` 重新同步
|
||||
|
||||
### 设置公网访问范围
|
||||
在 **反向代理 → 公网访问范围** 中启用「限制公网访问地区」,再添加允许的省级地区(支持多个)。例如仅保留「四川省」,保存应用后,只允许地址库判定为四川的公网 IP 访问。默认关闭,升级不会自动收紧现有访问范围。
|
||||
在 **反向代理 → 高级设置:公网访问范围** 中启用「限制公网访问地区」,再勾选允许的省级地区(支持多个)。例如仅保留「四川省」,保存应用后,只允许地址库判定为四川的公网 IP 访问。默认关闭,升级不会自动收紧现有访问范围。
|
||||
|
||||
- 所有普通域名、子域名、HTTP 跳转及 WebSocket 均在转发前检查;其他地区、境外及归属地未知来源返回 Nginx `444`(直接断开,无响应正文)
|
||||
- IPv4、IPv6 使用同一策略;RFC1918 私有 IPv4、回环、IPv6 ULA 和链路本地地址继续放行;LAN 接口已分配的全球 IPv6 子网也自动豁免。使用「局域网直连解析」可通过 LAN IPv4 访问
|
||||
- 不修改防火墙,不限制 SSH、RustDesk 等其他服务;启用此功能时不再运行旧版地区日志扫描封禁逻辑,已有登录防护独立生效
|
||||
- 不修改防火墙,不限制 SSH、RustDesk 等其他服务;旧版的地区日志扫描封禁已移除,升级时会把旧配置迁移到这里;登录防护独立生效
|
||||
- 使用实际连接来源(同端口分流时为本机 stream 提供的 PROXY protocol),不信任访客提交的 `X-Forwarded-For` / `X-Real-IP`;若前面还有 CDN 或隐藏来源的代理,看到的是该代理的出口 IP
|
||||
- 数据来自 [ip2region](https://github.com/lionsoul2014/ip2region),随安装包附带 34 个省级地区的离线 CIDR,不需要在线归属地接口或额外 Nginx 模块。版本、源文件 SHA-256 和网段数见 `root/usr/lib/minigate/geo/metadata.json`,原许可证随包保留
|
||||
- 地址归属可能误判或随运营商调整变化;外出、漫游或使用外省代理出口时可能被拦截。数据缺失或配置为空时仍拒绝公网,不自动退回全部放行。可从局域网关闭开关恢复访问
|
||||
@@ -358,7 +368,7 @@ python3 scripts/build-geo-data.py /path/to/ip2region/data
|
||||
安装后,反向代理自动拒绝直接使用 IP 地址访问的请求(返回 444 断开连接)。同时,每个监听端口都会生成 `default_server` 兜底规则,未配置的 Host / 子域名前缀不会落到同端口第一个反代站点。只有通过正确域名访问才能到达后端服务,这可以有效防止网络扫描器探测和域名串站。
|
||||
|
||||
### 访客追踪
|
||||
总览页面实时显示所有通过反向代理访问的 IP 地址、归属地和在线状态,帮助你发现异常访问。
|
||||
总览页面实时显示所有通过反向代理访问的 IP 地址、归属地和最近请求结果,帮助你发现异常访问。
|
||||
|
||||
---
|
||||
|
||||
@@ -380,16 +390,23 @@ minigate/
|
||||
│ │ ├── acme.lua # ACME 证书
|
||||
│ │ ├── proxy.lua # 反向代理
|
||||
│ │ └── login_guard.lua # 登录防护
|
||||
│ └── view/minigate/log.htm # 日志页面
|
||||
│ └── view/minigate/
|
||||
│ ├── log.htm # 日志页面(按时间合并各来源)
|
||||
│ ├── style.htm # 各配置页共用的深浅色样式
|
||||
│ ├── proxy_empty.htm # 反代页无域名时的提示
|
||||
│ └── ddns_history.htm # 最近一天 IP 变化明细
|
||||
└── root/
|
||||
├── etc/config/minigate # UCI 配置(含 login_guard 段)
|
||||
├── etc/init.d/minigate # 服务脚本(启停含 login_guard)
|
||||
├── etc/init.d/minigate # procd 服务脚本(Nginx、登录防护、配置变更自动重载)
|
||||
├── usr/share/nftables.d/ # 登录防护封禁集合与规则(随 fw4 加载)
|
||||
└── usr/lib/minigate/
|
||||
├── ddns.sh # DDNS(双栈)
|
||||
├── acme.sh # 证书管理
|
||||
├── proxy.sh # 反代(协议分流+IPv6+访问日志)
|
||||
├── login_guard.sh # 登录防护 watcher + CLI
|
||||
├── after_commit.sh # LuCI 保存后等配置提交再应用
|
||||
├── login_guard.sh # 登录防护 watcher + CLI(IPv4/IPv6)
|
||||
├── job.sh # 签发证书、手动同步等后台任务
|
||||
├── geo_lookup.sh # 离线省级归属地查询
|
||||
├── uninstall.sh # 卸载
|
||||
└── update.sh # GitHub Release 安全更新器
|
||||
```
|
||||
|
||||
@@ -408,6 +425,20 @@ minigate/
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v2026.10.28
|
||||
- 修复:通配符主域名停用或删除后,其子域名会退回成 443 端口的明文 HTTP 站点,并可能让 Nginx 因两个默认站点无法加载;现在子域名随主域名一起停用,并可单独停用
|
||||
- 修复:同一端口同时有 HTTP 和 HTTPS 规则时生成无法加载的配置;冲突规则会被跳过并在总览提示
|
||||
- 配置检查失败时恢复上一份可用配置;生成过程加锁,避免保存、续期和更新同时触发时互相覆盖
|
||||
- 服务改由 procd 管理:Nginx 与登录防护异常退出后自动拉起,LuCI「保存并应用」后只重载受影响的部分,不再轮询等待配置写入
|
||||
- 登录防护支持 IPv6 来源、任意前缀长度的白名单;封禁规则随防火墙重载自动加载,重载后的封禁 30 秒内恢复;封禁失败不再误记为已封禁
|
||||
- DDNS 只推送公网地址,外部查询默认用 HTTPS;运行状态移出 `/etc/config/minigate`,定时检查不再改写配置
|
||||
- 证书:固定 acme.sh 3.1.6 并校验 SHA-256;剩余超过 30 天不重复签发;签发和手动同步改为后台任务,不受网页超时影响
|
||||
- 所有站点自动支持 WebSocket;归属地优先查离线地址库,境外地址才用 HTTPS 接口
|
||||
- 移除旧版地区日志扫描(升级时迁移到请求前准入);定时任务只管理本插件脚本,不再误删用户任务
|
||||
- 界面:名称统一为 MiniGate;总开关移到总览顶部,未启用时有提示;DDNS 卡片列出全部记录并按最差状态着色;证书卡片显示到期日期、剩余天数和测试证书;反代地址可直接点击,并显示配置问题;卸载移到底部「危险操作」区
|
||||
- 界面:日志「全部」按时间合并各来源,新增登录防护、软件更新来源,向上翻看时不再被拉回底部;DDNS 手动同步不再刷新整页;反代页规则在前、高级设置在后,地区改为复选框,启用开关统一为勾选框;配置页跟随深色主题;表单增加域名、Zone ID、检查间隔、阈值和白名单校验
|
||||
- CI 对每次推送和 PR 运行测试,发布任务只在测试通过后执行
|
||||
|
||||
### v2026.10.26
|
||||
- 访问记录区分「已拦截、已转发、已响应、请求失败、结果未知」,显示最近请求的 HTTP 状态与拦截/转发/其他次数
|
||||
- 「在线/离线」改为近期请求说明,避免把被拒绝的外地扫描误认为访问成功;保留拦截记录便于核查
|
||||
|
||||
+55
-3
@@ -13,6 +13,9 @@ dependency_ready() {
|
||||
# 页面基于 CBI,由 luci-compat 提供;模块已存在时不依赖包数据库记录
|
||||
[ -f "${MINIGATE_LUCI_DIR:-/usr/lib/lua/luci}/cbi.lua" ]
|
||||
;;
|
||||
nginx-ssl)
|
||||
command -v nginx >/dev/null 2>&1
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
@@ -23,13 +26,13 @@ install_dependencies() {
|
||||
local p
|
||||
if command -v opkg >/dev/null 2>&1; then
|
||||
PKG="opkg"
|
||||
for p in curl jsonfilter nftables nginx-mod-stream openssl-util luci-compat; do
|
||||
for p in nginx-ssl curl jsonfilter nftables nginx-mod-stream openssl-util luci-compat; do
|
||||
dependency_ready "$p" && continue
|
||||
opkg list-installed 2>/dev/null | grep -q "^${p} " || { opkg update 2>/dev/null; opkg install "$p" 2>/dev/null; }
|
||||
done
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
PKG="apk"
|
||||
for p in curl jsonfilter nftables nginx-mod-stream openssl-util luci-compat; do
|
||||
for p in nginx-ssl curl jsonfilter nftables nginx-mod-stream openssl-util luci-compat; do
|
||||
dependency_ready "$p" && continue
|
||||
apk info -e "$p" >/dev/null 2>&1 || apk add "$p" 2>/dev/null
|
||||
done
|
||||
@@ -69,11 +72,28 @@ fi
|
||||
echo "[*] 已清理旧的独立 login-guard"
|
||||
}
|
||||
|
||||
# 旧版的事后地区扫描已由反代请求前准入取代:移除它的防火墙集合,避免继续全局封禁
|
||||
remove_legacy_geofence() {
|
||||
local handle
|
||||
command -v nft >/dev/null 2>&1 || return 0
|
||||
for handle in $(nft -a list chain inet fw4 input 2>/dev/null |
|
||||
awk 'index($0, "@minigate_blocked") { for (i=1; i<=NF; i++) if ($i=="handle") print $(i+1) }'); do
|
||||
nft delete rule inet fw4 input handle "$handle" 2>/dev/null || true
|
||||
done
|
||||
nft delete set inet fw4 minigate_blocked 2>/dev/null || true
|
||||
}
|
||||
remove_legacy_geofence
|
||||
|
||||
mkdir -p /usr/lib/minigate
|
||||
mkdir -p /etc/minigate/acme /etc/minigate/certs /etc/minigate/nginx/sites /etc/minigate/nginx/streams /etc/minigate/login-guard
|
||||
rm -f /usr/lib/minigate/after_commit.sh /usr/lib/minigate/geofence.sh
|
||||
cp "$D"/root/usr/lib/minigate/*.sh /usr/lib/minigate/; chmod +x /usr/lib/minigate/*.sh
|
||||
cp -R "$D"/root/usr/lib/minigate/geo /usr/lib/minigate/
|
||||
cp "$D"/root/etc/init.d/minigate /etc/init.d/; chmod +x /etc/init.d/minigate
|
||||
# 登录防护的封禁集合和规则随防火墙每次重载自动加载
|
||||
mkdir -p /usr/share/nftables.d/table-pre /usr/share/nftables.d/chain-pre/input
|
||||
cp "$D"/root/usr/share/nftables.d/table-pre/20-minigate-login-guard.nft /usr/share/nftables.d/table-pre/
|
||||
cp "$D"/root/usr/share/nftables.d/chain-pre/input/20-minigate-login-guard.nft /usr/share/nftables.d/chain-pre/input/
|
||||
|
||||
# 配置:保留旧的,但确保有 login_guard 区段
|
||||
if [ -f /etc/config/minigate ]; then
|
||||
@@ -92,6 +112,38 @@ fi
|
||||
|
||||
# v1.3.11 起由每条 HTTPS 规则的监听端口直接承担 HTTP 跳转。
|
||||
uci -q delete minigate.global.http_redirect_port 2>/dev/null || true
|
||||
|
||||
# 旧版地区扫描的允许省份迁移到请求前准入(按名称前缀匹配地区代码)
|
||||
migrate_legacy_geofence() {
|
||||
local allowed codes name code
|
||||
[ "$(uci -q get minigate.global.geo_enabled)" = "1" ] || return 0
|
||||
[ "$(uci -q get minigate.global.access_enabled)" = "1" ] && return 0
|
||||
allowed=$(uci -q get minigate.global.geo_allowed)
|
||||
codes=""
|
||||
for name in $(echo "$allowed" | tr ',' ' '); do
|
||||
code=$(awk -v name="$name" 'index($2, name) == 1 { print $1; exit }' "$D"/root/usr/lib/minigate/geo/regions.txt)
|
||||
[ -n "$code" ] && codes="$codes $code"
|
||||
done
|
||||
[ -n "$codes" ] || return 0
|
||||
uci -q delete minigate.global.access_regions
|
||||
for code in $codes; do uci -q add_list minigate.global.access_regions="$code"; done
|
||||
uci -q set minigate.global.access_enabled=1
|
||||
echo "[*] 已将旧版地区限制迁移为反代请求前准入:$codes"
|
||||
}
|
||||
migrate_legacy_geofence
|
||||
uci -q delete minigate.global.geo_enabled 2>/dev/null || true
|
||||
uci -q delete minigate.global.geo_allowed 2>/dev/null || true
|
||||
uci -q delete minigate.global.blocked_ips 2>/dev/null || true
|
||||
|
||||
# 运行状态已移到 /var/run/minigate,清掉旧版写在配置文件里的状态字段
|
||||
for sec in $(uci -q -X show minigate 2>/dev/null | grep '=ddns$' | cut -d. -f2 | cut -d= -f1); do
|
||||
for opt in status status_msg last_ip last_ip6 last_update next_sync; do
|
||||
uci -q delete "minigate.${sec}.${opt}" 2>/dev/null || true
|
||||
done
|
||||
done
|
||||
for opt in status last_domain last_issue cert_expiry last_error; do
|
||||
uci -q delete "minigate.acme.${opt}" 2>/dev/null || true
|
||||
done
|
||||
uci -q commit minigate 2>/dev/null || true
|
||||
|
||||
mkdir -p /usr/lib/lua/luci/controller
|
||||
@@ -100,7 +152,7 @@ mkdir -p /usr/lib/lua/luci/view/minigate
|
||||
cp "$D"/luasrc/controller/minigate.lua /usr/lib/lua/luci/controller/
|
||||
cp "$D"/luasrc/model/cbi/minigate/*.lua /usr/lib/lua/luci/model/cbi/minigate/
|
||||
cp "$D"/luasrc/view/minigate/*.htm /usr/lib/lua/luci/view/minigate/
|
||||
rm -rf /tmp/luci-* 2>/dev/null
|
||||
rm -rf /tmp/luci-indexcache* /tmp/luci-modulecache 2>/dev/null
|
||||
/etc/init.d/minigate enable 2>/dev/null
|
||||
/etc/init.d/minigate start 9>&- 2>/dev/null || true
|
||||
echo ""; echo "==== 安装完成 ===="
|
||||
|
||||
@@ -1,10 +1,173 @@
|
||||
module("luci.controller.minigate", package.seeall)
|
||||
|
||||
local RUN_DIR = "/var/run/minigate"
|
||||
local JOB_DIR = RUN_DIR .. "/jobs"
|
||||
local DDNS_STATE_DIR = RUN_DIR .. "/ddns"
|
||||
local ACME_STATE_FILE = RUN_DIR .. "/acme.state"
|
||||
local PROXY_STATUS_FILE = RUN_DIR .. "/proxy.status"
|
||||
local CERT_DIR = "/etc/minigate/certs"
|
||||
|
||||
local function shellquote(value)
|
||||
value = tostring(value or "")
|
||||
return "'" .. value:gsub("'", "'\"'\"'") .. "'"
|
||||
end
|
||||
|
||||
local function write_json(data)
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json(data)
|
||||
end
|
||||
|
||||
local function bad_request(message, extra)
|
||||
luci.http.status(400, "Bad Request")
|
||||
local data = extra or {}
|
||||
data.success = false
|
||||
data.message = message
|
||||
write_json(data)
|
||||
end
|
||||
|
||||
-- 读取 key=value 状态文件;warning 等可重复的键收集为数组
|
||||
function read_state(path)
|
||||
local data = {}
|
||||
local file = io.open(path, "r")
|
||||
if not file then return nil end
|
||||
for line in file:lines() do
|
||||
local key, value = line:match("^([%w_]+)=(.*)$")
|
||||
if key == "warning" then
|
||||
data.warnings = data.warnings or {}
|
||||
data.warnings[#data.warnings + 1] = value
|
||||
elseif key then
|
||||
data[key] = value
|
||||
end
|
||||
end
|
||||
file:close()
|
||||
return data
|
||||
end
|
||||
|
||||
-- 与 ddns.sh 的 state_key 和 IP 历史文件使用相同的域名清洗规则
|
||||
function state_key(domain)
|
||||
return (tostring(domain or ""):gsub("[^%w_%-]", "_"))
|
||||
end
|
||||
|
||||
function ddns_state(domain)
|
||||
if not domain or domain == "" then return {} end
|
||||
return read_state(DDNS_STATE_DIR .. "/" .. state_key(domain) .. ".state") or {}
|
||||
end
|
||||
|
||||
local MONTHS = {Jan=1, Feb=2, Mar=3, Apr=4, May=5, Jun=6, Jul=7, Aug=8, Sep=9, Oct=10, Nov=11, Dec=12}
|
||||
|
||||
-- 公历日期转 UTC 秒数,不依赖路由器时区
|
||||
local function utc_epoch(y, m, d, hh, mm, ss)
|
||||
y = (m <= 2) and (y - 1) or y
|
||||
local era = math.floor(y / 400)
|
||||
local yoe = y - era * 400
|
||||
local mp = (m + 9) % 12
|
||||
local doy = math.floor((153 * mp + 2) / 5) + d - 1
|
||||
local doe = yoe * 365 + math.floor(yoe / 4) - math.floor(yoe / 100) + doy
|
||||
local days = era * 146097 + doe - 719468
|
||||
return days * 86400 + hh * 3600 + mm * 60 + ss
|
||||
end
|
||||
|
||||
-- openssl 的 notAfter 形如 "Dec 16 12:00:00 2026 GMT"
|
||||
function parse_cert_time(value)
|
||||
local mon, d, hh, mm, ss, y = tostring(value or ""):match("^(%a+)%s+(%d+)%s+(%d+):(%d+):(%d+)%s+(%d+)")
|
||||
if not mon or not MONTHS[mon] then return nil end
|
||||
return utc_epoch(tonumber(y), MONTHS[mon], tonumber(d), tonumber(hh), tonumber(mm), tonumber(ss))
|
||||
end
|
||||
|
||||
-- 读取已签发证书:域名、到期日期、剩余天数、是否为测试证书
|
||||
function cert_list()
|
||||
local fs = require "nixio.fs"
|
||||
local certs = {}
|
||||
if not fs.dir(CERT_DIR) then return certs end
|
||||
local now = os.time()
|
||||
for entry in fs.dir(CERT_DIR) do
|
||||
local path = CERT_DIR .. "/" .. entry
|
||||
local st = fs.lstat(path)
|
||||
if entry ~= "_default" and st and st.type == "dir" and fs.access(path .. "/fullchain.pem") then
|
||||
local handle = io.popen("openssl x509 -in " .. shellquote(path .. "/fullchain.pem") .. " -noout -enddate -issuer 2>/dev/null")
|
||||
local output = handle and handle:read("*a") or ""
|
||||
if handle then handle:close() end
|
||||
local not_after = output:match("notAfter=([^\n]+)")
|
||||
local issuer = output:match("issuer=([^\n]+)") or ""
|
||||
local expires = parse_cert_time(not_after)
|
||||
local item = {
|
||||
domain = entry:gsub("^_wildcard_%.", "*."),
|
||||
path = path,
|
||||
staging = (issuer:lower():find("staging", 1, true) or issuer:lower():find("fake", 1, true)) and true or false
|
||||
}
|
||||
if expires then
|
||||
item.expires = expires
|
||||
item.expires_date = os.date("%Y-%m-%d", expires)
|
||||
item.days_left = math.floor((expires - now) / 86400)
|
||||
end
|
||||
certs[#certs + 1] = item
|
||||
end
|
||||
end
|
||||
table.sort(certs, function(a, b) return (a.expires or 0) < (b.expires or 0) end)
|
||||
return certs
|
||||
end
|
||||
|
||||
function index()
|
||||
entry({"admin","services","minigate"}, alias("admin","services","minigate","general"), "MiniGate", 60).dependent=false
|
||||
entry({"admin","services","minigate","general"}, cbi("minigate/general"), "总览", 10)
|
||||
entry({"admin","services","minigate","ddns"}, cbi("minigate/ddns"), "动态DNS", 20)
|
||||
entry({"admin","services","minigate","acme"}, cbi("minigate/acme"), "SSL 证书", 30)
|
||||
entry({"admin","services","minigate","proxy"}, cbi("minigate/proxy"), "反向代理", 40)
|
||||
entry({"admin","services","minigate","login_guard"}, cbi("minigate/login_guard"), "登录防护", 45)
|
||||
entry({"admin","services","minigate","log"}, template("minigate/log"), "日志", 50)
|
||||
entry({"admin","services","minigate","status"}, call("action_status")).leaf=true
|
||||
entry({"admin","services","minigate","get_log"}, call("action_get_log")).leaf=true
|
||||
entry({"admin","services","minigate","acme_install"}, post("action_acme_install")).leaf=true
|
||||
entry({"admin","services","minigate","acme_issue"}, post("action_acme_issue")).leaf=true
|
||||
entry({"admin","services","minigate","acme_job"}, call("action_acme_job")).leaf=true
|
||||
entry({"admin","services","minigate","ddns_sync"}, post("action_ddns_sync")).leaf=true
|
||||
entry({"admin","services","minigate","ddns_job"}, call("action_ddns_job")).leaf=true
|
||||
entry({"admin","services","minigate","ddns_history"}, call("action_ddns_history")).leaf=true
|
||||
entry({"admin","services","minigate","proxy_access"}, call("action_proxy_access")).leaf=true
|
||||
entry({"admin","services","minigate","geo_lookup"}, call("action_geo_lookup")).leaf=true
|
||||
entry({"admin","services","minigate","lg_status"}, call("action_lg_status")).leaf=true
|
||||
entry({"admin","services","minigate","lg_ban"}, post("action_lg_ban")).leaf=true
|
||||
entry({"admin","services","minigate","lg_unban"}, post("action_lg_unban")).leaf=true
|
||||
entry({"admin","services","minigate","lg_flush"}, post("action_lg_flush")).leaf=true
|
||||
entry({"admin","services","minigate","update_status"}, call("action_update_status")).leaf=true
|
||||
entry({"admin","services","minigate","update_auto"}, call("action_update_auto")).leaf=true
|
||||
entry({"admin","services","minigate","update_check"}, call("action_update_check")).leaf=true
|
||||
entry({"admin","services","minigate","update_apply"}, post("action_update_apply")).leaf=true
|
||||
entry({"admin","services","minigate","uninstall"}, post("action_uninstall")).leaf=true
|
||||
end
|
||||
|
||||
-- ============== 后台任务 ==============
|
||||
|
||||
local function job_state(name)
|
||||
return read_state(JOB_DIR .. "/" .. name .. ".state") or {}
|
||||
end
|
||||
|
||||
-- 同名任务正在运行时不重复启动;超过 15 分钟仍为 running 视为异常退出
|
||||
local function job_running(name)
|
||||
local state = job_state(name)
|
||||
local started = tonumber(state.started or "")
|
||||
return state.status == "running" and started ~= nil and os.time() - started < 900
|
||||
end
|
||||
|
||||
local function start_job(name, command)
|
||||
local sys = require "luci.sys"
|
||||
if job_running(name) then return true end
|
||||
return sys.call("( /bin/sh /usr/lib/minigate/job.sh " .. name .. " " .. command ..
|
||||
" ) </dev/null >/dev/null 2>&1 &") == 0
|
||||
end
|
||||
|
||||
local function job_response(name)
|
||||
local state = job_state(name)
|
||||
return {
|
||||
running = job_running(name),
|
||||
done = state.status == "done",
|
||||
code = tonumber(state.code or ""),
|
||||
finished = tonumber(state.finished or "")
|
||||
}
|
||||
end
|
||||
|
||||
-- ============== 软件更新与卸载 ==============
|
||||
|
||||
local function update_response(command)
|
||||
local sys = require "luci.sys"
|
||||
local jsonc = require "luci.jsonc"
|
||||
@@ -30,42 +193,14 @@ local function update_response(command)
|
||||
if release_ok and type(release) == "table" then
|
||||
local tag = tostring(release.tag_name or ""):gsub("^v", "")
|
||||
if tag == tostring(data.latest) then
|
||||
local title = type(release.name) == "string" and release.name or ("minigate v" .. tag)
|
||||
local title = type(release.name) == "string" and release.name or ("MiniGate v" .. tag)
|
||||
local notes = type(release.body) == "string" and release.body or ""
|
||||
data.release_title = title
|
||||
data.release_notes = notes
|
||||
end
|
||||
end
|
||||
end
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json(data)
|
||||
end
|
||||
|
||||
function index()
|
||||
entry({"admin","services","minigate"}, alias("admin","services","minigate","general"), "minigate", 60).dependent=false
|
||||
entry({"admin","services","minigate","general"}, cbi("minigate/general"), "总览", 10)
|
||||
entry({"admin","services","minigate","ddns"}, cbi("minigate/ddns"), "动态DNS", 20)
|
||||
entry({"admin","services","minigate","acme"}, cbi("minigate/acme"), "SSL 证书", 30)
|
||||
entry({"admin","services","minigate","proxy"}, cbi("minigate/proxy"), "反向代理", 40)
|
||||
entry({"admin","services","minigate","login_guard"}, cbi("minigate/login_guard"), "登录防护", 45)
|
||||
entry({"admin","services","minigate","log"}, template("minigate/log"), "日志", 50)
|
||||
entry({"admin","services","minigate","status"}, call("action_status")).leaf=true
|
||||
entry({"admin","services","minigate","get_log"}, call("action_get_log")).leaf=true
|
||||
entry({"admin","services","minigate","acme_install"}, post("action_acme_install")).leaf=true
|
||||
entry({"admin","services","minigate","acme_issue"}, post("action_acme_issue")).leaf=true
|
||||
entry({"admin","services","minigate","ddns_sync"}, post("action_ddns_sync")).leaf=true
|
||||
entry({"admin","services","minigate","ddns_history"}, call("action_ddns_history")).leaf=true
|
||||
entry({"admin","services","minigate","proxy_access"}, call("action_proxy_access")).leaf=true
|
||||
entry({"admin","services","minigate","geo_lookup"}, call("action_geo_lookup")).leaf=true
|
||||
entry({"admin","services","minigate","lg_status"}, call("action_lg_status")).leaf=true
|
||||
entry({"admin","services","minigate","lg_ban"}, post("action_lg_ban")).leaf=true
|
||||
entry({"admin","services","minigate","lg_unban"}, post("action_lg_unban")).leaf=true
|
||||
entry({"admin","services","minigate","lg_flush"}, post("action_lg_flush")).leaf=true
|
||||
entry({"admin","services","minigate","update_status"}, call("action_update_status")).leaf=true
|
||||
entry({"admin","services","minigate","update_auto"}, call("action_update_auto")).leaf=true
|
||||
entry({"admin","services","minigate","update_check"}, call("action_update_check")).leaf=true
|
||||
entry({"admin","services","minigate","update_apply"}, post("action_update_apply")).leaf=true
|
||||
entry({"admin","services","minigate","uninstall"}, post("action_uninstall")).leaf=true
|
||||
write_json(data)
|
||||
end
|
||||
|
||||
function action_update_status()
|
||||
@@ -88,9 +223,7 @@ function action_update_apply()
|
||||
|
||||
if type(version) ~= "string" or #version > 32 or not (
|
||||
version:match("^%d+%.%d+%.%d+$") or version:match("^%d+%.%d+%.%d+%-%d+$")) then
|
||||
luci.http.status(400, "Bad Request")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success = false, message = "请先查看更新内容并确认版本"})
|
||||
bad_request("请先查看更新内容并确认版本")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -101,8 +234,7 @@ function action_update_apply()
|
||||
"; rm -f \"$runner\" ) >/dev/null 2>&1 & )") == 0
|
||||
end
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({
|
||||
write_json({
|
||||
success = ok,
|
||||
message = ok and "更新任务已启动" or "无法启动更新任务"
|
||||
})
|
||||
@@ -116,9 +248,7 @@ function action_uninstall()
|
||||
local ok = false
|
||||
|
||||
if confirm ~= "uninstall-minigate" or (purge ~= "0" and purge ~= "1") then
|
||||
luci.http.status(400, "Bad Request")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success = false, message = "卸载确认无效,请重新操作"})
|
||||
bad_request("卸载确认无效,请重新操作")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -129,136 +259,236 @@ function action_uninstall()
|
||||
"; rm -f \"$runner\" ) >/dev/null 2>&1 & )") == 0
|
||||
end
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({
|
||||
write_json({
|
||||
success = ok,
|
||||
message = ok and "卸载任务已启动" or "无法启动卸载任务"
|
||||
})
|
||||
end
|
||||
|
||||
-- ============== 总览状态 ==============
|
||||
|
||||
local STATUS_RANK = {error=4, partial=3, unknown=2, stopped=1, ok=0}
|
||||
|
||||
function action_status()
|
||||
local uci=require"luci.model.uci".cursor()
|
||||
local pr=false
|
||||
local f=io.open("/var/run/minigate-nginx.pid","r")
|
||||
if f then local p=f:read("*l"); f:close()
|
||||
if p and tonumber(p) then pr=(os.execute("kill -0 "..p.." 2>/dev/null")==0) end
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local pr = false
|
||||
local f = io.open("/var/run/minigate-nginx.pid", "r")
|
||||
if f then
|
||||
local p = f:read("*l"); f:close()
|
||||
if p and tonumber(p) then pr = (os.execute("kill -0 " .. p .. " 2>/dev/null") == 0) end
|
||||
end
|
||||
local al={}
|
||||
for _,lf in ipairs({"/var/log/minigate-ddns.log","/var/log/minigate-acme.log","/var/log/minigate-proxy.log"}) do
|
||||
local fh=io.open(lf,"r"); if fh then for l in fh:lines() do al[#al+1]=l end; fh:close() end
|
||||
end
|
||||
local st=#al-19; if st<1 then st=1 end
|
||||
local ll={}; for i=st,#al do ll[#ll+1]=al[i] end
|
||||
local ca=false; local cf=io.open("/etc/crontabs/root","r")
|
||||
if cf then local c=cf:read("*a"); cf:close(); ca=c:find("minigate")~=nil end
|
||||
local dl={}
|
||||
uci:foreach("minigate","ddns",function(s)
|
||||
dl[#dl+1]={
|
||||
name=s[".name"],
|
||||
enabled=s.enabled or"0",
|
||||
domain=s.domain or"",
|
||||
ip_version=s.ip_version or"ipv4",
|
||||
status=s.status or"unknown",
|
||||
status_msg=s.status_msg or"",
|
||||
last_ip=s.last_ip or"",
|
||||
last_ip6=s.last_ip6 or"",
|
||||
last_update=s.last_update or"",
|
||||
next_sync=s.next_sync or""
|
||||
local enabled = uci:get("minigate", "global", "enabled") or "0"
|
||||
|
||||
-- DDNS:列出所有记录,并给出最差状态用于卡片颜色
|
||||
local dl = {}
|
||||
local worst = nil
|
||||
uci:foreach("minigate", "ddns", function(s)
|
||||
local st = ddns_state(s.domain)
|
||||
local status = st.status or "unknown"
|
||||
local on = s.enabled == "1"
|
||||
if not on then status = "disabled" end
|
||||
dl[#dl + 1] = {
|
||||
name = s[".name"],
|
||||
enabled = on and "1" or "0",
|
||||
domain = s.domain or "",
|
||||
ip_version = s.ip_version or "ipv4",
|
||||
status = status,
|
||||
status_msg = st.message or "",
|
||||
last_ip = st.ip or "",
|
||||
last_ip6 = st.ip6 or "",
|
||||
last_update = st.updated or "",
|
||||
next_sync = st.next or ""
|
||||
}
|
||||
if on and (worst == nil or (STATUS_RANK[status] or 2) > (STATUS_RANK[worst] or 2)) then worst = status end
|
||||
end)
|
||||
|
||||
-- 全局 IPv6 监听状态
|
||||
local ipv6_listen = uci:get("minigate","global","ipv6_listen") or "0"
|
||||
local ipv6_listen = uci:get("minigate", "global", "ipv6_listen") or "0"
|
||||
|
||||
-- 收集代理规则信息
|
||||
-- 反代规则:与 proxy.sh 一致,主域名未启用的子域名不显示
|
||||
local proxy_rules = {}
|
||||
local wc_ports = {}
|
||||
uci:foreach("minigate","proxy_wildcard",function(s)
|
||||
if (s.enabled == "1" or s.enabled == true) and s.domain then
|
||||
local base = (s.domain or ""):gsub("^%*%.","")
|
||||
local ssl_val = tostring(s.ssl or "0")
|
||||
wc_ports[base] = {port=s.listen_port or "443", ssl=ssl_val}
|
||||
uci:foreach("minigate", "proxy_wildcard", function(s)
|
||||
if s.enabled == "1" and s.domain then
|
||||
local base = (s.domain or ""):gsub("^%*%.", "")
|
||||
wc_ports[base] = {port = s.listen_port or "443", ssl = tostring(s.ssl or "0")}
|
||||
end
|
||||
end)
|
||||
uci:foreach("minigate","subproxy",function(s)
|
||||
uci:foreach("minigate", "subproxy", function(s)
|
||||
local parent = s.parent_domain or ""
|
||||
local prefix = s.prefix or ""
|
||||
local taddr = s.target_addr or ""
|
||||
local tport = s.target_port or "80"
|
||||
if prefix ~= "" and parent ~= "" then
|
||||
local wc = wc_ports[parent] or {}
|
||||
local lport = wc.port or "443"
|
||||
local ssl_val = wc.ssl or "0"
|
||||
local scheme = (ssl_val == "1" or ssl_val == "true") and "https" or "http"
|
||||
proxy_rules[#proxy_rules+1] = {domain=prefix.."."..parent, target=taddr..":"..tport, listen_port=lport, scheme=scheme, ipv6_listen=ipv6_listen}
|
||||
local wc = wc_ports[parent]
|
||||
if prefix ~= "" and wc and (s.enabled or "1") == "1" then
|
||||
proxy_rules[#proxy_rules + 1] = {
|
||||
domain = prefix .. "." .. parent,
|
||||
target = (s.target_addr or "") .. ":" .. (s.target_port or "80"),
|
||||
listen_port = wc.port,
|
||||
scheme = wc.ssl == "1" and "https" or "http",
|
||||
ipv6_listen = ipv6_listen
|
||||
}
|
||||
end
|
||||
end)
|
||||
uci:foreach("minigate","proxy",function(s)
|
||||
if (s.enabled == "1" or s.enabled == true) and s.domain and s.target_addr then
|
||||
local ssl_val = tostring(s.ssl or "0")
|
||||
local scheme = (ssl_val == "1" or ssl_val == "true") and "https" or "http"
|
||||
proxy_rules[#proxy_rules+1] = {domain=s.domain, target=(s.target_addr or "")..":"..(s.target_port or "80"), listen_port=s.listen_port or "443", scheme=scheme, ipv6_listen=ipv6_listen}
|
||||
uci:foreach("minigate", "proxy", function(s)
|
||||
if s.enabled == "1" and s.domain and s.target_addr then
|
||||
proxy_rules[#proxy_rules + 1] = {
|
||||
domain = s.domain,
|
||||
target = (s.target_addr or "") .. ":" .. (s.target_port or "80"),
|
||||
listen_port = s.listen_port or "443",
|
||||
scheme = tostring(s.ssl or "1") == "1" and "https" or "http",
|
||||
ipv6_listen = ipv6_listen
|
||||
}
|
||||
end
|
||||
end)
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({
|
||||
enabled=uci:get("minigate","global","enabled")or"0",
|
||||
ipv6_listen=ipv6_listen,
|
||||
proxy_running=pr,
|
||||
cron_active=ca,
|
||||
recent_log=table.concat(ll,"\n"),
|
||||
ddns_list=dl,
|
||||
proxy_rules=proxy_rules,
|
||||
acme={
|
||||
enabled=uci:get("minigate","acme","enabled")or"0",
|
||||
status=uci:get("minigate","acme","status")or"unknown",
|
||||
last_domain=uci:get("minigate","acme","last_domain")or"",
|
||||
last_issue=uci:get("minigate","acme","last_issue")or"",
|
||||
cert_expiry=uci:get("minigate","acme","cert_expiry")or"",
|
||||
last_error=uci:get("minigate","acme","last_error")or""
|
||||
local proxy_state = read_state(PROXY_STATUS_FILE) or {}
|
||||
local acme_state = read_state(ACME_STATE_FILE) or {}
|
||||
|
||||
write_json({
|
||||
enabled = enabled,
|
||||
ipv6_listen = ipv6_listen,
|
||||
proxy_running = pr,
|
||||
proxy_check = proxy_state.result or "",
|
||||
proxy_message = proxy_state.message or "",
|
||||
proxy_time = proxy_state.time or "",
|
||||
proxy_warnings = proxy_state.warnings or {},
|
||||
ddns_status = worst or "",
|
||||
ddns_list = dl,
|
||||
proxy_rules = proxy_rules,
|
||||
certs = cert_list(),
|
||||
acme = {
|
||||
enabled = uci:get("minigate", "acme", "enabled") or "0",
|
||||
staging = uci:get("minigate", "acme", "staging") or "0",
|
||||
result = acme_state.result or "",
|
||||
domain = acme_state.domain or "",
|
||||
message = acme_state.message or "",
|
||||
time = acme_state.time or ""
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
function action_get_log()
|
||||
local sys=require"luci.sys"; local s=luci.http.formvalue("source")or"all"
|
||||
local fs={ddns="/var/log/minigate-ddns.log",acme="/var/log/minigate-acme.log",proxy="/var/log/minigate-proxy.log /var/log/minigate-nginx-error.log"}
|
||||
if not fs[s] then s="all" end
|
||||
local cmd=s=="all"and"cat /var/log/minigate-*.log 2>/dev/null|tail -200"or"cat "..(fs[s]or"").." 2>/dev/null|tail -200"
|
||||
luci.http.prepare_content("application/json"); luci.http.write_json({log=sys.exec(cmd)or""})
|
||||
-- ============== 日志 ==============
|
||||
|
||||
local LOG_SOURCES = {
|
||||
ddns = {{"DDNS", "/var/log/minigate-ddns.log"}},
|
||||
acme = {{"证书", "/var/log/minigate-acme.log"}},
|
||||
proxy = {{"反代", "/var/log/minigate-proxy.log"}, {"Nginx", "/var/log/minigate-nginx-error.log"}},
|
||||
login_guard = {{"登录防护", "/var/log/minigate-login-guard.log"}},
|
||||
update = {{"更新", "/var/log/minigate-update.log"}}
|
||||
}
|
||||
local LOG_ORDER = {"ddns", "acme", "proxy", "login_guard", "update"}
|
||||
|
||||
local function tail_lines(path, count)
|
||||
local lines = {}
|
||||
local file = io.open(path, "r")
|
||||
if not file then return lines end
|
||||
for line in file:lines() do
|
||||
lines[#lines + 1] = line
|
||||
if #lines > count then table.remove(lines, 1) end
|
||||
end
|
||||
file:close()
|
||||
return lines
|
||||
end
|
||||
|
||||
-- 统一时间戳便于合并:nginx 用 2026/10/28,其余脚本用 2026-10-28
|
||||
local function line_time(line)
|
||||
local stamp = line:match("^(%d%d%d%d[-/]%d%d[-/]%d%d %d%d:%d%d:%d%d)")
|
||||
return stamp and stamp:gsub("/", "-") or nil
|
||||
end
|
||||
|
||||
-- 「全部」按时间合并各来源,每行标出来源;没有时间戳的行(如 curl 输出)跟随上一行
|
||||
function merge_logs(sources, limit)
|
||||
local rows = {}
|
||||
for source_index, source in ipairs(sources) do
|
||||
local last = ""
|
||||
for line_index, line in ipairs(tail_lines(source[2], limit)) do
|
||||
last = line_time(line) or last
|
||||
rows[#rows + 1] = {time = last, source = source_index, index = line_index, text = "[" .. source[1] .. "] " .. line}
|
||||
end
|
||||
end
|
||||
table.sort(rows, function(a, b)
|
||||
if a.time ~= b.time then return a.time < b.time end
|
||||
if a.source ~= b.source then return a.source < b.source end
|
||||
return a.index < b.index
|
||||
end)
|
||||
local out = {}
|
||||
for i = math.max(1, #rows - limit + 1), #rows do out[#out + 1] = rows[i].text end
|
||||
return table.concat(out, "\n")
|
||||
end
|
||||
|
||||
function action_get_log()
|
||||
local source = luci.http.formvalue("source") or "all"
|
||||
local sources = {}
|
||||
if LOG_SOURCES[source] then
|
||||
sources = LOG_SOURCES[source]
|
||||
else
|
||||
for _, name in ipairs(LOG_ORDER) do
|
||||
for _, item in ipairs(LOG_SOURCES[name]) do sources[#sources + 1] = item end
|
||||
end
|
||||
end
|
||||
write_json({log = merge_logs(sources, 300)})
|
||||
end
|
||||
|
||||
-- ============== 证书 ==============
|
||||
|
||||
function action_acme_install()
|
||||
local sys=require"luci.sys"; sys.call("/bin/sh /usr/lib/minigate/acme.sh install 2>&1")
|
||||
local fs=require"nixio.fs"; local ok=fs.access("/etc/minigate/acme/data/acme.sh")and fs.access("/etc/minigate/acme/data/dnsapi/dns_cf.sh")
|
||||
luci.http.prepare_content("application/json"); luci.http.write_json({success=ok,message=ok and"安装成功"or"安装失败"})
|
||||
local ok = start_job("acme", "/bin/sh /usr/lib/minigate/acme.sh install")
|
||||
write_json({success = ok, message = ok and "已开始安装" or "无法启动安装任务"})
|
||||
end
|
||||
|
||||
function action_acme_issue()
|
||||
local sys=require"luci.sys"; local d=luci.http.formvalue("domain")or""
|
||||
local cmd="/bin/sh /usr/lib/minigate/acme.sh issue"; if d~=""then cmd=cmd.." "..shellquote(d)end
|
||||
sys.call(cmd.." 2>&1")
|
||||
local uci=require"luci.model.uci".cursor(); local st=uci:get("minigate","acme","status")or"unknown"
|
||||
local le=uci:get("minigate","acme","last_error")or""
|
||||
local msg=(st=="ok" and le~="")and"现有证书有效;最近一次签发失败,已保留有效证书"or((st=="ok")and"签发成功!"or"签发失败,查看日志")
|
||||
luci.http.prepare_content("application/json"); luci.http.write_json({success=(st=="ok"),message=msg})
|
||||
local d = luci.http.formvalue("domain") or ""
|
||||
if d ~= "" and (#d > 253 or not d:match("^[%*%w][%w%.%-]*$")) then
|
||||
bad_request("域名无效")
|
||||
return
|
||||
end
|
||||
local ok = start_job("acme", "/bin/sh /usr/lib/minigate/acme.sh issue " .. shellquote(d))
|
||||
write_json({success = ok, message = ok and "已开始签发" or "无法启动签发任务"})
|
||||
end
|
||||
|
||||
function action_acme_job()
|
||||
local fs = require "nixio.fs"
|
||||
local data = job_response("acme")
|
||||
local state = read_state(ACME_STATE_FILE) or {}
|
||||
data.result = state.result or ""
|
||||
data.message = state.message or ""
|
||||
data.domain = state.domain or ""
|
||||
data.installed = fs.access("/etc/minigate/acme/data/acme.sh") and fs.access("/etc/minigate/acme/data/dnsapi/dns_cf.sh") and true or false
|
||||
write_json(data)
|
||||
end
|
||||
|
||||
-- ============== DDNS ==============
|
||||
|
||||
local function valid_ddns_section(sec)
|
||||
return sec ~= "" and #sec <= 64 and sec:match("^[%w_]+$") and
|
||||
require"luci.model.uci".cursor():get("minigate", sec) == "ddns"
|
||||
end
|
||||
|
||||
function action_ddns_sync()
|
||||
local sys=require"luci.sys"; local sec=luci.http.formvalue("section")or""
|
||||
if sec~="" and (#sec>64 or not sec:match("^[%w_]+$") or require"luci.model.uci".cursor():get("minigate",sec)~="ddns") then
|
||||
luci.http.status(400,"Bad Request")
|
||||
luci.http.prepare_content("application/json"); luci.http.write_json({success=false,status="invalid",message="DDNS 记录参数无效"})
|
||||
local sec = luci.http.formvalue("section") or ""
|
||||
if not valid_ddns_section(sec) then
|
||||
bad_request("DDNS 记录参数无效", {status = "invalid"})
|
||||
return
|
||||
end
|
||||
if sec~=""then sys.call("/bin/sh /usr/lib/minigate/ddns.sh single "..shellquote(sec).." 2>&1")
|
||||
else sys.call("/bin/sh /usr/lib/minigate/ddns.sh force 2>&1") end
|
||||
local uci=require"luci.model.uci".cursor()
|
||||
local st=sec~=""and(uci:get("minigate",sec,"status")or"unknown")or"done"
|
||||
local ip=sec~=""and(uci:get("minigate",sec,"last_ip")or"")or""
|
||||
local ip6=sec~=""and(uci:get("minigate",sec,"last_ip6")or"")or""
|
||||
luci.http.prepare_content("application/json"); luci.http.write_json({success=(st=="ok"or st=="partial"),status=st,ip=ip,ip6=ip6})
|
||||
local ok = start_job("ddns-" .. sec, "/bin/sh /usr/lib/minigate/ddns.sh single " .. shellquote(sec))
|
||||
write_json({success = ok, message = ok and "已开始同步" or "无法启动同步任务"})
|
||||
end
|
||||
|
||||
function action_ddns_job()
|
||||
local sec = luci.http.formvalue("section") or ""
|
||||
if not valid_ddns_section(sec) then
|
||||
bad_request("DDNS 记录参数无效", {status = "invalid"})
|
||||
return
|
||||
end
|
||||
local uci = require "luci.model.uci".cursor()
|
||||
local data = job_response("ddns-" .. sec)
|
||||
local st = ddns_state(uci:get("minigate", sec, "domain"))
|
||||
data.status = st.status or "unknown"
|
||||
data.message = st.message or ""
|
||||
data.ip = st.ip or ""
|
||||
data.ip6 = st.ip6 or ""
|
||||
data.updated = st.updated or ""
|
||||
data.next = st.next or ""
|
||||
data.success = data.status == "ok" or data.status == "partial"
|
||||
write_json(data)
|
||||
end
|
||||
|
||||
local function history_section_name(value)
|
||||
@@ -302,9 +532,7 @@ function action_ddns_history()
|
||||
local sections = {}
|
||||
|
||||
if #requested > 64 or (requested ~= "" and not requested:match("^[%w_%-]+$")) then
|
||||
luci.http.status(400, "Bad Request")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success=false, message="DDNS 记录参数无效"})
|
||||
bad_request("DDNS 记录参数无效")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -354,8 +582,7 @@ function action_ddns_history()
|
||||
return a.start_time > b.start_time
|
||||
end)
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({
|
||||
write_json({
|
||||
success=true,
|
||||
generated_at=now,
|
||||
window_start=cutoff,
|
||||
@@ -365,6 +592,8 @@ function action_ddns_history()
|
||||
})
|
||||
end
|
||||
|
||||
-- ============== 访问记录 ==============
|
||||
|
||||
function action_proxy_access()
|
||||
local sys = require "luci.sys"
|
||||
local limit = tonumber(luci.http.formvalue("limit") or "5") or 5
|
||||
@@ -440,8 +669,25 @@ function action_proxy_access()
|
||||
}
|
||||
end
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({ visitors = result })
|
||||
write_json({ visitors = result })
|
||||
end
|
||||
|
||||
-- ============== 归属地 ==============
|
||||
|
||||
local GEO_CACHE_DIR = "/tmp/minigate-geo-cache"
|
||||
local GEO_CACHE_MAX = 500
|
||||
|
||||
-- 缓存超过上限时删除最旧的一批,避免长期遭遇扫描时占满内存
|
||||
local function prune_geo_cache()
|
||||
local sys = require "luci.sys"
|
||||
local count = tonumber((sys.exec("ls " .. GEO_CACHE_DIR .. " 2>/dev/null | wc -l") or ""):match("%d+") or "0") or 0
|
||||
if count > GEO_CACHE_MAX then
|
||||
sys.call("cd " .. GEO_CACHE_DIR .. " && ls -tr | head -n 100 | xargs rm -f -- 2>/dev/null")
|
||||
end
|
||||
end
|
||||
|
||||
local function json_field(raw, key)
|
||||
return raw:match('"' .. key .. '"%s*:%s*"([^"]*)"') or ""
|
||||
end
|
||||
|
||||
function action_geo_lookup()
|
||||
@@ -449,73 +695,60 @@ function action_geo_lookup()
|
||||
local fs = require "nixio.fs"
|
||||
local ip = luci.http.formvalue("ip") or ""
|
||||
-- 安全校验:只允许 IP 地址字符
|
||||
if not ip:match("^[%d%.%:a-fA-F]+$") then
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({ ip = ip, geo = "无效IP" })
|
||||
if #ip > 39 or not ip:match("^[%d%.%:a-fA-F]+$") then
|
||||
write_json({ ip = ip, geo = "无效IP" })
|
||||
return
|
||||
end
|
||||
|
||||
local cache_dir = "/tmp/minigate-geo-cache"
|
||||
local cache_file = cache_dir .. "/" .. ip:gsub("[^%w%.%-_:]", "_")
|
||||
fs.mkdirr(cache_dir)
|
||||
local cache_file = GEO_CACHE_DIR .. "/" .. ip:gsub("[^%w%.%-_:]", "_")
|
||||
fs.mkdirr(GEO_CACHE_DIR)
|
||||
local cached = fs.readfile(cache_file)
|
||||
if cached and cached ~= "" then
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({ ip = ip, geo = cached:gsub("%s+$", "") })
|
||||
write_json({ ip = ip, geo = cached:gsub("%s+$", "") })
|
||||
return
|
||||
end
|
||||
|
||||
local geo = nil
|
||||
-- 先查随包的离线省级地址库,命中时不把访客 IP 发给任何外部服务
|
||||
local geo = (sys.exec("/bin/sh /usr/lib/minigate/geo_lookup.sh " .. shellquote(ip) .. " 2>/dev/null") or ""):gsub("%s+$", "")
|
||||
if geo == "" then geo = nil end
|
||||
|
||||
-- API 1: ip9.com.cn(UTF-8,国内快)
|
||||
-- 境外或离线库未覆盖的地址,再用 HTTPS 接口查询
|
||||
if not geo then
|
||||
local raw = sys.exec("curl -s --connect-timeout 1 --max-time 2 'https://ip9.com.cn/get?ip=" .. ip .. "' 2>/dev/null")
|
||||
if raw and raw ~= "" then
|
||||
local country = raw:match('"country":"([^"]*)"') or ""
|
||||
local prov = raw:match('"prov":"([^"]*)"') or ""
|
||||
local city = raw:match('"city":"([^"]*)"') or ""
|
||||
local isp = raw:match('"isp":"([^"]*)"') or ""
|
||||
local loc = (country .. prov .. city):gsub("中国", "")
|
||||
if isp ~= "" then loc = loc .. " " .. isp end
|
||||
loc = loc:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if loc ~= "" then geo = loc end
|
||||
end
|
||||
local raw = sys.exec("curl -s --proto '=https' --connect-timeout 1 --max-time 2 " .. shellquote("https://ip9.com.cn/get?ip=" .. ip) .. " 2>/dev/null") or ""
|
||||
local loc = (json_field(raw, "country") .. json_field(raw, "prov") .. json_field(raw, "city")):gsub("中国", "")
|
||||
local isp = json_field(raw, "isp")
|
||||
if isp ~= "" then loc = loc .. " " .. isp end
|
||||
loc = loc:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if loc ~= "" then geo = loc end
|
||||
end
|
||||
|
||||
-- API 2: ip-api.com(UTF-8,国外覆盖好)
|
||||
if not geo then
|
||||
local raw = sys.exec("curl -s --connect-timeout 1 --max-time 2 'http://ip-api.com/json/" .. ip .. "?lang=zh-CN&fields=status,country,regionName,city,isp' 2>/dev/null")
|
||||
if raw and raw ~= "" then
|
||||
local status = raw:match('"status":"([^"]*)"')
|
||||
if status == "success" then
|
||||
local country = raw:match('"country":"([^"]*)"') or ""
|
||||
local region = raw:match('"regionName":"([^"]*)"') or ""
|
||||
local city = raw:match('"city":"([^"]*)"') or ""
|
||||
local isp = raw:match('"isp":"([^"]*)"') or ""
|
||||
local loc = (country .. region .. city):gsub("中国", "")
|
||||
if isp ~= "" then loc = loc .. " " .. isp end
|
||||
loc = loc:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if loc ~= "" then geo = loc end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- API 3: pconline(GBK 需转码)
|
||||
if not geo then
|
||||
local raw = sys.exec("curl -s --connect-timeout 1 --max-time 2 'https://whois.pconline.com.cn/ipJson.jsp?ip=" .. ip .. "&json=true' 2>/dev/null | iconv -f gbk -t utf-8 2>/dev/null")
|
||||
if raw and raw ~= "" then
|
||||
local addr = raw:match('"addr":"([^"]*)"')
|
||||
if addr and addr ~= "" then geo = addr:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") end
|
||||
end
|
||||
local raw = sys.exec("curl -s --proto '=https' --connect-timeout 1 --max-time 2 " .. shellquote("https://whois.pconline.com.cn/ipJson.jsp?ip=" .. ip .. "&json=true") .. " 2>/dev/null | iconv -f gbk -t utf-8 2>/dev/null") or ""
|
||||
local addr = json_field(raw, "addr")
|
||||
if addr ~= "" then geo = addr:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") end
|
||||
end
|
||||
|
||||
fs.writefile(cache_file, geo or "未知")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({ ip = ip, geo = geo or "未知" })
|
||||
prune_geo_cache()
|
||||
write_json({ ip = ip, geo = geo or "未知" })
|
||||
end
|
||||
|
||||
-- ============== 登录防护 API ==============
|
||||
|
||||
local function valid_ban_ip(ip)
|
||||
if ip:match("^%d+%.%d+%.%d+%.%d+$") then return true end
|
||||
return #ip <= 39 and ip:match("^[%x:]+$") ~= nil and select(2, ip:gsub(":", "")) >= 2
|
||||
end
|
||||
|
||||
local function parse_ban_set(raw, banned)
|
||||
-- nft -j 在 key 和 value 之间有空格,要用 %s* 匹配;按 elem 块配对 val 与 expires
|
||||
for block in raw:gmatch('{[^{}]-"val"%s*:%s*"[^"]+"[^{}]-}') do
|
||||
local ip = block:match('"val"%s*:%s*"([%x:%.]+)"')
|
||||
local expires = tonumber(block:match('"expires"%s*:%s*(%d+)') or "")
|
||||
if ip and expires then banned[#banned + 1] = { ip = ip, remaining = expires } end
|
||||
end
|
||||
end
|
||||
|
||||
function action_lg_status()
|
||||
local sys = require "luci.sys"
|
||||
local fs = require "nixio.fs"
|
||||
@@ -535,28 +768,13 @@ function action_lg_status()
|
||||
local bantime = tonumber(uci:get("minigate","login_guard","bantime")) or 43200
|
||||
local window_s = tonumber(uci:get("minigate","login_guard","window")) or 600
|
||||
|
||||
-- 服务是否在跑
|
||||
-- 服务是否在跑(procd 以 /bin/sh login_guard.sh run 启动)
|
||||
local pid_out = sys.exec("pgrep -f '^/bin/sh /usr/lib/minigate/login_guard.sh run' 2>/dev/null")
|
||||
local running = (pid_out ~= nil and pid_out:match("%d") ~= nil)
|
||||
|
||||
-- 已封禁列表(解析 nft -j 输出)
|
||||
-- 注意:nft -j 在 key 和 value 之间有空格,要用 %s* 匹配
|
||||
-- 每个 elem 内 val 在前 expires 在后,分别提取后按序号配对(避免跨 elem 匹配错乱)
|
||||
local banned = {}
|
||||
local raw = sys.exec("nft -j list set inet fw4 login_banned_v4 2>/dev/null") or ""
|
||||
local ips = {}
|
||||
for v in raw:gmatch('"val"%s*:%s*"([0-9%.]+)"') do
|
||||
ips[#ips+1] = v
|
||||
end
|
||||
local exps = {}
|
||||
for e in raw:gmatch('"expires"%s*:%s*(%d+)') do
|
||||
exps[#exps+1] = tonumber(e)
|
||||
end
|
||||
for i = 1, #ips do
|
||||
if exps[i] then
|
||||
banned[#banned+1] = { ip = ips[i], remaining = exps[i] }
|
||||
end
|
||||
end
|
||||
parse_ban_set(sys.exec("nft -j list set inet fw4 login_banned_v4 2>/dev/null") or "", banned)
|
||||
parse_ban_set(sys.exec("nft -j list set inet fw4 login_banned_v6 2>/dev/null") or "", banned)
|
||||
-- 按剩余时间倒序
|
||||
table.sort(banned, function(a,b) return a.remaining > b.remaining end)
|
||||
local banned_total = #banned
|
||||
@@ -569,28 +787,29 @@ function action_lg_status()
|
||||
local watching = {}
|
||||
local counter_dir = "/var/run/minigate/login-guard/counters"
|
||||
local now = os.time()
|
||||
local list_out = sys.exec("ls " .. counter_dir .. " 2>/dev/null") or ""
|
||||
for ip in list_out:gmatch("[^\n]+") do
|
||||
local path = counter_dir .. "/" .. ip
|
||||
local fh = io.open(path, "r")
|
||||
if fh then
|
||||
local line = fh:read("*l") or ""
|
||||
fh:close()
|
||||
local first, count = line:match("(%d+)%s+(%d+)")
|
||||
if first and count then
|
||||
local first_time = tonumber(first)
|
||||
local stat = fs.stat(path)
|
||||
local last_time = (stat and stat.mtime) or first_time
|
||||
local age = now - first_time
|
||||
if age <= window_s then
|
||||
watching[#watching+1] = {
|
||||
ip = ip,
|
||||
count = tonumber(count),
|
||||
age = age,
|
||||
first_time = first_time,
|
||||
last_time = last_time,
|
||||
last_seen = os.date("%Y-%m-%d %H:%M:%S", last_time)
|
||||
}
|
||||
if fs.dir(counter_dir) then
|
||||
for ip in fs.dir(counter_dir) do
|
||||
local path = counter_dir .. "/" .. ip
|
||||
local fh = io.open(path, "r")
|
||||
if fh then
|
||||
local line = fh:read("*l") or ""
|
||||
fh:close()
|
||||
local first, count = line:match("(%d+)%s+(%d+)")
|
||||
if first and count then
|
||||
local first_time = tonumber(first)
|
||||
local stat = fs.stat(path)
|
||||
local last_time = (stat and stat.mtime) or first_time
|
||||
local age = now - first_time
|
||||
if age <= window_s then
|
||||
watching[#watching+1] = {
|
||||
ip = ip,
|
||||
count = tonumber(count),
|
||||
age = age,
|
||||
first_time = first_time,
|
||||
last_time = last_time,
|
||||
last_seen = os.date("%Y-%m-%d %H:%M:%S", last_time)
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -604,8 +823,7 @@ function action_lg_status()
|
||||
watching_limited[#watching_limited + 1] = watching[i]
|
||||
end
|
||||
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({
|
||||
write_json({
|
||||
enabled = enabled,
|
||||
running = running,
|
||||
threshold = threshold,
|
||||
@@ -623,32 +841,27 @@ end
|
||||
function action_lg_ban()
|
||||
local sys = require "luci.sys"
|
||||
local ip = luci.http.formvalue("ip") or ""
|
||||
if not ip:match("^%d+%.%d+%.%d+%.%d+$") then
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success=false, error="无效 IP"})
|
||||
if not valid_ban_ip(ip) then
|
||||
write_json({success=false, error="无效 IP"})
|
||||
return
|
||||
end
|
||||
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh ban '" .. ip .. "' >/dev/null 2>&1")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success = (rc == 0), ip = ip})
|
||||
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh ban " .. shellquote(ip) .. " >/dev/null 2>&1")
|
||||
write_json({success = (rc == 0), ip = ip})
|
||||
end
|
||||
|
||||
function action_lg_unban()
|
||||
local sys = require "luci.sys"
|
||||
local ip = luci.http.formvalue("ip") or ""
|
||||
if not ip:match("^%d+%.%d+%.%d+%.%d+$") then
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success=false, error="无效 IP"})
|
||||
if not valid_ban_ip(ip) then
|
||||
write_json({success=false, error="无效 IP"})
|
||||
return
|
||||
end
|
||||
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh unban '" .. ip .. "' >/dev/null 2>&1")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success = (rc == 0), ip = ip})
|
||||
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh unban " .. shellquote(ip) .. " >/dev/null 2>&1")
|
||||
write_json({success = (rc == 0), ip = ip})
|
||||
end
|
||||
|
||||
function action_lg_flush()
|
||||
local sys = require "luci.sys"
|
||||
local rc = sys.call("/bin/sh /usr/lib/minigate/login_guard.sh flush >/dev/null 2>&1")
|
||||
luci.http.prepare_content("application/json")
|
||||
luci.http.write_json({success = (rc == 0)})
|
||||
write_json({success = (rc == 0)})
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
local m,s,o
|
||||
local sys=require"luci.sys"
|
||||
local fs=require"nixio.fs"
|
||||
local uc=require"luci.model.uci".cursor()
|
||||
local ctl=require"luci.controller.minigate"
|
||||
|
||||
local function esc(v)
|
||||
v=tostring(v or "")
|
||||
@@ -10,96 +10,99 @@ local function esc(v)
|
||||
return v
|
||||
end
|
||||
|
||||
local function shellquote(v)
|
||||
v=tostring(v or "")
|
||||
return "'"..v:gsub("'","'\"'\"'").."'"
|
||||
local function acme_installed()
|
||||
return fs.access("/etc/minigate/acme/data/acme.sh") and fs.access("/etc/minigate/acme/data/dnsapi/dns_cf.sh")
|
||||
end
|
||||
|
||||
local dd={}
|
||||
uc:foreach("minigate","ddns",function(sec) if sec.domain and sec.domain~=""then dd[#dd+1]=sec.domain end end)
|
||||
|
||||
m=Map("minigate","MiniGate - SSL 证书","ACME(Let's Encrypt)自动签发。域名和令牌自动关联「动态DNS」。")
|
||||
m=Map("minigate","MiniGate - SSL 证书","ACME(Let's Encrypt)自动签发,每天自动续期。域名和令牌自动关联「动态DNS」。")
|
||||
|
||||
s=m:section(SimpleSection); s.template="minigate/style"
|
||||
|
||||
s=m:section(NamedSection,"acme","acme","ACME 设置"); s.anonymous=true
|
||||
|
||||
o=s:option(Flag,"enabled","启用 ACME"); o.rmempty=false
|
||||
|
||||
o=s:option(Value,"email","账户邮箱"); o.placeholder="admin@example.com"; o.rmempty=true
|
||||
o.description="可选。"
|
||||
o.datatype="string"
|
||||
o.description="可选,用于接收 Let's Encrypt 到期提醒。"
|
||||
|
||||
o=s:option(ListValue,"key_type","密钥类型")
|
||||
o:value("ec-256","ECC P-256(推荐)"); o:value("ec-384","ECC P-384"); o:value("rsa-2048","RSA 2048"); o:value("rsa-4096","RSA 4096")
|
||||
o.default="ec-256"
|
||||
|
||||
o=s:option(Flag,"staging","测试模式"); o.rmempty=false; o.default="1"
|
||||
o.description="正式使用请关闭。"
|
||||
o.description="测试模式签发的证书浏览器不信任,只用于验证配置。确认能签发成功后,请关闭测试模式再签发正式证书。"
|
||||
|
||||
-- 证书列表
|
||||
-- 证书列表:到期日期、剩余天数和证书类型直接从证书文件读取
|
||||
o=s:option(DummyValue,"_certs","已签发证书"); o.rawhtml=true
|
||||
o.cfgvalue=function()
|
||||
local cd="/etc/minigate/certs"
|
||||
local h='<table class="table"><tr class="tr table-titles"><th class="th">域名</th><th class="th">过期时间</th><th class="th">路径</th></tr>'
|
||||
local found=false
|
||||
if fs.dir(cd)then
|
||||
for entry in fs.dir(cd)do
|
||||
local fp=cd.."/"..entry
|
||||
-- lstat 返回 table,检查 type 字段
|
||||
local st=fs.lstat(fp)
|
||||
if entry~="_default" and st and st.type=="dir"then
|
||||
local cert=fp.."/fullchain.pem"
|
||||
if fs.access(cert)then
|
||||
found=true
|
||||
local exp=""
|
||||
local f=io.popen("openssl x509 -in "..shellquote(cert).." -noout -enddate 2>/dev/null")
|
||||
if f then local l=f:read("*l"); if l then exp=l:gsub("notAfter=","") end; f:close() end
|
||||
local dn=entry:gsub("_wildcard_%.", "*.")
|
||||
h=h..'<tr class="tr"><td class="td"><strong>'..esc(dn)..'</strong></td><td class="td">'..esc(exp)..'</td><td class="td"><code>'..esc(fp)..'/</code></td></tr>'
|
||||
end
|
||||
end
|
||||
end
|
||||
local certs=ctl.cert_list()
|
||||
local h=''
|
||||
if uc:get("minigate","acme","staging")=="1" then
|
||||
h=h..'<div class="mgx-note">测试模式已开启:签发的证书浏览器不信任。正式使用请关闭测试模式并保存,再点击下方「立即签发」。</div>'
|
||||
end
|
||||
if not found then h=h..'<tr class="tr"><td class="td" colspan="3" style="text-align:center;color:#999;padding:15px">暂无证书</td></tr>' end
|
||||
h=h..'<table class="table"><tr class="tr table-titles"><th class="th">域名</th><th class="th">到期</th><th class="th">类型</th></tr>'
|
||||
for _,c in ipairs(certs) do
|
||||
local left=c.days_left
|
||||
local cls=(left==nil and "mgx-muted") or (left<0 and "mgx-err") or ((left<14 or c.staging) and "mgx-warn") or "mgx-ok"
|
||||
local when=c.expires_date and (c.expires_date..(left<0 and "(已过期)" or "(剩 "..left.." 天)")) or "未知"
|
||||
h=h..'<tr class="tr"><td class="td"><strong>'..esc(c.domain)..'</strong></td><td class="td"><span class="'..cls..'">'..esc(when)..'</span></td>'
|
||||
h=h..'<td class="td">'..(c.staging and '<span class="mgx-warn">测试证书(不受信任)</span>' or '<span class="mgx-ok">正式证书</span>')..'</td></tr>'
|
||||
end
|
||||
if #certs==0 then h=h..'<tr class="tr"><td class="td mgx-muted" colspan="3" style="text-align:center;padding:15px">暂无证书</td></tr>' end
|
||||
return h..'</table>'
|
||||
end
|
||||
|
||||
o=s:option(DummyValue,"_ast","acme.sh 状态"); o.rawhtml=true
|
||||
o.cfgvalue=function()
|
||||
if fs.access("/etc/minigate/acme/data/acme.sh")and fs.access("/etc/minigate/acme/data/dnsapi/dns_cf.sh")then return'<span style="color:#4caf50">✓ 已安装</span>'
|
||||
elseif fs.access("/etc/minigate/acme/data/acme.sh")then return'<span style="color:#ff9800">⚠ 缺DNS插件</span>'
|
||||
else return'<span style="color:#f44336">✗ 未安装</span>'end
|
||||
if acme_installed() then return'<span class="mgx-ok">✓ 已安装</span>'
|
||||
elseif fs.access("/etc/minigate/acme/data/acme.sh")then return'<span class="mgx-warn">⚠ 缺 DNS 插件,请重新安装</span>'
|
||||
else return'<span class="mgx-err">✗ 未安装(首次签发时会自动安装)</span>'end
|
||||
end
|
||||
|
||||
-- 操作
|
||||
-- 操作:签发在后台进行,页面轮询结果,不受网页请求超时影响
|
||||
s=m:section(NamedSection,"acme","acme","操作"); s.anonymous=true
|
||||
o=s:option(DummyValue,"_act"," "); o.rawhtml=true
|
||||
o.cfgvalue=function()
|
||||
local iu=luci.dispatcher.build_url("admin/services/minigate/acme_install")
|
||||
local su=luci.dispatcher.build_url("admin/services/minigate/acme_issue")
|
||||
local ju=luci.dispatcher.build_url("admin/services/minigate/acme_job")
|
||||
local opts=""
|
||||
for _,d in ipairs(dd)do opts=opts..'<option value="'..esc(d)..'">'..esc(d)..'</option>' end
|
||||
return[[
|
||||
<div style="margin-bottom:15px"><button class="cbi-button cbi-button-reload" id="bi" onclick="doI()" style="min-width:220px">下载并安装 acme.sh</button></div>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:10px">
|
||||
<label>签发域名:</label>
|
||||
<select id="sd" style="padding:5px 10px;border-radius:4px;border:1px solid #ccc;min-width:250px">]]..
|
||||
local install_button=acme_installed() and "" or
|
||||
'<div class="mgx-actions"><button type="button" class="cbi-button cbi-button-reload" id="bi" onclick="doI()" style="min-width:220px">下载并安装 acme.sh</button></div>'
|
||||
return install_button..[[
|
||||
<div class="mgx-actions">
|
||||
<label for="sd">签发域名:</label>
|
||||
<select id="sd" class="mgx-select">]]..
|
||||
(opts~=""and opts or'<option value="">请先配置动态DNS</option>')..[[</select>
|
||||
<button class="cbi-button cbi-button-apply" id="bs" onclick="doS()">立即签发 / 续期</button></div>
|
||||
<div id="ap" class="mg-acme-note" style="display:none"><span style="display:inline-block;animation:sp 1s linear infinite;margin-right:8px">⏳</span><span id="am">处理中...</span></div>
|
||||
<div id="ar" style="display:none"><div id="ai" class="mg-acme-note" role="status"></div></div>
|
||||
<style>@keyframes sp{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
|
||||
.mg-acme-note{margin-top:12px;border-radius:4px;padding:10px 15px;font-size:13px;line-height:1.6;background:rgba(127,127,127,.12);border:1px solid rgba(127,127,127,.2)}
|
||||
.mg-acme-note.ok{background:rgba(47,158,68,.12);border-color:rgba(47,158,68,.32);color:#2e7d32}
|
||||
.mg-acme-note.fail{background:rgba(224,49,49,.1);border-color:rgba(224,49,49,.3);color:#c62828}
|
||||
:root[data-darkmode="true"] .mg-acme-note.ok{color:#78c98d}
|
||||
:root[data-darkmode="true"] .mg-acme-note.fail{color:#e58a84}
|
||||
@media(prefers-color-scheme:dark){:root:not([data-darkmode]) .mg-acme-note.ok{color:#78c98d}:root:not([data-darkmode]) .mg-acme-note.fail{color:#e58a84}}
|
||||
</style>
|
||||
<button type="button" class="cbi-button cbi-button-apply" id="bs" onclick="doS()">立即签发</button></div>
|
||||
<div class="mgx-small mgx-muted">证书剩余超过 30 天时不会重复签发,避免触发 Let's Encrypt 频率限制;到期前 30 天内会自动续期。</div>
|
||||
<div id="ap" class="mgx-note is-info" style="display:none"><span class="mgx-spin">⏳</span><span id="am">处理中...</span></div>
|
||||
<div id="ar" style="display:none"><div id="ai" class="mgx-note" role="status"></div></div>
|
||||
<script type="text/javascript">
|
||||
function sP(m){document.getElementById('ap').style.display='block';document.getElementById('ar').style.display='none';document.getElementById('am').textContent=m}
|
||||
function sR(ok,m){document.getElementById('ap').style.display='none';document.getElementById('ar').style.display='block';var e=document.getElementById('ai');e.className='mg-acme-note '+(ok?'ok':'fail');e.textContent=m}
|
||||
function sR(ok,m){document.getElementById('ap').style.display='none';document.getElementById('ar').style.display='block';var e=document.getElementById('ai');e.className='mgx-note '+(ok?'is-ok':'is-err');e.textContent=m}
|
||||
function acmePost(url,args,cb){var done=false;function finish(x,d){if(done)return;done=true;cb(d)}var p=XHR.post(url,args,finish);if(p&&typeof p.catch==='function')p.catch(function(){finish(null,null)})}
|
||||
function doI(){document.getElementById('bi').disabled=true;sP('下载安装中(约30秒)...');acmePost(']]..iu..[[',null,function(d){document.getElementById('bi').disabled=false;d?sR(d.success,d.message):sR(false,'请求未完成:安装可能仍在后台进行,请稍后刷新页面查看 acme.sh 状态');if(d&&d.success)setTimeout(function(){location.reload()},2000)})}
|
||||
function doS(){var d=document.getElementById('sd').value;if(!d){sR(false,'请选择域名');return}document.getElementById('bs').disabled=true;sP('签发 '+d+'(约1-2分钟)...');acmePost(']]..su..[[',{domain:d},function(r){document.getElementById('bs').disabled=false;r?sR(r.success,r.message):sR(false,'请求超时:签发可能仍在后台进行,请稍后刷新页面查看证书状态');if(r&&r.success)setTimeout(function(){location.reload()},2000)})}
|
||||
function acmeGet(url,cb){var done=false;function finish(x,d){if(done)return;done=true;cb(d)}var p=XHR.get(url,null,finish);if(p&&typeof p.catch==='function')p.catch(function(){finish(null,null)})}
|
||||
function acmeWait(button,kind,tries){
|
||||
acmeGet(']]..ju..[[',function(d){
|
||||
if(d&&d.running&&tries<150){setTimeout(function(){acmeWait(button,kind,tries+1)},2000);return}
|
||||
if(button)button.disabled=false;
|
||||
if(!d){sR(false,'无法读取任务结果,请稍后刷新页面查看');return}
|
||||
if(d.running){sR(false,'任务仍在进行,请稍后刷新页面查看');return}
|
||||
var ok,msg;
|
||||
if(kind==='install'){ok=d.code===0&&d.installed;msg=ok?'安装成功':'安装失败,请查看 ACME 日志';}
|
||||
else{ok=d.code===0&&d.result==='ok';msg=d.message||(ok?'签发成功':'签发失败,请查看 ACME 日志');}
|
||||
sR(ok,msg);
|
||||
if(ok)setTimeout(function(){location.reload()},2500);
|
||||
});
|
||||
}
|
||||
function doI(){var b=document.getElementById('bi');b.disabled=true;sP('正在下载并校验 acme.sh(约30秒)...');acmePost(']]..iu..[[',null,function(d){if(!d||!d.success){b.disabled=false;sR(false,(d&&d.message)||'无法启动安装任务');return}setTimeout(function(){acmeWait(b,'install',0)},2000)})}
|
||||
function doS(){var d=document.getElementById('sd').value;if(!d){sR(false,'请选择域名');return}var b=document.getElementById('bs');b.disabled=true;sP('正在签发 '+d+'(约1-2分钟,可离开本页)...');acmePost(']]..su..[[',{domain:d},function(r){if(!r||!r.success){b.disabled=false;sR(false,(r&&r.message)||'无法启动签发任务');return}setTimeout(function(){acmeWait(b,'issue',0)},3000)})}
|
||||
</script>]]
|
||||
end
|
||||
|
||||
|
||||
@@ -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='<span style="color:#4caf50">✓ 正常</span>'
|
||||
elseif st=="partial"then
|
||||
h='<span style="color:#ff9800">⚠ 部分成功</span>'
|
||||
elseif st=="error"then
|
||||
h='<span style="color:#f44336">✗ '..esc(msg~=""and msg or"异常")..'</span>'
|
||||
else h='<span style="color:#999">未同步</span>' end
|
||||
|
||||
-- 显示 IP 地址
|
||||
if ip~=""then h=h..'<br><span style="font-size:11px;color:#888">A: '..esc(ip)..'</span>' end
|
||||
if ip6~=""then h=h..'<br><span style="font-size:11px;color:#2196f3">AAAA: '..esc(ip6)..'</span>' end
|
||||
if msg~=""and st~="error"then h=h..'<br><span style="font-size:11px;color:#888">'..esc(msg)..'</span>' end
|
||||
if lu~=""then h=h..'<br><span style="font-size:11px;color:#888">更新: '..esc(lu)..'</span>' end
|
||||
if ns~=""then h=h..'<br><span style="font-size:11px;color:#888">下次: '..esc(ns)..'</span>' end
|
||||
|
||||
h=h..'<br><button class="cbi-button cbi-button-action" style="margin-top:4px;font-size:12px;padding:2px 10px" '
|
||||
h=h..'onclick="doSync(\''..section..'\',this)">手动同步</button>'
|
||||
h=h..'<span id="sr-'..section..'" style="margin-left:8px;font-size:12px"></span>'
|
||||
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='<span id="ss-'..section..'"><span class="'..label[2]..'">'..label[1]..'</span>'
|
||||
if st.status=="error" and (st.message or "")~="" then h=h..' <span class="mgx-err">'..esc(st.message)..'</span>' end
|
||||
if (st.ip or "")~="" then h=h..'<span class="mgx-small mgx-muted">A: '..esc(st.ip)..'</span>' end
|
||||
if (st.ip6 or "")~="" then h=h..'<span class="mgx-small mgx-info">AAAA: '..esc(st.ip6)..'</span>' end
|
||||
if (st.message or "")~="" and st.status~="error" then h=h..'<span class="mgx-small mgx-muted">'..esc(st.message)..'</span>' end
|
||||
if (st.updated or "")~="" then h=h..'<span class="mgx-small mgx-muted">更新: '..esc(st.updated)..'</span>' end
|
||||
if (st.next or "")~="" then h=h..'<span class="mgx-small mgx-muted">下次: '..esc(st.next)..'</span>' end
|
||||
h=h..'</span><br><button type="button" class="cbi-button cbi-button-action mgx-sync" onclick="doSync(\''..section..'\',this)">手动同步</button>'
|
||||
h=h..'<span id="sr-'..section..'" class="mgx-sync-result" role="status"></span>'
|
||||
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[[<script type="text/javascript">
|
||||
function doSync(s,b){
|
||||
var e=document.getElementById("sr-"+s),done=false;
|
||||
function show(ok,text){var span=document.createElement("span");span.style.color=ok?"#4caf50":"#f44336";span.textContent=text;e.textContent="";e.appendChild(span);}
|
||||
function finish(x,d){
|
||||
if(done)return;done=true;
|
||||
b.disabled=false;b.textContent="手动同步";
|
||||
if(d&&d.success){
|
||||
show(true,"\u2713 "+[d.ip,d.ip6].filter(Boolean).join(" / "));
|
||||
setTimeout(function(){location.reload()},1500);
|
||||
}else{
|
||||
show(false,"\u2717 "+((d&&d.message)||(d?"同步失败,查看日志":"请求失败,请检查网络后重试")));
|
||||
}
|
||||
}
|
||||
b.disabled=true;b.textContent="同步中...";e.textContent="";
|
||||
var p=XHR.post("]]..url..[[",{section:s},finish);
|
||||
var mgFormDirty=false;
|
||||
document.addEventListener('change',function(e){if(e.target&&e.target.form&&e.target.className.indexOf('mgx-')<0)mgFormDirty=true;},true);
|
||||
function mgEsc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
|
||||
function mgShowState(s,d){
|
||||
var box=document.getElementById("ss-"+s);if(!box||!d)return;
|
||||
var labels={ok:['✓ 正常','mgx-ok'],partial:['⚠ 部分成功','mgx-warn'],error:['✗ 异常','mgx-err'],stopped:['已停止','mgx-muted']};
|
||||
var l=labels[d.status]||['未同步','mgx-muted'];
|
||||
var h='<span class="'+l[1]+'">'+l[0]+'</span>';
|
||||
if(d.status=='error'&&d.message)h+=' <span class="mgx-err">'+mgEsc(d.message)+'</span>';
|
||||
if(d.ip)h+='<span class="mgx-small mgx-muted">A: '+mgEsc(d.ip)+'</span>';
|
||||
if(d.ip6)h+='<span class="mgx-small mgx-info">AAAA: '+mgEsc(d.ip6)+'</span>';
|
||||
if(d.message&&d.status!='error')h+='<span class="mgx-small mgx-muted">'+mgEsc(d.message)+'</span>';
|
||||
if(d.updated)h+='<span class="mgx-small mgx-muted">更新: '+mgEsc(d.updated)+'</span>';
|
||||
if(d.next)h+='<span class="mgx-small mgx-muted">下次: '+mgEsc(d.next)+'</span>';
|
||||
box.innerHTML=h;
|
||||
}
|
||||
function mgRequest(method,url,args,cb){
|
||||
var done=false;
|
||||
function finish(x,d){if(done)return;done=true;cb(d);}
|
||||
var p=XHR[method](url,args,finish);
|
||||
if(p&&typeof p.catch==="function")p.catch(function(){finish(null,null)});
|
||||
}
|
||||
// 同步在后台进行,完成后原地更新状态,不刷新页面,表单里未保存的修改不会丢
|
||||
function doSync(s,b){
|
||||
var e=document.getElementById("sr-"+s),tries=0;
|
||||
function show(cls,text){e.innerHTML='<span class="'+cls+'">'+mgEsc(text)+'</span>';}
|
||||
function done(){b.disabled=false;b.textContent="手动同步";}
|
||||
if(mgFormDirty&&!confirm("表单里有尚未保存的修改。手动同步使用已保存的配置,确定继续吗?"))return;
|
||||
b.disabled=true;b.textContent="同步中...";e.textContent="";
|
||||
function poll(){
|
||||
mgRequest('get',"]]..job..[[",{section:s},function(d){
|
||||
if(d&&d.running&&tries++<90){setTimeout(poll,2000);return;}
|
||||
done();
|
||||
if(!d){show('mgx-err',"✗ 无法读取同步结果,请稍后刷新页面");return;}
|
||||
if(d.running){show('mgx-warn',"仍在同步,请稍后刷新页面查看");return;}
|
||||
mgShowState(s,d);
|
||||
if(d.success)show('mgx-ok',"✓ 同步完成 "+[d.ip,d.ip6].filter(Boolean).join(" / "));
|
||||
else show('mgx-err',"✗ "+(d.message||"同步失败,查看日志"));
|
||||
});
|
||||
}
|
||||
mgRequest('post',"]]..url..[[",{section:s},function(d){
|
||||
if(!d||!d.success){done();show('mgx-err',"✗ "+((d&&d.message)||"请求失败,请检查网络后重试"));return;}
|
||||
setTimeout(poll,1500);
|
||||
});
|
||||
}
|
||||
</script>]]
|
||||
end
|
||||
|
||||
|
||||
@@ -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 [[
|
||||
<style>
|
||||
.mg-wrap{display:flex;flex-direction:column;gap:16px;--mg-success:#2f9e44;--mg-info:#1971c2;--mg-warning:#f08c00;--mg-danger:#e03131;--mg-danger-solid:#c92a2a;--mg-muted:#6b7280;--mg-link:#15803d;--mg-accent:#7c3aed;--mg-surface:#fff;--mg-surface-muted:#f6f8fb;--mg-surface-alt:#f8fafc;--mg-surface-hover:#eef4ff;--mg-control:#fff;--mg-text:#1f2937;--mg-text-soft:#2f3a47;--mg-text-muted:#5f6b7a;--mg-border:#d7dce3;--mg-border-soft:#e6eaf0;--mg-code-bg:#e8edf6;--mg-shadow:0 10px 24px rgba(15,23,42,.08)}
|
||||
.mg-map{--mg-danger:#c92a2a;--mg-danger-hover:#fff5f5;--mg-danger-border:#f1b8b8}
|
||||
.mg-page-head{display:flex;align-items:flex-start;justify-content:space-between;gap:28px}
|
||||
.mg-page-head-copy{min-width:0;flex:1}
|
||||
.mg-page-head-copy>h2{margin-top:0}
|
||||
.mg-title-action{display:flex;flex:0 0 auto;margin-top:15px}
|
||||
.mg-wrap{--mg-danger-hover:#fff5f5;--mg-danger-border:#f1b8b8}
|
||||
.mg-notice{padding:12px 16px;border-radius:8px;border:1px solid var(--mg-border);border-left:4px solid var(--mg-warning);background:var(--mg-surface);color:var(--mg-text);font-size:13px;line-height:1.6}
|
||||
.mg-notice[hidden]{display:none}
|
||||
.mg-row{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
|
||||
.mg-dot{display:inline-block;flex:0 0 auto;width:8px;height:8px;border-radius:50%;transform:translateY(-1px)}
|
||||
.mg-sub{color:var(--mg-text-muted)}
|
||||
.mg-warn-text{color:var(--mg-warning)}
|
||||
.mg-danger-text{color:var(--mg-danger)}
|
||||
.mg-uninstall-trigger{min-width:112px;min-height:38px;border:1px solid var(--mg-danger-border)!important;border-radius:6px!important;background:transparent!important;color:var(--mg-danger)!important;font-weight:600}
|
||||
.mg-uninstall-trigger:hover,.mg-uninstall-trigger:focus{background:var(--mg-danger-hover)!important}
|
||||
.mg-status-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}
|
||||
@@ -90,13 +92,13 @@ o.cfgvalue = function()
|
||||
.mg-danger-button:hover{filter:brightness(.94)}
|
||||
.mg-danger-button:focus{outline-color:var(--mg-danger)!important}
|
||||
:root[data-darkmode="true"] .mg-wrap{--mg-success:#78c98d;--mg-info:#7db7df;--mg-warning:#d7a64a;--mg-danger:#e58a84;--mg-danger-solid:#a9504d;--mg-muted:#9aa7b3;--mg-link:#7bcf91;--mg-accent:#b69af4;--mg-surface:var(--background-color-medium,#171e26);--mg-surface-muted:var(--background-color-high,#131a22);--mg-surface-alt:var(--background-color-low,#19212a);--mg-surface-hover:#202a34;--mg-control:var(--background-color-high,#111820);--mg-text:var(--text-color-high,#d5dbe3);--mg-text-soft:var(--text-color-high,#c8d0d9);--mg-text-muted:var(--text-color-medium,#9aa7b3);--mg-border:var(--border-color-medium,rgba(148,163,184,.18));--mg-border-soft:var(--border-color-low,rgba(148,163,184,.12));--mg-code-bg:var(--background-color-low,#253142);--mg-shadow:none}
|
||||
:root[data-darkmode="true"] .mg-map{--mg-danger:#e58a84;--mg-danger-hover:rgba(229,138,132,.1);--mg-danger-border:rgba(229,138,132,.45)}
|
||||
:root[data-darkmode="true"] .mg-wrap{--mg-danger-hover:rgba(229,138,132,.1);--mg-danger-border:rgba(229,138,132,.45)}
|
||||
@media(prefers-color-scheme:dark){
|
||||
:root:not([data-darkmode]) .mg-wrap{--mg-success:#78c98d;--mg-info:#7db7df;--mg-warning:#d7a64a;--mg-danger:#e58a84;--mg-danger-solid:#a9504d;--mg-muted:#9aa7b3;--mg-link:#7bcf91;--mg-accent:#b69af4;--mg-surface:#171e26;--mg-surface-muted:#131a22;--mg-surface-alt:#19212a;--mg-surface-hover:#202a34;--mg-control:#111820;--mg-text:#d5dbe3;--mg-text-soft:#c8d0d9;--mg-text-muted:#9aa7b3;--mg-border:rgba(148,163,184,.18);--mg-border-soft:rgba(148,163,184,.12);--mg-code-bg:#253142;--mg-shadow:none}
|
||||
:root:not([data-darkmode]) .mg-map{--mg-danger:#e58a84;--mg-danger-hover:rgba(229,138,132,.1);--mg-danger-border:rgba(229,138,132,.45)}
|
||||
:root:not([data-darkmode]) .mg-wrap{--mg-danger-hover:rgba(229,138,132,.1);--mg-danger-border:rgba(229,138,132,.45)}
|
||||
}
|
||||
@media(max-width:900px){.mg-status-grid{grid-template-columns:1fr}.mg-card{min-height:auto}}
|
||||
@media(max-width:640px){.mg-page-head{flex-direction:column;gap:12px}.mg-title-action{width:100%;margin-top:0}.mg-uninstall-trigger{width:100%}.mg-modal{padding:12px}.mg-modal-dialog{max-height:88vh}.mg-modal-actions{display:grid;grid-template-columns:1fr 1fr}.mg-modal-actions button{width:100%;min-width:0}
|
||||
@media(max-width:640px){.mg-uninstall-trigger{width:100%}.mg-update-actions{width:100%}.mg-modal{padding:12px}.mg-modal-dialog{max-height:88vh}.mg-modal-actions{display:grid;grid-template-columns:1fr 1fr}.mg-modal-actions button{width:100%;min-width:0}
|
||||
.mg-table{min-width:0}.mg-table thead{display:none}
|
||||
.mg-table tbody tr{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;padding:12px 14px;border-bottom:1px solid var(--mg-border-soft)}
|
||||
.mg-table tbody tr:nth-child(even){background:var(--mg-surface-alt)}
|
||||
@@ -113,9 +115,7 @@ o.cfgvalue = function()
|
||||
</style>
|
||||
|
||||
<div class="mg-wrap">
|
||||
<div id="mg-title-action" class="mg-title-action" hidden>
|
||||
<button type="button" id="mg-uninstall-open" class="cbi-button mg-uninstall-trigger" onclick="mgOpenUninstallDialog()">卸载</button>
|
||||
</div>
|
||||
<div id="mg-disabled" class="mg-notice" role="status" hidden>MiniGate 尚未启用:勾选上方「启用 MiniGate」,再点击页面底部的「保存并应用」。</div>
|
||||
<div id="mg" class="mg-status-grid">
|
||||
<div id="mg-ddns-card" class="mg-card" style="--accent:var(--mg-success)">
|
||||
<div class="mg-card-title">动态 DNS</div>
|
||||
@@ -177,15 +177,28 @@ o.cfgvalue = function()
|
||||
<div id="mg-v-list" class="mg-table-wrap"><div class="mg-empty">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<div id="mg-danger" class="mg-panel">
|
||||
<div class="mg-panel-head"><div class="mg-panel-title">危险操作</div></div>
|
||||
<div class="mg-update-body">
|
||||
<div class="mg-update-copy">
|
||||
<div class="mg-update-version">卸载 MiniGate</div>
|
||||
<div class="mg-update-message">停止所有服务并移除程序;卸载时可选择是否保留配置、证书和封禁记录。</div>
|
||||
</div>
|
||||
<div class="mg-update-actions">
|
||||
<button type="button" id="mg-uninstall-open" class="cbi-button mg-uninstall-trigger" onclick="mgOpenUninstallDialog()">卸载</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mg-uninstall-modal" class="mg-modal" role="dialog" aria-modal="true" aria-hidden="true" aria-labelledby="mg-uninstall-modal-title">
|
||||
<div class="mg-modal-dialog">
|
||||
<div class="mg-modal-head">
|
||||
<h3 id="mg-uninstall-modal-title" class="mg-modal-title">卸载 minigate</h3>
|
||||
<h3 id="mg-uninstall-modal-title" class="mg-modal-title">卸载 MiniGate</h3>
|
||||
<button type="button" id="mg-uninstall-close" class="mg-modal-close" aria-label="关闭卸载确认" title="关闭卸载确认" onclick="mgCloseUninstallDialog()">×</button>
|
||||
</div>
|
||||
<div class="mg-uninstall-body">
|
||||
<p>卸载会停止动态 DNS、证书续期、反向代理和登录防护,并移除 minigate 的程序及 LuCI 页面。</p>
|
||||
<div class="mg-uninstall-warning">由 minigate 提供的公网访问会立即中断。</div>
|
||||
<p>卸载会停止动态 DNS、证书续期、反向代理和登录防护,并移除 MiniGate 的程序及 LuCI 页面。</p>
|
||||
<div class="mg-uninstall-warning">由 MiniGate 提供的公网访问会立即中断。</div>
|
||||
<label class="mg-uninstall-choice" for="mg-uninstall-purge">
|
||||
<input type="checkbox" id="mg-uninstall-purge" onchange="mgUpdateUninstallChoice()">
|
||||
<span>同时删除配置、证书、封禁记录和日志</span>
|
||||
@@ -338,33 +351,6 @@ var _updateReloading=false;
|
||||
var _uninstallDialogOpener=null;
|
||||
var _uninstallStarting=false;
|
||||
|
||||
function mgPreparePageHeader(){
|
||||
var action=document.getElementById('mg-title-action');
|
||||
var wrap=document.querySelector('.mg-wrap');
|
||||
if(!action||!wrap)return;
|
||||
var map=wrap;
|
||||
while(map&&(' '+map.className+' ').indexOf(' cbi-map ')<0)map=map.parentNode;
|
||||
if(!map)return;
|
||||
var title=null,description=null;
|
||||
for(var i=0;i<map.children.length;i++){
|
||||
var child=map.children[i];
|
||||
if(!title&&child.tagName&&child.tagName.toLowerCase()==='h2')title=child;
|
||||
else if(title&&(' '+child.className+' ').indexOf(' cbi-map-descr ')>=0){description=child;break;}
|
||||
}
|
||||
if(!title)return;
|
||||
var head=document.createElement('div');
|
||||
var copy=document.createElement('div');
|
||||
head.className='mg-page-head';
|
||||
copy.className='mg-page-head-copy';
|
||||
map.className+=' mg-map';
|
||||
map.insertBefore(head,title);
|
||||
head.appendChild(copy);
|
||||
copy.appendChild(title);
|
||||
if(description)copy.appendChild(description);
|
||||
action.hidden=false;
|
||||
head.appendChild(action);
|
||||
}
|
||||
|
||||
function mgOpenUninstallDialog(){
|
||||
if(_uninstallStarting)return;
|
||||
var modal=document.getElementById('mg-uninstall-modal');
|
||||
@@ -419,7 +405,7 @@ function mgStartUninstall(){
|
||||
return;
|
||||
}
|
||||
confirm.textContent='卸载中...';
|
||||
status.textContent='正在移除 minigate,完成后将返回服务页面。';
|
||||
status.textContent='正在移除 MiniGate,完成后将返回服务页面。';
|
||||
setTimeout(function(){location.href=']] .. services_url .. [[';},3000);
|
||||
});
|
||||
}
|
||||
@@ -564,8 +550,6 @@ document.addEventListener('keydown',function(e){
|
||||
}
|
||||
});
|
||||
|
||||
mgPreparePageHeader();
|
||||
|
||||
var limitSel=document.getElementById('mg-v-limit');
|
||||
if(limitSel){
|
||||
limitSel.value=_visitorLimit;
|
||||
@@ -595,48 +579,106 @@ function mgLoadStatus(){
|
||||
});
|
||||
}
|
||||
|
||||
function mgRenderStatus(d){
|
||||
var d1=document.getElementById('mg-d1'),d2=document.getElementById('mg-d2');
|
||||
var card=document.getElementById('mg-ddns-card');
|
||||
if(d.ddns_list&&d.ddns_list.length>0){
|
||||
var e=d.ddns_list[0];
|
||||
var c=e.status=='ok'?mgColor('success'):(e.status=='partial'?mgColor('warning'):(e.enabled=='1'?mgColor('danger'):mgColor('muted')));
|
||||
var l=e.status=='ok'?'\u2713 \u8fd0\u884c\u4e2d':(e.status=='partial'?'\u26a0 \u90e8\u5206\u6210\u529f':(e.enabled=='1'?'\u26a0 \u5f02\u5e38':'\u672a\u542f\u7528'));
|
||||
card.style.setProperty('--accent',c);
|
||||
d1.innerHTML='<span style="color:'+c+'">'+l+'</span>';
|
||||
var info=(e.domain||'')+'\n';
|
||||
if(e.last_ip)info+='A: '+e.last_ip+'\n';
|
||||
if(e.last_ip6)info+='AAAA: '+e.last_ip6+'\n';
|
||||
var sm=e.status_msg||'';
|
||||
if(sm){
|
||||
var dupA=e.last_ip&&sm.indexOf('A:'+e.last_ip)>=0;
|
||||
var dupAAAA=e.last_ip6&&sm.indexOf('AAAA:'+e.last_ip6)>=0;
|
||||
var onlyIpStatus=sm.replace(/A:[^;]+;?/g,'').replace(/AAAA:[^;]+;?/g,'').replace(/\s+/g,'')=='';
|
||||
if(!(onlyIpStatus&&(dupA||dupAAAA)))info+=sm+'\n';
|
||||
var MG_DDNS_STATE={ok:['\u2713 正常','success'],partial:['\u26a0 部分成功','warning'],error:['\u26a0 异常','danger'],stopped:['已停止','muted'],unknown:['未同步','muted'],disabled:['未启用','muted']};
|
||||
|
||||
function mgStateHtml(label,color){
|
||||
return '<span style="color:'+mgColor(color)+'">'+escHtml(label)+'</span>';
|
||||
}
|
||||
if(e.last_update)info+='\u66f4\u65b0: '+e.last_update+'\n';
|
||||
if(e.next_sync)info+='\u4e0b\u6b21: '+e.next_sync;
|
||||
if(d.ddns_list.length>1)info+='\n(+'+(d.ddns_list.length-1)+' \u6761\u8bb0\u5f55)';
|
||||
d2.innerHTML=escHtml(info).replace(/\n/g,'<br>');
|
||||
}else{d1.innerHTML='<span style="color:'+mgColor('muted')+'">\u672a\u914d\u7f6e</span>';d2.textContent='';card.style.setProperty('--accent',mgColor('muted'));}
|
||||
|
||||
var a1=document.getElementById('mg-a1'),a2=document.getElementById('mg-a2');
|
||||
if(d.acme&&d.acme.enabled=='1'){
|
||||
a1.innerHTML=d.acme.status=='ok'?'<span style="color:'+mgColor('info')+'">\u2713 \u6709\u6548</span>':'<span style="color:'+mgColor('danger')+'">'+escHtml(d.acme.status)+'</span>';
|
||||
a2.innerHTML=escHtml(d.acme.last_domain||'')+(d.acme.cert_expiry?'<br>\u8fc7\u671f: '+escHtml(d.acme.cert_expiry):'');
|
||||
}else{a1.innerHTML='<span style="color:'+mgColor('muted')+'">\u672a\u542f\u7528</span>';a2.textContent='';}
|
||||
function mgDot(color){
|
||||
return '<span class="mg-dot" style="background:'+mgColor(color)+'"></span>';
|
||||
}
|
||||
|
||||
var p1=document.getElementById('mg-p1'),p2=document.getElementById('mg-p2');
|
||||
p1.innerHTML=d.proxy_running?'<span style="color:'+mgColor('warning')+'">\u2713 \u8fd0\u884c\u4e2d</span>':'<span style="color:'+mgColor('muted')+'">\u5df2\u505c\u6b62</span>';
|
||||
var pinfo='';
|
||||
if(d.proxy_rules&&d.proxy_rules.length>0){
|
||||
for(var i=0;i<d.proxy_rules.length;i++){
|
||||
var r=d.proxy_rules[i];
|
||||
var url=r.scheme+'://'+r.domain+(r.listen_port!='443'&&r.listen_port!='80'?':'+r.listen_port:'');
|
||||
var v6tag=r.ipv6_listen=='1'?' <span style="color:'+mgColor('info')+';font-size:10px">[IPv6]</span>':'';
|
||||
pinfo+='<div><span class="mg-link">'+escHtml(url)+'</span>'+v6tag+' \u2192 '+escHtml(r.target)+'</div>';
|
||||
}}else if(d.proxy_running){pinfo='Nginx \u8fd0\u884c\u4e2d';}
|
||||
p2.innerHTML=pinfo;
|
||||
function mgRenderDdns(d){
|
||||
var d1=document.getElementById('mg-d1'),d2=document.getElementById('mg-d2');
|
||||
var card=document.getElementById('mg-ddns-card');
|
||||
var list=mgList(d.ddns_list)||[];
|
||||
if(!list.length){
|
||||
d1.innerHTML=mgStateHtml('未配置','muted');d2.textContent='';
|
||||
card.style.setProperty('--accent',mgColor('muted'));
|
||||
return;
|
||||
}
|
||||
// 卡片颜色取所有已启用记录中最差的状态,任一记录异常都能看到
|
||||
var overall=MG_DDNS_STATE[d.ddns_status||'disabled']||MG_DDNS_STATE.unknown;
|
||||
card.style.setProperty('--accent',mgColor(overall[1]));
|
||||
d1.innerHTML=mgStateHtml(overall[0],overall[1]);
|
||||
var h='';
|
||||
for(var i=0;i<list.length;i++){
|
||||
var e=list[i],st=MG_DDNS_STATE[e.status]||MG_DDNS_STATE.unknown;
|
||||
var ips=[e.last_ip?'A '+e.last_ip:'',e.last_ip6?'AAAA '+e.last_ip6:''].filter(Boolean).join(' · ');
|
||||
h+='<div class="mg-row">'+mgDot(st[1])+'<strong>'+escHtml(e.domain||'--')+'</strong>'+(ips?'<span class="mg-sub">'+escHtml(ips)+'</span>':'')+'</div>';
|
||||
if(e.status_msg&&(e.status=='error'||e.status=='partial'))h+='<div class="mg-'+(e.status=='error'?'danger':'warn')+'-text">'+escHtml(e.status_msg)+'</div>';
|
||||
if(e.last_update||e.next_sync)h+='<div class="mg-sub">'+escHtml((e.last_update?'更新 '+e.last_update:'')+(e.next_sync?' · 下次 '+e.next_sync:''))+'</div>';
|
||||
}
|
||||
d2.innerHTML=h;
|
||||
}
|
||||
|
||||
function mgCertLine(c){
|
||||
var left=typeof c.days_left==='number'?c.days_left:null;
|
||||
var color=left===null?'muted':(left<0?'danger':(left<14||c.staging?'warning':'success'));
|
||||
var when=c.expires_date?('到期 '+c.expires_date+(left===null?'':(left<0?'(已过期)':'(剩 '+left+' 天)'))):'到期时间未知';
|
||||
return '<div class="mg-row">'+mgDot(color)+'<strong>'+escHtml(c.domain)+'</strong><span class="mg-sub">'+escHtml(when)+'</span>'+(c.staging?'<span class="mg-warn-text">测试证书,浏览器不信任</span>':'')+'</div>';
|
||||
}
|
||||
|
||||
function mgRenderCerts(d){
|
||||
var a1=document.getElementById('mg-a1'),a2=document.getElementById('mg-a2');
|
||||
var card=document.getElementById('mg-cert-card');
|
||||
var certs=mgList(d.certs)||[];
|
||||
var acme=d.acme||{};
|
||||
var label,color;
|
||||
if(certs.length){
|
||||
var expired=false,soon=false,staging=false;
|
||||
for(var i=0;i<certs.length;i++){
|
||||
var left=certs[i].days_left;
|
||||
if(typeof left==='number'&&left<0)expired=true;
|
||||
else if(typeof left==='number'&&left<14)soon=true;
|
||||
if(certs[i].staging)staging=true;
|
||||
}
|
||||
if(expired){label='\u26a0 有证书已过期';color='danger';}
|
||||
else if(soon){label='\u26a0 即将到期';color='warning';}
|
||||
else if(staging){label='\u26a0 测试证书';color='warning';}
|
||||
else{label='\u2713 有效';color='success';}
|
||||
}else if(acme.enabled=='1'){label='暂无证书';color='muted';}
|
||||
else{label='未启用';color='muted';}
|
||||
card.style.setProperty('--accent',mgColor(color));
|
||||
a1.innerHTML=mgStateHtml(label,color);
|
||||
var h='';
|
||||
for(var j=0;j<certs.length;j++)h+=mgCertLine(certs[j]);
|
||||
if(acme.result=='error'||acme.result=='partial')h+='<div class="mg-'+(acme.result=='error'?'danger':'warn')+'-text">最近一次签发:'+escHtml(acme.message)+'</div>';
|
||||
a2.innerHTML=h;
|
||||
}
|
||||
|
||||
function mgRenderProxy(d){
|
||||
var p1=document.getElementById('mg-p1'),p2=document.getElementById('mg-p2');
|
||||
var card=document.getElementById('mg-proxy-card');
|
||||
var warnings=mgList(d.proxy_warnings)||[];
|
||||
var label,color;
|
||||
if(!d.proxy_running){label='已停止';color='muted';}
|
||||
else if(d.proxy_check=='error'){label='\u26a0 运行中,最近一次配置未生效';color='danger';}
|
||||
else if(warnings.length){label='\u26a0 运行中,有规则未生效';color='warning';}
|
||||
else{label='\u2713 运行中';color='success';}
|
||||
card.style.setProperty('--accent',mgColor(color));
|
||||
p1.innerHTML=mgStateHtml(label,color);
|
||||
var h='';
|
||||
var rules=mgList(d.proxy_rules)||[];
|
||||
for(var i=0;i<rules.length;i++){
|
||||
var r=rules[i];
|
||||
var url=r.scheme+'://'+r.domain+(r.listen_port!='443'&&r.listen_port!='80'?':'+r.listen_port:'');
|
||||
var v6tag=r.ipv6_listen=='1'?' <span class="mg-sub" style="font-size:10px">[IPv6]</span>':'';
|
||||
h+='<div><a class="mg-link" href="'+escHtml(url)+'" target="_blank" rel="noopener noreferrer">'+escHtml(url)+'</a>'+v6tag+' \u2192 '+escHtml(r.target)+'</div>';
|
||||
}
|
||||
if(!rules.length&&d.proxy_running)h='Nginx 运行中,暂无代理规则';
|
||||
if(d.proxy_check=='error'&&d.proxy_message)h+='<div class="mg-danger-text">'+escHtml(d.proxy_message)+'</div>';
|
||||
for(var k=0;k<warnings.length;k++)h+='<div class="mg-warn-text">'+escHtml(warnings[k])+'</div>';
|
||||
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();
|
||||
</script>
|
||||
]] 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
|
||||
@@ -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()
|
||||
<option value="30">30 条</option>
|
||||
</select>
|
||||
</label>
|
||||
<input id="lg-ban-input" class="lg-input" type="text" placeholder="输入 IP 手动封禁" />
|
||||
<button class="lg-btn lg-btn-primary" onclick="lgManualBan()">封禁</button>
|
||||
<button class="lg-btn lg-btn-danger" onclick="lgFlushAll()">清空全部</button>
|
||||
<input id="lg-ban-input" class="lg-input" type="text" placeholder="输入 IPv4 或 IPv6 手动封禁" />
|
||||
<button type="button" class="lg-btn lg-btn-primary" onclick="lgManualBan()">封禁</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lg-banned-list" class="lg-table-wrap"><div class="lg-empty">加载中...</div></div>
|
||||
<div class="lg-panel-foot">
|
||||
<span class="lg-foot-note">清空后所有已封禁 IP 立即恢复访问,无法撤销。</span>
|
||||
<button type="button" class="lg-btn-outline-danger" onclick="lgFlushAll()">清空全部封禁</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg-panel">
|
||||
@@ -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
|
||||
@@ -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 '<strong style="color:#4caf50">' .. prefix .. '.' .. base .. '</strong>'
|
||||
return '<strong class="mgx-ok">' .. (prefix .. "." .. base):gsub("[<>&\"']", "") .. '</strong>'
|
||||
end
|
||||
return '<span style="color:#999">--</span>'
|
||||
return '<span class="mgx-muted">--</span>'
|
||||
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
|
||||
@@ -1,22 +1,46 @@
|
||||
<%+header%>
|
||||
<%+minigate/style%>
|
||||
<style>
|
||||
.mgx-log-tools{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:12px}
|
||||
.mgx-log{margin:0;max-height:600px;overflow-y:auto;padding:15px;border-radius:8px;border:1px solid var(--mgx-border);background:var(--mgx-log-bg);color:var(--mgx-log-text);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;line-height:1.5;white-space:pre-wrap;word-break:break-all}
|
||||
</style>
|
||||
<h2>MiniGate - 日志</h2>
|
||||
<div style="margin-bottom:15px">
|
||||
<select id="ls" style="padding:5px 10px;border-radius:4px;border:1px solid #ccc">
|
||||
<option value="all">全部</option><option value="ddns">DDNS</option><option value="acme">ACME</option><option value="proxy">代理</option>
|
||||
<div class="mgx-log-tools">
|
||||
<label for="ls">来源</label>
|
||||
<select id="ls" class="mgx-select" style="min-width:140px">
|
||||
<option value="all">全部(按时间合并)</option><option value="ddns">DDNS</option><option value="acme">证书</option><option value="proxy">反向代理</option><option value="login_guard">登录防护</option><option value="update">软件更新</option>
|
||||
</select>
|
||||
<button class="cbi-button cbi-button-action" onclick="rl()" style="margin-left:10px">刷新</button>
|
||||
<label style="margin-left:15px"><input type="checkbox" id="ar" checked> 自动刷新(5秒)</label>
|
||||
<button type="button" class="cbi-button cbi-button-action" onclick="rl(true)">刷新</button>
|
||||
<label><input type="checkbox" id="ar" checked> 自动刷新(5 秒)</label>
|
||||
<span id="lh" class="mgx-small mgx-muted" role="status"></span>
|
||||
</div>
|
||||
<pre id="lc" style="background:#1e1e2e;color:#cdd6f4;font-family:monospace;font-size:12px;line-height:1.5;padding:15px;border-radius:8px;max-height:600px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;margin:0">加载中...</pre>
|
||||
<pre id="lc" class="mgx-log">加载中...</pre>
|
||||
<script type="text/javascript">
|
||||
var lt;
|
||||
var lt,loading=false;
|
||||
var lu='<%=luci.dispatcher.build_url("admin/services/minigate/get_log")%>';
|
||||
function rl(){var s=document.getElementById('ls').value;var c=document.getElementById('lc');
|
||||
XHR.get(lu,{source:s},function(x,d){if(d&&typeof d.log==='string'){c.textContent=d.log||'(暂无)';c.scrollTop=c.scrollHeight}else c.textContent='(无法读取)'})}
|
||||
function sa(){if(lt)clearInterval(lt);if(document.getElementById('ar').checked)lt=setInterval(function(){if(!document.hidden)rl()},5000)}
|
||||
// 只有本来就停在底部时才跟随新日志;往上翻看旧内容时不被拉回
|
||||
function atBottom(c){return c.scrollHeight-c.scrollTop-c.clientHeight<24}
|
||||
function rl(jump){
|
||||
if(loading)return;
|
||||
loading=true;
|
||||
var s=document.getElementById('ls').value,c=document.getElementById('lc'),follow=jump||atBottom(c),done=false;
|
||||
function finish(x,d){
|
||||
if(done)return;done=true;loading=false;
|
||||
if(d&&typeof d.log==='string'){
|
||||
var top=c.scrollTop;
|
||||
c.textContent=d.log||'(暂无)';
|
||||
c.scrollTop=follow?c.scrollHeight:top;
|
||||
document.getElementById('lh').textContent=follow?'':'已暂停跟随最新日志,滚动到底部恢复';
|
||||
}else c.textContent='(无法读取)';
|
||||
}
|
||||
var p=XHR.get(lu,{source:s},finish);
|
||||
if(p&&typeof p.catch==='function')p.catch(function(){finish(null,null)});
|
||||
}
|
||||
function sa(){if(lt)clearInterval(lt);if(document.getElementById('ar').checked)lt=setInterval(function(){if(!document.hidden)rl(false)},5000)}
|
||||
document.getElementById('ar').addEventListener('change',sa);
|
||||
document.addEventListener('visibilitychange',function(){if(!document.hidden&&document.getElementById('ar').checked)rl()});
|
||||
document.getElementById('ls').addEventListener('change',rl);
|
||||
rl();sa();
|
||||
document.getElementById('lc').addEventListener('scroll',function(){if(atBottom(this))document.getElementById('lh').textContent=''});
|
||||
document.addEventListener('visibilitychange',function(){if(!document.hidden&&document.getElementById('ar').checked)rl(false)});
|
||||
document.getElementById('ls').addEventListener('change',function(){rl(true)});
|
||||
rl(true);sa();
|
||||
</script>
|
||||
<%+footer%>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="mgx-note is-info">还没有可用的域名。请先在<a href="<%=luci.dispatcher.build_url("admin", "services", "minigate", "ddns")%>">「动态DNS」</a>添加域名,再回到这里配置反向代理。</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<style>
|
||||
/* MiniGate 普通配置页共用的配色:跟随 LuCI 深浅色主题,不再写死浅色 */
|
||||
:root{--mgx-success:#2f9e44;--mgx-warning:#d9480f;--mgx-danger:#e03131;--mgx-info:#1971c2;--mgx-muted:#6b7280;--mgx-surface:#f6f8fb;--mgx-border:#d7dce3;--mgx-text:#1f2937;--mgx-control:#fff;--mgx-log-bg:#f6f8fb;--mgx-log-text:#1f2937}
|
||||
:root[data-darkmode="true"]{--mgx-success:#78c98d;--mgx-warning:#d7a64a;--mgx-danger:#e58a84;--mgx-info:#7db7df;--mgx-muted:#9aa7b3;--mgx-surface:var(--background-color-high,#131a22);--mgx-border:var(--border-color-medium,rgba(148,163,184,.18));--mgx-text:var(--text-color-high,#d5dbe3);--mgx-control:var(--background-color-high,#111820);--mgx-log-bg:#0f141a;--mgx-log-text:#d5dbe3}
|
||||
@media(prefers-color-scheme:dark){
|
||||
:root:not([data-darkmode]){--mgx-success:#78c98d;--mgx-warning:#d7a64a;--mgx-danger:#e58a84;--mgx-info:#7db7df;--mgx-muted:#9aa7b3;--mgx-surface:#131a22;--mgx-border:rgba(148,163,184,.18);--mgx-text:#d5dbe3;--mgx-control:#111820;--mgx-log-bg:#0f141a;--mgx-log-text:#d5dbe3}
|
||||
}
|
||||
.mgx-ok{color:var(--mgx-success)}
|
||||
.mgx-warn{color:var(--mgx-warning)}
|
||||
.mgx-err{color:var(--mgx-danger)}
|
||||
.mgx-info{color:var(--mgx-info)}
|
||||
.mgx-muted{color:var(--mgx-muted)}
|
||||
.mgx-small{display:block;font-size:12px;line-height:1.7}
|
||||
.mgx-domain{font-weight:700}
|
||||
.mgx-note{margin:8px 0 12px;padding:10px 14px;border-radius:6px;border:1px solid var(--mgx-border);border-left:4px solid var(--mgx-warning);background:var(--mgx-surface);color:var(--mgx-text);font-size:13px;line-height:1.6}
|
||||
.mgx-note.is-ok{border-left-color:var(--mgx-success)}
|
||||
.mgx-note.is-err{border-left-color:var(--mgx-danger)}
|
||||
.mgx-note.is-info{border-left-color:var(--mgx-info)}
|
||||
.mgx-note a{color:var(--mgx-info)}
|
||||
.mgx-select{min-width:220px;max-width:100%;padding:5px 10px;border-radius:4px;border:1px solid var(--mgx-border);background:var(--mgx-control);color:var(--mgx-text)}
|
||||
.mgx-actions{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
|
||||
.mgx-sync{margin-top:4px;font-size:12px!important;padding:2px 10px!important}
|
||||
.mgx-sync-result{margin-left:8px;font-size:12px}
|
||||
@keyframes mgx-spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
|
||||
.mgx-spin{display:inline-block;margin-right:8px;animation:mgx-spin 1s linear infinite}
|
||||
</style>
|
||||
@@ -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` 校验文件完整性。
|
||||
@@ -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 >/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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 >/dev/null 2>&1 &
|
||||
@@ -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 "$@"
|
||||
Executable
+68
@@ -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"
|
||||
@@ -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 <ip>|flush}" ;;
|
||||
esac
|
||||
Executable
+24
@@ -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 >/dev/null 2>&1
|
||||
code=$?
|
||||
write_job done "$started" "$(date +%s)" "$code"
|
||||
exit "$code"
|
||||
@@ -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 <IP> 手动封禁
|
||||
@@ -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 拿封禁列表,输出每行: <IP> <剩余秒数>
|
||||
# 注意 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 <IP>"; exit 1; }
|
||||
nft delete element $TABLE $SET "{ $ip }" 2>&1
|
||||
local ip="$1" set
|
||||
set=$(set_for_ip "$ip")
|
||||
[ -n "$set" ] || { echo "用法: $0 unban <IP>"; 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 <IP>"; exit 1; }
|
||||
[ -n "$(ip_family "$ip")" ] || { echo "用法: $0 ban <IP>"; 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 <<EOH
|
||||
MiniGate 登录防护
|
||||
|
||||
$0 run 后台监控(init.d 用)
|
||||
$0 run 后台监控(procd 启动)
|
||||
$0 list 列出已封禁 IP
|
||||
$0 status 状态总览
|
||||
$0 ban <IP> 手动封禁
|
||||
|
||||
@@ -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" <<SEOF
|
||||
if [ -n "$cp" ]; then
|
||||
cat >> "$conf" <<SEOF
|
||||
ssl_certificate ${cp}/fullchain.pem;
|
||||
ssl_certificate_key ${cp}/key.pem;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
SEOF
|
||||
else
|
||||
warn "${domain} 没有可用证书,HTTPS 访问会失败;请先在「SSL 证书」页签发"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 判断目标地址是否为 IPv6
|
||||
@@ -345,22 +395,77 @@ SEOF
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_buffering off;
|
||||
SEOF
|
||||
[ "$ws" = "1" ] && cat >> "$conf" <<SEOF
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Connection \$minigate_connection_upgrade;
|
||||
}
|
||||
}
|
||||
SEOF
|
||||
echo " }" >> "$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
|
||||
@@ -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)" \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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>&-/);
|
||||
});
|
||||
@@ -131,6 +131,95 @@ test('overview renders legacy visitor responses as unknown and escapes log conte
|
||||
assert.doesNotMatch(html, /<img|已转发|在线|离线|拦截 undefined/);
|
||||
});
|
||||
|
||||
const statusData = extra => ({
|
||||
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 不是公网地址<b>' }
|
||||
]
|
||||
}));
|
||||
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, /<b>/);
|
||||
});
|
||||
|
||||
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: '"><img>', listen_port: '443', target: 'x' }]
|
||||
}));
|
||||
assert.match(page.element('mg-p1').innerHTML, /最近一次配置未生效/);
|
||||
const body = page.element('mg-p2').innerHTML;
|
||||
assert.match(body, /<a class="mg-link" href="https:\/\/box\.example\.com:2000" target="_blank" rel="noopener noreferrer">/);
|
||||
assert.match(body, /已保留上一份配置/);
|
||||
assert.match(body, /跳过 nas\.example\.com/);
|
||||
assert.doesNotMatch(body, /"><img>/);
|
||||
});
|
||||
|
||||
function ddnsPageScript() {
|
||||
const source = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/ddns.lua'), 'utf8');
|
||||
const start = source.indexOf('<script type="text/javascript">');
|
||||
const end = source.indexOf('</script>', start);
|
||||
return source.slice(start + '<script type="text/javascript">'.length, end).replace(/\]\]\.\.(\w+)\.\.\[\[/g, '$1');
|
||||
}
|
||||
|
||||
test('manual DDNS sync runs in the background and updates the row without reloading the page', () => {
|
||||
const page = fakePage(ddnsPageScript());
|
||||
let reloaded = false;
|
||||
page.context.location.reload = () => { reloaded = true; };
|
||||
const button = page.element('sync-button');
|
||||
page.context.doSync('cfg01', button);
|
||||
take(page, 'url').callback(null, { success: true });
|
||||
take(page, 'job').callback(null, { running: false, done: true, success: true, status: 'ok', ip: '198.51.100.20', updated: '2026-10-28 10:00:00' });
|
||||
assert.equal(reloaded, false);
|
||||
assert.equal(button.disabled, false);
|
||||
assert.match(page.element('sr-cfg01').innerHTML, /同步完成 198\.51\.100\.20/);
|
||||
assert.match(page.element('ss-cfg01').innerHTML, /A: 198\.51\.100\.20/);
|
||||
});
|
||||
|
||||
test('login guard renders empty JSON objects as empty lists and uses POST for changes', () => {
|
||||
const page = fakePage(loginGuardScript);
|
||||
take(page, 'lu').callback(null, { enabled: '1', running: true, banned: {}, banned_total: 0, watching: {}, watching_total: 0, threshold: 3 });
|
||||
@@ -151,7 +240,11 @@ test('state-changing LuCI endpoints require POST with the CSRF token', () => {
|
||||
}
|
||||
const ddns = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/ddns.lua'), 'utf8');
|
||||
const acme = fs.readFileSync(path.join(root, 'luasrc/model/cbi/minigate/acme.lua'), 'utf8');
|
||||
assert.match(ddns, /XHR\.post\("\]\]\.\.url\.\.\[\[",\{section:s\}/);
|
||||
assert.doesNotMatch(acme, /XHR\.get\(/);
|
||||
assert.match(ddns, /mgRequest\('post',"\]\]\.\.url\.\.\[\[",\{section:s\}/);
|
||||
// 安装与签发用 POST 启动后台任务,只有读取任务状态用 GET
|
||||
assert.match(acme, /acmePost\('\]\]\.\.iu\.\.\[\['/);
|
||||
assert.match(acme, /acmePost\('\]\]\.\.su\.\.\[\['/);
|
||||
assert.match(acme, /acmeGet\('\]\]\.\.ju\.\.\[\['/);
|
||||
assert.match(controller, /"acme_job"\}, call\("action_acme_job"\)\)/);
|
||||
assert.match(controller, /sec:match\("\^\[%w_\]\+\$"\)/);
|
||||
});
|
||||
@@ -196,7 +196,8 @@ test('LuCI exposes only a confirmed POST uninstall action and runs a temporary s
|
||||
test('the package contains the uninstaller and supports both OpenWrt package managers', () => {
|
||||
const makefile = fs.readFileSync(path.join(root, 'Makefile'), 'utf8');
|
||||
const source = fs.readFileSync(script, 'utf8');
|
||||
assert.match(makefile, /root\/usr\/lib\/minigate\/uninstall\.sh/);
|
||||
assert.match(makefile, /\$\(INSTALL_BIN\) \.\/root\/usr\/lib\/minigate\/\*\.sh /);
|
||||
assert.ok(fs.existsSync(path.join(root, 'root/usr/lib/minigate/uninstall.sh')));
|
||||
assert.match(source, /opkg remove "\$package"/);
|
||||
assert.match(source, /apk del "\$package"/);
|
||||
assert.match(source, /for package in minigate luci-app-minigate/);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#
|
||||
# nftables geoip expression, reads the xt_geoip database
|
||||
#
|
||||
include $(TOPDIR)/rules.mk
|
||||
include $(INCLUDE_DIR)/kernel.mk
|
||||
|
||||
PKG_NAME:=nft-geoip
|
||||
PKG_RELEASE:=1
|
||||
|
||||
PKG_LICENSE:=GPL-2.0-only
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
define KernelPackage/nft-geoip
|
||||
SUBMENU:=Netfilter Extensions
|
||||
TITLE:=nftables geoip expression (xt_geoip database)
|
||||
DEPENDS:=+kmod-nft-core
|
||||
FILES:=$(PKG_BUILD_DIR)/nft_geoip.ko
|
||||
AUTOLOAD:=$(call AutoProbe,nft_geoip)
|
||||
endef
|
||||
|
||||
define KernelPackage/nft-geoip/description
|
||||
Kernel module providing the "geoip" nftables expression.
|
||||
Country databases are read from /usr/share/xt_geoip/XX.iv4 and XX.iv6.
|
||||
endef
|
||||
|
||||
define Build/Prepare
|
||||
mkdir -p $(PKG_BUILD_DIR)
|
||||
$(CP) ./src/* $(PKG_BUILD_DIR)/
|
||||
endef
|
||||
|
||||
define Build/Compile
|
||||
$(KERNEL_MAKE) M="$(PKG_BUILD_DIR)" modules
|
||||
endef
|
||||
|
||||
$(eval $(call KernelPackage,nft-geoip))
|
||||
@@ -0,0 +1 @@
|
||||
obj-m += nft_geoip.o
|
||||
@@ -0,0 +1,376 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
/*
|
||||
* nft_geoip - nftables geoip expression on top of the xt_geoip database.
|
||||
*
|
||||
* Reads /usr/share/xt_geoip/<CC>.iv4 and <CC>.iv6 (as produced by
|
||||
* maxminddb-dump-country / xt_geoip_build) directly from the kernel.
|
||||
*
|
||||
* File format, endianness independent (network byte order on disk):
|
||||
* .iv4: array of { __be32 begin, end; }
|
||||
* .iv6: array of { struct in6_addr begin, end; }
|
||||
* Ranges are sorted and non-overlapping, end is inclusive, no header.
|
||||
* The IPv4 ranges are converted to host order once, right after loading.
|
||||
*/
|
||||
#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt
|
||||
|
||||
#include <linux/module.h>
|
||||
#include <linux/kernel.h>
|
||||
#include <linux/ctype.h>
|
||||
#include <linux/list.h>
|
||||
#include <linux/mutex.h>
|
||||
#include <linux/refcount.h>
|
||||
#include <linux/slab.h>
|
||||
#include <linux/vmalloc.h>
|
||||
#include <linux/string.h>
|
||||
#include <linux/kernel_read_file.h>
|
||||
#include <linux/ip.h>
|
||||
#include <linux/ipv6.h>
|
||||
#include <linux/netfilter.h>
|
||||
#include <linux/netfilter/nf_tables.h>
|
||||
#include <net/netfilter/nf_tables.h>
|
||||
|
||||
#include "nft_geoip.h"
|
||||
|
||||
#define GEOIP_DIR "/usr/share/xt_geoip"
|
||||
|
||||
struct geoip_subnet4 {
|
||||
u32 begin, end; /* big endian on disk, host order in memory */
|
||||
};
|
||||
|
||||
struct geoip_subnet6 {
|
||||
struct in6_addr begin, end;
|
||||
};
|
||||
|
||||
/* on-disk records must have no padding on any ABI */
|
||||
static_assert(sizeof(struct geoip_subnet4) == 8);
|
||||
static_assert(sizeof(struct geoip_subnet6) == 32);
|
||||
|
||||
struct geoip_country {
|
||||
struct list_head list;
|
||||
refcount_t ref;
|
||||
u16 cc; /* ('R' << 8) | 'U' */
|
||||
struct geoip_subnet4 *s4;
|
||||
u32 n4;
|
||||
struct geoip_subnet6 *s6;
|
||||
u32 n6;
|
||||
};
|
||||
|
||||
static LIST_HEAD(geoip_countries);
|
||||
static DEFINE_MUTEX(geoip_lock);
|
||||
|
||||
/* ---------- database loading ---------- */
|
||||
|
||||
static int geoip_read(u16 cc, const char *ext, void **buf, size_t *size)
|
||||
{
|
||||
char path[64];
|
||||
ssize_t ret;
|
||||
|
||||
snprintf(path, sizeof(path), GEOIP_DIR "/%c%c.%s",
|
||||
cc >> 8, cc & 0xff, ext);
|
||||
*buf = NULL;
|
||||
|
||||
/*
|
||||
* kernel_read_file_from_path() returns the number of bytes read on
|
||||
* success, not 0. Callers here test for "non-zero == failure" and
|
||||
* pass the value to ERR_PTR(), so only ever hand back 0 or -errno.
|
||||
*/
|
||||
ret = kernel_read_file_from_path(path, 0, buf, INT_MAX, size,
|
||||
READING_UNKNOWN);
|
||||
return ret < 0 ? (int)ret : 0;
|
||||
}
|
||||
|
||||
static void geoip_fixup4(struct geoip_subnet4 *s, u32 n)
|
||||
{
|
||||
u32 i;
|
||||
|
||||
for (i = 0; i < n; i++) {
|
||||
s[i].begin = be32_to_cpu((__force __be32)s[i].begin);
|
||||
s[i].end = be32_to_cpu((__force __be32)s[i].end);
|
||||
}
|
||||
}
|
||||
|
||||
static struct geoip_country *geoip_load(u16 cc)
|
||||
{
|
||||
struct geoip_country *c;
|
||||
size_t sz4 = 0, sz6 = 0;
|
||||
int err4, err6;
|
||||
|
||||
c = kzalloc(sizeof(*c), GFP_KERNEL);
|
||||
if (!c)
|
||||
return ERR_PTR(-ENOMEM);
|
||||
|
||||
c->cc = cc;
|
||||
refcount_set(&c->ref, 1);
|
||||
|
||||
err4 = geoip_read(cc, "iv4", (void **)&c->s4, &sz4);
|
||||
err6 = geoip_read(cc, "iv6", (void **)&c->s6, &sz6);
|
||||
|
||||
/* a country may legitimately have only one of the two families */
|
||||
if (err4 && err6) {
|
||||
pr_warn("%c%c: cannot read database from %s (iv4: %d, iv6: %d)\n",
|
||||
cc >> 8, cc & 0xff, GEOIP_DIR, err4, err6);
|
||||
kfree(c);
|
||||
return ERR_PTR(err4 < 0 ? err4 : -EIO);
|
||||
}
|
||||
|
||||
/* a missing file is fine, any other error is worth a message */
|
||||
if (err4 && err4 != -ENOENT)
|
||||
pr_warn("%c%c.iv4: read failed: %d\n", cc >> 8, cc & 0xff, err4);
|
||||
if (err6 && err6 != -ENOENT)
|
||||
pr_warn("%c%c.iv6: read failed: %d\n", cc >> 8, cc & 0xff, err6);
|
||||
|
||||
if (err4) {
|
||||
c->s4 = NULL;
|
||||
sz4 = 0;
|
||||
}
|
||||
if (err6) {
|
||||
c->s6 = NULL;
|
||||
sz6 = 0;
|
||||
}
|
||||
|
||||
if (sz4 % sizeof(struct geoip_subnet4) ||
|
||||
sz6 % sizeof(struct geoip_subnet6)) {
|
||||
pr_warn("%c%c: bad database size (iv4: %zu, iv6: %zu)\n",
|
||||
cc >> 8, cc & 0xff, sz4, sz6);
|
||||
vfree(c->s4);
|
||||
vfree(c->s6);
|
||||
kfree(c);
|
||||
return ERR_PTR(-EINVAL);
|
||||
}
|
||||
|
||||
c->n4 = sz4 / sizeof(struct geoip_subnet4);
|
||||
c->n6 = sz6 / sizeof(struct geoip_subnet6);
|
||||
geoip_fixup4(c->s4, c->n4);
|
||||
return c;
|
||||
}
|
||||
|
||||
static struct geoip_country *geoip_get(u16 cc)
|
||||
{
|
||||
struct geoip_country *c;
|
||||
|
||||
mutex_lock(&geoip_lock);
|
||||
list_for_each_entry(c, &geoip_countries, list) {
|
||||
if (c->cc == cc) {
|
||||
refcount_inc(&c->ref);
|
||||
goto out;
|
||||
}
|
||||
}
|
||||
|
||||
c = geoip_load(cc);
|
||||
if (!IS_ERR(c))
|
||||
list_add_tail(&c->list, &geoip_countries);
|
||||
out:
|
||||
mutex_unlock(&geoip_lock);
|
||||
return c;
|
||||
}
|
||||
|
||||
static void geoip_put(struct geoip_country *c)
|
||||
{
|
||||
mutex_lock(&geoip_lock);
|
||||
if (refcount_dec_and_test(&c->ref)) {
|
||||
list_del(&c->list);
|
||||
vfree(c->s4);
|
||||
vfree(c->s6);
|
||||
kfree(c);
|
||||
}
|
||||
mutex_unlock(&geoip_lock);
|
||||
}
|
||||
|
||||
/* ---------- lookup ---------- */
|
||||
|
||||
static bool geoip_match4(const struct geoip_country *c, __be32 addr)
|
||||
{
|
||||
u32 ip = ntohl(addr);
|
||||
int lo = 0, hi = (int)c->n4 - 1;
|
||||
|
||||
while (lo <= hi) {
|
||||
int mid = lo + (hi - lo) / 2;
|
||||
const struct geoip_subnet4 *s = &c->s4[mid];
|
||||
|
||||
if (ip < s->begin)
|
||||
hi = mid - 1;
|
||||
else if (ip > s->end)
|
||||
lo = mid + 1;
|
||||
else
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool geoip_match6(const struct geoip_country *c,
|
||||
const struct in6_addr *addr)
|
||||
{
|
||||
int lo = 0, hi = (int)c->n6 - 1;
|
||||
|
||||
while (lo <= hi) {
|
||||
int mid = lo + (hi - lo) / 2;
|
||||
const struct geoip_subnet6 *s = &c->s6[mid];
|
||||
|
||||
if (memcmp(addr, &s->begin, sizeof(*addr)) < 0)
|
||||
hi = mid - 1;
|
||||
else if (memcmp(addr, &s->end, sizeof(*addr)) > 0)
|
||||
lo = mid + 1;
|
||||
else
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ---------- expression ---------- */
|
||||
|
||||
struct nft_geoip {
|
||||
u32 flags;
|
||||
u8 count;
|
||||
struct geoip_country *c[NFT_GEOIP_MAX];
|
||||
};
|
||||
|
||||
static void nft_geoip_eval(const struct nft_expr *expr,
|
||||
struct nft_regs *regs,
|
||||
const struct nft_pktinfo *pkt)
|
||||
{
|
||||
const struct nft_geoip *priv = nft_expr_priv(expr);
|
||||
bool dst = priv->flags & NFT_GEOIP_F_DST;
|
||||
bool hit = false;
|
||||
unsigned int i;
|
||||
|
||||
switch (nft_pf(pkt)) {
|
||||
case NFPROTO_IPV4: {
|
||||
const struct iphdr *iph = ip_hdr(pkt->skb);
|
||||
__be32 a = dst ? iph->daddr : iph->saddr;
|
||||
|
||||
for (i = 0; i < priv->count && !hit; i++)
|
||||
hit = geoip_match4(priv->c[i], a);
|
||||
break;
|
||||
}
|
||||
case NFPROTO_IPV6: {
|
||||
const struct ipv6hdr *ip6h = ipv6_hdr(pkt->skb);
|
||||
const struct in6_addr *a = dst ? &ip6h->daddr : &ip6h->saddr;
|
||||
|
||||
for (i = 0; i < priv->count && !hit; i++)
|
||||
hit = geoip_match6(priv->c[i], a);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
regs->verdict.code = NFT_BREAK;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hit ^ !!(priv->flags & NFT_GEOIP_F_INVERT))
|
||||
return; /* match: continue with the next expression */
|
||||
|
||||
regs->verdict.code = NFT_BREAK;
|
||||
}
|
||||
|
||||
static const struct nla_policy nft_geoip_policy[NFTA_GEOIP_MAX + 1] = {
|
||||
[NFTA_GEOIP_FLAGS] = { .type = NLA_U32 },
|
||||
[NFTA_GEOIP_CC] = { .type = NLA_BINARY,
|
||||
.len = NFT_GEOIP_MAX * 2 },
|
||||
};
|
||||
|
||||
static int nft_geoip_init(const struct nft_ctx *ctx,
|
||||
const struct nft_expr *expr,
|
||||
const struct nlattr * const tb[])
|
||||
{
|
||||
struct nft_geoip *priv = nft_expr_priv(expr);
|
||||
const u8 *cc;
|
||||
int len, i;
|
||||
|
||||
if (!tb[NFTA_GEOIP_CC])
|
||||
return -EINVAL;
|
||||
|
||||
len = nla_len(tb[NFTA_GEOIP_CC]);
|
||||
if (len < 2 || len % 2)
|
||||
return -EINVAL;
|
||||
|
||||
priv->flags = tb[NFTA_GEOIP_FLAGS] ?
|
||||
ntohl(nla_get_be32(tb[NFTA_GEOIP_FLAGS])) : 0;
|
||||
priv->count = 0;
|
||||
|
||||
cc = nla_data(tb[NFTA_GEOIP_CC]);
|
||||
for (i = 0; i < len / 2; i++) {
|
||||
struct geoip_country *c;
|
||||
u8 a = toupper(cc[2 * i]), b = toupper(cc[2 * i + 1]);
|
||||
|
||||
if (!isalnum(a) || !isalnum(b)) {
|
||||
while (priv->count)
|
||||
geoip_put(priv->c[--priv->count]);
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
c = geoip_get((a << 8) | b);
|
||||
if (IS_ERR(c)) {
|
||||
while (priv->count)
|
||||
geoip_put(priv->c[--priv->count]);
|
||||
return PTR_ERR(c);
|
||||
}
|
||||
priv->c[priv->count++] = c;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void nft_geoip_destroy(const struct nft_ctx *ctx,
|
||||
const struct nft_expr *expr)
|
||||
{
|
||||
struct nft_geoip *priv = nft_expr_priv(expr);
|
||||
|
||||
while (priv->count)
|
||||
geoip_put(priv->c[--priv->count]);
|
||||
}
|
||||
|
||||
static int nft_geoip_dump(struct sk_buff *skb, const struct nft_expr *expr,
|
||||
bool reset)
|
||||
{
|
||||
const struct nft_geoip *priv = nft_expr_priv(expr);
|
||||
u8 cc[NFT_GEOIP_MAX * 2];
|
||||
unsigned int i;
|
||||
|
||||
for (i = 0; i < priv->count; i++) {
|
||||
cc[2 * i] = priv->c[i]->cc >> 8;
|
||||
cc[2 * i + 1] = priv->c[i]->cc & 0xff;
|
||||
}
|
||||
|
||||
if (nla_put_be32(skb, NFTA_GEOIP_FLAGS, htonl(priv->flags)) ||
|
||||
nla_put(skb, NFTA_GEOIP_CC, priv->count * 2, cc))
|
||||
return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static struct nft_expr_type nft_geoip_type;
|
||||
|
||||
static const struct nft_expr_ops nft_geoip_ops = {
|
||||
.type = &nft_geoip_type,
|
||||
.size = NFT_EXPR_SIZE(sizeof(struct nft_geoip)),
|
||||
.eval = nft_geoip_eval,
|
||||
.init = nft_geoip_init,
|
||||
.destroy = nft_geoip_destroy,
|
||||
.dump = nft_geoip_dump,
|
||||
#ifdef NFT_REDUCE_READONLY
|
||||
.reduce = NFT_REDUCE_READONLY,
|
||||
#endif
|
||||
};
|
||||
|
||||
static struct nft_expr_type nft_geoip_type __read_mostly = {
|
||||
.name = "geoip",
|
||||
.ops = &nft_geoip_ops,
|
||||
.policy = nft_geoip_policy,
|
||||
.maxattr = NFTA_GEOIP_MAX,
|
||||
.owner = THIS_MODULE,
|
||||
};
|
||||
|
||||
static int __init nft_geoip_module_init(void)
|
||||
{
|
||||
return nft_register_expr(&nft_geoip_type);
|
||||
}
|
||||
|
||||
static void __exit nft_geoip_module_exit(void)
|
||||
{
|
||||
nft_unregister_expr(&nft_geoip_type);
|
||||
}
|
||||
|
||||
module_init(nft_geoip_module_init);
|
||||
module_exit(nft_geoip_module_exit);
|
||||
|
||||
MODULE_LICENSE("GPL");
|
||||
MODULE_DESCRIPTION("nftables geoip expression using the xt_geoip database");
|
||||
MODULE_ALIAS_NFT_EXPR("geoip");
|
||||
MODULE_AUTHOR("Konstantine Shevlakov <kosnt.shell@gmail.com>");
|
||||
@@ -0,0 +1,19 @@
|
||||
#ifndef _NFT_GEOIP_H
|
||||
#define _NFT_GEOIP_H
|
||||
|
||||
#define NFT_GEOIP_MAX 16 /* countries per expression */
|
||||
|
||||
enum nft_geoip_flags {
|
||||
NFT_GEOIP_F_DST = (1 << 0), /* match daddr instead of saddr */
|
||||
NFT_GEOIP_F_INVERT = (1 << 1), /* geoip ... != cc */
|
||||
};
|
||||
|
||||
enum nft_geoip_attributes {
|
||||
NFTA_GEOIP_UNSPEC,
|
||||
NFTA_GEOIP_FLAGS, /* be32, enum nft_geoip_flags */
|
||||
NFTA_GEOIP_CC, /* binary, N x 2 ASCII chars, N <= NFT_GEOIP_MAX */
|
||||
__NFTA_GEOIP_MAX
|
||||
};
|
||||
#define NFTA_GEOIP_MAX (__NFTA_GEOIP_MAX - 1)
|
||||
|
||||
#endif /* _NFT_GEOIP_H */
|
||||
+3
-3
@@ -50,12 +50,12 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=wwand
|
||||
PKG_VERSION:=1.8.0_pre14
|
||||
PKG_RELEASE:=82
|
||||
PKG_VERSION:=1.8.0_pre29
|
||||
PKG_RELEASE:=83
|
||||
|
||||
PKG_SOURCE_PROTO:=git
|
||||
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git
|
||||
PKG_SOURCE_VERSION:=7c6f90d468a76060da225e0a2a60101b74d83bc5
|
||||
PKG_SOURCE_VERSION:=0944a14ea95e90b6596f81bc49f07e4f73d905e3
|
||||
PKG_MIRROR_HASH:=skip
|
||||
|
||||
PKG_LICENSE:=GPL-2.0-only
|
||||
|
||||
Reference in new issue
Block a user