🎁 Sync 2026-10-28 19:35:15

This commit is contained in:
github-actions[bot] committed 2026-10-28 19:35:15 +08:00
1 parent 85209f310d
commit 390801c844
90 files changed
+5821 -1967

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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)?
+9 -95
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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")
+2 -2
View File
@@ -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)
]) ])
]
+81
View File
@@ -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
View File
@@ -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))))
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+61
View File
@@ -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 / 新功能
+17
View File
@@ -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%;
+1
View File
@@ -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;
}
}
+22 -1
View File
@@ -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 }};
+2 -2
View File
@@ -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 {
+174 -104
View File
@@ -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 ""
+1
View File
@@ -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
View File
@@ -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)?
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 "==== 安装完成 ===="
+454 -241
View File
@@ -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
+55 -52
View File
@@ -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">&#10003; 已安装</span>'
elseif fs.access("/etc/minigate/acme/data/acme.sh")then return'<span style="color:#ff9800">&#9888; 缺DNS插件</span>'
else return'<span style="color:#f44336">&#10007; 未安装</span>'end
if acme_installed() then return'<span class="mgx-ok">&#10003; 已安装</span>'
elseif fs.access("/etc/minigate/acme/data/acme.sh")then return'<span class="mgx-warn">&#9888; 缺 DNS 插件,请重新安装</span>'
else return'<span class="mgx-err">&#10007; 未安装(首次签发时会自动安装)</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">&#9203;</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">&#9203;</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
+105 -59
View File
@@ -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={"&#10003; 正常","mgx-ok"},partial={"&#9888; 部分成功","mgx-warn"},error={"&#10007; 异常","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">&#10003; 正常</span>'
elseif st=="partial"then
h='<span style="color:#ff9800">&#9888; 部分成功</span>'
elseif st=="error"then
h='<span style="color:#f44336">&#10007; '..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{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
+135 -104
View File
@@ -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()">&times;</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
+110 -87
View File
@@ -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
+37 -13
View File
@@ -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>
+26
View File
@@ -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>
+64
View File
@@ -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` 校验文件完整性。
+106 -75
View File
@@ -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"
}
+91 -50
View File
@@ -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 &
+125 -44
View File
@@ -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 "$@"
+68
View File
@@ -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"
-198
View File
@@ -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
+24
View File
@@ -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"
+302 -110
View File
@@ -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> 手动封禁
+225 -92
View File
@@ -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
+6 -2
View File
@@ -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)" \
+4 -2
View File
@@ -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
}
+6 -2
View File
@@ -5,7 +5,7 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
OUT_DIR="${1:-$ROOT_DIR/dist}"
PKG_NAME="minigate"
PKG_VERSION="2026.10.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"
+6 -3
View File
@@ -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'))
+27 -8
View File
@@ -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', () => {
-92
View File
@@ -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/);
});
+47 -9
View File
@@ -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', () => {
-81
View File
@@ -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']);
});
+77
View File
@@ -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);
});
+4 -3
View File
@@ -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
`;
+106 -39
View File
@@ -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', () => {
+210
View File
@@ -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'));
});
+127
View File
@@ -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>&-/);
});
+95 -2
View File
@@ -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, /不是公网地址&lt;b&gt;/);
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_\]\+\$"\)/);
});
+2 -1
View File
@@ -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/);
+36
View File
@@ -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))
+1
View File
@@ -0,0 +1 @@
obj-m += nft_geoip.o
+376
View File
@@ -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>");
+19
View File
@@ -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
View File
@@ -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