From 6f5bbf015d39098c0a4231932d39194a7b9986b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 6 Nov 2026 03:00:09 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=88=20Sync=202026-11-06=2003:00:09?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crypto-hw-eip93-mt7621/Makefile | 48 +++++ ...001-Build-on-kernels-older-than-6.15.patch | 129 ++++++++++++ luci-app-open-box/Makefile | 4 +- luci-app-passwall/Makefile | 2 +- .../passwall/cbi/nodes_dynamiclist_com.htm | 13 +- .../view/passwall/cbi/nodes_listvalue_com.htm | 9 +- .../view/passwall/cbi/nodes_multivalue.htm | 2 +- .../passwall/cbi/nodes_multivalue_com.htm | 8 +- .../view/passwall/cbi/nodes_value_com.htm | 9 +- .../luasrc/view/passwall/global/backup.htm | 2 +- .../view/passwall/node_list/node_list.htm | 116 +++++++++-- luci-app-passwall2/Makefile | 2 +- .../passwall2/cbi/nodes_dynamiclist_com.htm | 13 +- .../passwall2/cbi/nodes_listvalue_com.htm | 9 +- .../view/passwall2/cbi/nodes_multivalue.htm | 4 +- .../passwall2/cbi/nodes_multivalue_com.htm | 8 +- .../view/passwall2/cbi/nodes_value_com.htm | 9 +- .../luasrc/view/passwall2/global/backup.htm | 2 +- .../view/passwall2/node_list/node_list.htm | 108 +++++++++- .../usr/share/passwall2/helper_dnsmasq.lua | 2 +- mise/Makefile | 4 +- shunt/Makefile | 4 +- shunt/README.md | 55 +++-- shunt/files/shunt.rpcd | 40 ++-- shunt/files/shunt.uc | 164 ++++++++++++--- shunt/src/config.uc | 6 +- shunt/src/netifd.uc | 75 ++++--- shunt/src/nft.uc | 155 ++++++++++----- shunt/src/route.uc | 188 ++++++++++-------- 29 files changed, 922 insertions(+), 268 deletions(-) create mode 100644 crypto-hw-eip93-mt7621/Makefile create mode 100644 crypto-hw-eip93-mt7621/patches/0001-Build-on-kernels-older-than-6.15.patch diff --git a/crypto-hw-eip93-mt7621/Makefile b/crypto-hw-eip93-mt7621/Makefile new file mode 100644 index 000000000..74c862b8d --- /dev/null +++ b/crypto-hw-eip93-mt7621/Makefile @@ -0,0 +1,48 @@ +include $(TOPDIR)/rules.mk +include $(INCLUDE_DIR)/kernel.mk + +PKG_NAME:=crypto-hw-eip93-mt7621 +PKG_RELEASE:=1 + +PKG_SOURCE_PROTO:=git +PKG_SOURCE_URL:=https://github.com/denisyuriev/crypto-hw-eip93-mt7621.git +PKG_SOURCE_DATE:=2026-11-05 +PKG_SOURCE_VERSION:=7de663c76dd500343c970a60121b4e88ae7d8cdf +PKG_MIRROR_HASH:=skip + +PKG_LICENSE:=GPL-2.0 +PKG_BUILD_PARALLEL:=1 + +include $(INCLUDE_DIR)/package.mk + +define KernelPackage/crypto-hw-eip93-mt7621 + SUBMENU:=Cryptographic API modules + TITLE:=MTK EIP93 crypto engine (MT7621) + DEPENDS:=@TARGET_ramips_mt7621 \ + +kmod-crypto-authenc \ + +kmod-crypto-des \ + +kmod-crypto-md5 \ + +kmod-crypto-sha1 \ + +kmod-crypto-sha256 + FILES:=$(PKG_BUILD_DIR)/crypto-hw-eip93.ko + AUTOLOAD:=$(call AutoLoad,09,crypto-hw-eip93) + $(call AddDepends/crypto) +endef + +define KernelPackage/crypto-hw-eip93-mt7621/description + Hardware crypto engine EIP-93 found in the MediaTek MT7621 SoC. + DES/3DES/AES in ECB/CBC modes, MD5/SHA1/SHA2 hashes and HMAC. +endef + +NOSTDINC_FLAGS = \ + -I$(PKG_BUILD_DIR) \ + -I$(STAGING_DIR)/usr/include/mac80211-backport/uapi + +define Build/Compile + $(KERNEL_MAKE) $(PKG_JOBS) \ + M="$(PKG_BUILD_DIR)" \ + CONFIG_CRYPTO_DEV_EIP93=m \ + modules +endef + +$(eval $(call KernelPackage,crypto-hw-eip93-mt7621)) diff --git a/crypto-hw-eip93-mt7621/patches/0001-Build-on-kernels-older-than-6.15.patch b/crypto-hw-eip93-mt7621/patches/0001-Build-on-kernels-older-than-6.15.patch new file mode 100644 index 000000000..6ad30beae --- /dev/null +++ b/crypto-hw-eip93-mt7621/patches/0001-Build-on-kernels-older-than-6.15.patch @@ -0,0 +1,129 @@ +From 0eeeade621c4d132e78896c8bc49a77ba9efe4c3 Mon Sep 17 00:00:00 2001 +From: koshev-msk +Date: Thu, 5 Nov 2026 14:01:50 +0300 +Subject: [PATCH] Build on kernels older than 6.15 + +Several helpers the driver uses only exist in newer kernels: + +- scatterwalk_start_at_pos(), scatterwalk_next(), scatterwalk_done_src(), + walk->addr and memcpy_{from,to}_sglist() were added in 6.15. Provide + scatterwalk_start_at_pos() and map memcpy_{from,to}_sglist() to + scatterwalk_map_and_copy() on older kernels, and walk the list with the + classic scatter_walk API in eip93_gcm_ghash_sg(). +- MD5_BLOCK_SIZE and SHA256_STATE_WORDS were added to and + in 6.18. Define them locally when missing. + +Newer kernels use the code as-is. Build-tested against 6.12.112 mipsel +with -Werror. +--- + eip93-gcm.c | 39 +++++++++++++++++++++++++++++++++++++++ + sw/md5/md5-core.h | 5 +++++ + sw/sha256/sha256-core.h | 5 +++++ + 3 files changed, 49 insertions(+) + +diff --git a/eip93-gcm.c b/eip93-gcm.c +index 4b8db2f..9460e8b 100644 +--- a/eip93-gcm.c ++++ b/eip93-gcm.c +@@ -17,6 +17,7 @@ + #include + #include + #include ++#include + + #include "eip93-bench.h" + #include "eip93-cipher.h" +@@ -26,6 +27,29 @@ + #include "sw/ghash/gf128.h" + #include "sw/ghash/ghash.h" + ++#if LINUX_VERSION_CODE < KERNEL_VERSION(6, 15, 0) ++/* ++ * memcpy_{from,to}_sglist() and scatterwalk_start_at_pos() appeared in 6.15. ++ * Older kernels have the generic helper and the same walk fields. ++ */ ++#define memcpy_from_sglist(buf, sg, start, n) \ ++ scatterwalk_map_and_copy(buf, sg, start, n, 0) ++#define memcpy_to_sglist(sg, start, buf, n) \ ++ scatterwalk_map_and_copy(buf, sg, start, n, 1) ++ ++static inline void scatterwalk_start_at_pos(struct scatter_walk *walk, ++ struct scatterlist *sg, ++ unsigned int pos) ++{ ++ while (pos > sg->length) { ++ pos -= sg->length; ++ sg = sg_next(sg); ++ } ++ walk->sg = sg; ++ walk->offset = sg->offset + pos; ++} ++#endif ++ + /* + * Longest request the MIPS code still finishes ahead of the engine. + * EIP93_DEVELOPER find, five 3 s windows, median. Steps below 256 are +@@ -138,6 +162,7 @@ static void eip93_gcm_ghash_sg(struct ghash_mips_desc *gh, const void *tab, + if (!len) + return; + ++#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 15, 0) + scatterwalk_start_at_pos(&walk, sg, skip); + while (len) { + unsigned int n = scatterwalk_next(&walk, len); +@@ -146,6 +171,20 @@ static void eip93_gcm_ghash_sg(struct ghash_mips_desc *gh, const void *tab, + scatterwalk_done_src(&walk, n); + len -= n; + } ++#else ++ scatterwalk_start_at_pos(&walk, sg, skip); ++ scatterwalk_done(&walk, 0, 1); ++ while (len) { ++ unsigned int n = scatterwalk_clamp(&walk, len); ++ void *p = scatterwalk_map(&walk); ++ ++ ghash_mips_acc_update(gh, tab, p, n); ++ scatterwalk_unmap(p); ++ scatterwalk_advance(&walk, n); ++ len -= n; ++ scatterwalk_done(&walk, 0, len); ++ } ++#endif + } + + static void eip93_gcm_tag(struct ghash_mips_desc *gh, const void *tab, +diff --git a/sw/md5/md5-core.h b/sw/md5/md5-core.h +index ac1400a..6a5d8a7 100644 +--- a/sw/md5/md5-core.h ++++ b/sw/md5/md5-core.h +@@ -8,6 +8,11 @@ + #include + #include + ++/* Added to in 6.18 */ ++#ifndef MD5_BLOCK_SIZE ++#define MD5_BLOCK_SIZE 64 ++#endif ++ + /* + * Own context, same layout as struct md5_ctx. Names stay off the md5-lib + * exports: this file is linked into crypto-hw-eip93. +diff --git a/sw/sha256/sha256-core.h b/sw/sha256/sha256-core.h +index b379322..28085fa 100644 +--- a/sw/sha256/sha256-core.h ++++ b/sw/sha256/sha256-core.h +@@ -8,6 +8,11 @@ + #include + #include + ++/* Added to in 6.18 */ ++#ifndef SHA256_STATE_WORDS ++#define SHA256_STATE_WORDS 8 ++#endif ++ + /* + * Own context, same layout as struct __sha256_ctx. Names stay off the + * sha256-lib / sha224-lib exports: this file is linked into +-- +2.43.0 + diff --git a/luci-app-open-box/Makefile b/luci-app-open-box/Makefile index cd96e2b50..e757c2e62 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.405 -PKG_RELEASE:=64 +PKG_VERSION:=0.1.407 +PKG_RELEASE:=65 PKG_MAINTAINER:=liandu2024 PKG_LICENSE:=GPL-3.0-or-later diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile index df2db27a7..0712fa486 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.11.3 -PKG_RELEASE:=365 +PKG_RELEASE:=366 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall/luasrc/view/passwall/cbi/nodes_dynamiclist_com.htm b/luci-app-passwall/luasrc/view/passwall/cbi/nodes_dynamiclist_com.htm index 3a74ce204..2e0a2980a 100644 --- a/luci-app-passwall/luasrc/view/passwall/cbi/nodes_dynamiclist_com.htm +++ b/luci-app-passwall/luasrc/view/passwall/cbi/nodes_dynamiclist_com.htm @@ -79,6 +79,10 @@ justify-content: space-between; box-sizing: border-box; } +/* 使用控件自带箭头,清除主题背景箭头 */ +.dl-dropdown-display.cbi-input-select { + background-image: none !important; +} .dl-dropdown-label, .dl-selected-label { display: inline-block; @@ -100,7 +104,7 @@ overscroll-behavior: contain; } .dl-dropdown-search { - width: 100%; + width: 100% !important; max-width: 100% !important; min-width: 0 !important; box-sizing: border-box; @@ -136,6 +140,10 @@ flex-direction: column; gap: 5px; } +/* 选择框与已选列表之间保持相同的行间距 */ +.dl-selected-list:not(:empty) { + margin-bottom: 5px; +} .dl-remove { margin-left: 8px; cursor: pointer; @@ -260,7 +268,8 @@ const panelRadius = styleSelect.borderRadius; const optionColor = !dl_isTransparent(styleOption.backgroundColor) ? styleOption.backgroundColor : !dl_isTransparent(styleSelect.backgroundColor) ? styleSelect.backgroundColor : styleBody.backgroundColor const titleColor = dl_getColorSchema(optionColor) === "light" ? dl_darker(optionColor, 30) : dl_lighter(optionColor, 30) - const selectStyleCSS = [`#${CSS.escape(cbid + ".display")}, .dl-selected-item {`, dl_style2Css(styleSelect, styleNames), dl_style2Css(styleSelect, ["backgroundColor"]), "}"] + // 自带箭头已占据布局空间,右内边距使用主题的左内边距 + const selectStyleCSS = [`#${CSS.escape(cbid + ".display")}, .dl-selected-item {`, dl_style2Css(styleSelect, styleNames), dl_style2Css(styleSelect, ["backgroundColor"]), `padding-right: ${styleSelect.paddingLeft} !important;`, "}"] const optionStyleCSS = [`#${CSS.escape(cbid + ".panel")} {`, dl_style2Css(styleOption, styleNames), `background-color: ${optionColor};`, `border-radius: ${panelRadius};`, "}"] const titleStyleCSS = [`#${CSS.escape(cbid + ".panel")} .dl-group-title {`, `background-color: ${titleColor} !important;`, "}"] styleNode.textContent = [].concat(selectStyleCSS, optionStyleCSS, titleStyleCSS).join("\n") diff --git a/luci-app-passwall/luasrc/view/passwall/cbi/nodes_listvalue_com.htm b/luci-app-passwall/luasrc/view/passwall/cbi/nodes_listvalue_com.htm index 30de14bb8..ad8ce3908 100644 --- a/luci-app-passwall/luasrc/view/passwall/cbi/nodes_listvalue_com.htm +++ b/luci-app-passwall/luasrc/view/passwall/cbi/nodes_listvalue_com.htm @@ -88,6 +88,10 @@ justify-content: space-between; box-sizing: border-box; } +/* 使用控件自带箭头,清除主题背景箭头 */ +.lv-dropdown-display.cbi-input-select { + background-image: none !important; +} .lv-dropdown-label { display: inline-block; overflow: hidden; @@ -108,7 +112,7 @@ overscroll-behavior: contain; } .lv-dropdown-search { - width: 100%; + width: 100% !important; max-width: 100% !important; min-width: 0 !important; box-sizing: border-box; @@ -254,7 +258,8 @@ const panelRadius = styleSelect.borderRadius; const optionColor = !lv_isTransparent(styleOption.backgroundColor) ? styleOption.backgroundColor : !lv_isTransparent(styleSelect.backgroundColor) ? styleSelect.backgroundColor : styleBody.backgroundColor const titleColor = lv_getColorSchema(optionColor) === "light" ? lv_darker(optionColor, 30) : lv_lighter(optionColor, 30) - const selectStyleCSS = [`#${CSS.escape(cbid + ".display")} {`, lv_style2Css(styleSelect, styleNames), lv_style2Css(styleSelect, ["backgroundColor"]), "}"] + // 自带箭头已占据布局空间,右内边距使用主题的左内边距 + const selectStyleCSS = [`#${CSS.escape(cbid + ".display")} {`, lv_style2Css(styleSelect, styleNames), lv_style2Css(styleSelect, ["backgroundColor"]), `padding-right: ${styleSelect.paddingLeft} !important;`, "}"] const optionStyleCSS = [`#${CSS.escape(cbid + ".panel")} {`, lv_style2Css(styleOption, styleNames), `background-color: ${optionColor};`, `border-radius: ${panelRadius};`, "}"] const titleStyleCSS = [`#${CSS.escape(cbid + ".panel")} .lv-group-title {`, `background-color: ${titleColor} !important;`, "}"] styleNode.textContent = [].concat(selectStyleCSS, optionStyleCSS, titleStyleCSS).join("\n") diff --git a/luci-app-passwall/luasrc/view/passwall/cbi/nodes_multivalue.htm b/luci-app-passwall/luasrc/view/passwall/cbi/nodes_multivalue.htm index f5c6ea39b..e61cb420a 100644 --- a/luci-app-passwall/luasrc/view/passwall/cbi/nodes_multivalue.htm +++ b/luci-app-passwall/luasrc/view/passwall/cbi/nodes_multivalue.htm @@ -63,7 +63,7 @@ local function idSafe(id) end %> -
+
+
+
<%:You choose node is:%>
+
-
-
- -
- +
+ <% if #shunt_rule_list > 0 then %> -
- -
+
+ +
<% for i, v in ipairs(shunt_rule_list) do %> - ')" value="<%=v.remarks%>" /> + ')" value="<%=v.remarks%>" /> <% end %>
diff --git a/luci-app-passwall2/Makefile b/luci-app-passwall2/Makefile index 054d5269d..27ba6769e 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.11.3 -PKG_RELEASE:=166 +PKG_RELEASE:=167 PKG_PO_VERSION:=$(PKG_VERSION) PKG_CONFIG_DEPENDS:= \ diff --git a/luci-app-passwall2/luasrc/view/passwall2/cbi/nodes_dynamiclist_com.htm b/luci-app-passwall2/luasrc/view/passwall2/cbi/nodes_dynamiclist_com.htm index 38594857b..f32f6b795 100644 --- a/luci-app-passwall2/luasrc/view/passwall2/cbi/nodes_dynamiclist_com.htm +++ b/luci-app-passwall2/luasrc/view/passwall2/cbi/nodes_dynamiclist_com.htm @@ -79,6 +79,10 @@ justify-content: space-between; box-sizing: border-box; } +/* 使用控件自带箭头,清除主题背景箭头 */ +.dl-dropdown-display.cbi-input-select { + background-image: none !important; +} .dl-dropdown-label, .dl-selected-label { display: inline-block; @@ -100,7 +104,7 @@ overscroll-behavior: contain; } .dl-dropdown-search { - width: 100%; + width: 100% !important; max-width: 100% !important; min-width: 0 !important; box-sizing: border-box; @@ -136,6 +140,10 @@ flex-direction: column; gap: 5px; } +/* 选择框与已选列表之间保持相同的行间距 */ +.dl-selected-list:not(:empty) { + margin-bottom: 5px; +} .dl-remove { margin-left: 8px; cursor: pointer; @@ -260,7 +268,8 @@ const panelRadius = styleSelect.borderRadius; const optionColor = !dl_isTransparent(styleOption.backgroundColor) ? styleOption.backgroundColor : !dl_isTransparent(styleSelect.backgroundColor) ? styleSelect.backgroundColor : styleBody.backgroundColor const titleColor = dl_getColorSchema(optionColor) === "light" ? dl_darker(optionColor, 30) : dl_lighter(optionColor, 30) - const selectStyleCSS = [`#${CSS.escape(cbid + ".display")}, .dl-selected-item {`, dl_style2Css(styleSelect, styleNames), dl_style2Css(styleSelect, ["backgroundColor"]), "}"] + // 自带箭头已占据布局空间,右内边距使用主题的左内边距 + const selectStyleCSS = [`#${CSS.escape(cbid + ".display")}, .dl-selected-item {`, dl_style2Css(styleSelect, styleNames), dl_style2Css(styleSelect, ["backgroundColor"]), `padding-right: ${styleSelect.paddingLeft} !important;`, "}"] const optionStyleCSS = [`#${CSS.escape(cbid + ".panel")} {`, dl_style2Css(styleOption, styleNames), `background-color: ${optionColor};`, `border-radius: ${panelRadius};`, "}"] const titleStyleCSS = [`#${CSS.escape(cbid + ".panel")} .dl-group-title {`, `background-color: ${titleColor} !important;`, "}"] styleNode.textContent = [].concat(selectStyleCSS, optionStyleCSS, titleStyleCSS).join("\n") diff --git a/luci-app-passwall2/luasrc/view/passwall2/cbi/nodes_listvalue_com.htm b/luci-app-passwall2/luasrc/view/passwall2/cbi/nodes_listvalue_com.htm index 30de14bb8..ad8ce3908 100644 --- a/luci-app-passwall2/luasrc/view/passwall2/cbi/nodes_listvalue_com.htm +++ b/luci-app-passwall2/luasrc/view/passwall2/cbi/nodes_listvalue_com.htm @@ -88,6 +88,10 @@ justify-content: space-between; box-sizing: border-box; } +/* 使用控件自带箭头,清除主题背景箭头 */ +.lv-dropdown-display.cbi-input-select { + background-image: none !important; +} .lv-dropdown-label { display: inline-block; overflow: hidden; @@ -108,7 +112,7 @@ overscroll-behavior: contain; } .lv-dropdown-search { - width: 100%; + width: 100% !important; max-width: 100% !important; min-width: 0 !important; box-sizing: border-box; @@ -254,7 +258,8 @@ const panelRadius = styleSelect.borderRadius; const optionColor = !lv_isTransparent(styleOption.backgroundColor) ? styleOption.backgroundColor : !lv_isTransparent(styleSelect.backgroundColor) ? styleSelect.backgroundColor : styleBody.backgroundColor const titleColor = lv_getColorSchema(optionColor) === "light" ? lv_darker(optionColor, 30) : lv_lighter(optionColor, 30) - const selectStyleCSS = [`#${CSS.escape(cbid + ".display")} {`, lv_style2Css(styleSelect, styleNames), lv_style2Css(styleSelect, ["backgroundColor"]), "}"] + // 自带箭头已占据布局空间,右内边距使用主题的左内边距 + const selectStyleCSS = [`#${CSS.escape(cbid + ".display")} {`, lv_style2Css(styleSelect, styleNames), lv_style2Css(styleSelect, ["backgroundColor"]), `padding-right: ${styleSelect.paddingLeft} !important;`, "}"] const optionStyleCSS = [`#${CSS.escape(cbid + ".panel")} {`, lv_style2Css(styleOption, styleNames), `background-color: ${optionColor};`, `border-radius: ${panelRadius};`, "}"] const titleStyleCSS = [`#${CSS.escape(cbid + ".panel")} .lv-group-title {`, `background-color: ${titleColor} !important;`, "}"] styleNode.textContent = [].concat(selectStyleCSS, optionStyleCSS, titleStyleCSS).join("\n") diff --git a/luci-app-passwall2/luasrc/view/passwall2/cbi/nodes_multivalue.htm b/luci-app-passwall2/luasrc/view/passwall2/cbi/nodes_multivalue.htm index 8e52206cb..e61cb420a 100644 --- a/luci-app-passwall2/luasrc/view/passwall2/cbi/nodes_multivalue.htm +++ b/luci-app-passwall2/luasrc/view/passwall2/cbi/nodes_multivalue.htm @@ -63,7 +63,7 @@ local function idSafe(id) end %> -
+
+
+
<%:You choose node is:%>
+
-
+
+
+
<%- if #shunt_rule_list > 0 then for i, v in ipairs(shunt_rule_list) do -%> - ')" value="<%=v.remarks%>" /> + ')" value="<%=v.remarks%>" /> <%- end -%> <% else %> - + <% end %> +
+
diff --git a/luci-app-passwall2/root/usr/share/passwall2/helper_dnsmasq.lua b/luci-app-passwall2/root/usr/share/passwall2/helper_dnsmasq.lua index ac1d5d525..57d0e3b76 100644 --- a/luci-app-passwall2/root/usr/share/passwall2/helper_dnsmasq.lua +++ b/luci-app-passwall2/root/usr/share/passwall2/helper_dnsmasq.lua @@ -259,7 +259,7 @@ function add_rule(var) end local cache_text = "" - local nodes_address_md5 = sys.exec("echo -n $(uci show %s | grep '\\.address') | md5sum" % c_config) + local nodes_address_md5 = sys.exec("echo -n $(uci show %s | grep '\\.address') | md5sum" % c_config):gsub("%s+$", "") local new_text = TMP_DNSMASQ_PATH .. DNSMASQ_CONF_FILE .. DEFAULT_DNS .. LOCAL_DNS .. TUN_DNS .. nodes_address_md5 .. NFTFLAG if fs.access(CACHE_TEXT_FILE) then for line in io.lines(CACHE_TEXT_FILE) do diff --git a/mise/Makefile b/mise/Makefile index 4d0758032..62f6677b6 100644 --- a/mise/Makefile +++ b/mise/Makefile @@ -14,8 +14,8 @@ MISE_HASH_x86_64:=3832f39c325e343f81fe3d92b2447c5d1a5eea1bc85092bb7b6c2580622264 MISE_HASH_aarch64:=06186cfbfe947049b21d58575fb0ea800cc26ed1375f20f4b678cb3a9d679437 PKG_NAME:=mise -PKG_VERSION:=2026.11.1 -PKG_RELEASE:=50 +PKG_VERSION:=2026.11.2 +PKG_RELEASE:=51 PKG_SOURCE:=$(PKG_NAME)-v$(PKG_VERSION)-linux-$(MISE_ARCH)-musl.tar.gz PKG_SOURCE_URL:=https://github.com/jdx/mise/releases/download/v$(PKG_VERSION)/ PKG_HASH:=skip diff --git a/shunt/Makefile b/shunt/Makefile index 13ae859a1..3f51ccb0e 100644 --- a/shunt/Makefile +++ b/shunt/Makefile @@ -5,8 +5,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=shunt -PKG_VERSION:=0.3.0 -PKG_RELEASE:=11 +PKG_VERSION:=0.4.0 +PKG_RELEASE:=12 PKG_LICENSE:=GPL-3.0-or-later PKG_LICENSE_FILES:= PKG_MAINTAINER:=Dirk Brenken diff --git a/shunt/README.md b/shunt/README.md index b02dcacb6..807578382 100644 --- a/shunt/README.md +++ b/shunt/README.md @@ -53,6 +53,7 @@ shunt check * Domain and address lists from a file - a community-maintained set of thousands of names or networks, without a single UCI entry per line * Per-policy killswitch: reject the traffic when the interface or proxy is gone, instead of leaking it out of the normal uplink - clients fail at once rather than on a timeout * Optional health tracking: an interface that is up but dead - a WireGuard tunnel whose peer is gone - is detected by ICMP probes through the interface itself, and its policies fall back until it answers again +* Failover per policy: list several interfaces in order, and new connections move to the next one when an interface goes down or stops answering - running connections stay where they started * Per-policy action: `route` marks the traffic for the policy interface, `bypass` exempts it from every policy below, `tproxy` hands it to a transparent proxy on the router * Your own networks stay reachable from a policy client, without listing them anywhere * Puts its own table back when something deletes it, `fw4 flush` included @@ -195,11 +196,11 @@ Each `config policy` section is one routing policy. **The section must be named, | :--- | :--- | | enabled | `0` skips the section entirely | | action | `route` (default), `bypass` or `tproxy`, see below | -| interface | netifd logical name (`wan`, `trm_wwan`) or raw netdev (`wg0`, `phy0-sta0`); `route` only | +| interface | netifd logical name (`wan`, `trm_wwan`) or raw netdev (`wg0`, `phy0-sta0`); a list, in order, for [failover](#failover-several-interfaces); `route` only | | tproxy_port | port the transparent proxy listens on; `tproxy` only, required there | | fallback | `main` (default) or `block`, see below; `route` and `tproxy` | | keep_local | `1` (default) keeps traffic on `main` where `main` has a route for it, see below; `route` only | -| gw4 / gw6 | gateway override; normally unnecessary | +| gw4 / gw6 | gateway override for a single interface; normally unnecessary | | src | client addresses or CIDRs whose traffic this policy owns | | src_mac | client MAC addresses, ORed with `src` | | src_dev | ingress devices (`br-guest`, `lan2`), ORed with `src` and `src_mac`, see below | @@ -212,7 +213,7 @@ Each `config policy` section is one routing policy. **The section must be named, `src`, `src_mac`, `src_dev`, `dst`, `dst_file`, `domain` and `domain_file` are lists and may repeat. -Interfaces are resolved through netifd: a logical name resolves to its `l3_device`, a raw netdev is adopted if netifd knows it, and a device netifd knows nothing about passes through as given - which on OpenWrt means a tunnel started outside netifd, since wireguard and the other tunnel types have netifd protocols of their own. Gateways are discovered from the same dump, merged across sibling entries, because netifd splits families. `gw4`/`gw6` override discovery and always win; on a point to point interface no gateway is needed at all. +Interfaces are resolved through netifd: a logical name resolves to its `l3_device`, a raw netdev is adopted if netifd knows it, and a device netifd knows nothing about passes through as given - which on OpenWrt means a tunnel started outside netifd, since wireguard and the other tunnel types have netifd protocols of their own. Gateways are discovered from the same dump, merged across sibling entries, because netifd splits families. `gw4`/`gw6` override discovery for a policy with a single interface and always win there; on a point to point interface no gateway is needed at all. There is deliberately no list of supported tunnel protocols. shunt asks netifd for the device and the gateway and renders a default route into the policy table - `default via dev ` when a gateway is known, `default dev ` when none is needed. Wireguard, OpenVPN in both `tun` and `tap` mode, L2TP, PPTP, Tailscale, NetBird, a second physical uplink or a mobile connection all reduce to those two shapes, so none of them needs a case of its own. See [Which tunnels work](#which-tunnels-work) for the one thing that genuinely does not fit. @@ -450,19 +451,46 @@ config global list track_ip '2606:4700:4700::1111' ``` -Every device a `route` policy uses is then probed every 5 seconds, each target once per round. A round counts as answered when any target replies, so one unreachable target, or a tunnel without IPv6, does not take the device down. Three unanswered rounds in a row make the device dead, three answered ones bring it back - 15 to 20 seconds either way, measured. The timing has no options. +Every interface a `route` policy uses is then probed every 5 seconds, each target once per round. A round counts as answered when any target replies, so one unreachable target, or a tunnel without IPv6, does not take the interface down. Three unanswered rounds in a row make the interface dead, three answered ones bring it back - 15 to 20 seconds either way, measured. The timing has no options. A dead device is handled exactly like one whose link went down: the default route leaves its policies' tables and the [fallback](#fallback-main-or-block) takes over. With `block` new connections are rejected at once; with `main` they take the normal uplink. Connections that were running into the dead interface are moved by that too and reset by the kernel, which they would not have survived anyway. Connections opened through `main` during the outage are left unmarked, so they stay on the normal uplink when the interface comes back instead of being torn down - only new connections return to it, measured. `tproxy` and `bypass` policies are not tracked. The probes leave from the router through the policy device itself: from a raw socket bound to the device, with a mark of its own that leads to a probe table holding the same default route as the device's policies. That table is never withdrawn, so a dead device keeps being probed and is noticed when it recovers, and the binding keeps a probe from ever leaving through another uplink and reporting the wrong interface alive. Two things follow from that path. The replies arrive on the policy device from an address the main table routes elsewhere, so `rp_filter` must be loose there - as it must for shunt anyway, see [Which tunnels work](#which-tunnels-work). And on WireGuard the targets must lie within the peer's `AllowedIPs`, which a full tunnel's `0.0.0.0/0` and `::/0` cover. -Without `track_ip` none of this exists and the ruleset is the same as without the option. `ubus call shunt status` lists each tracked device under `track`, alive or not and since when, and `shunt check` prints its probe mark and table. State changes are logged: +Without `track_ip` none of this exists and the ruleset is the same as without the option. `ubus call shunt status` lists each tracked interface under `track`, by its configured name with its current device, alive or not and since when, and `shunt check` prints its probe mark and table. State changes are logged: ``` shunt: track: wg0 does not answer - its policies fall back until it does shunt: track: wg0 answers again - its policies are back on it ``` + +### Failover: several interfaces + +`interface` takes a list. The interfaces are used in the order given: new connections go to the first one that works, and when it goes down - netifd reports it down, or with [track_ip](#health-tracking-track_ip) it stops answering - they go to the next one. When an earlier interface works again, new connections return to it. Only when none works does the policy's [fallback](#fallback-main-or-block) apply. + +``` +config policy 'streaming' + list interface 'wg0' + list interface 'wg1' + list interface 'wan' + list domain '*.example.com' + option fallback 'block' +``` + +A connection stays on the interface it started on. Each interface of the policy has a mark of its own, recorded on the connection's conntrack entry, so a switch moves new connections only: those running on the backup when the first interface returns keep the backup until they end - nothing is reset by the return, measured. Those running on an interface that dies are moved by its fallback and reset, as with a single interface; they would not have survived the dead path anyway. + +Without `track_ip` the switch follows netifd's interface state, at once on `ifdown` and `ifup`. That covers an interface that really goes down - a PPPoE or LTE link, a wifi uplink. A WireGuard interface stays up when its peer is gone, so for tunnels the switch needs `track_ip`. + +Each interface costs one of the 255 marks, with its own routing table and rules. `gw4`/`gw6` name the gateway of one interface, so with several they are ignored with an issue, and every interface takes the gateway netifd reports for it. `shunt check` prints a line per interface, `ubus call shunt status` shows under `active` which interface each policy's new connections use, and every switch is logged: + +``` +shunt: policy streaming: new connections use wg1 +shunt: policy streaming: no interface left - fallback block +``` + +There is no load balancing: an interface is used while it works and the next one only when it does not. + ### Default uplink and exceptions @@ -484,7 +512,9 @@ config policy 'isp' list src_mac 'aa:bb:cc:dd:ee:ff' ``` -In both forms the policy's own table decides for its clients in both address families, so the metrics matter only for traffic no policy selects. Set them explicitly on both sides anyway, including `wan6` where IPv6 comes from an interface of its own: netifd's default `metric 0` stays 0 for IPv4, but the kernel stores an IPv6 route with metric 0 as 1024. An interface left at the default therefore wins the IPv4 default route and loses the IPv6 one against any interface with an explicit metric - unselected clients then leave through different uplinks per family. +In both forms the policy's own table decides for its clients in both address families, so the metrics matter only for traffic no policy selects. Set them explicitly on both sides anyway: netifd's default `metric 0` stays 0 for IPv4, but the kernel stores an IPv6 route with metric 0 as 1024. An interface left at the default therefore wins the IPv4 default route and loses the IPv6 one against any interface with an explicit metric - unselected clients then leave through different uplinks per family. + +**On a DHCPv6 uplink the `metric` of `wan6` has no effect on IPv6.** odhcp6c gives the routes from the router advertisement a metric of their own - the default route 512 at the router's medium preference, 384 at high, 640 at low, on-link prefixes 256 - and netifd applies an interface's `metric` only to routes without one. IPv4 from DHCP does take the interface metric. So the tunnel's metric has to sit on the right side of both values: for the ISP as default above the uplink's IPv4 metric and above 640, `1024` covers both; for the tunnel as default below the uplink's IPv4 metric and below 384. `fallback 'block'` covers the policy's clients only. In the tunnel-as-default form, unselected clients move to the uplink when the tunnel goes down, because netifd withdraws the tunnel's route from `main` and the uplink's is left. Preventing that is a matter of the firewall, not of shunt. @@ -666,20 +696,21 @@ table inet shunt own table, see below set m_ client MACs, no family digit, counter set i_ client ingress devices, no family digit, counter -fwmark << 24, mask 0xff000000 - with track_ip: 0xff000000 counting down, one per tracked device +fwmark << 24, mask 0xff000000, one index per policy interface + with track_ip: 0xff000000 counting down, one per tracked interface ct mark same bits, set on a flow's first packet -ip rule pref 30000 + a tracked device's probes, with track_ip +ip rule pref 30000 + a tracked interface's probes, with track_ip 31000 + keep_local's main lookup 31500 + the policy table routing table 8000 + , for tproxy: local default dev lo with fallback 'block': prohibit default metric 9999 - 8500 + , a tracked device's probe table -chain mk_ with track_ip, a fallback 'main' policy's mark statement + 8500 + , a tracked interface's probe table +chain mk_ the mark statement of the interface in use, for a policy + with several interfaces, or with track_ip and fallback 'main' /etc/iproute2/rt_tables.d/shunt.conf the table name mapping, for `ip route show` only ``` -A `bypass` policy is only a rule in the prerouting and output chains: no mark, no table, no ip rule, and it does not count against the 255. A `tproxy` policy has rules in prerouting only, and no keep_local rule. The mark mask is fixed at `0xff000000`, which allows 255 policies; with `track_ip` each tracked device takes one of those marks for its probes. The `output` chain is `type route` so the router's own marked traffic is re-routed after the mark is set. +A `bypass` policy is only a rule in the prerouting and output chains: no mark, no table, no ip rule, and it does not count against the 255. A `tproxy` policy has rules in prerouting only, and no keep_local rule. The mark mask is fixed at `0xff000000`, which allows 255 policy interfaces - a policy with several interfaces takes one for each; with `track_ip` each tracked interface takes one more for its probes. The `output` chain is `type route` so the router's own marked traffic is re-routed after the mark is set. Every set carries per-element counters, so "is this element ever hit" is one look at `nft list set inet shunt ` rather than a tcpdump session. The two kinds count different things: nftables tests a rule left to right, so a **client** set counts every packet that matched the selector, whether or not the destination matched afterwards; a **learned** set is the last lookup in the rule, so a hit there means the packet really was marked. A busy client beside learned addresses at zero is a client that has not visited any of the routed domains, not a fault. diff --git a/shunt/files/shunt.rpcd b/shunt/files/shunt.rpcd index c1ef289fc..1f6ff8809 100644 --- a/shunt/files/shunt.rpcd +++ b/shunt/files/shunt.rpcd @@ -85,27 +85,29 @@ function rp_filter_blocked(policies) { let seen = {}, blocked = []; for (let p in (policies ?? [])) { - let dev = p.interface; - // A bypass policy routes into nothing, so its interface - if one is // left over in the config at all - is not a device shunt marks for. if (action_name(p.action) != 'route') continue; - if (!length(dev ?? '') || seen[dev]) - continue; + for (let mb in (p.members ?? [ { dev: p.interface } ])) { + let dev = mb.dev; - seen[dev] = true; + if (!length(dev ?? '') || seen[dev]) + continue; - let v = rp(dev); + seen[dev] = true; - // Absent device: no traffic, nothing dropped - not blocked. The - // daemon re-checks on ifup when it appears. - if (v == '') - continue; + let v = rp(dev); - if (v != '2') - push(blocked, dev); + // Absent device: no traffic, nothing dropped - not blocked. The + // daemon re-checks on ifup when it appears. + if (v == '') + continue; + + if (v != '2') + push(blocked, dev); + } } return blocked; @@ -176,7 +178,7 @@ function kernel_rules(marks) { for (let m in (marks ?? [])) if (m.mark != null) - want[sprintf('%d', m.mark)] = m.name; + want[sprintf('%d', m.mark)] = sprintf('%d', m.mark); let res = rtnl.request(RT.RTM_GETRULE, RT.NLM_F_DUMP, { family: RT.AF_UNSPEC }); @@ -236,7 +238,7 @@ function kernel_routes(marks) { n++; } - out[m.name] = n; + out[sprintf('%d', m.mark)] = n; } return out; @@ -272,15 +274,19 @@ return { mark: m.mark, rt_table: m.rt_table, rt_prio: m.rt_prio, - interface: (m.action == 'route') ? p?.interface : null, + interface: (m.action == 'route') + ? (p?.members?.[m.member]?.dev ?? p?.interface) : null, + iface: (m.action == 'route') ? m.iface : null, + member: m.member, + members: (m.action == 'route') ? length(p?.members ?? [ 1 ]) : null, fallback: (m.action != 'bypass') ? p?.fallback : null, tproxy_port: m.port, domains: length(p?.domains ?? []), file_domains: length(p?.file_domains ?? []), file_dst: length(p?.file_dst ?? []), rules: (m.action != 'bypass' && rules) - ? length(rules[m.name] ?? []) : null, - routes: (m.action != 'bypass') ? routes[m.name] : null + ? length(rules[sprintf('%d', m.mark)] ?? []) : null, + routes: (m.action != 'bypass') ? routes[sprintf('%d', m.mark)] : null }); } diff --git a/shunt/files/shunt.uc b/shunt/files/shunt.uc index fc28ca701..3dee6b273 100755 --- a/shunt/files/shunt.uc +++ b/shunt/files/shunt.uc @@ -14,7 +14,8 @@ import { openlog, syslog, LOG_PID, LOG_DAEMON, LOG_ERR, LOG_WARNING, import { load as cfg_load, parse as cfg_parse, MIN as cfg_min } from 'shunt.config'; import { compile as match_compile } from 'shunt.match'; import { load as files_load, DIR as FILES_DIR } from 'shunt.list_file'; -import { action_name, compile as nft_compile, flip as nft_flip, refresh, teardown, +import { action_name, compile as nft_compile, flip as nft_flip, active as nft_active, + refresh, teardown, addr_family, TABLE } from 'shunt.nft'; import { compile as route_compile, state_ops as route_state_ops } from 'shunt.route'; import { open as rt_open, exec as rt_exec, describe as rt_describe } from 'shunt.rt'; @@ -234,15 +235,17 @@ function policy_devices(policies) { let seen = {}, out = []; for (let p in (policies ?? [])) { - let dev = p.interface; - // bypass policies route into nothing - rp_filter does not apply. if (action_name(p.action) != 'route') continue; - if (length(dev ?? '') && !seen[dev]) { - seen[dev] = true; - push(out, dev); + for (let mb in (p.members ?? [ { dev: p.interface } ])) { + let dev = mb.dev; + + if (length(dev ?? '') && !seen[dev]) { + seen[dev] = true; + push(out, dev); + } } } @@ -291,6 +294,35 @@ function check_rp_filter(policies) { dev, dev)); } +// Link state of every configured policy interface as netifd reports it: +// false when it is down, null when netifd does not manage it. +function link_state(policies) { + let link = {}; + + for (let p in (policies ?? [])) + for (let mb in (p.members ?? [])) + if (mb.name != null) + link[mb.name] = mb.up; + + return link; +} + +// An interface is dead when netifd has it down or the probes do not get +// through it; either way its policies move on to the next one. +function dead_of(link, alive) { + let dead = {}; + + for (let n in keys(link ?? {})) + if (link[n] === false) + dead[n] = true; + + for (let n in keys(alive ?? {})) + if (alive[n] === false) + dead[n] = true; + + return dead; +} + // silent: build only what a teardown consumes and say nothing about the // configuration - flush() needs route.del and nothing else. function build_state(silent) { @@ -319,9 +351,10 @@ function build_state(silent) { // A teardown renders without the check: a policy whose module has // gone since it was applied still owns a table and rules to remove. // Kept, so a refresh renders against the same set of policies. + let link = link_state(cfg.policies); let opts = { tproxy: silent ? true : tproxy_available(cfg.policies), br_netfilter: silent ? [] : br_netfilter(), ipv6: ipv6(), - track: length(cfg.global.track_ips) > 0, dead: {} }; + track: length(cfg.global.track_ips) > 0, dead: dead_of(link, {}) }; let n = nft_compile(cfg.policies, opts); if (!silent) report('nft', n.issues); @@ -333,7 +366,8 @@ function build_state(silent) { log('info', 'no IPv6 in this kernel - IPv6 routes and rules skipped'); } - return { cfg, matcher, files, opts, nft: n, route: r, resolved: cfg.policies }; + return { cfg, matcher, files, opts, nft: n, route: r, resolved: cfg.policies, + link, alive: {} }; } // nft -f reads the entire ruleset before resolving a single name, so on a box @@ -414,29 +448,61 @@ function route_opts(state) { return { ...state.opts, probes: state.nft.probes }; } -// A tracked device changed state: the mark chains of its fallback 'main' -// policies are emptied or refilled, and its policies' default routes taken -// out or put back. Both are rendered again for the keeper, a re-apply after -// an outside flush, and the next interface event. -function track_apply(state, wq, dev, dead) { - state.opts.dead[dev] = dead; +// Link or probe state changed. Interfaces that became dead or alive are +// handled as one batch: the mark chains of the affected policies are refilled +// for the interface now in use, and the default routes of the changed +// interfaces taken out or put back - unless the caller renders every route +// anyway. All is rendered again for the keeper, a re-apply after an outside +// flush, and the next interface event. +function apply_dead(state, wq, routes) { + let dead = dead_of(state.link, state.alive); + let changed = []; + + for (let n in keys({ ...dead, ...state.opts.dead })) + if (!!dead[n] != !!state.opts.dead[n]) + push(changed, n); + + if (!length(changed)) + return; + + let before = nft_active(state.nft.chains, state.opts.dead); + + state.opts.dead = dead; let n = nft_compile(state.cfg.policies, state.opts); - let batch = nft_flip(n.chains, state.opts.dead); + let batch = nft_flip(n.chains, dead); state.nft = n; if (length(batch) && !nft_pipe(batch, 'track')) ensure_table(wq); + let after = nft_active(n.chains, dead); + + for (let pn in sort(keys(after))) { + if (before[pn] === after[pn]) + continue; + + let p = filter(state.resolved, (x) => x.name == pn)[0]; + let mb = (after[pn] != null) ? p?.members?.[after[pn]] : null; + + log('notice', mb + ? sprintf('policy %s: new connections use %s', pn, mb.name) + : sprintf('policy %s: no interface left - fallback %s', pn, p?.fallback ?? 'main')); + } + + if (!routes) + return; + let rt = rtnl(); - for (let op in route_state_ops(state.resolved, n.marks, route_opts(state), dev)) { - let why = rt ? rt_exec(rt, op) : 'ucode-mod-rtnl missing'; + for (let name in changed) + for (let op in route_state_ops(state.resolved, n.marks, route_opts(state), name)) { + let why = rt ? rt_exec(rt, op) : 'ucode-mod-rtnl missing'; - if (why != null) - debug(sprintf('track: not applied: %s - %s', rt_describe(op), why)); - } + if (why != null) + debug(sprintf('track: not applied: %s - %s', rt_describe(op), why)); + } state.route = route_compile(state.resolved, n.marks, route_opts(state)); } @@ -619,11 +685,26 @@ function run() { check_rp_filter(resolved); + state.resolved = resolved; + state.link = link_state(resolved); + + // The mark chains follow the new link state now; the routes are + // rendered below as a whole, with the new state in them. + apply_dead(state, wq, false); + r = route_compile(resolved, state.nft.marks, route_opts(state)); + rt_quiet(state.route.del); rt_quiet(r.add); state.route = r; - state.resolved = resolved; + + for (let h in (stats.track ?? [])) + for (let p in resolved) + for (let mb in (p.members ?? [])) + if (mb.name == h.name && mb.dev != h.dev) { + h.rebind = true; + h.dev = mb.dev; + } } c.listener('network.interface', (type, msg) => { @@ -646,7 +727,7 @@ function run() { let devs = {}; for (let h in stats.track) - devs[h.dev] = { alive: h.alive, since: h.since }; + devs[h.name] = { dev: h.dev, alive: h.alive, since: h.since }; return { interval: TRACK.interval, down: TRACK.down, up: TRACK.up, targets: state.cfg.global.track_ips, devices: devs }; @@ -658,7 +739,8 @@ function run() { let names = []; for (let m in state.nft.marks) - push(names, m.name); + if (!m.member) + push(names, m.name); return { started: stats.started, @@ -676,7 +758,9 @@ function run() { reapplied: stats.reapplied, dedupe: cache.size(), files: stats.files, - track: track_status() + track: track_status(), + active: nft_active(state.nft.chains, state.opts.dead), + down: filter(keys(state.link), (n) => state.link[n] === false) }; } @@ -771,7 +855,7 @@ function run() { let tg = track_targets(state.cfg.global.track_ips, addr_family, state.opts.ipv6); for (let pr in state.nft.probes) - push(tracked, { dev: pr.dev, mark: pr.mark, alive: true, good: 0, + push(tracked, { name: pr.name, dev: pr.dev, mark: pr.mark, alive: true, good: 0, bad: 0, seq: 0, got: false, sent: false, since: time(), socks: {}, err: null }); @@ -794,9 +878,10 @@ function run() { if (change) { h.since = time(); log('notice', (change == 'down') - ? sprintf('track: %s does not answer - its policies fall back until it does', h.dev) - : sprintf('track: %s answers again - its policies are back on it', h.dev)); - track_apply(state, wq, h.dev, change == 'down'); + ? sprintf('track: %s does not answer - its policies fall back until it does', h.name) + : sprintf('track: %s answers again - its policies are back on it', h.name)); + state.alive[h.name] = h.alive; + apply_dead(state, wq, true); } } @@ -804,6 +889,13 @@ function run() { h.seq = (h.seq + 1) & 0xffff; h.sent = true; + // The device behind the name changed on an interface event. + if (h.rebind) { + h.rebind = false; + close_sock(h, '4'); + close_sock(h, '6'); + } + for (let fam in [ '4', '6' ]) { if (!length(tg[fam])) continue; @@ -852,7 +944,7 @@ function run() { uloop.interval(TRACK.interval * 1000, round); log('info', sprintf('track: %s, %d target(s) every %ds', - join(', ', map(tracked, (h) => h.dev)), + join(', ', map(tracked, (h) => h.name)), length(state.cfg.global.track_ips), TRACK.interval)); } @@ -925,7 +1017,14 @@ function check() { length(state.cfg.policies), length(state.nft.marks)); // A bypass policy has none of the three - printing them would render - // its nulls as a mark of 0 in table 0. + // its nulls as a mark of 0 in table 0. A policy with several interfaces + // has a line for each. + let many = {}; + + for (let m in state.nft.marks) + if (m.member > 0) + many[m.name] = true; + for (let m in state.nft.marks) if (m.action == 'bypass') printf(' %-16s bypass\n', m.name); @@ -933,8 +1032,9 @@ function check() { printf(' %-16s mark 0x%08x table %d pref %d tproxy :%d\n', m.name, m.mark, m.rt_table, m.rt_prio, m.port); else - printf(' %-16s mark 0x%08x table %d pref %d\n', - m.name, m.mark, m.rt_table, m.rt_prio); + printf(' %-16s mark 0x%08x table %d pref %d%s\n', + m.name, m.mark, m.rt_table, m.rt_prio, + many[m.name] ? sprintf(' interface %d: %s', m.member + 1, m.iface) : ''); let total = length(state.files.issues) + length(state.matcher.issues) + length(state.nft.issues) + length(state.route.issues); diff --git a/shunt/src/config.uc b/shunt/src/config.uc index e54021ba9..8a07e6795 100644 --- a/shunt/src/config.uc +++ b/shunt/src/config.uc @@ -183,7 +183,11 @@ export function parse(sections) { push(policies, { name: s.name, action: v.action, - interface: v.interface, + // In order: the first is used while it works, the next ones + // take over one after the other. A single entry is the plain + // one-interface policy. + interfaces: uniq(filter(to_list(v.interface), + (i) => type(i) == 'string' && length(i))), fallback: v.fallback, keep_local: v.keep_local, gw4: v.gw4, diff --git a/shunt/src/netifd.uc b/shunt/src/netifd.uc index 36c53ca02..ba81774af 100644 --- a/shunt/src/netifd.uc +++ b/shunt/src/netifd.uc @@ -1,7 +1,7 @@ // shunt - interface resolution // -// Turns a configured interface name into its device and gateways from a -// netifd dump. Explicit config values always win. +// Turns each configured interface of a policy into its device, gateways and +// link state from a netifd dump. Explicit config values always win. // // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (c) 2026 Dirk Brenken @@ -17,34 +17,34 @@ function nexthop(entry, fam) { return null; } -export function resolve(policies, dump) { - let entries = dump?.interface ?? []; - let out = []; +// One configured name. A logical interface resolves to its l3_device and is +// up as netifd says; a raw netdev netifd knows is adopted and up with its +// interface; anything netifd knows nothing about passes through as given, +// with its state unknown. A logical interface without a device yet passes +// its name through, so its routes fail as they always did, and `up` says +// why. +function member(name, entries) { + let device = null, up = null; - for (let p in (policies ?? [])) { - let device = null; - - for (let e in entries) { - if (e?.interface == p?.interface && length(e?.l3_device ?? '')) { + for (let e in entries) + if (e?.interface == name) { + if (length(e.l3_device ?? '')) device = e.l3_device; + up = !!e.up && device != null; + break; + } + + if (device == null && up == null) + for (let e in entries) + if (e?.l3_device == name) { + device = name; + up = !!e.up; break; } - } - if (device == null) - for (let e in entries) - if (e?.l3_device == p?.interface) { - device = p.interface; - break; - } - - if (device == null) { - push(out, p); - continue; - } - - let gw4 = null, gw6 = null; + let gw4 = null, gw6 = null; + if (device != null) for (let e in entries) { if (e?.l3_device != device) continue; @@ -53,11 +53,32 @@ export function resolve(policies, dump) { gw6 ??= nexthop(e, 6); } + return { name, dev: device ?? name, gw4, gw6, up }; +} + +export function resolve(policies, dump) { + let entries = dump?.interface ?? []; + let out = []; + + for (let p in (policies ?? [])) { + let names = p?.interfaces ?? ((p?.interface != null) ? [ p.interface ] : []); + let members = map(names, (n) => member(n, entries)); + + // Gateway overrides belong to a single interface; with several the + // route renderer reports them and they are not applied. + if (length(members) == 1) { + members[0].gw4 = p.gw4 ?? members[0].gw4; + members[0].gw6 = p.gw6 ?? members[0].gw6; + } + + let first = members[0]; + push(out, { ...p, - interface: device, - gw4: p.gw4 ?? gw4, - gw6: p.gw6 ?? gw6 + members, + interface: first?.dev, + gw4: (length(members) == 1) ? first.gw4 : p.gw4, + gw6: (length(members) == 1) ? first.gw6 : p.gw6 }); } diff --git a/shunt/src/nft.uc b/shunt/src/nft.uc index 2b8655a60..847fc9b9d 100644 --- a/shunt/src/nft.uc +++ b/shunt/src/nft.uc @@ -139,6 +139,26 @@ export function action_name(s) { return ACTIONS[v] ? v : null; }; +// A policy's interfaces in order, resolved or as configured. +function members_of(p) { + if (p?.members) + return p.members; + + return (p?.interface != null) ? [ { name: p.interface, dev: p.interface } ] : []; +} + +// The interface a policy's new flows go to: the first one that is not dead. +// With all of them dead, a fallback 'main' policy marks nothing, so its +// traffic takes the normal uplink; a 'block' one keeps the first one's mark, +// whose table then holds only the prohibit route. +function active_member(c, dead) { + for (let j = 0; j < length(c.members); j++) + if (!dead?.[c.members[j].name]) + return j; + + return (c.fallback == 'main') ? null : 0; +} + function mask_shift(mask) { let n = 0; while (n < 32 && !((mask >> n) & 1)) @@ -151,34 +171,39 @@ export function compile(policies, opts) { let shift = mask_shift(mask); let capacity = mask >> shift; - // Health tracking probes every device a route policy uses, each from a - // socket with a mark of its own that routes it through the device's + // Health tracking probes every interface a route policy uses, each from + // a socket with a mark of its own that routes it through the interface's // probe table. Those marks come from the top of the mask, so policy - // marks keep their numbers whether tracking is on or not. + // marks keep their numbers whether tracking is on or not. Keyed by the + // configured name, which stays when the device behind it changes. let probes = [], probe_of = {}; if (opts?.track) for (let p in (policies ?? [])) { - let dev = p?.interface; - - if (action_name(p?.action) != 'route' || type(dev) != 'string' || - !length(dev) || probe_of[dev]) + if (action_name(p?.action) != 'route') continue; - let n = length(probes) + 1; + for (let mb in members_of(p)) { + let name = mb?.name; - probe_of[dev] = { dev, mark: (capacity + 1 - n) << shift, - rt_table: 8500 + n, rt_prio: 30000 + n }; - push(probes, probe_of[dev]); + if (type(name) != 'string' || !length(name) || probe_of[name]) + continue; + + let n = length(probes) + 1; + + probe_of[name] = { name, dev: mb.dev, mark: (capacity + 1 - n) << shift, + rt_table: 8500 + n, rt_prio: 30000 + n }; + push(probes, probe_of[name]); + } } capacity -= length(probes); - // fallback 'main' route policies mark new flows from a chain of their - // own, which the daemon empties while the policy's device is dead: a - // flow born then is unmarked, takes the normal uplink and keeps it when - // the device comes back, instead of being moved under it - masquerade - // resets a flow whose output interface changes. + // A route policy with several interfaces, and a tracked fallback 'main' + // one, marks new flows from a chain of its own, which the daemon fills + // with the mark of the interface in use - see active_member(). A flow + // keeps the mark it was born with, so a switch moves new flows only: + // masquerade resets a flow whose output interface changes. let chains = {}; // Rule records, not strings: a MAC rule belongs in prerouting only, and // both chains must render from one ordered list or precedence breaks. @@ -373,18 +398,27 @@ export function compile(policies, opts) { continue; } - // A bypass policy owns no mark, no table and no rule - it only ends - // evaluation - so it costs nothing from the mark capacity. - if (action != 'bypass' && ++idx > capacity) { + // Each interface of a route policy has a mark of its own, so a flow + // can stay on the interface it started on. A bypass policy owns no + // mark, no table and no rule - it only ends evaluation - so it costs + // nothing from the mark capacity. + let members = (action == 'route') ? members_of(p) : []; + let k = (length(members) > 1) ? length(members) : 1; + + if (action != 'bypass' && idx + k > capacity) { reject(pname, null, length(probes) - ? sprintf('mark capacity exceeded (%d policies fit in mask 0x%08x beside %d tracked interface(s))', + ? sprintf('mark capacity exceeded (%d policy interfaces fit in mask 0x%08x beside %d tracked interface(s))', capacity, mask, length(probes)) - : sprintf('mark capacity exceeded (%d policies fit in mask 0x%08x)', + : sprintf('mark capacity exceeded (%d policy interfaces fit in mask 0x%08x)', capacity, mask)); continue; } - let mark = (action != 'bypass') ? idx << shift : null; + if (action != 'bypass') + idx += k; + + let first = idx - k + 1; + let mark = (action != 'bypass') ? first << shift : null; // One transport term for all three rule shapes. `th dport` reads the // port at the transport header offset, which works for tcp and udp // alike, so a port without a protocol needs no rule per protocol. @@ -419,17 +453,18 @@ export function compile(policies, opts) { // route also records the mark on the conntrack entry, so the flow's // later packets can be marked from it without another lookup - see // the restore rules below. - let setmark = (mark != null) - ? sprintf('meta mark set (meta mark & 0x%08x) | 0x%08x ct mark set (ct mark & 0x%08x) | 0x%08x ', - ~mask & 0xffffffff, mark, ~mask & 0xffffffff, mark) - : ''; + let sm = (mk) => sprintf('meta mark set (meta mark & 0x%08x) | 0x%08x ct mark set (ct mark & 0x%08x) | 0x%08x ', + ~mask & 0xffffffff, mk, ~mask & 0xffffffff, mk); + let setmark = (mark != null) ? sm(mark) : ''; let chain = null; + let fb = p.fallback ?? 'main'; - if (action == 'route' && probe_of[p.interface] && - (p.fallback ?? 'main') == 'main') { + if (action == 'route' && (k > 1 || + (probe_of[members[0]?.name] && fb == 'main'))) { chain = `mk_${pname}`; - chains[pname] = { chain, dev: p.interface, stmt: trim(setmark) }; + chains[pname] = { chain, fallback: fb, members: map(members, + (mb, j) => ({ name: mb.name, stmt: trim(sm((first + j) << shift)) })) }; } // tproxy names the family of its address, so its statement exists @@ -492,11 +527,14 @@ export function compile(policies, opts) { let rmark = sprintf('meta mark set (meta mark & 0x%08x) | 0x%08x ', ~mask & 0xffffffff, mark); - if (action == 'route') { - let r = sprintf('\t\t%s%scounter return', ctm, rmark); - push(restore, r); - push(restore_out, r); - } + if (action == 'route') + for (let j = 0; j < k; j++) { + let mj = (first + j) << shift; + let r = sprintf('\t\tct state != new ct direction original ct mark & 0x%08x == 0x%08x meta mark set (meta mark & 0x%08x) | 0x%08x counter return', + mask, mj, ~mask & 0xffffffff, mj); + push(restore, r); + push(restore_out, r); + } // A proxied flow needs the statement on every packet, not only the // mark: a udp proxy answers from a socket bound to the original @@ -523,18 +561,26 @@ export function compile(policies, opts) { // keep_local's main lookup keeps the band a released version already // used, so an upgrade removes the old rule with the new one's del, // and the policy table rule moves up out of the way. - let entry = (action == 'bypass') - ? { name: pname, action, index: null, mark: null, - rt_table: null, rt_prio: null, rt_prio_local: null } - : { name: pname, action, index: idx, mark, - rt_table: 8000 + idx, - rt_prio: 31500 + idx, - rt_prio_local: 31000 + idx }; + if (action == 'bypass') + push(marks, { name: pname, action, index: null, mark: null, + rt_table: null, rt_prio: null, rt_prio_local: null }); + else + for (let j = 0; j < k; j++) { + let ix = first + j; + let entry = { name: pname, action, index: ix, mark: ix << shift, + rt_table: 8000 + ix, + rt_prio: 31500 + ix, + rt_prio_local: 31000 + ix }; - if (action == 'tproxy') - entry.port = +tp_port; + if (action == 'tproxy') + entry.port = +tp_port; + else { + entry.member = j; + entry.iface = members[j]?.name; + } - push(marks, entry); + push(marks, entry); + } if (has_mac) push(sets, sprintf( @@ -672,10 +718,11 @@ export function compile(policies, opts) { for (let n in sort(keys(chains))) { let c = chains[n]; + let j = active_member(c, opts?.dead); push(mkchains, `\tchain ${c.chain} {`); - if (!opts?.dead?.[c.dev]) - push(mkchains, `\t\t${c.stmt}`); + if (j != null) + push(mkchains, `\t\t${c.members[j].stmt}`); push(mkchains, '\t}'); } @@ -711,15 +758,27 @@ export function flip(chains, dead) { for (let n in sort(keys(chains ?? {}))) { let c = chains[n]; + let j = active_member(c, dead); push(out, sprintf('flush chain %s %s', TABLE, c.chain)); - if (!dead?.[c.dev]) - push(out, sprintf('add rule %s %s %s', TABLE, c.chain, c.stmt)); + if (j != null) + push(out, sprintf('add rule %s %s %s', TABLE, c.chain, c.members[j].stmt)); } return length(out) ? join('\n', out) + '\n' : ''; }; +// Which interface each chained policy's new flows go to, as an index into +// its interfaces, or null when none - for the status. +export function active(chains, dead) { + let out = {}; + + for (let n in keys(chains ?? {})) + out[n] = active_member(chains[n], dead); + + return out; +}; + // A write may carry its own timeout - the TTL of the answer it came from, // already clamped by the caller - or fall back to entry_ttl. slack goes on // top of either: a client asks again when the TTL runs out, and the write diff --git a/shunt/src/route.uc b/shunt/src/route.uc index db06b839b..233e0344d 100644 --- a/shunt/src/route.uc +++ b/shunt/src/route.uc @@ -71,18 +71,19 @@ export function compile(policies, marks, opts) { let probe_of = {}, probed = {}; for (let pr in (opts?.probes ?? [])) - probe_of[pr.dev] = pr; + probe_of[pr.name] = pr; + // A route policy with several interfaces has one entry for each. let by_name = {}; for (let m in (marks ?? [])) - by_name[m.name] = m; + push(by_name[m.name] ??= [], m); function reject(policy, entry, reason) { push(issues, { policy, entry, reason }); } for (let p in (policies ?? [])) { - let m = by_name[p?.name]; + let m = by_name[p?.name]?.[0]; if (!m) continue; @@ -122,32 +123,57 @@ export function compile(policies, marks, opts) { continue; } - let iface = p.interface; - if (type(iface) != 'string' || match(iface, RE_IFACE) == null) { - reject(p.name, iface, 'invalid or missing interface'); + // Every interface of the policy, in order, each with the mark, table + // and rules nft.uc numbered for it. A single one is the plain policy. + let members = p.members ?? ((p.interface != null) + ? [ { name: p.interface, dev: p.interface } ] : []); + let ms = filter(by_name[p.name], (e) => e.action == 'route'); + let many = length(members) > 1; + + if (!length(members)) { + reject(p.name, p.interface, 'invalid or missing interface'); continue; } + let bad = false; + + for (let mb in members) + if (type(mb.dev) != 'string' || match(mb.dev, RE_IFACE) == null) { + reject(p.name, mb.dev ?? mb.name, 'invalid or missing interface'); + bad = true; + } + + if (bad) + continue; + let fb = p.fallback ?? 'main'; if (fb != 'main' && fb != 'block') { reject(p.name, p.fallback, "fallback must be 'main' or 'block'"); continue; } + // An override names the gateway of one interface. With several, each + // takes the gateway netifd reports for it. let gw = { '4': null, '6': null }; let gw_bad = false; - for (let fam in [ '4', '6' ]) { - let g = p[`gw${fam}`]; - if (g == null) - continue; - if (sprintf('%d', addr_family(g)) == fam && index(g, '/') < 0) - gw[fam] = g; - else { - reject(p.name, g, `invalid gw${fam}`); - gw_bad = true; - } + if (many) { + if (p.gw4 != null || p.gw6 != null) + reject(p.name, p.gw4 ?? p.gw6, + 'gw4/gw6 apply to a single interface - ignored, the gateways come from netifd'); } + else + for (let fam in [ '4', '6' ]) { + let g = p[`gw${fam}`]; + if (g == null) + continue; + if (sprintf('%d', addr_family(g)) == fam && index(g, '/') < 0) + gw[fam] = g; + else { + reject(p.name, g, `invalid gw${fam}`); + gw_bad = true; + } + } if (gw_bad) continue; @@ -159,66 +185,74 @@ export function compile(policies, marks, opts) { keep = true; } - let table = m.rt_table; + for (let j = 0; j < length(members) && j < length(ms); j++) { + let m = ms[j], mb = members[j]; + let iface = mb.dev, name = mb.name ?? mb.dev; + let mgw = many ? { '4': mb.gw4, '6': mb.gw6 } : gw; + let table = m.rt_table; - push(tables, sprintf('%d\tshunt_%s', m.rt_table, m.name)); + push(tables, many + ? sprintf('%d\tshunt_%s_%s', table, m.name, name) + : sprintf('%d\tshunt_%s', table, m.name)); - let pr = probe_of[iface]; + let pr = probe_of[name]; - if (pr && !probed[iface]) { - probed[iface] = true; - push(tables, sprintf('%d\tshunt_track_%s', pr.rt_table, iface)); + if (pr && !probed[name]) { + probed[name] = true; + push(tables, sprintf('%d\tshunt_track_%s', pr.rt_table, name)); + + for (let fam in fams) { + push(add, { cmd: 'newroute', + msg: default_route(fam, pr.rt_table, iface, mgw[fam]) }); + push(add, { cmd: 'newrule', msg: { family: AF[fam], + action: FR_ACT_TO_TBL, priority: pr.rt_prio, + fwmark: pr.mark, fwmask: mask, table: pr.rt_table } }); + + unshift(del, { cmd: 'flush', msg: { family: AF[fam], table: pr.rt_table } }); + unshift(del, { cmd: 'delrule', msg: { family: AF[fam], priority: pr.rt_prio } }); + } + } + + // A dead interface is handled as one whose link went down: its + // table loses the default route, and the fallback below it - main + // or the prohibit route - takes over for the flows marked for it. + let dead = !!opts?.dead?.[name]; for (let fam in fams) { - push(add, { cmd: 'newroute', - msg: default_route(fam, pr.rt_table, iface, gw[fam]) }); - push(add, { cmd: 'newrule', msg: { family: AF[fam], - action: FR_ACT_TO_TBL, priority: pr.rt_prio, - fwmark: pr.mark, fwmask: mask, table: pr.rt_table } }); + let family = AF[fam]; - unshift(del, { cmd: 'flush', msg: { family: AF[fam], table: pr.rt_table } }); - unshift(del, { cmd: 'delrule', msg: { family: AF[fam], priority: pr.rt_prio } }); - } - } + if (!dead) + push(add, { cmd: 'newroute', name, + msg: default_route(fam, table, iface, mgw[fam]) }); - // A dead device is handled as one whose link went down: its policy - // table loses the default route, and the fallback below it - main - // or the prohibit route - takes over. - let dead = !!opts?.dead?.[iface]; + if (fb == 'block') + push(add, { cmd: 'newroute', msg: { family, table, + dst: ANY[fam], type: RTN_PROHIBIT, + protocol: RTPROT_BOOT, scope: RT_SCOPE_UNIVERSE, + priority: BLOCK_METRIC } }); - for (let fam in fams) { - let family = AF[fam]; + // Ahead of the policy rule and on the same mark: main is + // consulted with its default route suppressed, so marked + // traffic to anything main has a specific route for - every + // attached subnet, every static route - keeps taking it, and + // only what would have used the default route reaches the + // policy table. + if (keep) + push(add, { cmd: 'newrule', msg: { family, + action: FR_ACT_TO_TBL, priority: m.rt_prio_local, + fwmark: m.mark, fwmask: mask, table: RT_TABLE_MAIN, + suppress_prefixlen: 0 } }); - if (!dead) - push(add, { cmd: 'newroute', - msg: default_route(fam, table, iface, gw[fam]) }); - - if (fb == 'block') - push(add, { cmd: 'newroute', msg: { family, table, - dst: ANY[fam], type: RTN_PROHIBIT, - protocol: RTPROT_BOOT, scope: RT_SCOPE_UNIVERSE, - priority: BLOCK_METRIC } }); - - // Ahead of the policy rule and on the same mark: main is - // consulted with its default route suppressed, so marked traffic - // to anything main has a specific route for - every attached - // subnet, every static route - keeps taking it, and only what - // would have used the default route reaches the policy table. - if (keep) push(add, { cmd: 'newrule', msg: { family, - action: FR_ACT_TO_TBL, priority: m.rt_prio_local, - fwmark: m.mark, fwmask: mask, table: RT_TABLE_MAIN, - suppress_prefixlen: 0 } }); + action: FR_ACT_TO_TBL, priority: m.rt_prio, + fwmark: m.mark, fwmask: mask, table } }); - push(add, { cmd: 'newrule', msg: { family, - action: FR_ACT_TO_TBL, priority: m.rt_prio, - fwmark: m.mark, fwmask: mask, table } }); - - unshift(del, { cmd: 'flush', msg: { family, table } }); - unshift(del, { cmd: 'delrule', msg: { family, priority: m.rt_prio } }); - // Deleted whether or not it is rendered now: keep_local may have - // been on when the running ruleset was applied. - unshift(del, { cmd: 'delrule', msg: { family, priority: m.rt_prio_local } }); + unshift(del, { cmd: 'flush', msg: { family, table } }); + unshift(del, { cmd: 'delrule', msg: { family, priority: m.rt_prio } }); + // Deleted whether or not it is rendered now: keep_local may + // have been on when the running ruleset was applied. + unshift(del, { cmd: 'delrule', msg: { family, priority: m.rt_prio_local } }); + } } } @@ -230,22 +264,18 @@ export function compile(policies, marks, opts) { }; }; -// What a device changing state needs and nothing more: the default route of -// each of its route policies added back or taken out. Gateways and checks -// as compile() has them, so the two always render the same route. -export function state_ops(policies, marks, opts, dev) { - let r = compile(policies, marks, { ...opts, dead: { ...opts?.dead, [dev]: false } }); +// What an interface changing state needs and nothing more: the default +// route of each of its policies' tables added back or taken out. Gateways +// and checks as compile() has them, so the two always render the same route. +export function state_ops(policies, marks, opts, name) { + let r = compile(policies, marks, { ...opts, dead: { ...opts?.dead, [name]: false } }); let ops = []; - for (let op in r.add) { - let m = op.msg; - - if (op.cmd != 'newroute' || m.type != RTN_UNICAST || m.oif != dev || - m.table >= 8500) - continue; - - push(ops, opts?.dead?.[dev] ? { cmd: 'delroute', msg: m } : op); - } + for (let op in r.add) + if (op.name == name) + push(ops, opts?.dead?.[name] + ? { cmd: 'delroute', msg: op.msg } + : { cmd: op.cmd, msg: op.msg }); return ops; };