none.'));
+ o.placeholder = 'fq_codel limit 1024 flows 128';
+ o.modalonly = true;
+
+ /* clients */
+ s = m.section(form.GridSection, 'client', _('Clients'),
+ _('Clients are matched from top to bottom: put specific hosts before wide subnets that contain them.'));
+ s.addremove = true;
+ s.anonymous = false;
+ s.nodescriptions = true;
+ s.sortable = true;
+ s.modaltitle = _('Client');
+
+ s.renderSectionAdd = function(extra_class) {
+ const el = form.GridSection.prototype.renderSectionAdd.apply(this, arguments);
+ const nameEl = el.querySelector('.cbi-section-create-name');
+
+ ui.addValidator(nameEl, 'uciname', true, function(v) {
+ if (uci.get('ifb-shaper', v) != null)
+ return _('This name is already used');
+
+ return true;
+ }, 'blur', 'keyup');
+
+ return el;
+ };
+
+ o = s.option(form.ListValue, 'shaper', _('Shaper'));
+ o.rmempty = false;
+ o.load = function(section_id) {
+ this.keylist = [];
+ this.vallist = [];
+
+ uci.sections('ifb-shaper', 'shaper', sh => this.value(sh['.name']));
+
+ return form.ListValue.prototype.load.apply(this, [ section_id ]);
+ };
+
+ o = s.option(form.DynamicList, 'address', _('Addresses'),
+ _('Hosts or networks (IPv4 and/or IPv6) sharing one limit.'));
+ o.datatype = 'ipmask';
+ o.placeholder = '192.168.1.0/24';
+ o.rmempty = false;
+
+ o = s.option(RateValue, 'down_ceil', _('Download max'));
+ o.placeholder = '50Mbit';
+ o.rmempty = false;
+
+ o = s.option(RateValue, 'down_rate', _('Download guaranteed'));
+ o.placeholder = '1Mbit';
+ o.modalonly = true;
+
+ o = s.option(RateValue, 'up_ceil', _('Upload max'));
+ o.placeholder = '20Mbit';
+ o.rmempty = false;
+
+ o = s.option(RateValue, 'up_rate', _('Upload guaranteed'));
+ o.placeholder = '1Mbit';
+ o.modalonly = true;
+
+ return m.render();
+ }
+});
diff --git a/luci-app-ifb-shaper/po/ru/ifb-shaper.po b/luci-app-ifb-shaper/po/ru/ifb-shaper.po
new file mode 100644
index 000000000..56139df1b
--- /dev/null
+++ b/luci-app-ifb-shaper/po/ru/ifb-shaper.po
@@ -0,0 +1,95 @@
+msgid ""
+msgstr ""
+"Content-Type: text/plain; charset=UTF-8\n"
+"Language: ru\n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+
+msgid "A shaper is bound to a logical interface; clients refer to it by the name of this section."
+msgstr "Шейпер привязан к логическому интерфейсу; клиенты ссылаются на него по имени этой секции."
+
+msgid "Addresses"
+msgstr "Адреса"
+
+msgid "Aggregate rate of all clients."
+msgstr "Суммарная скорость всех клиентов."
+
+msgid "Bypass networks"
+msgstr "Сети в обход"
+
+msgid "Client"
+msgstr "Клиент"
+
+msgid "Clients"
+msgstr "Клиенты"
+
+msgid "Clients are matched from top to bottom: put specific hosts before wide subnets that contain them."
+msgstr "Клиенты сопоставляются сверху вниз: конкретные хосты ставьте выше широких подсетей, которые их содержат."
+
+msgid "Default class ceil"
+msgstr "Максимум класса по умолчанию"
+
+msgid "Default class rate"
+msgstr "Скорость класса по умолчанию"
+
+msgid "Download guaranteed"
+msgstr "Загрузка: гарантированная"
+
+msgid "Download max"
+msgstr "Загрузка: максимум"
+
+msgid "Download total"
+msgstr "Загрузка: общая"
+
+msgid "Enabled"
+msgstr "Включено"
+
+msgid "Hosts or networks (IPv4 and/or IPv6) sharing one limit."
+msgstr "Хосты или сети (IPv4 и/или IPv6) с одним общим лимитом."
+
+msgid "IFB Shaper"
+msgstr "IFB шейпер"
+
+msgid "Interface"
+msgstr "Интерфейс"
+
+msgid "Leaf qdisc"
+msgstr "Конечный qdisc"
+
+msgid "Local class ceil"
+msgstr "Максимум локального класса"
+
+msgid "Local class rate"
+msgstr "Скорость локального класса"
+
+msgid "Per-client traffic shaper. Download is shaped on the egress of the interface device, upload on an IFB device (ifb-<device>) that is created automatically."
+msgstr "Шейпер трафика по клиентам. Загрузка ограничивается на egress устройства интерфейса, отдача — на IFB-устройстве (ifb-<device>), которое создаётся автоматически."
+
+msgid "Shaper"
+msgstr "Шейпер"
+
+msgid "Shapers"
+msgstr "Шейперы"
+
+msgid "This name is already used"
+msgstr "Это имя уже используется"
+
+msgid "Total rate"
+msgstr "Общая скорость"
+
+msgid "Traffic that matches no client."
+msgstr "Трафик, не попавший ни под одного клиента."
+
+msgid "Traffic to and from these networks skips the client limits and goes to the local class."
+msgstr "Трафик в эти сети и из них минует лимиты клиентов и попадает в локальный класс."
+
+msgid "Upload guaranteed"
+msgstr "Отдача: гарантированная"
+
+msgid "Upload max"
+msgstr "Отдача: максимум"
+
+msgid "Upload total"
+msgstr "Отдача: общая"
+
+msgid "tc arguments of the qdisc attached to every client sub-class, or none."
+msgstr "Аргументы tc для qdisc, который вешается на каждый подкласс клиента, либо none."
diff --git a/luci-app-ifb-shaper/root/usr/share/luci/menu.d/luci-app-ifb-shaper.json b/luci-app-ifb-shaper/root/usr/share/luci/menu.d/luci-app-ifb-shaper.json
new file mode 100644
index 000000000..285d1fe0f
--- /dev/null
+++ b/luci-app-ifb-shaper/root/usr/share/luci/menu.d/luci-app-ifb-shaper.json
@@ -0,0 +1,14 @@
+{
+ "admin/network/ifb-shaper": {
+ "title": "IFB Shaper",
+ "order": 60,
+ "action": {
+ "type": "view",
+ "path": "ifb-shaper/config"
+ },
+ "depends": {
+ "acl": [ "luci-app-ifb-shaper" ],
+ "uci": { "ifb-shaper": true }
+ }
+ }
+}
diff --git a/luci-app-ifb-shaper/root/usr/share/rpcd/acl.d/luci-app-ifb-shaper.json b/luci-app-ifb-shaper/root/usr/share/rpcd/acl.d/luci-app-ifb-shaper.json
new file mode 100644
index 000000000..22664b1da
--- /dev/null
+++ b/luci-app-ifb-shaper/root/usr/share/rpcd/acl.d/luci-app-ifb-shaper.json
@@ -0,0 +1,11 @@
+{
+ "luci-app-ifb-shaper": {
+ "description": "Grant UCI access for luci-app-ifb-shaper",
+ "read": {
+ "uci": [ "ifb-shaper" ]
+ },
+ "write": {
+ "uci": [ "ifb-shaper" ]
+ }
+ }
+}
diff --git a/luci-app-open-box/Makefile b/luci-app-open-box/Makefile
index 7c07dba34..2fe8e8335 100644
--- a/luci-app-open-box/Makefile
+++ b/luci-app-open-box/Makefile
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-open-box
-PKG_VERSION:=0.1.334
-PKG_RELEASE:=31
+PKG_VERSION:=0.1.336
+PKG_RELEASE:=32
PKG_MAINTAINER:=liandu2024
PKG_LICENSE:=GPL-3.0-or-later
diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile
index e13ccc777..3ee93278e 100644
--- a/luci-app-passwall/Makefile
+++ b/luci-app-passwall/Makefile
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.10.16
-PKG_RELEASE:=334
+PKG_RELEASE:=335
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/global.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/global.lua
index 160b77613..8e9c14a8a 100644
--- a/luci-app-passwall/luasrc/model/cbi/passwall/client/global.lua
+++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/global.lua
@@ -710,13 +710,12 @@ if has_smartdns then
o:value("notice", "Notice")
o:value("info", "Info")
o:value("debug", "Debug")
- o:depends({dns_shunt = "smartdns", log_smartdns = "1"})
+ o:depends("log_smartdns", "1")
o = s:taboption("log", Value, "smartdns_log_size", translate("Log Size") .. " (MB)")
o.default = "1"
o.datatype = "and(uinteger,min(1))"
- o.rmempty = false
- o:depends({dns_shunt = "smartdns", log_smartdns = "1"})
+ o:depends("log_smartdns", "1")
o = s:taboption("log", DummyValue, "_smartdns_log", translate("Log File"))
o.rawhtml = true
@@ -734,7 +733,7 @@ if has_smartdns then
end
return s
end
- o:depends({dns_shunt = "smartdns", log_smartdns = "1"})
+ o:depends("log_smartdns", "1")
if api.fs.access("/usr/lib/smartdns_ui.so") or api.fs.access("/usr/lib/smartdns/smartdns_ui.so") then
o = s:taboption("log", Flag, "smartdns_ui", "SmartDNS " .. translate("Enable WebUI"))
@@ -745,12 +744,10 @@ if has_smartdns then
o = s:taboption("log", Value, "smartdns_ui_port", translate("WebUI Port"))
o.default = "9880"
o.datatype = "range(1,65535)"
- o.rmempty = false
- o:depends({dns_shunt = "smartdns", smartdns_ui = "1"})
+ o:depends("smartdns_ui", "1")
o = s:taboption("log", Value, "smartdns_ui_data_dir", translate("WebUI Database Directory"))
o.default = api.CACHE_PATH .. "/smartdns"
- o.rmempty = false
o.validate = function(self, value, section)
value = api.trim(value)
if value:sub(1, 1) ~= "/" or value:find("%s") then
@@ -758,13 +755,12 @@ if has_smartdns then
end
return value
end
- o:depends({dns_shunt = "smartdns", smartdns_ui = "1"})
+ o:depends("smartdns_ui", "1")
o = s:taboption("log", Value, "smartdns_ui_log_max_age", translate("WebUI Log Retention Days"))
o.default = "7"
o.datatype = "and(uinteger,min(1))"
- o.rmempty = false
- o:depends({dns_shunt = "smartdns", smartdns_ui = "1"})
+ o:depends("smartdns_ui", "1")
o = s:taboption("log", DummyValue, "_smartdns_ui_link", "WebUI")
o.rawhtml = true
@@ -776,7 +772,7 @@ if has_smartdns then
translate("Open WebUI"), port
)
end
- o:depends({dns_shunt = "smartdns", smartdns_ui = "1"})
+ o:depends("smartdns_ui", "1")
end
end
diff --git a/luci-app-passwall/luasrc/passwall/api.lua b/luci-app-passwall/luasrc/passwall/api.lua
index 3f30db51e..91bd68dad 100644
--- a/luci-app-passwall/luasrc/passwall/api.lua
+++ b/luci-app-passwall/luasrc/passwall/api.lua
@@ -178,9 +178,11 @@ function get_cache_var(key)
return val
end
-function get_new_port()
- local cmd_format = ". /usr/share/passwall/utils.sh ; echo -n $(get_new_port %s tcp,udp)"
- return tonumber(sys.exec(string.format(cmd_format, "auto")))
+function get_new_port(start_port, protocol)
+ start_port = tonumber(start_port) or "auto"
+ if protocol ~= "tcp" and protocol ~= "udp" then protocol = "tcp,udp" end
+ local cmd_format = ". /usr/share/passwall/utils.sh ; echo -n $(get_new_port %s %s)"
+ return tonumber(sys.exec(string.format(cmd_format, util.shellquote(tostring(start_port)), util.shellquote(protocol))))
end
function exec_call(cmd)
diff --git a/luci-app-passwall/luasrc/view/passwall/global/status_bottom.htm b/luci-app-passwall/luasrc/view/passwall/global/status_bottom.htm
index 10cc34b85..9f1d5d8e8 100644
--- a/luci-app-passwall/luasrc/view/passwall/global/status_bottom.htm
+++ b/luci-app-passwall/luasrc/view/passwall/global/status_bottom.htm
@@ -17,12 +17,15 @@
position: sticky;
bottom: 0;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, .3);
- color: #525f7f;
background: #fff;
z-index: 99;
box-sizing: border-box;
}
+html[data-darkmode="true"] .status-bar {
+ background: #2f2f2fcc;
+}
+
.status-bar .inner {
padding: 0.5em;
}
diff --git a/luci-app-passwall/root/usr/share/passwall/helper_smartdns_add.lua b/luci-app-passwall/root/usr/share/passwall/helper_smartdns_add.lua
index 737bc157b..4ceec9177 100644
--- a/luci-app-passwall/root/usr/share/passwall/helper_smartdns_add.lua
+++ b/luci-app-passwall/root/usr/share/passwall/helper_smartdns_add.lua
@@ -181,6 +181,15 @@ local only_global = (DEFAULT_PROXY_MODE == "proxy" and CHN_LIST == "0" and USE_G
if only_global == 1 then
DEFAULT_DNS_GROUP = "proxy"
end
+local AUTO_DIRECT_PORT = 0
+if DEFAULT_DNS_GROUP == AUTO_GROUP then
+ AUTO_DIRECT_PORT = api.get_new_port(15350)
+ -- 避开同一进程尚未启动的主监听和直连监听端口。
+ for _ = 1, 2 do
+ if not AUTO_DIRECT_PORT or (AUTO_DIRECT_PORT ~= tonumber(LISTEN_PORT) and AUTO_DIRECT_PORT ~= LOCAL_PORT) then break end
+ AUTO_DIRECT_PORT = api.get_new_port(AUTO_DIRECT_PORT + 1)
+ end
+end
local force_https_soa = api.uci_get_c("@global[0]", "force_https_soa") or 0
local force_qtype_soa = tonumber(force_https_soa) == 1 and "force-qtype-SOA 65" or "force-qtype-SOA -,65"
local proxy_server_name = "psw-proxy-server"
@@ -191,6 +200,7 @@ config_lines = {
tonumber(LISTEN_PORT) ~= 0 and ("bind-tcp " .. listen_address) or "",
LOCAL_PORT > 0 and ("bind [::]:" .. LOCAL_PORT .. "@lo -group direct -no-rules") or "",
LOCAL_PORT > 0 and ("bind-tcp [::]:" .. LOCAL_PORT .. "@lo -group direct -no-rules") or "",
+ AUTO_DIRECT_PORT > 0 and ("bind [::]:" .. AUTO_DIRECT_PORT .. "@lo -group auto-direct") or "",
"log-level " .. LOG_LEVEL,
"log-num " .. (LOG and "1" or "0"),
LOG and string.format("log-file %q", FLAG_PATH .. "/smartdns.log") or "",
@@ -236,10 +246,13 @@ if DNS_REDIRECT then
end
for _, server_param in ipairs(DIRECT_SERVERS) do
table.insert(config_lines, server_param .. " -group direct -exclude-default-group")
- if DEFAULT_DNS_GROUP == AUTO_GROUP then
- -- 自动组的直连副本仅接受大陆 IP,普通直连组保持默认行为。
- table.insert(auto_config_lines, server_param .. " -group " .. AUTO_GROUP .. " -exclude-default-group -whitelist-ip")
- end
+end
+if DEFAULT_DNS_GROUP == AUTO_GROUP then
+ -- 自动组先通过独立入口去除 CNAME,再按原逻辑筛选 IP。
+ table.insert(auto_config_lines, "server 127.0.0.1:" .. AUTO_DIRECT_PORT .. " -group " .. AUTO_GROUP .. " -exclude-default-group -whitelist-ip")
+ table.insert(auto_config_lines, "group-begin auto-direct -inherit none")
+ merge_array(auto_config_lines, DIRECT_SERVERS)
+ merge_array(auto_config_lines, {"force-no-CNAME yes", "group-end"})
end
-- 由下方 DNS_MODE 分支调用,服务器参数来自 PassWall 的 REMOTE_DNS 或 TUN_DNS。
diff --git a/luci-app-passwall/root/usr/share/passwall/subscribe.lua b/luci-app-passwall/root/usr/share/passwall/subscribe.lua
index e849b10f5..be5eee6fb 100755
--- a/luci-app-passwall/root/usr/share/passwall/subscribe.lua
+++ b/luci-app-passwall/root/usr/share/passwall/subscribe.lua
@@ -60,9 +60,26 @@ local has_singbox = api.finded_com("sing-box")
local has_xray = api.finded_com("xray")
local has_hysteria2 = api.finded_com("hysteria")
local DEFAULT_ALLOWINSECURE = true
-local DEFAULT_FILTER_KEYWORD_MODE = uci_get("@global_subscribe[0]", "filter_keyword_mode") or "0"
-local DEFAULT_FILTER_KEYWORD_DISCARD_LIST = uci_get("@global_subscribe[0]", "filter_discard_list") or {}
-local DEFAULT_FILTER_KEYWORD_KEEP_LIST = uci_get("@global_subscribe[0]", "filter_keep_list") or {}
+-- 展开 DynamicList 每行中的关键字,保留原有空格并忽略空项和重复项。
+local function split_filter_keywords(list)
+ local keywords, seen = {}, {}
+ if type(list) == "string" then list = {list} end
+ for _, row in ipairs(list or {}) do
+ for keyword in row:gmatch("[^|]+") do
+ if not seen[keyword] then
+ seen[keyword] = true
+ keywords[#keywords + 1] = keyword
+ end
+ end
+ end
+ return keywords
+end
+
+local DEFAULT_FILTER_KEYWORD = {
+ mode = uci_get("@global_subscribe[0]", "filter_keyword_mode") or "0",
+ discard_list = split_filter_keywords(uci_get("@global_subscribe[0]", "filter_discard_list")),
+ keep_list = split_filter_keywords(uci_get("@global_subscribe[0]", "filter_keep_list"))
+}
-- 取节点使用core类型(节点订阅页面未设置时,自动取默认)
local DEFAULT_SS_TYPE = api.get_core("ss_type", {{has_ss_rust,"shadowsocks-rust"},{has_singbox,"sing-box"},{has_xray,"xray"}})
local DEFAULT_TROJAN_TYPE = api.get_core("trojan_type", {{has_singbox,"sing-box"},{has_xray,"xray"}})
@@ -75,71 +92,38 @@ local core_has = {
["shadowsocks-rust"] = has_ss_rust,
["hysteria2"] = has_hysteria2
}
--- 判断是否过滤节点关键字
-local function is_filter_keyword(sub_cfg, value)
- local mode = DEFAULT_FILTER_KEYWORD_MODE
- local discard_list = DEFAULT_FILTER_KEYWORD_DISCARD_LIST
- local keep_list = DEFAULT_FILTER_KEYWORD_KEEP_LIST
- if sub_cfg then
- local filter_keyword_mode = sub_cfg.filter_keyword_mode or "5" -- 5 is global
- if filter_keyword_mode == "0" then
- mode = "0"
- elseif filter_keyword_mode == "1" then
- mode = "1"
- discard_list = sub_cfg.filter_discard_list or {}
- elseif filter_keyword_mode == "2" then
- mode = "2"
- keep_list = sub_cfg.filter_keep_list or {}
- elseif filter_keyword_mode == "3" then
- mode = "3"
- keep_list = sub_cfg.filter_keep_list or {}
- discard_list = sub_cfg.filter_discard_list or {}
- elseif filter_keyword_mode == "4" then
- mode = "4"
- keep_list = sub_cfg.filter_keep_list or {}
- discard_list = sub_cfg.filter_discard_list or {}
+-- 每个订阅只准备一次过滤配置,5 / 未设置时继承全局。
+local function prepare_keyword_filter(sub_cfg)
+ local mode = sub_cfg and sub_cfg.filter_keyword_mode
+ if mode == "0" or mode == "1" or mode == "2" or mode == "3" or mode == "4" then
+ return {
+ mode = mode,
+ discard_list = split_filter_keywords(sub_cfg.filter_discard_list),
+ keep_list = split_filter_keywords(sub_cfg.filter_keep_list)
+ }
+ end
+ return DEFAULT_FILTER_KEYWORD
+end
+
+local function match_filter_keywords(value, keywords)
+ for _, keyword in ipairs(keywords) do
+ if value:find(keyword, 1, true) then
+ return true
end
end
- if mode == "1" then
- for k,v in ipairs(discard_list) do
- if value:find(v, 1, true) then
- return true
- end
- end
- elseif mode == "2" then
- local result = true
- for k,v in ipairs(keep_list) do
- if value:find(v, 1, true) then
- result = false
- end
- end
- return result
- elseif mode == "3" then
- local result = false
- for k,v in ipairs(discard_list) do
- if value:find(v, 1, true) then
- result = true
- end
- end
- for k,v in ipairs(keep_list) do
- if value:find(v, 1, true) then
- result = false
- end
- end
- return result
- elseif mode == "4" then
- local result = true
- for k,v in ipairs(keep_list) do
- if value:find(v, 1, true) then
- result = false
- end
- end
- for k,v in ipairs(discard_list) do
- if value:find(v, 1, true) then
- result = true
- end
- end
- return result
+ return false
+end
+
+-- 节点解析出备注后,再按当前订阅的配置判断是否丢弃。
+local function is_filter_keyword(filter, value)
+ if filter.mode == "1" then
+ return match_filter_keywords(value, filter.discard_list)
+ elseif filter.mode == "2" then
+ return not match_filter_keywords(value, filter.keep_list)
+ elseif filter.mode == "3" then
+ return match_filter_keywords(value, filter.discard_list) and not match_filter_keywords(value, filter.keep_list)
+ elseif filter.mode == "4" then
+ return match_filter_keywords(value, filter.discard_list) or not match_filter_keywords(value, filter.keep_list)
end
return false
end
@@ -2146,6 +2130,7 @@ local function parse_link(raw, add_mode, group, sub_cfg)
if sub_cfg then
cfgid = sub_cfg[".name"]
end
+ local keyword_filter = add_mode == "2" and prepare_keyword_filter(sub_cfg)
local nodes, szType
local node_list = {}
-- SSD 似乎是这种格式 ssd:// 开头的
@@ -2202,7 +2187,7 @@ local function parse_link(raw, add_mode, group, sub_cfg)
log('丢弃节点:' .. result.remarks .. " ,原因:" .. result.error_msg)
elseif not result.type then
log('丢弃节点:' .. result.remarks .. " ,找不到可使用二进制。")
- elseif (add_mode == "2" and is_filter_keyword(sub_cfg, result.remarks)) or not result.address or result.remarks == "NULL" or result.address == "127.0.0.1" or
+ elseif (add_mode == "2" and is_filter_keyword(keyword_filter, result.remarks)) or not result.address or result.remarks == "NULL" or result.address == "127.0.0.1" or
(not datatypes.hostname(result.address) and not (api.is_ip(result.address))) then
log('丢弃过滤节点:' .. result.type .. ' 节点,' .. result.remarks)
else
diff --git a/luci-app-passwall2/Makefile b/luci-app-passwall2/Makefile
index cbdf2a7f5..7a0620b40 100644
--- a/luci-app-passwall2/Makefile
+++ b/luci-app-passwall2/Makefile
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.10.1
-PKG_RELEASE:=142
+PKG_RELEASE:=143
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
diff --git a/luci-app-passwall2/luasrc/model/cbi/passwall2/client/app_update.lua b/luci-app-passwall2/luasrc/model/cbi/passwall2/client/app_update.lua
index fb83c5183..90089d934 100644
--- a/luci-app-passwall2/luasrc/model/cbi/passwall2/client/app_update.lua
+++ b/luci-app-passwall2/luasrc/model/cbi/passwall2/client/app_update.lua
@@ -10,6 +10,14 @@ s = m:section(NamedSection, "@global_app[0]", "global_app", translate("App Updat
s:appendTemplate("/app_update/app_version", {com = com})
+o = s:option(ListValue, "gh_proxy_url", translate("GitHub Proxy"), translate("Use GitHub instead of proxy nodes for component updates."))
+o.default = ""
+o:value("", translate("Close(Not use)"))
+o:value("https://gh-proxy.org/", "gh-proxy.org")
+o:value("https://ghproxy.cc/", "ghproxy.cc")
+o:value("https://mirror.ghproxy.com/", "ghproxy.com")
+o:value("https://ghfast.top/", "ghfast.top")
+
local k, v
for k, v in pairs(com) do
o = s:option(Value, k:gsub("%-","_") .. "_file", translatef("%s App Path", v.name))
diff --git a/luci-app-passwall2/luasrc/passwall2/api.lua b/luci-app-passwall2/luasrc/passwall2/api.lua
index 63afb968a..46bd70533 100644
--- a/luci-app-passwall2/luasrc/passwall2/api.lua
+++ b/luci-app-passwall2/luasrc/passwall2/api.lua
@@ -1170,7 +1170,14 @@ local default_file_tree = {
}
function get_api_json(url)
- local return_code, content = curl_auto(url, nil, curl_args)
+ local gh_proxy = uci_get_c("@global_app[0]", "gh_proxy_url") or ""
+ local return_code, content
+ if gh_proxy ~= "" then
+ url = gh_proxy .. url
+ return_code, content = curl_direct(url, nil, curl_args)
+ else
+ return_code, content = curl_auto(url, nil, curl_args)
+ end
if return_code ~= 0 or content == "" then return {} end
return jsonc.parse(content) or {}
end
@@ -1293,7 +1300,14 @@ function to_download(app_name, url, size, task_id)
local _curl_args = clone(curl_args)
table.insert(_curl_args, "--speed-limit 51200 --speed-time 15 --max-time 300")
- local return_code, result = curl_auto(url, tmp_file, _curl_args)
+ local gh_proxy = uci_get_c("@global_app[0]", "gh_proxy_url") or ""
+ local return_code, result
+ if gh_proxy ~= "" then
+ url = gh_proxy .. url
+ return_code, result = curl_direct(url, tmp_file, _curl_args)
+ else
+ return_code, result = curl_auto(url, tmp_file, _curl_args)
+ end
result = return_code == 0
if not result then
@@ -1461,7 +1475,14 @@ end
function to_check_self()
local url = "https://raw.githubusercontent.com/Openwrt-Passwall/openwrt-passwall2/main/luci-app-passwall2/Makefile"
local tmp_file = "/tmp/passwall2_makefile"
- local return_code, result = curl_auto(url, tmp_file, curl_args)
+ local gh_proxy = uci_get_c("@global_app[0]", "gh_proxy_url") or ""
+ local return_code, result
+ if gh_proxy ~= "" then
+ url = gh_proxy .. url
+ return_code, result = curl_direct(url, tmp_file, curl_args)
+ else
+ return_code, result = curl_auto(url, tmp_file, curl_args)
+ end
result = return_code == 0
if not result then
exec("/bin/rm", {"-f", tmp_file})
diff --git a/luci-app-passwall2/luasrc/view/passwall2/global/status_bottom.htm b/luci-app-passwall2/luasrc/view/passwall2/global/status_bottom.htm
index fdbb29d56..703ea67b2 100644
--- a/luci-app-passwall2/luasrc/view/passwall2/global/status_bottom.htm
+++ b/luci-app-passwall2/luasrc/view/passwall2/global/status_bottom.htm
@@ -17,12 +17,15 @@
position: sticky;
bottom: 0;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, .3);
- color: #525f7f;
background: #fff;
z-index: 99;
box-sizing: border-box;
}
+html[data-darkmode="true"] .status-bar {
+ background: #2f2f2fcc;
+}
+
.status-bar .inner {
padding: 0.5em;
}
diff --git a/luci-app-passwall2/po/fa/passwall2.po b/luci-app-passwall2/po/fa/passwall2.po
index 4b928688b..f9d6ae621 100644
--- a/luci-app-passwall2/po/fa/passwall2.po
+++ b/luci-app-passwall2/po/fa/passwall2.po
@@ -442,7 +442,7 @@ msgid "Failure Tolerance (%)"
msgstr "تحمل شکست (%)"
msgid "The maximum acceptable speed test failure rate. For example, 1 means allowing a 1% failure rate."
-msgstr "حداکثر نرخ شکست قابل قبول در تست سرعت. برای مثال، عدد ۱ به معنای مجاز بودن نرخ شکست ۱٪ است.
+msgstr "حداکثر نرخ شکست قابل قبول در تست سرعت. برای مثال، عدد ۱ به معنای مجاز بودن نرخ شکست ۱٪ است."
msgid "Shunt"
msgstr "شنت (Shunt)"
@@ -1009,6 +1009,15 @@ msgstr "در حال انتقال..."
msgid "App Update"
msgstr "بهروزرسانی برنامه"
+msgid "GitHub Proxy"
+msgstr "پروکسی معکوس GitHub"
+
+msgid "Use GitHub instead of proxy nodes for component updates."
+msgstr "برای بررسی و دانلود بهروزرسانی اجزا، مستقیماً از سرویس پروکسی معکوس GitHub استفاده کنید، بدون عبور از گرههای پروکسی."
+
+msgid "Close(Not use)"
+msgstr "خاموش (عدم استفاده)"
+
msgid "if you want to run from memory, change the path, /tmp beginning then save the application and update it manually."
msgstr "اگر میخواهید از حافظه اجرا کنید، مسیر را تغییر دهید، با /tmp شروع شود سپس برنامه را ذخیره و دستی بهروزرسانی کنید."
diff --git a/luci-app-passwall2/po/ru/passwall2.po b/luci-app-passwall2/po/ru/passwall2.po
index 516944d66..3941f856c 100644
--- a/luci-app-passwall2/po/ru/passwall2.po
+++ b/luci-app-passwall2/po/ru/passwall2.po
@@ -1010,6 +1010,15 @@ msgstr "Перемещение..."
msgid "App Update"
msgstr "Обновление компонентов"
+msgid "GitHub Proxy"
+msgstr "Обратный прокси для GitHub"
+
+msgid "Use GitHub instead of proxy nodes for component updates."
+msgstr "При обновлении компонентов проверять и загружать их напрямую через сервис обратного прокси для GitHub, без использования прокси-узлов."
+
+msgid "Close(Not use)"
+msgstr "Отключено (не использовать)"
+
msgid "if you want to run from memory, change the path, /tmp beginning then save the application and update it manually."
msgstr "Если вы хотите запускать из оперативной памяти, измените путь на начинающийся с /tmp, затем сохраните настройки и обновите компонент вручную."
@@ -2443,7 +2452,7 @@ msgid "Gecko Packet Size (max)"
msgstr "Размер упаковки Gecko (макс)"
msgid "Enable UPnP/NAT-PMP port mapping on your gateway to improve hole punching success."
-msgstr "Включите сопоставление портов UPnP/NAT-PMP на вашем шлюзе, чтобы повысить вероятность успешного выполнения операций "пробивания дыр"."
+msgstr "Включите сопоставление портов UPnP/NAT-PMP на вашем шлюзе, чтобы повысить вероятность успешного выполнения операций \"пробивания дыр\"."
msgid "valid time (hh:mm)"
msgstr "Время действия (чч:мм)"
diff --git a/luci-app-passwall2/po/zh-cn/passwall2.po b/luci-app-passwall2/po/zh-cn/passwall2.po
index cbd7365b7..0e3dc0148 100644
--- a/luci-app-passwall2/po/zh-cn/passwall2.po
+++ b/luci-app-passwall2/po/zh-cn/passwall2.po
@@ -1001,6 +1001,15 @@ msgstr "移动中"
msgid "App Update"
msgstr "组件更新"
+msgid "GitHub Proxy"
+msgstr "GitHub 反向代理"
+
+msgid "Use GitHub instead of proxy nodes for component updates."
+msgstr "组件更新时使用 GitHub 反向代理服务直连检查和下载,不通过代理节点。"
+
+msgid "Close(Not use)"
+msgstr "关闭(不使用)"
+
msgid "if you want to run from memory, change the path, /tmp beginning then save the application and update it manually."
msgstr "如果你希望从内存中运行,请更改路径,/tmp 开头,然后保存应用后,再手动更新。"
diff --git a/luci-app-passwall2/po/zh-tw/passwall2.po b/luci-app-passwall2/po/zh-tw/passwall2.po
index 61755c441..9e750ca09 100644
--- a/luci-app-passwall2/po/zh-tw/passwall2.po
+++ b/luci-app-passwall2/po/zh-tw/passwall2.po
@@ -1001,6 +1001,15 @@ msgstr "移動中"
msgid "App Update"
msgstr "组件更新"
+msgid "GitHub Proxy"
+msgstr "GitHub 反向代理"
+
+msgid "Use GitHub instead of proxy nodes for component updates."
+msgstr "元件更新時使用 GitHub 反向代理服務直接檢查與下載,不經過代理節點。"
+
+msgid "Close(Not use)"
+msgstr "關閉(不使用)"
+
msgid "if you want to run from memory, change the path, /tmp beginning then save the application and update it manually."
msgstr "如果你希望从內存中運行,請更改路径,/tmp 開头,然後保存應用後,再手動更新。"
diff --git a/luci-app-passwall2/root/etc/hotplug.d/iface/98-passwall2 b/luci-app-passwall2/root/etc/hotplug.d/iface/98-passwall2
index f80354d10..81883bfd1 100644
--- a/luci-app-passwall2/root/etc/hotplug.d/iface/98-passwall2
+++ b/luci-app-passwall2/root/etc/hotplug.d/iface/98-passwall2
@@ -1,35 +1,39 @@
#!/bin/sh
[ "$ACTION" = "ifup" ] || [ "$ACTION" = "ifupdate" ] || exit 0
+[ -n "$DEVICE" ] || exit 0
. /usr/share/passwall2/utils.sh
-([ "$(get_cache_var "ENABLED_DEFAULT_ACL")" = "1" ] || [ "$(get_cache_var "ENABLED_ACLS")" = "1" ]) && [ -f ${LOCK_PATH}/${CONFIG}_ready.lock ] && {
+[ -f "${LOCK_PATH}/${CONFIG}_ready.lock" ] || exit 0
+[ "$(get_cache_var "ENABLED_DEFAULT_ACL")" = "1" ] || [ "$(get_cache_var "ENABLED_ACLS")" = "1" ] || exit 0
- default_device=$(ip route show default 2>/dev/null | awk -F 'dev ' '{print $2}' | awk '{print $1}' | head -n1)
- default6_device=$(ip -6 route show default 2>/dev/null | awk -F 'dev ' '{print $2}' | awk '{print $1}' | head -n1)
- ( [ "$default_device" = "$DEVICE" ] || [ "$default6_device" = "$DEVICE" ] ) && {
+default_device=$(ip route show default 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i + 1); break } exit }')
+default6_device=$(ip -6 route show default 2>/dev/null | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i + 1); break } exit }')
+[ "$default_device" = "$DEVICE" ] || [ "$default6_device" = "$DEVICE" ] || exit 0
- if [ "$ACTION" = "ifupdate" ]; then
- USE_TABLES=$(get_cache_var "USE_TABLES")
- [ -n "$USE_TABLES" ] && { ${APP_PATH}/${USE_TABLES}.sh update_wan_sets >/dev/null 2>&1 & }
- exit 0
- fi
+if [ "$ACTION" = "ifupdate" ]; then
+ USE_TABLES=$(get_cache_var "USE_TABLES")
+ [ -n "$USE_TABLES" ] && { "${APP_PATH}/${USE_TABLES}.sh" update_wan_sets >/dev/null 2>&1 & }
+ exit 0
+fi
- [ ! -d ${LOCK_PATH} ] && mkdir -p ${LOCK_PATH}
- LOCK_FILE="${LOCK_PATH}/${CONFIG}_ifup.lock"
- if [ -s ${LOCK_FILE} ]; then
- SPID=$(cat ${LOCK_FILE})
- if [ -e /proc/${SPID}/status ]; then
- exit 1
- fi
- cat /dev/null > ${LOCK_FILE}
- fi
- echo $$ > ${LOCK_FILE}
-
- /etc/init.d/${CONFIG} restart >/dev/null 2>&1 &
- logger -p notice -t network -s "${CONFIG}: restart when $INTERFACE ifup"
-
- rm -rf ${LOCK_FILE}
- }
+mkdir -p "$LOCK_PATH" || {
+ logger -p err -t network -s "${CONFIG}: cannot create lock directory $LOCK_PATH"
+ exit 1
}
+
+(
+ command exec 9>"${LOCK_PATH}/${CONFIG}_ifup.lock" || {
+ logger -p err -t network -s "${CONFIG}: cannot open ifup lock"
+ exit 1
+ }
+ flock -xn 9 || exit 0
+
+ # Keep this lock file: removing it would allow locks on different inodes.
+ # The worker holds the lock until restart returns; children must not inherit it.
+ logger -p notice -t network -s "${CONFIG}: restart when $INTERFACE ifup" 9>&-
+ "/etc/init.d/${CONFIG}" restart 9>&-
+) /dev/null 2>&1 &
+
+exit 0
diff --git a/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua b/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua
index 9e777cf98..a41d23319 100755
--- a/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua
+++ b/luci-app-passwall2/root/usr/share/passwall2/subscribe.lua
@@ -30,9 +30,26 @@ local has_ssr = api.is_finded("ssr-local") and api.is_finded("ssr-redir")
local has_singbox = api.finded_com("sing-box")
local has_xray = api.finded_com("xray")
local DEFAULT_ALLOWINSECURE = true
-local DEFAULT_FILTER_KEYWORD_MODE = uci_get("@global_subscribe[0]", "filter_keyword_mode") or "0"
-local DEFAULT_FILTER_KEYWORD_DISCARD_LIST = uci_get("@global_subscribe[0]", "filter_discard_list") or {}
-local DEFAULT_FILTER_KEYWORD_KEEP_LIST = uci_get("@global_subscribe[0]", "filter_keep_list") or {}
+-- Expand each DynamicList row, preserving spaces and ignoring empty or duplicate keywords.
+local function split_filter_keywords(list)
+ local keywords, seen = {}, {}
+ if type(list) == "string" then list = {list} end
+ for _, row in ipairs(list or {}) do
+ for keyword in row:gmatch("[^|]+") do
+ if not seen[keyword] then
+ seen[keyword] = true
+ keywords[#keywords + 1] = keyword
+ end
+ end
+ end
+ return keywords
+end
+
+local DEFAULT_FILTER_KEYWORD = {
+ mode = uci_get("@global_subscribe[0]", "filter_keyword_mode") or "0",
+ discard_list = split_filter_keywords(uci_get("@global_subscribe[0]", "filter_discard_list")),
+ keep_list = split_filter_keywords(uci_get("@global_subscribe[0]", "filter_keep_list"))
+}
-- Nodes should be retrieved using the core type (if not set on the node subscription page, the default type will be used automatically).
local DEFAULT_SS_TYPE = api.get_core("ss_type", {{has_ss_rust,"shadowsocks-rust"},{has_singbox,"sing-box"},{has_xray,"xray"}})
local DEFAULT_TROJAN_TYPE = api.get_core("trojan_type", {{has_singbox,"sing-box"},{has_xray,"xray"}})
@@ -44,71 +61,38 @@ local core_has = {
["sing-box"] = has_singbox,
["shadowsocks-rust"] = has_ss_rust,
}
--- Determine whether to filter node keywords
-local function is_filter_keyword(sub_cfg, value)
- local mode = DEFAULT_FILTER_KEYWORD_MODE
- local discard_list = DEFAULT_FILTER_KEYWORD_DISCARD_LIST
- local keep_list = DEFAULT_FILTER_KEYWORD_KEEP_LIST
- if sub_cfg then
- local filter_keyword_mode = sub_cfg.filter_keyword_mode or "5" -- 5 is global
- if filter_keyword_mode == "0" then
- mode = "0"
- elseif filter_keyword_mode == "1" then
- mode = "1"
- discard_list = sub_cfg.filter_discard_list or {}
- elseif filter_keyword_mode == "2" then
- mode = "2"
- keep_list = sub_cfg.filter_keep_list or {}
- elseif filter_keyword_mode == "3" then
- mode = "3"
- keep_list = sub_cfg.filter_keep_list or {}
- discard_list = sub_cfg.filter_discard_list or {}
- elseif filter_keyword_mode == "4" then
- mode = "4"
- keep_list = sub_cfg.filter_keep_list or {}
- discard_list = sub_cfg.filter_discard_list or {}
+-- Prepare once per subscription; mode 5 or an unset mode inherits the global filter.
+local function prepare_keyword_filter(sub_cfg)
+ local mode = sub_cfg and sub_cfg.filter_keyword_mode
+ if mode == "0" or mode == "1" or mode == "2" or mode == "3" or mode == "4" then
+ return {
+ mode = mode,
+ discard_list = split_filter_keywords(sub_cfg.filter_discard_list),
+ keep_list = split_filter_keywords(sub_cfg.filter_keep_list)
+ }
+ end
+ return DEFAULT_FILTER_KEYWORD
+end
+
+local function match_filter_keywords(value, keywords)
+ for _, keyword in ipairs(keywords) do
+ if value:find(keyword, 1, true) then
+ return true
end
end
- if mode == "1" then
- for k,v in ipairs(discard_list) do
- if value:find(v, 1, true) then
- return true
- end
- end
- elseif mode == "2" then
- local result = true
- for k,v in ipairs(keep_list) do
- if value:find(v, 1, true) then
- result = false
- end
- end
- return result
- elseif mode == "3" then
- local result = false
- for k,v in ipairs(discard_list) do
- if value:find(v, 1, true) then
- result = true
- end
- end
- for k,v in ipairs(keep_list) do
- if value:find(v, 1, true) then
- result = false
- end
- end
- return result
- elseif mode == "4" then
- local result = true
- for k,v in ipairs(keep_list) do
- if value:find(v, 1, true) then
- result = false
- end
- end
- for k,v in ipairs(discard_list) do
- if value:find(v, 1, true) then
- result = true
- end
- end
- return result
+ return false
+end
+
+-- Decide whether to discard a parsed node using the current subscription's filter.
+local function is_filter_keyword(filter, value)
+ if filter.mode == "1" then
+ return match_filter_keywords(value, filter.discard_list)
+ elseif filter.mode == "2" then
+ return not match_filter_keywords(value, filter.keep_list)
+ elseif filter.mode == "3" then
+ return match_filter_keywords(value, filter.discard_list) and not match_filter_keywords(value, filter.keep_list)
+ elseif filter.mode == "4" then
+ return match_filter_keywords(value, filter.discard_list) or not match_filter_keywords(value, filter.keep_list)
end
return false
end
@@ -2520,6 +2504,7 @@ local function parse_link(raw, add_mode, group, sub_cfg)
if sub_cfg then
cfgid = sub_cfg[".name"]
end
+ local keyword_filter = add_mode == "2" and prepare_keyword_filter(sub_cfg)
local nodes, szType, clashTable
local node_list = {}
-- ssd appear to be in this format, starting with ssd://.
@@ -2563,7 +2548,7 @@ local function parse_link(raw, add_mode, group, sub_cfg)
log(2, i18n.translatef("Discard node: %s, Reason:", node.remarks) .. " " .. node.error_msg)
elseif not node.type then
log(2, i18n.translatef("Discard node: %s, Reason:", node.remarks) .. " " .. i18n.translatef("No usable binary was found."))
- elseif (add_mode == "2" and is_filter_keyword(sub_cfg, node.remarks)) or not node.address or node.remarks == "NULL" or node.address == "127.0.0.1" or
+ elseif (add_mode == "2" and is_filter_keyword(keyword_filter, node.remarks)) or not node.address or node.remarks == "NULL" or node.address == "127.0.0.1" or
(not datatypes.hostname(node.address) and not (api.is_ip(node.address))) then
log(2, i18n.translatef("Discard filter nodes: %s type node %s", node.type, node.remarks))
else
diff --git a/luci-app-ssr-plus/Makefile b/luci-app-ssr-plus/Makefile
index 2bc048934..2c198daa7 100644
--- a/luci-app-ssr-plus/Makefile
+++ b/luci-app-ssr-plus/Makefile
@@ -4,7 +4,7 @@ LUCI_TITLE:=luci-app-ssr-plus
LUCI_PKGARCH:=all
PKG_NAME:=luci-app-ssr-plus
PKG_VERSION:=196
-PKG_RELEASE:=89
+PKG_RELEASE:=90
PKG_CONFIG_DEPENDS:= \
CONFIG_PACKAGE_$(PKG_NAME)_Iptables_Transparent_Proxy \
diff --git a/luci-app-ssr-plus/luasrc/view/shadowsocksr/status_bottom.htm b/luci-app-ssr-plus/luasrc/view/shadowsocksr/status_bottom.htm
index 87507714b..6d25c6edc 100644
--- a/luci-app-ssr-plus/luasrc/view/shadowsocksr/status_bottom.htm
+++ b/luci-app-ssr-plus/luasrc/view/shadowsocksr/status_bottom.htm
@@ -17,12 +17,15 @@
position: sticky;
bottom: 0;
box-shadow: 0 0 2rem 0 rgba(136, 152, 170, .3);
- color: #525f7f;
background: #fff;
z-index: 99;
box-sizing: border-box;
}
+html[data-darkmode="true"] .status-bar {
+ background: #2f2f2fcc;
+}
+
.status-bar .inner {
padding: 0.5em;
}
diff --git a/luci-app-zerotier/Makefile b/luci-app-zerotier/Makefile
index be4917b3c..0e7e8832e 100644
--- a/luci-app-zerotier/Makefile
+++ b/luci-app-zerotier/Makefile
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-zerotier
PKG_VERSION:=2.2
-PKG_RELEASE:=9
+PKG_RELEASE:=10
PKG_LICENSE:=Apache-2.0
LUCI_TITLE:=LuCI for ZeroTier
diff --git a/luci-app-zerotier/README.md b/luci-app-zerotier/README.md
index a9ef7adef..7617a4517 100644
--- a/luci-app-zerotier/README.md
+++ b/luci-app-zerotier/README.md
@@ -8,6 +8,8 @@ EEOF
}
# luci-app-zerotier (JavaScript Edition)
+**English** | [简体中文](README_zh-CN.md)
+
A modern LuCI interface for ZeroTier VPN on OpenWrt, converted from the original Lua version and continuously improved.
Forked from https://github.com/zhengmz/luci-app-zerotier, initially converted to JavaScript using AUGMENT Code plugin in VS Code, then further refined and bug-fixed using [OpenCode](https://opencode.ai) + GLM-5.1.
@@ -52,7 +54,7 @@ The custom `luci-zerotier` RPC object provides these methods (no `luci.exec` nee
| `remote_network_list` | read | Every network's full record in a single tunnel round trip |
| `remote_peer_list` | read | The controller's live `/peer` list (status, version, latency, paths) |
| `remote_moon_plan` | read | The exact remote command a moon creation would run, plus a digest |
-| `remote_host_set` | write | Add/update a remote host; writes the private key to a `0600` file |
+| `remote_host_set` | write | Add/update a remote host; generates a dedicated key pair when none is stored and returns the public key (pasting a private key still works for API callers) |
| `remote_host_del` | write | Remove a remote host and its key file |
| `remote_network_set` | write | Create or update a network on the remote controller |
| `remote_network_del` | write | Delete a network on the remote controller |
@@ -257,6 +259,30 @@ stderr and continues. Two consequences are handled explicitly:
16px row font). Single adaptation from the supplied SVG: the stroke
follows `currentColor` instead of hard black so the glyph stays visible
under the dark theme.
+- Host list polish (no release bump): each configured host's clickable
+ name now renders at the page-title scale -- it is a real h2 (argon:
+ 1.25rem, same as the "ZeroTier - Remote Controller" heading) with only
+ the card chrome (padding/white background/shadow) neutralised inline,
+ so typography stays the theme's. The monospace connection line below
+ is unchanged, and host blocks gain margin-bottom so consecutive hosts
+ no longer touch.
+- Add-host flow redesigned (no release bump): pasting an SSH private key
+ into the browser is gone. Saving an SSH-mode host with no stored key now
+ generates a dedicated ed25519 pair on the router (dropbear's ssh-keygen
+ compat binary, verified present in every default OpenWrt image via the
+ dropbear package's unconditional ALTERNATIVES entry) and returns the
+ public half, which a modal shows as a ready-to-copy
+ `echo '