🤞 Sync 2026-10-06 13:52:46

This commit is contained in:
github-actions[bot] committed 2026-10-06 13:52:46 +08:00
1 parent 4d2d69bed9
commit 947eaea2ee
42 files changed
+2681 -1582

No files matched your search

+3 -3
View File
@@ -1,12 +1,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=lua-periphery
PKG_VERSION:=2.5.0
PKG_RELEASE:=1
PKG_VERSION:=2.5.1
PKG_RELEASE:=2
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/vsergeev/lua-periphery.git
PKG_SOURCE_VERSION:=e91d515ebbf04bd4cfc10a3f4c7e1f12736350af
PKG_SOURCE_VERSION:=6c966f76a1ba280d71871d737f4802c34d72ff55
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=MIT
+10 -7
View File
@@ -1,19 +1,22 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-flowproxy
PKG_VERSION:=1.0.4
PKG_RELEASE:=4
PKG_VERSION:=1.0.3
PKG_RELEASE:=5
LUCI_TITLE:=LuCI Support for FlowProxy Traffic Diversion
LUCI_DEPENDS:=+luci-base +nftables +kmod-nft-core +ip-full
LUCI_DEPENDS:=+luci-base +nftables +ip-full
LUCI_PKGARCH:=all
define Package/$(PKG_NAME)/conffiles
/etc/config/flowproxy
endef
define Package/$(PKG_NAME)/postinst
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
[ -f /etc/uci-defaults/99_flowproxy ] && ( . /etc/uci-defaults/99_flowproxy ) && rm -f /etc/uci-defaults/99_flowproxy
(sleep 1; /etc/init.d/rpcd reload) >/dev/null 2>&1 &
fi
[ -n "$${IPKG_INSTROOT}" ] || {
/etc/init.d/rpcd restart
}
exit 0
endef
@@ -40,18 +40,6 @@ var callRestartService = rpc.declare({
method: 'restart_service'
});
var callGetDatasetInfo = rpc.declare({
object: 'luci.flowproxy',
method: 'get_dataset_info',
params: ['file_path']
});
var callUpdateDataset = rpc.declare({
object: 'luci.flowproxy',
method: 'update_dataset',
params: ['url', 'file_path']
});
return L.view.extend({
load: function() {
return Promise.all([
@@ -108,12 +96,6 @@ return L.view.extend({
}, [ _('Restart') ]));
} else {
content.push(E('span', { 'style': 'color: red; font-weight: bold;' }, [ _('stopped') ]));
content.push(' ');
content.push(E('button', {
'class': 'btn cbi-button cbi-button-apply',
'style': 'margin-left: 10px;',
'click': ui.createHandlerFn(this, 'handleRestart')
}, [ _('Restart') ]));
}
L.dom.content(statusEl, content);
}
@@ -207,54 +189,11 @@ return L.view.extend({
s.taboption('settings', form.Flag, 'dns_proxy_enabled', _('Force upstream DNS to proxy server')).rmempty = false;
o = s.taboption('settings', form.Value, 'proxy_server_ip_addr', _('Proxy server IP address'));
o.datatype = 'ip4addr'; o.rmempty = false;
o.placeholder = '192.168.1.254';
o.rmempty = true;
o.validate = function(section_id, value) {
var isEnabled = (s.formvalue(section_id, 'enabled') === '1');
// 1. 如果值为空:仅当启用了服务尝试保存时才提示必填;未启用时允许留空,绝不展示刺眼红框
if (!value || value.trim() === '') {
if (isEnabled) {
return _('Proxy server IP address is required when FlowProxy is enabled.');
}
return true;
}
value = value.trim();
// 2. 基础 IPv4 格式校验
if (!value.match(/^([0-9]{1,3}\.){3}[0-9]{1,3}$/)) {
return _('Invalid IPv4 address format');
}
var octets = value.split('.');
for (var oi = 0; oi < 4; oi++) {
var oct = parseInt(octets[oi], 10);
if (isNaN(oct) || oct < 0 || oct > 255) {
return _('Invalid IPv4 address format');
}
}
if (value === '127.0.0.1' || value === '0.0.0.0') {
return _('Invalid proxy server IP address.');
}
// 3. 拦截本路由器自身的 IP 地址,彻底避免产生自指路由环路导致网络崩溃
var allLocalIps = [];
for (var k = 0; k < ifaces.length; k++) {
var addrs = ifaces[k].getIPAddrs() || [];
for (var a = 0; a < addrs.length; a++) {
var pureIp = addrs[a].split('/')[0];
if (pureIp && allLocalIps.indexOf(pureIp) === -1) {
allLocalIps.push(pureIp);
}
}
}
if (allLocalIps.indexOf(value) !== -1) {
return _('The proxy server IP address cannot be the local router IP (%s).').format(value);
}
var ipInCidr = function(ipStr, cidrStr) {
if (!cidrStr || cidrStr.indexOf(':') !== -1 || !ipStr || ipStr.indexOf(':') !== -1) return null;
if (!cidrStr || cidrStr.indexOf(':') !== -1 || !ipStr || ipStr.indexOf(':') !== -1) return false;
var parts = cidrStr.split('/');
var cidrIp = parts[0];
var maskBits = parseInt(parts[1] || '32', 10);
@@ -270,19 +209,15 @@ return L.view.extend({
var ipNum = ipToNum(ipStr);
var cidrNum = ipToNum(cidrIp);
if (maskBits === 0) return { inSubnet: true, isBcastOrNet: false };
if (maskBits === 0) return true;
var mask = (0xffffffff << (32 - maskBits)) >>> 0;
var inSubnet = ((ipNum & mask) === (cidrNum & mask));
var netNum = (cidrNum & mask) >>> 0;
var bcastNum = (netNum | (~mask >>> 0)) >>> 0;
var isBcastOrNet = (maskBits < 31 && (ipNum === netNum || ipNum === bcastNum));
return { inSubnet: inSubnet, isBcastOrNet: isBcastOrNet };
return (ipNum & mask) === (cidrNum & mask);
};
var selected_interface = s.formvalue(section_id, 'interface');
if (!selected_interface || selected_interface === 'auto') return true;
if (!value || !selected_interface) return true;
var target_iface = null;
for (var i = 0; i < ifaces.length; i++) {
@@ -299,12 +234,9 @@ return L.view.extend({
if (!ips || ips.length === 0) return true;
var inSubnet = false;
var isBcastOrNet = false;
for (var j = 0; j < ips.length; j++) {
var res = ipInCidr(value, ips[j]);
if (res && res.inSubnet) {
if (ipInCidr(value, ips[j])) {
inSubnet = true;
if (res.isBcastOrNet) isBcastOrNet = true;
break;
}
}
@@ -313,61 +245,19 @@ return L.view.extend({
return _('The proxy server IP address must be within the subnet of the selected interface (%s).').format(ips.join(', '));
}
if (isBcastOrNet) {
return _('The proxy server IP address cannot be the network address or broadcast address.');
}
return true;
};
o = s.taboption('settings', form.Value, 'proxy_server_dns_port', _('Proxy server DNS port'));
o.datatype = 'port'; o.default = '5353'; o.rmempty = false;
var iface_opt = s.taboption('settings', form.ListValue, 'interface', _('Proxy server interface'));
iface_opt.value('auto', _('Auto (Detect automatically)'));
devices.forEach(function(d) { iface_opt.value(d.getName(), d.getName()); });
iface_opt.default = 'auto';
iface_opt.onchange = function(ev, sid, val) {
var ip_opt = m.lookupOption('proxy_server_ip_addr', sid)[0];
if (ip_opt) {
var currVal = ip_opt.formvalue(sid);
// 仅当输入框已有非空输入时才触发重新比对子网,避免在空值时派发失焦导致误报红框
if (currVal && currVal.trim() !== '') {
var el = document.getElementById(ip_opt.cbid(sid));
if (el) {
var inputEl = el.tagName === 'INPUT' ? el : el.querySelector('input');
if (inputEl) {
inputEl.dispatchEvent(new CustomEvent('input', { bubbles: true }));
}
}
}
}
};
o = s.taboption('settings', form.ListValue, 'interface', _('Proxy server interface'));
devices.forEach(function(d) { o.value(d.getName(), d.getName()); });
o.default = 'br-lan';
o = s.taboption('settings', form.Value, 'traffic_mark', _('Traffic Mark'));
o.datatype = 'or(uinteger, hex)';
o.default = '0x666';
o.validate = function(section_id, value) {
if (!value) return true;
var num = parseInt(value, (typeof value === 'string' && value.indexOf('0x') === 0) ? 16 : 10);
if (isNaN(num) || num <= 0) return _('Traffic mark cannot be 0.');
return true;
};
o = s.taboption('settings', form.Value, 'routing_table', _('Routing Table ID'));
o.datatype = 'and(uinteger,range(1, 4294967295))';
o.default = '888';
o.validate = function(section_id, value) {
var num = parseInt(value, 10);
if (num === 0 || num === 253 || num === 254 || num === 255) {
return _('Routing table ID %d is reserved by the system (0, 253, 254, 255). Please use another ID.').format(num);
}
return true;
};
o = s.taboption('settings', form.Value, 'rule_priority', _('Rule Priority'));
o.datatype = 'and(uinteger,range(10, 32765))';
o.default = '1000';
s.taboption('settings', form.Value, 'traffic_mark', _('Traffic Mark')).datatype = 'or(uinteger, hex)';
s.taboption('settings', form.Value, 'routing_table', _('Routing Table ID')).datatype = 'and(uinteger,range(1, 4294967295))';
s.taboption('settings', form.Value, 'rule_priority', _('Rule Priority')).datatype = 'and(uinteger,range(0, 4294967295))';
// --- Rules ---
var nftsets = uci.sections('flowproxy', 'nftset').map(function(ss) { return '@' + ss['.name']; });
@@ -529,14 +419,16 @@ return L.view.extend({
}
return true;
};
// 联动:当 match_type 改变时,仅在已有输入内容时重新校验 match_value
// 联动:当 match_type 改变时,强制触发 match_value 的校验
match_type.onchange = function(ev, sid, val) {
var value_input = m.lookupOption('match_value', sid)[0];
if (value_input) setTimeout(function() {
var el = document.getElementById(value_input.cbid(sid));
if (el) {
var inputEl = el.tagName === 'INPUT' ? el : el.querySelector('input');
if (inputEl && inputEl.value && inputEl.value.trim() !== '') {
if (inputEl) {
inputEl.dispatchEvent(new CustomEvent('change', { bubbles: true }));
inputEl.dispatchEvent(new CustomEvent('blur', { bubbles: true }));
inputEl.dispatchEvent(new CustomEvent('input', { bubbles: true }));
}
}
@@ -568,15 +460,7 @@ return L.view.extend({
var path = uci.get('flowproxy', sid, 'file_path') || '/usr/share/flowproxy/chnroute.txt';
var resNode = E('span', { 'style': 'margin-left: 10px; font-weight: bold; color: #444;', 'id': 'line-count-status' }, [ '...' ]);
var input = node.querySelector('input'); if (input) input.parentNode.appendChild(resNode);
callGetDatasetInfo(path).then(function(res) {
if (res && res.exists) {
L.dom.content(resNode, [ _('(%d lines)').format(res.lines) ]);
} else {
L.dom.content(resNode, [ _('(file not found)') ]);
}
}).catch(function() {
L.dom.content(resNode, [ _('(n/a)') ]);
});
fs.exec('/usr/bin/wc', ['-l', path]).then(function(res) { L.dom.content(resNode, (res.code === 0) ? [ _('(%d lines)').format(res.stdout.trim().split(' ')[0]) ] : [ _('(n/a)') ]); });
return node;
});
};
@@ -590,21 +474,14 @@ return L.view.extend({
if (!path) path = '/usr/share/flowproxy/chnroute.txt';
if (!url) { ui.addNotification(null, E('p', _('Please set download_url first')), 'error'); return; }
ui.showModal(null, [ E('p', { 'class': 'spinning', 'id': 'download-msg' }, [ _('Downloading chnroute data...') ]) ]);
return callUpdateDataset(url, path).then(function(res) {
return fs.exec('/usr/bin/wget', ['-q', '-O', path, url, '--timeout=10', '--no-check-certificate']).then(function(res) {
var msgEl = document.getElementById('download-msg');
if (res && res.success) {
if (res.code === 0) {
if (msgEl) { msgEl.classList.remove('spinning'); L.dom.content(msgEl, [ _('Updated successfully.') ]); }
var cn = document.getElementById('line-count-status');
if (cn) L.dom.content(cn, [ _('(%d lines)').format(res.lines || 0) ]);
var cn = document.getElementById('line-count-status'); if (cn) fs.exec('/usr/bin/wc', ['-l', path]).then(function(r) { L.dom.content(cn, (r.code === 0) ? [ _('(%d lines)').format(r.stdout.trim().split(' ')[0]) ] : [ _('(n/a)') ]); });
setTimeout(ui.hideModal, 1500);
} else {
ui.hideModal();
ui.addNotification(null, E('p', _('Download failed: %s').format(res ? res.error : 'Unknown')), 'error');
}
}).catch(function(e) {
ui.hideModal();
ui.addNotification(null, E('p', _('Error: %s').format(e.message || String(e))), 'error');
});
} else { ui.hideModal(); ui.addNotification(null, E('p', _('Download failed')), 'error'); }
}).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', _('Error: %s').format(e.message)), 'error'); });
};
var sg = s.taboption('lists', form.SectionValue, '_list_custom', form.GridSection, 'nftset', _('Custom nftables sets'));
+1 -50
View File
@@ -118,7 +118,7 @@ msgstr ""
msgid "Match Value"
msgstr ""
msgid "Proxy server interface"
msgid "Network interface"
msgstr ""
msgid "Please set download_url first"
@@ -145,9 +145,6 @@ msgstr ""
msgid "Routing Table ID"
msgstr ""
msgid "Rule Priority"
msgstr ""
msgid "Rules"
msgstr ""
@@ -229,49 +226,3 @@ msgstr ""
msgid "this will delete ALL current %s rules and generate default templates. are you sure?"
msgstr ""
msgid "(file not found)"
msgstr ""
msgid "Download failed: %s"
msgstr ""
msgid "The proxy server IP address must be within the subnet of the selected interface (%s)."
msgstr ""
msgid "Invalid proxy server IP address."
msgstr ""
msgid "The proxy server IP address cannot be the local router IP (%s)."
msgstr ""
msgid "The proxy server IP address cannot be the network address or broadcast address."
msgstr ""
msgid "Traffic mark cannot be 0."
msgstr ""
msgid "Routing table ID %d is reserved by the system (0, 253, 254, 255). Please use another ID."
msgstr ""
msgid "Restart"
msgstr ""
msgid "Restarting FlowProxy service..."
msgstr ""
msgid "FlowProxy service restarted successfully."
msgstr ""
msgid "Failed to restart FlowProxy service."
msgstr ""
msgid "RPC error: %s"
msgstr ""
msgid "Proxy server IP address is required when FlowProxy is enabled."
msgstr ""
msgid "Invalid IPv4 address format"
msgstr ""
msgid "Auto (Detect automatically)"
msgstr ""
@@ -250,31 +250,3 @@ msgstr "RPC 错误: %s"
msgid "The proxy server IP address must be within the subnet of the selected interface (%s)."
msgstr "代理服务器 IP 地址必须在所选网络接口的子网网段之内 (%s)。"
msgid "(file not found)"
msgstr "(文件不存在)"
msgid "Download failed: %s"
msgstr "下载失败: %s"
msgid "Invalid proxy server IP address."
msgstr "无效的代理服务器 IP 地址。"
msgid "The proxy server IP address cannot be the local router IP (%s)."
msgstr "代理服务器 IP 地址不能为本路由器自身 IP (%s)。"
msgid "The proxy server IP address cannot be the network address or broadcast address."
msgstr "代理服务器 IP 地址不能为网络地址或广播地址。"
msgid "Traffic mark cannot be 0."
msgstr "流量标记不能为 0。"
msgid "Routing table ID %d is reserved by the system (0, 253, 254, 255). Please use another ID."
msgstr "路由表 ID %d 为系统保留路由表 (0, 253, 254, 255),请使用其他 ID。"
msgid "Proxy server IP address is required when FlowProxy is enabled."
msgstr "启用代理分流时,代理服务器 IP 地址为必填项。"
msgid "Invalid IPv4 address format"
msgstr "无效的 IPv4 地址格式"
msgid "Auto (Detect automatically)"
msgstr "自动探测"
+1 -1
View File
@@ -3,7 +3,7 @@ config flowproxy 'global'
option tcp_enabled '1'
option udp_enabled '1'
option proxy_server_ip_addr ''
option interface 'auto'
option interface 'br-lan'
option traffic_mark '0x666'
option routing_table '888'
option rule_priority '1000'
@@ -1,26 +0,0 @@
#!/bin/sh
# Hotplug trigger for flowproxy to restore policy routing on interface reload
[ "$ACTION" = "ifup" ] || exit 0
enabled=$(uci -q get flowproxy.global.enabled || echo 0)
[ "$enabled" = "1" ] || exit 0
proxy_ip=$(uci -q get flowproxy.global.proxy_server_ip_addr)
[ -n "$proxy_ip" ] || exit 0
target_iface=""
if [ -f "/var/run/flowproxy.mark" ]; then
target_iface=$(cut -d':' -f4 /var/run/flowproxy.mark 2>/dev/null)
fi
if [ -z "$target_iface" ]; then
target_iface=$(uci -q get flowproxy.global.interface)
if [ -z "$target_iface" ] || [ "$target_iface" = "auto" ]; then
target_iface=$(ip route get "$proxy_ip" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") {print $(i+1); exit}}')
fi
fi
target_iface="${target_iface:-br-lan}"
if [ "$DEVICE" = "$target_iface" ] || [ "$INTERFACE" = "$target_iface" ]; then
[ -x /etc/init.d/flowproxy ] && /etc/init.d/flowproxy add_route
fi
+77 -192
View File
@@ -4,8 +4,6 @@
START=99
STOP=10
USE_PROCD=1
EXTRA_COMMANDS="add_route"
EXTRA_HELP=" add_route Add routing rules and policy"
NAME="flowproxy"
UCI_CONFIG="flowproxy"
@@ -59,62 +57,9 @@ check_env() {
check_config() {
local proxy_server_ip_addr=$(uci -q get "$UCI_CONFIG.global.proxy_server_ip_addr")
[ -z "$proxy_server_ip_addr" ] && { log_msg error "proxy_server_ip_addr not configured"; return 1; }
if [ "$proxy_server_ip_addr" = "127.0.0.1" ] || [ "$proxy_server_ip_addr" = "0.0.0.0" ]; then
log_msg error "Invalid proxy_server_ip_addr: $proxy_server_ip_addr"; return 1;
fi
# 纵深防御:严禁将代理服务器设为本路由器自身的任一 IP,防止策略路由自指死循环导致网络崩溃
if ip -4 -o addr show 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | grep -Fxq "$proxy_server_ip_addr"; then
log_msg error "proxy_server_ip_addr ($proxy_server_ip_addr) is a local IP of this router, aborting to prevent routing loop"
return 1
fi
# 检查路由表 ID 是否为系统保留表 (0, 253, 254, 255)
local routing_table=$(uci -q get "$UCI_CONFIG.global.routing_table" || echo "888")
if [ "$routing_table" = "0" ] || [ "$routing_table" = "253" ] || [ "$routing_table" = "254" ] || [ "$routing_table" = "255" ]; then
log_msg error "routing_table ($routing_table) is a reserved system table (0, 253, 254, 255), aborting"
return 1
fi
return 0
}
probe_proxy_server() {
local proxy_ip="$1"
local proxy_dns_port="$2"
local interface="$3"
log_msg info "Probing proxy server $proxy_ip on interface $interface..."
# 1. 检查 ARP 缓存或快速唤醒以获取 MAC 地址
local mac=""
if [ -f "/proc/net/arp" ]; then
mac=$(awk -v ip="$proxy_ip" '$1 == ip { print $4; exit }' /proc/net/arp)
fi
if [ -n "$mac" ] && [ "$mac" != "00:00:00:00:00:00" ]; then
log_msg info "Proxy server is REACHABLE: IP=$proxy_ip, MAC=$mac (found in ARP cache)"
else
# 快速单发 ping 1 秒唤醒 ARP 学习
ping -c 1 -W 1 "$proxy_ip" >/dev/null 2>&1
if [ -f "/proc/net/arp" ]; then
mac=$(awk -v ip="$proxy_ip" '$1 == ip { print $4; exit }' /proc/net/arp)
fi
if [ -n "$mac" ] && [ "$mac" != "00:00:00:00:00:00" ]; then
log_msg info "Proxy server is REACHABLE: IP=$proxy_ip, MAC=$mac"
else
log_msg warn "Proxy server $proxy_ip is NOT responding to ARP yet (it may be booting or offline, traffic will still be routed via policy table)"
fi
fi
# 2. 若启用了上游 DNS 代理,在日志中记录目标地址与端口
local dns_proxy=$(uci -q get "$UCI_CONFIG.global.dns_proxy_enabled" || echo 0)
if [ "$dns_proxy" = "1" ]; then
log_msg info "Upstream DNS redirection target configured: $proxy_ip#$proxy_dns_port"
fi
}
apply_nft_rules() {
log_msg info "Generating nftables configuration..."
local conf_level=$(uci -q get "$UCI_CONFIG.global.log_level" || echo "info")
@@ -126,14 +71,10 @@ apply_nft_rules() {
FLOWPROXY_LOG_LEVEL="$conf_level" "$GENERATE_SCRIPT" >/dev/null 2> "$err_file"
local ret=$?
# 将脚本的输出记录到日志:成功时按 debug 输出,失败时按 error 输出
# 将脚本的输出记录到日志
if [ -s "$err_file" ]; then
while read -r line; do
if [ $ret -eq 0 ]; then
log_msg debug "Script: $line"
else
log_msg error "Generator: $line"
fi
log_msg debug "Script: $line"
done < "$err_file"
rm -f "$err_file"
fi
@@ -142,16 +83,14 @@ apply_nft_rules() {
log_msg info "Applying nftables rules from $NFT_CONF"
# 确保内核支持 inet 表
nft delete table inet flowproxy 2>/dev/null
local nft_err
nft_err=$(nft -f "$NFT_CONF" 2>&1)
if [ $? -eq 0 ]; then
if nft -f "$NFT_CONF"; then
return 0
else
log_msg error "nftables rules application failed: $nft_err"
log_msg error "nftables rules application failed (syntax error)"
return 1
fi
else
log_msg error "Failed to generate nftables config (exit code: $ret)"
log_msg error "Failed to generate nftables config"
return 1
fi
else
@@ -160,66 +99,33 @@ apply_nft_rules() {
fi
}
get_proxy_interface() {
local proxy_ip="$1"
local cfg_iface=$(uci -q get "$UCI_CONFIG.global.interface")
if [ -n "$cfg_iface" ] && [ "$cfg_iface" != "auto" ]; then
echo "$cfg_iface"
return
fi
local auto_dev=""
if [ -n "$proxy_ip" ]; then
auto_dev=$(ip route get "$proxy_ip" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") {print $(i+1); exit}}')
fi
echo "${auto_dev:-br-lan}"
}
add_route() {
check_config || return 1
local proxy_server_ip_addr=$(uci -q get "$UCI_CONFIG.global.proxy_server_ip_addr")
local interface=$(get_proxy_interface "$proxy_server_ip_addr")
local traffic_mark=$(uci -q get "$UCI_CONFIG.global.traffic_mark")
traffic_mark="${traffic_mark:-0x666}"
local routing_table=$(uci -q get "$UCI_CONFIG.global.routing_table")
routing_table="${routing_table:-888}"
local rule_priority=$(uci -q get "$UCI_CONFIG.global.rule_priority")
rule_priority="${rule_priority:-1000}"
local interface=$(uci -q get "$UCI_CONFIG.global.interface" || echo "br-lan")
local traffic_mark=$(uci -q get "$UCI_CONFIG.global.traffic_mark" || echo "0x666")
local routing_table=$(uci -q get "$UCI_CONFIG.global.routing_table" || echo "888")
local rule_priority=$(uci -q get "$UCI_CONFIG.global.rule_priority" || echo "1000")
# 1. 先尝试用旧记录清理一次,防止由于配置变更导致的残留
delete_route "quiet"
log_msg info "Setting up routing table $routing_table (dev: $interface) and rule for mark $traffic_mark with priority $rule_priority..."
log_msg info "Setting up routing table $routing_table and rule for mark $traffic_mark with priority $rule_priority..."
# 2. 记录当前使用的标记值、路由表 ID、优先级和实际出口网卡
echo "$traffic_mark:$routing_table:$rule_priority:$interface" > "/var/run/flowproxy.mark"
# 2. 记录当前使用的标记值、路由表 ID 和优先级
echo "$traffic_mark:$routing_table:$rule_priority" > "/var/run/flowproxy.mark"
# 3. 再次确保新标记和表的规则是干净的
while ip rule del fwmark "$traffic_mark" lookup "$routing_table" 2>/dev/null; do :; done
ip route flush table "$routing_table" 2>/dev/null
# 4. 添加路由和策略
local route_err rule_err
route_err=$(ip route add default via "$proxy_server_ip_addr" dev "$interface" table "$routing_table" 2>&1)
if [ $? -ne 0 ]; then
log_msg error "Failed to add default route to table $routing_table via $proxy_server_ip_addr (dev: $interface): $route_err"
log_msg error "Proxy server IP $proxy_server_ip_addr is unreachable or has no valid route on interface $interface"
return 1
fi
rule_err=$(ip rule add fwmark "$traffic_mark" lookup "$routing_table" priority "$rule_priority" 2>&1)
local err
ip route add default via "$proxy_server_ip_addr" dev "$interface" table "$routing_table" 2>/dev/null
err=$(ip rule add fwmark "$traffic_mark" lookup "$routing_table" priority "$rule_priority" 2>&1)
if [ $? -eq 0 ]; then
log_msg info "Route added: via $proxy_server_ip_addr dev $interface table $routing_table for mark $traffic_mark with priority $rule_priority"
else
log_msg error "Failed to add ip rule: $rule_err"
return 1
fi
# 5. 禁用目标网卡的 ICMP 重定向通告,防止客户端绕行或 TCP 连接挂起
if [ -d "/proc/sys/net/ipv4/conf/$interface" ]; then
sysctl -w "net.ipv4.conf.$interface.send_redirects=0" >/dev/null 2>&1
log_msg debug "Disabled send_redirects for $interface"
log_msg error "Failed to add ip rule: $err"
fi
}
@@ -228,23 +134,18 @@ delete_route() {
local quiet="$1"
# 1. 获取两组清理目标:旧值(记录文件) 和 新值(当前UCI)
local old_mark="" old_table="" old_prio="" old_iface=""
local old_mark="" old_table="" old_prio=""
if [ -f "/var/run/flowproxy.mark" ]; then
local saved_val=$(cat "/var/run/flowproxy.mark")
old_mark=$(echo "$saved_val" | cut -d':' -f1)
old_table=$(echo "$saved_val" | cut -d':' -f2)
old_prio=$(echo "$saved_val" | cut -d':' -f3)
old_iface=$(echo "$saved_val" | cut -d':' -f4)
[ "$quiet" != "quiet" ] && rm -f "/var/run/flowproxy.mark"
fi
local curr_mark=$(uci -q get "$UCI_CONFIG.global.traffic_mark")
curr_mark="${curr_mark:-0x666}"
local curr_table=$(uci -q get "$UCI_CONFIG.global.routing_table")
curr_table="${curr_table:-888}"
local curr_prio=$(uci -q get "$UCI_CONFIG.global.rule_priority")
curr_prio="${curr_prio:-1000}"
local curr_iface=$(get_proxy_interface "$(uci -q get "$UCI_CONFIG.global.proxy_server_ip_addr")")
local curr_mark=$(uci -q get "$UCI_CONFIG.global.traffic_mark" || echo "0x666")
local curr_table=$(uci -q get "$UCI_CONFIG.global.routing_table" || echo "888")
local curr_prio=$(uci -q get "$UCI_CONFIG.global.rule_priority" || echo "1000")
[ "$quiet" != "quiet" ] && log_msg info "Cleaning up FlowProxy routes (Atomic Mode)..."
@@ -252,10 +153,6 @@ delete_route() {
safe_del_rule() {
local m="$1" t="$2" p="$3"
[ -z "$m" ] || [ -z "$t" ] && return
if [ "$t" = "0" ] || [ "$t" = "253" ] || [ "$t" = "254" ] || [ "$t" = "255" ]; then
log_msg error "Refusing to flush reserved system routing table $t"
return
fi
log_msg debug "Attempting cleanup: mark=$m, table=$t, priority=${p:-any}"
# 精准清理 (带优先级)
[ -n "$p" ] && while ip rule del fwmark "$m" lookup "$t" priority "$p" 2>/dev/null; do
@@ -274,12 +171,6 @@ delete_route() {
safe_del_rule "$old_mark" "$old_table" "$old_prio"
[ "$old_mark" != "$curr_mark" ] || [ "$old_table" != "$curr_table" ] && \
safe_del_rule "$curr_mark" "$curr_table" "$curr_prio"
# 恢复 ICMP 重定向通告
[ -n "$old_iface" ] && [ -d "/proc/sys/net/ipv4/conf/$old_iface" ] && \
sysctl -w "net.ipv4.conf.$old_iface.send_redirects=1" >/dev/null 2>&1
[ -n "$curr_iface" ] && [ "$curr_iface" != "$old_iface" ] && [ -d "/proc/sys/net/ipv4/conf/$curr_iface" ] && \
sysctl -w "net.ipv4.conf.$curr_iface.send_redirects=1" >/dev/null 2>&1
}
delete_nft() {
@@ -301,46 +192,68 @@ setup_dns() {
[ -z "$proxy_server_ip_addr" ] && { log_msg error "proxy_server_ip_addr not configured, cannot setup DNS"; return 1; }
log_msg info "Setting up DNS redirection to $proxy_server_ip_addr#$proxy_server_dns_port via drop-in config..."
log_msg info "Setting up DNS redirection to $proxy_server_ip_addr#$proxy_server_dns_port..."
mkdir -p /tmp/dnsmasq.d
cat > /tmp/dnsmasq.d/flowproxy.conf << EOF
# Generated by FlowProxy - DO NOT EDIT MANUALLY
no-resolv
server=$proxy_server_ip_addr#$proxy_server_dns_port
EOF
# 同步分发至各个可能存在的 dnsmasq 实例目录
for dir in /tmp/dnsmasq*.d; do
if [ -d "$dir" ] && [ "$dir" != "/tmp/dnsmasq.d" ]; then
cp -f /tmp/dnsmasq.d/flowproxy.conf "$dir/flowproxy.conf"
fi
done
# 历史遗留自愈:若早期版本存在残留的 dns_backup 节,静默移除避免脏数据
if uci -q get "$UCI_CONFIG.dns_backup" >/dev/null; then
uci -q delete "$UCI_CONFIG.dns_backup"
uci -q commit "$UCI_CONFIG"
# Persistent backup in UCI
if ! uci -q get "$UCI_CONFIG.dns_backup" >/dev/null; then
local old_noresolv=$(uci -q get dhcp.@dnsmasq[0].noresolv)
local old_dns_redirect=$(uci -q get dhcp.@dnsmasq[0].dns_redirect)
local old_servers=$(uci -q get dhcp.@dnsmasq[0].server | tr '\n' ',' | sed 's/,$//')
log_msg debug "Backing up DNS: noresolv=${old_noresolv:-empty}, redirect=${old_dns_redirect:-empty}, servers=${old_servers:-empty}"
uci set "$UCI_CONFIG.dns_backup=dns_backup"
[ -n "$old_noresolv" ] && uci set "$UCI_CONFIG.dns_backup.noresolv=$old_noresolv"
[ -n "$old_dns_redirect" ] && uci set "$UCI_CONFIG.dns_backup.dns_redirect=$old_dns_redirect"
[ -n "$old_servers" ] && uci set "$UCI_CONFIG.dns_backup.servers=$old_servers"
uci commit "$UCI_CONFIG"
fi
log_msg debug "Applying new DNS: noresolv=1, redirect=0, server=$proxy_server_ip_addr#$proxy_server_dns_port"
uci set dhcp.@dnsmasq[0].noresolv='1'
uci set dhcp.@dnsmasq[0].dns_redirect='0'
# Delete current servers and add our proxy
uci -q delete dhcp.@dnsmasq[0].server
uci add_list dhcp.@dnsmasq[0].server="$proxy_server_ip_addr#$proxy_server_dns_port"
uci commit dhcp
/etc/init.d/dnsmasq restart
log_msg info "DNS drop-in configuration applied successfully"
}
cleanup_dns() {
log_msg info "Cleaning up DNS redirection drop-in configuration..."
rm -f /tmp/dnsmasq.d/flowproxy.conf
for dir in /tmp/dnsmasq*.d; do
[ -d "$dir" ] && rm -f "$dir/flowproxy.conf"
done
# 历史遗留自愈
if uci -q get "$UCI_CONFIG.dns_backup" >/dev/null; then
uci -q delete "$UCI_CONFIG.dns_backup"
uci -q commit "$UCI_CONFIG"
fi
log_msg info "Restoring original DNS configuration..."
local noresolv=$(uci -q get "$UCI_CONFIG.dns_backup.noresolv")
local dns_redirect=$(uci -q get "$UCI_CONFIG.dns_backup.dns_redirect")
local servers=$(uci -q get "$UCI_CONFIG.dns_backup.servers")
log_msg debug "Restoring DNS values: noresolv=${noresolv:-0}, redirect=${dns_redirect:-1}, servers=${servers:-empty}"
/etc/init.d/dnsmasq restart
# Restore noresolv (default to 0 if not backed up)
[ -n "$noresolv" ] && uci set dhcp.@dnsmasq[0].noresolv="$noresolv" || uci set dhcp.@dnsmasq[0].noresolv="0"
# Restore dns_redirect (only restore if it originally existed, avoid polluting config)
if [ -n "$dns_redirect" ]; then
uci set dhcp.@dnsmasq[0].dns_redirect="$dns_redirect"
else
uci -q delete dhcp.@dnsmasq[0].dns_redirect
fi
# Restore servers correctly splitting comma-separated list
uci -q delete dhcp.@dnsmasq[0].server
if [ -n "$servers" ]; then
for s in $(echo "$servers" | tr ',' ' '); do
[ -n "$s" ] && uci add_list dhcp.@dnsmasq[0].server="$s"
done
fi
uci commit dhcp
uci delete "$UCI_CONFIG.dns_backup"
uci commit "$UCI_CONFIG"
/etc/init.d/dnsmasq restart
fi
}
start_service() {
@@ -348,42 +261,23 @@ start_service() {
if [ "$enabled" -eq 0 ]; then
log_msg info "FlowProxy is disabled, ensuring service is stopped"
[ -x /etc/init.d/flowproxy ] && /etc/init.d/flowproxy disable 2>/dev/null
stop_service
return 0
fi
# 状态自愈:当 enabled=1 时,确保服务已在 rc.d 中 enable,防止 luci-reload 因未启用而静默跳过执行
[ -x /etc/init.d/flowproxy ] && /etc/init.d/flowproxy enable 2>/dev/null
log_msg info "Starting FlowProxy..."
check_env || return 1
check_config || return 1
local proxy_server_ip_addr=$(uci -q get "$UCI_CONFIG.global.proxy_server_ip_addr")
local proxy_server_dns_port=$(uci -q get "$UCI_CONFIG.global.proxy_server_dns_port" || echo 5353)
local interface=$(get_proxy_interface "$proxy_server_ip_addr")
probe_proxy_server "$proxy_server_ip_addr" "$proxy_server_dns_port" "$interface"
apply_nft_rules || return 1
if ! add_route; then
log_msg error "Routing setup failed, aborting and cleaning up service..."
stop_service
return 1
fi
add_route
setup_dns
touch "$STATE_FILE"
log_msg info "FlowProxy service started successfully"
}
stop_service() {
log_msg info "Stopping FlowProxy..."
local enabled=$(uci -q get "$UCI_CONFIG.global.enabled" || echo 0)
if [ "$enabled" -eq 0 ]; then
[ -x /etc/init.d/flowproxy ] && /etc/init.d/flowproxy disable 2>/dev/null
fi
# 强制先移除标记,防止状态误报
rm -f "$STATE_FILE"
delete_route
@@ -392,15 +286,13 @@ stop_service() {
log_msg info "FlowProxy service stopped"
}
reload_service() {
log_msg info "Reloading FlowProxy..."
restart_service() {
stop_service
start_service
}
restart_service() {
stop_service
start_service
reload_service() {
restart_service
}
service_triggers() {
@@ -408,11 +300,4 @@ service_triggers() {
procd_add_reload_trigger "$UCI_CONFIG"
# 监听防火墙重载事件,防止规则被 fw4 清空后无法自动恢复
procd_add_reload_trigger "firewall"
# 监听网络接口事件(procd trigger 需接收 UCI 逻辑网络名,如 lan/wan)
local dev=$(get_proxy_interface "$(uci -q get "$UCI_CONFIG.global.proxy_server_ip_addr")")
local net_name=""
if command -v ubus >/dev/null 2>&1 && [ -n "$dev" ]; then
net_name=$(ubus call network.interface dump 2>/dev/null | jsonfilter -e "@.interface[@.device=\"$dev\"].interface" -e "@.interface[@.l3_device=\"$dev\"].interface" 2>/dev/null | head -n 1)
fi
procd_add_reload_interface_trigger "${net_name:-lan}"
}
@@ -1,23 +0,0 @@
#!/bin/sh
[ ! -f "/usr/share/ucitrack/luci-app-flowproxy.json" ] && {
cat > /usr/share/ucitrack/luci-app-flowproxy.json << EEOF
{
"config": "flowproxy",
"init": "flowproxy"
}
EEOF
}
uci -q batch <<-EOF >/dev/null
delete ucitrack.@flowproxy[-1]
add ucitrack flowproxy
set ucitrack.@flowproxy[-1].init=flowproxy
commit ucitrack
EOF
/etc/init.d/flowproxy enabled || /etc/init.d/flowproxy enable 2>/dev/null
rm -f /tmp/luci-indexcache* 2>/dev/null
rm -f /tmp/luci-modulecache/* 2>/dev/null
exit 0
@@ -44,15 +44,20 @@ gen_set_definition() {
[ -n "$uci_elems" ] && { [ -n "$elems" ] && elems="${elems}, "; elems="${elems}${uci_elems}"; }
file_path=$(uci -q get "$CONFIG.$section.file_path")
if [ -n "$file_path" ]; then
if [ -f "$file_path" ]; then
local file_elems=$(get_file_elements "$file_path")
if [ -n "$file_elems" ]; then
[ -n "$elems" ] && elems="${elems}, "
elems="${elems}${file_elems}"
fi
else
log_debug "Set file $file_path not found, continuing without file elements"
download_url=$(uci -q get "$CONFIG.$section.download_url")
if [ -n "$download_url" ] && [ -n "$file_path" ]; then
if [ ! -f "$file_path" ]; then
echo "Downloading $section data from $download_url to $file_path..." >&2
wget -q -O "$file_path" "$download_url" --timeout=10 --no-check-certificate
[ $? -ne 0 ] && { echo "Failed to download $section data" >&2; rm -f "$file_path"; }
fi
fi
if [ -n "$file_path" ] && [ -f "$file_path" ]; then
local file_elems=$(get_file_elements "$file_path")
if [ -n "$file_elems" ]; then
[ -n "$elems" ] && elems="${elems}, "
elems="${elems}${file_elems}"
fi
fi
@@ -178,68 +183,15 @@ TCP_ENABLED=$(uci -q get "$CONFIG.global.tcp_enabled" || echo "1")
UDP_ENABLED=$(uci -q get "$CONFIG.global.udp_enabled" || echo "1")
TRAFFIC_MARK=$(uci -q get "$CONFIG.global.traffic_mark" || echo "0x666")
PROXY_SERVER_IP_ADDR=$(uci -q get "$CONFIG.global.proxy_server_ip_addr")
get_lan_devices() {
local dev="" devs="" nets=""
local z_idx=0
while :; do
local z_name=$(uci -q get firewall.@zone[$z_idx].name)
[ -z "$z_name" ] && break
if [ "$z_name" = "lan" ]; then
nets=$(uci -q get firewall.@zone[$z_idx].network)
break
fi
z_idx=$((z_idx + 1))
done
[ -z "$nets" ] && nets="lan"
for net in $nets; do
dev=""
if command -v ubus >/dev/null 2>&1; then
dev=$(ubus call network.interface."$net" status 2>/dev/null | jsonfilter -e "@.l3_device" 2>/dev/null)
[ -z "$dev" ] && dev=$(ubus call network.interface."$net" status 2>/dev/null | jsonfilter -e "@.device" 2>/dev/null)
fi
[ -z "$dev" ] && dev=$(uci -q get network."$net".device || uci -q get network."$net".device)
if [ -n "$dev" ]; then
case " $devs " in
*" $dev "*) ;;
*) devs="${devs}${devs:+ }$dev" ;;
esac
fi
done
echo "${devs:-br-lan}"
}
render_nft_lan_filter() {
local devs="$1"
set -- $devs
local count=$#
if [ "$count" -le 1 ]; then
echo " iifname != \"$1\" return"
else
local set_str=""
for d in "$@"; do
[ -n "$set_str" ] && set_str="${set_str}, "
set_str="${set_str}\"${d}\""
done
echo " iifname != { $set_str } return"
fi
}
LAN_DEVICES=$(get_lan_devices)
LAN_NFT_FILTER=$(render_nft_lan_filter "$LAN_DEVICES")
if [ "$TCP_ENABLED" = "1" ]; then
cat >> "$OUTPUT_FILE" << EOF
chain LAN_MARKFLOW_TCP {
type filter hook prerouting priority mangle; policy accept;
$LAN_NFT_FILTER
fib daddr type { unspec, local, anycast, multicast } counter return
EOF
if [ -n "$PROXY_SERVER_IP_ADDR" ]; then
echo " ip saddr $PROXY_SERVER_IP_ADDR counter return" >> "$OUTPUT_FILE"
echo " ip daddr $PROXY_SERVER_IP_ADDR counter return" >> "$OUTPUT_FILE"
echo " ip saddr $PROXY_SERVER_IP_ADDR ip protocol tcp counter return" >> "$OUTPUT_FILE"
fi
for s in $SECTIONS_TCP; do process_rule "$s" "tcp" >> "$OUTPUT_FILE"; done
echo " ip protocol tcp counter meta mark set $TRAFFIC_MARK" >> "$OUTPUT_FILE"
@@ -250,12 +202,10 @@ if [ "$UDP_ENABLED" = "1" ]; then
cat >> "$OUTPUT_FILE" << EOF
chain LAN_MARKFLOW_UDP {
type filter hook prerouting priority mangle; policy accept;
$LAN_NFT_FILTER
fib daddr type { unspec, local, anycast, multicast } counter return
EOF
if [ -n "$PROXY_SERVER_IP_ADDR" ]; then
echo " ip saddr $PROXY_SERVER_IP_ADDR counter return" >> "$OUTPUT_FILE"
echo " ip daddr $PROXY_SERVER_IP_ADDR counter return" >> "$OUTPUT_FILE"
echo " ip saddr $PROXY_SERVER_IP_ADDR ip protocol udp counter return" >> "$OUTPUT_FILE"
fi
for s in $SECTIONS_UDP; do process_rule "$s" "udp" >> "$OUTPUT_FILE"; done
echo " ip protocol udp counter meta mark set $TRAFFIC_MARK" >> "$OUTPUT_FILE"
@@ -13,6 +13,11 @@
},
"write": {
"uci": [ "flowproxy" ],
"file": {
"/usr/bin/wget": [ "exec" ],
"/usr/bin/wc": [ "exec" ],
"/usr/share/flowproxy/generate_nft.sh": [ "exec" ]
},
"ubus": {
"luci.flowproxy": [ "*" ]
}
@@ -9,7 +9,6 @@ const STATE_FILE = '/var/run/flowproxy.running';
const IP_CACHE = '/tmp/flowproxy/lan_ip.cache';
const LOG_FILE = '/var/log/flowproxy.log';
const NFT_TABLE = 'flowproxy';
const DNS_DROPIN_FILE = '/tmp/dnsmasq.d/flowproxy.conf';
// 辅助函数:安全 trim
function safe_trim(str) {
@@ -19,10 +18,10 @@ function safe_trim(str) {
const methods = {
get_status: {
call: function() {
// 1. 快速探测标记文件
// 1. 快速探测
let is_running = access(STATE_FILE, 'f');
// 2. 自愈:检查内核状态 (nft 链与策略路由)
// 2. 自愈:检查内核状态并同步标记文件
let tcp_active = (system('nft list chain inet ' + NFT_TABLE + ' LAN_MARKFLOW_TCP >/dev/null 2>&1') === 0);
let udp_active = (system('nft list chain inet ' + NFT_TABLE + ' LAN_MARKFLOW_UDP >/dev/null 2>&1') === 0);
let real_running = (tcp_active || udp_active);
@@ -41,6 +40,7 @@ const methods = {
if (ubus_conn) {
let ifaces = ubus_conn.call('network.interface', 'dump');
if (type(ifaces?.interface) === 'array') {
// 优先寻找名为 'lan' 的接口,或者寻找拥有 ipv4 地址的第一个非回环接口
let fallback_ip = '';
for (let iface in ifaces.interface) {
if (iface.interface === 'lan' && iface['ipv4-address']?.[0]?.address) {
@@ -64,8 +64,9 @@ const methods = {
}
let uci_cursor = cursor();
let proxy_server_ip_addr = uci_cursor.get(UCI_CONFIG, 'global', 'proxy_server_ip_addr') || '';
let dns_running = access(DNS_DROPIN_FILE, 'f') ? 1 : 0;
let dns_running = (uci_cursor.get(UCI_CONFIG, 'dns_backup') ? 1 : 0);
return {
running: real_running ? 1 : 0,
@@ -113,67 +114,6 @@ const methods = {
}
},
get_dataset_info: {
args: { file_path: 'string' },
call: function(request) {
let file_path = request.args?.file_path || '/usr/share/flowproxy/chnroute.txt';
if (!match(file_path, /^\/(usr\/share\/flowproxy|tmp\/flowproxy)\/[a-zA-Z0-9_.-]+$/)) {
return { error: 'Invalid path' };
}
if (!access(file_path, 'r')) {
return { exists: false, lines: 0, size: 0 };
}
let s = stat(file_path);
let lines = 0;
let fd = popen(sprintf("wc -l '%s' 2>/dev/null", file_path));
if (fd) {
let out = fd.read('all');
fd.close();
if (out) {
let parts = split(trim(out), /\s+/);
lines = int(parts[0]) || 0;
}
}
return { exists: true, lines: lines, size: s ? s.size : 0 };
}
},
update_dataset: {
args: { url: 'string', file_path: 'string' },
call: function(request) {
let url = request.args?.url;
let file_path = request.args?.file_path || '/usr/share/flowproxy/chnroute.txt';
if (!url || !match(url, /^https?:\/\/[A-Za-z0-9_.\/:~%#?&=+-]+$/)) {
return { success: false, error: 'Invalid URL' };
}
if (!match(file_path, /^\/(usr\/share\/flowproxy|tmp\/flowproxy)\/[a-zA-Z0-9_.-]+$/)) {
return { success: false, error: 'Invalid target file path' };
}
let tmp_file = file_path + '.tmp';
let cmd = sprintf("wget -q -O '%s' '%s' --timeout=15 --no-check-certificate 2>&1", tmp_file, url);
let rc = system(cmd);
if (rc === 0 && access(tmp_file, 'f')) {
let s = stat(tmp_file);
if (s && s.size > 0) {
system(sprintf("mv -f '%s' '%s'", tmp_file, file_path));
let lines = 0;
let fd = popen(sprintf("wc -l '%s' 2>/dev/null", file_path));
if (fd) {
let out = fd.read('all');
fd.close();
if (out) {
let parts = split(trim(out), /\s+/);
lines = int(parts[0]) || 0;
}
}
return { success: true, lines: lines };
}
}
system(sprintf("rm -f '%s'", tmp_file));
return { success: false, error: 'Download failed or file empty' };
}
},
generate_nft_config: {
call: function() {
system('mkdir -p /tmp/flowproxy');
@@ -185,18 +125,19 @@ const methods = {
get_runtime_config: {
call: function() {
let res_file = '/tmp/flowproxy/runtime.tmp';
let dns_file = '/tmp/flowproxy/dns_runtime.tmp';
system('mkdir -p /tmp/flowproxy');
system('/usr/share/flowproxy/generate_nft.sh runtime > ' + res_file + ' 2>&1');
let nft_runtime = readfile(res_file) || "(failed)";
system('rm -f ' + res_file);
let dns_runtime = "\n--- [ DNSmasq Drop-in Runtime Configuration ] ---\n";
let dns_content = readfile(DNS_DROPIN_FILE);
if (dns_content) {
dns_runtime += dns_content;
} else {
dns_runtime += "(no active dnsmasq drop-in overrides)";
}
system('uci show dhcp.@dnsmasq[0] | grep -E "noresolv|dns_redirect|server" | sed -E "s/^dhcp\\.[^.]*\\.([^=]*)=(.*)/\\1: \\2/; s/\\x27//g; s/#/:/g" > ' + dns_file + ' 2>&1');
let dns_out = readfile(dns_file);
system('rm -f ' + dns_file);
let dns_runtime = "\n--- [ DNSmasq Runtime Configuration ] ---\n";
dns_runtime += (dns_out || "(no active dnsmasq overrides)");
return { runtime: safe_trim(nft_runtime + dns_runtime) };
}
@@ -208,6 +149,7 @@ const methods = {
try {
let ubus_conn = connect();
if (ubus_conn) {
// 使用 network.device status 以获取所有物理设备 (包括 eth0 等 bridge 成员)
let dev_status = ubus_conn.call('network.device', 'status');
if (dev_status) {
for (let name in dev_status) {
+3 -3
View File
@@ -6,13 +6,13 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-ipsec-server
PKG_VERSION:=20211223
PKG_RELEASE:=1
PKG_VERSION:=20260815
PKG_RELEASE:=2
PKG_MAINTAINER:=Lienol <lawlienol@gmail.com>
LUCI_TITLE:=LuCI support for IPSec VPN Server
LUCI_DEPENDS:=+kmod-tun +luci-lib-jsonc +strongswan +strongswan-minimal +strongswan-mod-kernel-libipsec +strongswan-mod-openssl +strongswan-mod-xauth-generic +xl2tpd
LUCI_DEPENDS:=+kmod-tun +luci-lib-jsonc +strongswan +strongswan-minimal +strongswan-mod-kernel-libipsec +strongswan-mod-openssl +strongswan-mod-xauth-generic +strongswan-mod-eap-mschapv2 +strongswan-mod-eap-identity +strongswan-mod-pem +strongswan-mod-pkcs1 +strongswan-mod-pkcs8 +strongswan-mod-dhcp +strongswan-mod-farp
LUCI_PKGARCH:=all
define Package/$(PKG_NAME)/conffiles
+26
View File
@@ -0,0 +1,26 @@
conn ikev2-EAP
keyexchange=ikev2
auto=add
fragmentation=yes
forceencaps=yes
left=%defaultroute
leftid=@ns.miaogongzi.cc
leftauth=pubkey
leftcert=full.pem
leftsendcert=always
leftsubnet=0.0.0.0/0
right=%any
rightid=%any
rightauth=eap-mschapv2
eap_identity=%identity
rightsourceip=10.0.20.0/24
rightdns=10.0.0.1
ike=aes256-sha256-modp1024,aes256-sha1-modp1024,aes128-sha256-modp1024,aes128-sha1-modp1024,aes256-sha256-modp2048,aes256-sha1-modp2048
esp=aes256-sha256,aes128-sha256,aes256-sha1,aes128-sha1
dpdaction=clear
dpddelay=30s
rekey=no
compress=no
@@ -11,14 +11,12 @@ function index()
entry({"admin", "vpn", "ipsec-server", "settings"}, cbi("ipsec-server/settings"), _("General Settings"), 10).leaf = true
entry({"admin", "vpn", "ipsec-server", "users"}, cbi("ipsec-server/users"), _("Users Manager"), 20).leaf = true
entry({"admin", "vpn", "ipsec-server", "l2tp_user"}, cbi("ipsec-server/l2tp_user")).leaf = true
entry({"admin", "vpn", "ipsec-server", "online"}, cbi("ipsec-server/online"), _("L2TP Online Users"), 30).leaf = true
entry({"admin", "vpn", "ipsec-server", "status"}, call("act_status")).leaf = true
end
function act_status()
local e = {}
e["ipsec_status"] = luci.sys.call("/usr/bin/pgrep ipsec >/dev/null") == 0
e["l2tp_status"] = luci.sys.call("top -bn1 | grep -v grep | grep '/var/etc/xl2tpd' >/dev/null") == 0
e["ipsec_status"] = luci.sys.call("pidof charon >/dev/null 2>&1") == 0
luci.http.prepare_content("application/json")
luci.http.write_json(e)
end
@@ -1,10 +1,10 @@
local d = require "luci.dispatcher"
local sys = require "luci.sys"
local fs = require "nixio.fs"
m = Map("luci-app-ipsec-server", "L2TP/IPSec PSK " .. translate("Users Manager"))
m.redirect = d.build_url("admin", "vpn", "ipsec-server", "users")
if sys.call("command -v xl2tpd > /dev/null") == 0 then
if fs.access("/usr/sbin/xl2tpd") then
s = m:section(NamedSection, arg[1], "l2tp_users", "")
s.addremove = false
s.anonymous = true
@@ -19,6 +19,7 @@ if sys.call("command -v xl2tpd > /dev/null") == 0 then
o = s:option(Value, "password", translate("Password"))
o.placeholder = translate("Password")
o.password = true
o.rmempty = false
o = s:option(Value, "ipaddress", translate("IP address"))
@@ -1,83 +0,0 @@
local o = require "luci.dispatcher"
local fs = require "nixio.fs"
local jsonc = require "luci.jsonc"
local sessions = {}
local session_path = "/var/etc/xl2tpd/session"
if fs.access(session_path) then
for filename in fs.dir(session_path) do
local session_file = session_path .. "/" .. filename
local file = io.open(session_file, "r")
local t = jsonc.parse(file:read("*a"))
if t then
t.session_file = session_file
sessions[#sessions + 1] = t
end
file:close()
end
end
local blacklist = {}
local firewall_user_path = "/etc/firewall.user"
if fs.access(firewall_user_path) then
for line in io.lines(firewall_user_path) do
local m = line:match('xl2tpd%-blacklist%-([^\n]+)')
if m then
local t = {}
t.ip = m
blacklist[#blacklist + 1] = t
end
end
end
f = SimpleForm("processes")
f.reset = false
f.submit = false
t = f:section(Table, sessions, translate("L2TP Online Users"))
t:option(DummyValue, "username", translate("Username"))
t:option(DummyValue, "interface", translate("Interface"))
t:option(DummyValue, "ip", translate("Client IP"))
t:option(DummyValue, "remote_ip", translate("IP address"))
t:option(DummyValue, "login_time", translate("Login Time"))
_blacklist = t:option(Button, "_blacklist", translate("Blacklist"))
function _blacklist.render(e, t, a)
e.title = translate("Add to Blacklist")
e.inputstyle = "remove"
Button.render(e, t, a)
end
function _blacklist.write(t, s)
local e = t.map:get(s, "remote_ip")
luci.util.execi("echo 'iptables -I INPUT -s %s -p udp -m multiport --dports 500,4500,1701 -j DROP ## xl2tpd-blacklist-%s' >> /etc/firewall.user" % {e, e})
luci.util.execi("iptables -I INPUT -s %s -p udp -m multiport --dports 500,4500,1701 -j DROP" % {e})
luci.util.execi("rm -f " .. t.map:get(s, "session_file"))
null, t.tag_error[s] = luci.sys.process.signal(t.map:get(s, "pid"), 9)
luci.http.redirect(o.build_url("admin/vpn/ipsec-server/online"))
end
_kill = t:option(Button, "_kill", translate("Forced offline"))
_kill.inputstyle = "remove"
function _kill.write(t, s)
luci.util.execi("rm -f " .. t.map:get(s, "session_file"))
null, t.tag_error[t] = luci.sys.process.signal(t.map:get(s, "pid"), 9)
luci.http.redirect(o.build_url("admin/vpn/ipsec-server/online"))
end
t = f:section(Table, blacklist, translate("Blacklist"))
t:option(DummyValue, "ip", translate("IP address"))
_blacklist2 = t:option(Button, "_blacklist2", translate("Blacklist"))
function _blacklist2.render(e, t, a)
e.title = translate("Remove from Blacklist")
e.inputstyle = "apply"
Button.render(e, t, a)
end
function _blacklist2.write(t, s)
local e = t.map:get(s, "ip")
luci.util.execi("sed -i -e '/## xl2tpd-blacklist-%s/d' /etc/firewall.user" % {e})
luci.util.execi("iptables -D INPUT -s %s -p udp -m multiport --dports 500,4500,1701 -j DROP" % {e})
luci.http.redirect(o.build_url("admin/vpn/ipsec-server/online"))
end
return f
@@ -1,5 +1,3 @@
local sys = require "luci.sys"
m = Map("luci-app-ipsec-server", translate("IPSec VPN Server"))
m.template = "ipsec-server/ipsec-server_status"
@@ -9,56 +7,53 @@ s.anonymous = true
o = s:option(DummyValue, "ipsec-server_status", translate("Current Condition"))
o.rawhtml = true
o.cfgvalue = function(t, n)
return '<font class="ipsec-server_status"></font>'
return '<span class="ipsec-server-status is-loading" role="status" aria-live="polite">' .. translate("Checking...") .. '</span>'
end
enabled = s:option(Flag, "enabled", translate("Enable"))
enabled.description = translate("Use a client that supports IPSec Xauth PSK (iOS or Android) to connect to this server.")
enabled.description = translate("Enable IPsec access for IKEv2 PSK, IKEv1 XAuth PSK, and IKEv2 EAP clients.")
enabled.default = 0
enabled.rmempty = false
clientip = s:option(Value, "clientip", translate("VPN Client IP"))
clientip.description = translate("VPN Client reserved started IP addresses with the same subnet mask, such as: 192.168.100.10/24")
clientip.description = translate("VPN client address pool in CIDR notation, such as: 10.0.10.0/24")
clientip.datatype = "ip4addr"
clientip.optional = false
clientip.rmempty = false
clientip.placeholder = "10.0.10.0/24"
serverip = s:option(Value, "serverip", translate("VPN Server IP"))
serverip.description = translate("VPN server address and subnet in CIDR notation, such as: 10.0.0.1/24")
serverip.datatype = "ip4addr"
serverip.optional = false
serverip.rmempty = false
serverip.placeholder = "10.0.0.1/24"
domain = s:option(Value, "domain", translate("VPN Domain"))
domain.description = translate("Domain name used for IKEv2 certificate and Remote ID, such as: ns.miaogongzi.cc")
domain.datatype = "host"
domain.optional = false
domain.rmempty = false
domain.placeholder = "ns.miaogongzi.cc"
domain.default = "ns.miaogongzi.cc"
certs_name = s:option(Value, "certs_name", translate("Certificate File Name"))
certs_name.description = translate("Server certificate file name under /etc/ipsec.d/certs/, such as: server.pem")
certs_name.optional = false
certs_name.rmempty = false
certs_name.placeholder = "server.pem"
certs_name.default = "server.pem"
private_key = s:option(Value, "private_key", translate("Private Key File Name"))
private_key.description = translate("Server private key file name under /etc/ipsec.d/private/, such as: server_key.pem")
private_key.optional = false
private_key.rmempty = false
private_key.placeholder = "server_key.pem"
private_key.default = "server_key.pem"
secret = s:option(Value, "secret", translate("Secret Pre-Shared Key"))
secret.description = translate("Used by IKEv2 PSK and IKEv1 XAuth PSK clients.")
secret.password = true
if sys.call("command -v xl2tpd > /dev/null") == 0 then
o = s:option(DummyValue, "l2tp_status", "L2TP " .. translate("Current Condition"))
o.rawhtml = true
o.cfgvalue = function(t, n)
return '<font class="l2tp_status"></font>'
end
o = s:option(Flag, "l2tp_enable", "L2TP " .. translate("Enable"))
o.description = translate("Use a client that supports L2TP over IPSec PSK to connect to this server.")
o.default = 0
o.rmempty = false
o = s:option(Value, "l2tp_localip", "L2TP " .. translate("Server IP"))
o.description = translate("VPN Server IP address, such as: 192.168.101.1")
o.datatype = "ip4addr"
o.rmempty = true
o.default = "192.168.101.1"
o.placeholder = o.default
o = s:option(Value, "l2tp_remoteip", "L2TP " .. translate("Client IP"))
o.description = translate("VPN Client IP address range, such as: 192.168.101.10-20")
o.rmempty = true
o.default = "192.168.101.10-20"
o.placeholder = o.default
if sys.call("ls -L /usr/lib/ipsec/libipsec* 2>/dev/null >/dev/null") == 0 then
o = s:option(DummyValue, "_o", " ")
o.rawhtml = true
o.cfgvalue = function(t, n)
return string.format('<a style="color: red">%s</a>', translate("L2TP/IPSec is not compatible with kernel-libipsec, which will disable this module."))
end
o:depends("l2tp_enable", true)
end
end
secret.rmempty = false
return m
@@ -1,10 +1,10 @@
local d = require "luci.dispatcher"
local sys = require "luci.sys"
local fs = require "nixio.fs"
m = Map("luci-app-ipsec-server")
s = m:section(TypedSection, "ipsec_users", "IPSec Xauth PSK " .. translate("Users Manager"))
s.description = translate("Use a client that supports IPSec Xauth PSK (iOS or Android) to connect to this server.")
s = m:section(TypedSection, "ipsec_users", "IPSec Xauth PSK/EAP " .. translate("Users Manager"))
s.description = translate("Use a client that supports IPSec Xauth PSK or IPSec EAP (iOS, Android, macOS, or Windows) to connect to this server.")
s.addremove = true
s.anonymous = true
s.template = "cbi/tblsection"
@@ -19,9 +19,10 @@ o.rmempty = false
o = s:option(Value, "password", translate("Password"))
o.placeholder = translate("Password")
o.password = true
o.rmempty = false
if sys.call("command -v xl2tpd > /dev/null") == 0 then
if fs.access("/usr/sbin/xl2tpd") then
s = m:section(TypedSection, "l2tp_users", "L2TP/IPSec PSK " .. translate("Users Manager"))
s.description = translate("Use a client that supports L2TP over IPSec PSK to connect to this server.")
s.addremove = true
@@ -31,7 +32,7 @@ if sys.call("command -v xl2tpd > /dev/null") == 0 then
function s.create(e, t)
t = TypedSection.create(e, t)
luci.http.redirect(e.extedit:format(t))
end
end
o = s:option(Flag, "enabled", translate("Enabled"))
o.default = 1
@@ -43,6 +44,7 @@ if sys.call("command -v xl2tpd > /dev/null") == 0 then
o = s:option(Value, "password", translate("Password"))
o.placeholder = translate("Password")
o.password = true
o.rmempty = false
o = s:option(Value, "ipaddress", translate("IP address"))
@@ -1,21 +1,61 @@
<style>
.cbi-map {
padding-bottom: 30px;
}
.ipsec-server-status {
display: inline-flex;
align-items: center;
font-weight: 600;
}
.ipsec-server-status::before {
width: .65em;
height: .65em;
margin-right: .55em;
border-radius: 50%;
background: #6b7280;
content: "";
}
.ipsec-server-status.is-running::before {
background: #198754;
}
.ipsec-server-status.is-stopped::before {
background: #c0392b;
}
</style>
<% include("cbi/map") %>
<script type="text/javascript">//<![CDATA[
XHR.poll(2, '<%=luci.dispatcher.build_url("admin", "vpn", "ipsec-server", "status")%>', null,
function(x, result)
{
var ipsec_status = document.getElementsByClassName('ipsec-server_status')[0];
if (ipsec_status) {
ipsec_status.setAttribute("style","font-weight:bold;");
ipsec_status.setAttribute("color",result.ipsec_status ? "green":"red");
ipsec_status.innerHTML = result.ipsec_status?'<%=translate("RUNNING")%>':'<%=translate("NOT RUNNING")%>';
}
var l2tp_status = document.getElementsByClassName('l2tp_status')[0];
if (l2tp_status) {
l2tp_status.setAttribute("style","font-weight:bold;");
l2tp_status.setAttribute("color",result.l2tp_status ? "green":"red");
l2tp_status.innerHTML = result.l2tp_status?'<%=translate("RUNNING")%>':'<%=translate("NOT RUNNING")%>';
}
(function() {
var status = document.getElementsByClassName('ipsec-server-status')[0];
if (!status) {
return;
}
)
XHR.poll(5, '<%=luci.dispatcher.build_url("admin", "vpn", "ipsec-server", "status")%>', null,
function(x, result) {
var className;
var label;
if (!result || typeof result.ipsec_status !== 'boolean') {
className = 'ipsec-server-status is-unknown';
label = '<%=translate("Status unavailable")%>';
}
else {
className = 'ipsec-server-status ' + (result.ipsec_status ? 'is-running' : 'is-stopped');
label = result.ipsec_status ? '<%=translate("RUNNING")%>' : '<%=translate("NOT RUNNING")%>';
}
if (status.className !== className) {
status.className = className;
status.textContent = label;
}
}
);
})();
//]]>
</script>
+48 -36
View File
@@ -1,38 +1,56 @@
msgid "IPSec VPN Server"
msgstr "IPSec VPN 服务器"
msgid "Use a client that supports IPSec Xauth PSK (iOS or Android) to connect to this server."
msgstr "使用支持 IPSec Xauth PSK(iOS 或 Android)的客户端连接到此服务端。"
msgid "Enable IPsec access for IKEv2 PSK, IKEv1 XAuth PSK, and IKEv2 EAP clients."
msgstr "启用 IKEv2 PSK、IKEv1 XAuth PSK 和 IKEv2 EAP 客户端接入。"
msgid "Use a client that supports L2TP over IPSec PSK to connect to this server."
msgstr "使用支持 L2TP over IPSec PSK 的客户端连接到此服务端。"
msgid "Use a client that supports IPSec Xauth PSK or IPSec EAP (iOS, Android, macOS, or Windows) to connect to this server."
msgstr "请使用支持 IPSec Xauth PSK 或 IPSec EAP 的客户端(iOS、Android、macOS、Windows)连接到此服务器。"
msgid "Current Condition"
msgstr "当前状态"
msgid "General settings"
msgid "Checking..."
msgstr "正在检查..."
msgid "Status unavailable"
msgstr "状态不可用"
msgid "General Settings"
msgstr "基本设置"
msgid "Enable"
msgstr "启用"
msgid "Enabled"
msgstr "启用"
msgid "VPN Client IP"
msgstr "VPN客户端地址段"
msgstr "VPN 客户端地址池"
msgid "VPN Client reserved started IP addresses with the same subnet mask, such as: 192.168.100.10/24"
msgstr "VPN客户端获取IP的起始地址,例如:192.168.100.10/24"
msgid "VPN Server IP"
msgstr "VPN 服务端地址"
msgid "VPN client address pool in CIDR notation, such as: 10.0.10.0/24"
msgstr "VPN 客户端地址池(CIDR 格式),例如:10.0.10.0/24"
msgid "VPN server address and subnet in CIDR notation, such as: 10.0.0.1/24"
msgstr "VPN 服务端地址和子网(CIDR 格式),例如:10.0.0.1/24"
msgid "Domain name used for IKEv2 certificate and Remote ID, such as: ns.miaogongzi.cc"
msgstr "用于 IKEv2 证书和远程 ID 的域名,例如:ns.miaogongzi.cc"
msgid "Server private key file name under /etc/ipsec.d/private/, such as: server_key.pem"
msgstr "位于 /etc/ipsec.d/private/ 的服务器私钥文件名,例如:server_key.pem"
msgid "Server certificate file name under /etc/ipsec.d/certs/, such as: server.pem"
msgstr "位于 /etc/ipsec.d/certs/ 的服务器证书文件名,例如:server.pem"
msgid "Secret Pre-Shared Key"
msgstr "PSK密钥"
msgstr "PSK 密钥"
msgid "VPN Server IP address, such as: 192.168.101.1"
msgstr "VPN服务端IP地址,例如:192.168.101.1"
msgid "VPN Client IP address range, such as: 192.168.101.10-20"
msgstr "VPN客户端获取IP范围,例如:192.168.101.10-20"
msgid "L2TP/IPSec is not compatible with kernel-libipsec, which will disable this module."
msgstr "L2TP/IPSec不兼容kernel-libipsec,开启将会禁用此模块。"
msgid "Used by IKEv2 PSK and IKEv1 XAuth PSK clients."
msgstr "供 IKEv2 PSK 和 IKEv1 XAuth PSK 客户端使用。"
msgid "Users Manager"
msgstr "用户管理"
@@ -49,29 +67,23 @@ msgstr "IP 地址"
msgid "Automatically"
msgstr "自动分配"
msgid "Online Users"
msgstr "在线用户"
msgid "Static Routes"
msgstr "静态路由"
msgid "L2TP Online Users"
msgstr "L2TP 在线用户"
msgid "Login Time"
msgstr "登录时间"
msgid "Blacklist"
msgstr "黑名单"
msgid "Add to Blacklist"
msgstr "加入黑名单"
msgid "Remove from Blacklist"
msgstr "移出黑名单"
msgid "Forced offline"
msgstr "强制下线"
msgid "Use a client that supports L2TP over IPSec PSK to connect to this server."
msgstr "使用支持 L2TP over IPSec PSK 的客户端连接到此服务器。"
msgid "NOT RUNNING"
msgstr "未运行"
msgid "RUNNING"
msgstr "运行中"
msgid "Certificate File Name"
msgstr "证书文件名"
msgid "Private Key File Name"
msgstr "私钥文件名"
msgid "VPN Domain"
msgstr "VPN 域名"
@@ -2,6 +2,9 @@
config service 'ipsec'
option enabled '0'
option secret 'ipsec'
option clientip '192.168.100.10/24'
option clientip '10.0.10.0/24'
option serverip '10.0.0.1/24'
option certs_name 'server.pem'
option private_key 'server_key.pem'
option domain 'ns.miaogongzi.cc'
@@ -13,6 +13,10 @@ L2TP_OPTIONS_FILE=${L2TP_PATH}/options.xl2tpd
L2TP_LOG_FILE=${L2TP_PATH}/xl2tpd.log
vt_clientip=$(uci -q get ${CONFIG}.@service[0].clientip)
vt_serverip=$(uci -q get ${CONFIG}.@service[0].serverip)
certs_name=$(uci -q get ${CONFIG}.@service[0].certs_name)
private_key=$(uci -q get ${CONFIG}.@service[0].private_key)
vpn_domain=$(uci -q get ${CONFIG}.@service[0].domain)
l2tp_enabled=$(uci -q get ${CONFIG}.@service[0].l2tp_enable)
l2tp_localip=$(uci -q get ${CONFIG}.@service[0].l2tp_localip)
@@ -24,8 +28,8 @@ get_enabled_anonymous_secs() {
ipt_rule() {
if [ "$1" = "add" ]; then
iptables -t nat -I POSTROUTING -s ${vt_clientip} -m comment --comment "${ipt_flag}" -j MASQUERADE 2>/dev/null
iptables -I forwarding_rule -s ${vt_clientip} -m comment --comment "${ipt_flag}" -j ACCEPT 2>/dev/null
iptables -t nat -I POSTROUTING -s ${vt_serverip} -m comment --comment "${ipt_flag}" -j MASQUERADE 2>/dev/null
iptables -I forwarding_rule -s ${vt_serverip} -m comment --comment "${ipt_flag}" -j ACCEPT 2>/dev/null
iptables -I forwarding_rule -m policy --dir in --pol ipsec --proto esp -m comment --comment "${ipt_flag}" -j ACCEPT 2>/dev/null
iptables -I forwarding_rule -m policy --dir out --pol ipsec --proto esp -m comment --comment "${ipt_flag}" -j ACCEPT 2>/dev/null
iptables -I INPUT -p udp -m multiport --dports 500,4500 -m comment --comment "${ipt_flag}" -j ACCEPT 2>/dev/null
@@ -70,9 +74,9 @@ gen_include() {
start() {
local vt_enabled=$(uci -q get ${CONFIG}.@service[0].enabled)
[ "$vt_enabled" = 0 ] && return 1
[ "$vt_enabled" = 1 ] || return 0
local vt_gateway="${vt_clientip%.*}.1"
local vt_gateway="${vt_serverip%.*}.1"
local vt_secret=$(uci -q get ${CONFIG}.@service[0].secret)
local l2tp_enabled=$(uci -q get ${CONFIG}.@service[0].l2tp_enable)
@@ -185,8 +189,8 @@ start() {
conn Non-L2TP
leftsubnet=0.0.0.0/0
rightsubnet=${vt_clientip}
rightsourceip=${vt_clientip}
rightsubnet=${vt_serverip}
rightsourceip=${vt_serverip}
rightdns=${vt_gateway}
ike=aes128-sha1-modp2048,aes128-sha1-modp1024,3des-sha1-modp1024,3des-sha1-modp1536
esp=aes128-sha1,3des-sha1
@@ -198,6 +202,52 @@ start() {
leftauth=psk
rightauth=psk
rightauth2=xauth
conn IKEv2-PSK
keyexchange=ikev2
auto=add
left=%defaultroute
leftauth=psk
leftsubnet=0.0.0.0/0
right=%any
rightauth=psk
rightsourceip=${vt_clientip}
ike=aes128-sha1-modp2048,aes128-sha1-modp1024,3des-sha1-modp1024,3des-sha1-modp1536
esp=aes128-sha1,3des-sha1
dpdaction=clear
dpddelay=30s
rekey=no
compress=no
conn ikev2-EAP
keyexchange=ikev2
auto=add
fragmentation=yes
forceencaps=yes
left=%defaultroute
leftid=@$vpn_domain
leftauth=pubkey
leftcert=${certs_name}
leftsendcert=always
leftsubnet=0.0.0.0/0
right=%any
rightid=%any
rightauth=eap-mschapv2
eap_identity=%identity
rightsourceip=${vt_clientip}
rightdns=10.0.0.1
ike=aes256-sha256-modp1024,aes256-sha1-modp1024,aes128-sha256-modp1024,aes128-sha1-modp1024,aes256-sha256-modp2048,aes256-sha1-modp2048
esp=aes256-sha256,aes128-sha256,aes256-sha1,aes128-sha1
dpdaction=clear
dpddelay=30s
rekey=no
compress=no
$ipsec_l2tp_config
EOF
@@ -205,6 +255,7 @@ start() {
cat > /etc/ipsec.secrets <<-EOF
# /etc/ipsec.secrets - strongSwan IPsec secrets file
: PSK "$vt_secret"
: RSA /etc/ipsec.d/private/$private_key
EOF
local ipsec_users=$(get_enabled_anonymous_secs "@ipsec_users")
@@ -220,6 +271,7 @@ start() {
[ -n "${u_password}" ] || continue
echo "${u_username} : XAUTH '${u_password}'" >> ${IPSEC_SECRETS_FILE}
echo "${u_username} : EAP '${u_password}'" >> ${IPSEC_SECRETS_FILE}
done
}
unset user
@@ -230,7 +282,7 @@ start() {
gen_include
uci -q batch <<-EOF >/dev/null
set network.ipsec_server.ipaddr="${vt_clientip%.*}.1"
set network.ipsec_server.ipaddr="${vt_serverip%.*}.1"
commit network
EOF
ifup ipsec_server > /dev/null 2>&1
@@ -254,7 +306,7 @@ gen_iface_and_firewall() {
set network.ipsec_server.device="ipsec0"
set network.ipsec_server.device="ipsec0"
set network.ipsec_server.proto="static"
set network.ipsec_server.ipaddr="${vt_clientip%.*}.1"
set network.ipsec_server.ipaddr="${vt_serverip%.*}.1"
set network.ipsec_server.netmask="255.255.255.0"
commit network
@@ -7,6 +7,7 @@
}
EEOF
}
rm -f /usr/share/ucitrack/luci-app-luci-app-ipsec-server.json
uci -q batch <<-EOF >/dev/null
delete firewall.luci_app_ipsec_server
@@ -14,6 +15,7 @@ uci -q batch <<-EOF >/dev/null
set firewall.luci_app_ipsec_server.type=script
set firewall.luci_app_ipsec_server.path=/var/etc/ipsecvpn.include
set firewall.luci_app_ipsec_server.reload=1
commit firewall
EOF
uci -q batch <<-EOF >/dev/null
@@ -1,27 +0,0 @@
#!/bin/sh
_LOGOUT_TIME="$(date "+%Y-%m-%d %H:%M:%S")"
CONFIG="luci-app-ipsec-server"
L2TP_PATH=/var/etc/xl2tpd
L2TP_SESSION_PATH=${L2TP_PATH}/session
_USERNAME=${PEERNAME}
_IFACE=${1}
_TTY=${2}
_SPEED=${3}
_LOCALIP=${4}
_PEERIP=${5}
_REMOTEIP=${6}
_BYTES_SENT=${BYTES_SENT}
_BYTES_RCVD=${BYTES_RCVD}
_CONNECT_TIME=${CONNECT_TIME}
rm -f ${L2TP_SESSION_PATH}/${_USERNAME}.${_IFACE}
rm -f /var/run/${_IFACE}.pid
#可根据退出的账号自定义脚本,如静态路由表,组网等。
SCRIPT="/usr/share/xl2tpd/ip-down.d/${_USERNAME}"
[ -s "$SCRIPT" ] && {
[ ! -x "$SCRIPT" ] && chmod 0755 "$SCRIPT"
"$SCRIPT" "$@"
}
@@ -1,58 +0,0 @@
#!/bin/sh
_LOGIN_TIME="$(date "+%Y-%m-%d %H:%M:%S")"
CONFIG="luci-app-ipsec-server"
L2TP_PATH=/var/etc/xl2tpd
L2TP_SESSION_PATH=${L2TP_PATH}/session
_USERNAME=${PEERNAME}
_IFACE=${1}
_TTY=${2}
_SPEED=${3}
_LOCALIP=${4}
_PEERIP=${5}
_PID=$(cat /var/run/${_IFACE}.pid 2>/dev/null)
_REMOTEIP=$(cat /var/etc/xl2tpd/xl2tpd.log 2>/dev/null | grep "PID: ${_PID}" | grep -o -E '([0-9]{1,3}[\.]){3}[0-9]{1,3}')
mkdir -p ${L2TP_SESSION_PATH}
cat <<-EOF > ${L2TP_SESSION_PATH}/${_USERNAME}.${_IFACE}
{
"username": "${_USERNAME}",
"interface": "${_IFACE}",
"tty": "${_TTY}",
"speed": "${_SPEED}",
"ip": "${_PEERIP}",
"remote_ip": "${_REMOTEIP}",
"pid": "${_PID}",
"login_time": "${_LOGIN_TIME}"
}
EOF
#只能单用户使用
cfgid=$(uci show ${CONFIG} | grep "@l2tp_users" | grep "\.username='${_USERNAME}'" | cut -d '.' -sf 2)
[ -n "$cfgid" ] && {
HAS_LOGIN=$(ls ${L2TP_SESSION_PATH} | grep "^${_USERNAME}\.ppp" | grep -v "${_IFACE}")
[ -n "$HAS_LOGIN" ] && {
#踢出之前的用户
KO_IFACE=$(echo $HAS_LOGIN | awk -F '.' '{print $2}')
KO_PID=$(cat /var/run/${KO_IFACE}.pid 2>/dev/null)
[ -n "$KO_PID" ] && kill -9 ${KO_PID} >/dev/null 2>&1
rm -f ${L2TP_SESSION_PATH}/${HAS_LOGIN}
rm -f /var/run/${KO_IFACE}.pid
}
routes=$(uci -q get ${CONFIG}.${cfgid}.routes)
[ -n "$routes" ] && {
for router in ${routes}; do
route add -net ${router} dev ${_IFACE} >/dev/null 2>&1
done
}
}
#可根据登录的账号自定义脚本,如组网、日志、限速、权限等特殊待遇。
SCRIPT="/usr/share/xl2tpd/ip-up.d/${_USERNAME}"
[ -s "$SCRIPT" ] && {
[ ! -x "$SCRIPT" ] && chmod 0755 "$SCRIPT"
"$SCRIPT" "$@"
}
+8 -3
View File
@@ -8,8 +8,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=mwan3-nft
PKG_VERSION:=3.6.9
PKG_RELEASE:=3
PKG_VERSION:=3.6.12
PKG_RELEASE:=4
PKG_MAINTAINER:=Florian Eckert <fe@dev.tdt.de>
PKG_LICENSE:=GPL-2.0
@@ -35,7 +35,8 @@ define Package/$(PKG_NAME)
+ucode-mod-uci \
+ucode-mod-ubus \
+ucode-mod-fs \
+ucode-mod-socket
+ucode-mod-socket \
+ucode-mod-log
CONFLICTS:=mwan3
TITLE:=Multiwan hotplug script with connection tracking support (ucode rtmon)
MAINTAINER:=Florian Eckert <fe@dev.tdt.de>
@@ -220,6 +221,10 @@ define Package/$(PKG_NAME)/install
$(INSTALL_BIN) ./files/usr/share/rpcd/ucode/mwan3 \
$(1)/usr/share/rpcd/ucode/
$(INSTALL_DIR) $(1)/usr/share/ucode/mwan3
$(INSTALL_DATA) ./files/usr/share/ucode/mwan3/common.uc \
$(1)/usr/share/ucode/mwan3/
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) ./files/usr/sbin/mwan3 \
$(1)/usr/sbin/
+721 -179
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,5 +1,5 @@
# For full documentation of mwan3 configuration:
# https://openwrt.org/docs/guide-user/network/wan/multiwan/mwan3#mwan3_configuration
# https://openwrt.org/docs/guide-user/network/wan/multiwan/mwan3-nft#configuration
config globals 'globals'
option mmx_mask '0x3F00'
+44 -1
View File
@@ -52,9 +52,24 @@ LOG notice "Execute $ACTION event on interface $INTERFACE (${DEVICE:-unknown})"
case "$ACTION" in
connected)
# A tracker recovery must restore the member's routing before the policy
# re-selects it, so the member is routable the instant it is marked, then
# snap new flows off the worse-metric backups back toward it. The policy
# rebuild commits as one nft transaction so no packet classifies against a
# half-rebuilt policy chain. The snap-back stays outside that batch on
# purpose: a recovery's worse-metric pins point at backups that are still
# up, so a source re-pinned in the window before the snap reaches a live
# member, not a dead one, unlike a teardown whose pins must be flushed in
# the same transaction because they point at the departing member.
mwan3_set_iface_hotplug_state $INTERFACE "online"
mwan3_create_iface_nft $INTERFACE $DEVICE
mwan3_create_iface_rules $INTERFACE $DEVICE
mwan3_create_iface_route $INTERFACE $DEVICE
mwan3_nft_batch_start
mwan3_set_policies_nft
mwan3_nft_batch_commit
mwan3_flush_worse_metric_sticky $INTERFACE
mwan3_flush_unreplied_conntrack
;;
ifup)
@@ -66,24 +81,52 @@ case "$ACTION" in
if [ "$MWAN3_STARTUP" != "init" ]; then
mwan3_set_general_rules
if [ "$status" = "online" ]; then
# Same atomic policy rebuild and out-of-batch snap-back as the
# connected arm above; see that comment for why the snap-back is
# not folded into the batch. The batch sits inside the online
# guard so the init path never opens one.
mwan3_nft_batch_start
mwan3_set_policies_nft
mwan3_nft_batch_commit
mwan3_flush_worse_metric_sticky $INTERFACE
mwan3_flush_unreplied_conntrack
fi
fi
[ "$ACTION" = ifup ] && procd_running mwan3 "track_$INTERFACE" && procd_send_signal mwan3 "track_$INTERFACE" USR2
;;
disconnected)
# A soft tracker failure drops the member from the policy; flush its
# sticky pins after the rebuild so a pinned source re-evaluates to the
# survivor, the same treatment a hard ifdown gets. The policy rebuild
# and the sticky flush commit as one nft transaction so no packet
# classifies against a half-rebuilt policy. The member's marked flows
# are cleared by the conntrack flush at the handler tail, which is
# kernel state outside nftables and stays a separate operation.
mwan3_set_iface_hotplug_state $INTERFACE "offline"
mwan3_nft_batch_start
mwan3_set_policies_nft
mwan3_delete_iface_map_entries $INTERFACE
mwan3_nft_batch_commit
;;
ifdown)
# Drop the member from the policy before tearing down its routing, so the
# policy never marks for a member whose rules are already gone; flush its
# sticky sets after the rebuild so the rebuild cannot re-seed them. The
# policy rebuild and the sticky flush commit as one nft transaction; the
# routing teardown and the conntrack flush follow as separate steps.
mwan3_set_iface_hotplug_state $INTERFACE "offline"
mwan3_nft_batch_start
mwan3_set_policies_nft
mwan3_delete_iface_map_entries $INTERFACE
mwan3_nft_batch_commit
mwan3_delete_iface_rules $INTERFACE
mwan3_delete_iface_route $INTERFACE
mwan3_delete_iface_nft $INTERFACE
procd_running mwan3 "track_$INTERFACE" && procd_send_signal mwan3 "track_$INTERFACE" USR1
mwan3_set_policies_nft
;;
esac
+32 -39
View File
@@ -66,6 +66,7 @@ start_service() {
mwan3_set_connected_sets
mwan3_set_custom_sets
mwan3_set_general_rules
mwan3_set_member_backstops
config_foreach mwan3_ifup interface "init"
wait $hotplug_pids
mwan3_set_general_nft
@@ -134,11 +135,13 @@ stop_service() {
# Delete ip rules created by this mwan3 instance. A rule must pass
# both gates before it is deleted:
# priority gate -- the priority lies in one of mwan3's three
# configured rule-base ranges (iif, fwmark, unreachable). The
# priority gate -- the priority lies in one of mwan3's four
# rule-base ranges (iif, fwmark, unreachable, source). The
# fwmark range extends to fwmark_base + MM_UNREACHABLE to cover
# the global blackhole and unreachable rules at fwmark_base +
# MM_BLACKHOLE / MM_UNREACHABLE.
# MM_BLACKHOLE / MM_UNREACHABLE. The source band is not
# configurable; it is derived from the unreachable base and
# sits immediately above the unreachable band.
# content gate -- the line either references a mwan3-owned routing
# table (lookup <N> with N in 1..MWAN3_INTERFACE_MAX) or carries
# an fwmark masked by mwan3's MMX_MASK. The priority gate alone
@@ -149,13 +152,15 @@ stop_service() {
# /var/run/mwan3/mmx_mask and /var/state/mwan3 (set at start_service
# time), so they reflect what the running instance created, not
# whatever is currently in /etc/config/mwan3.
local line pref tbl iif_min iif_max fw_min fw_max ur_min ur_max mask
local line pref tbl iif_min iif_max fw_min fw_max ur_min ur_max src_min src_max mask
iif_min=$((MWAN3_IIF_RULE_BASE + 1))
iif_max=$((MWAN3_IIF_RULE_BASE + MWAN3_INTERFACE_MAX))
fw_min=$((MWAN3_FWMARK_RULE_BASE + 1))
fw_max=$((MWAN3_FWMARK_RULE_BASE + MM_UNREACHABLE))
ur_min=$((MWAN3_UNREACHABLE_RULE_BASE + 1))
ur_max=$((MWAN3_UNREACHABLE_RULE_BASE + MWAN3_INTERFACE_MAX))
src_min=$((MWAN3_UNREACHABLE_RULE_BASE + MWAN3_INTERFACE_MAX + 2))
src_max=$((MWAN3_UNREACHABLE_RULE_BASE + 2 * MWAN3_INTERFACE_MAX + 1))
# ip rule list prints fwmark masks lowercase; normalise for matching
mask=$(echo "$MMX_MASK" | tr 'A-Z' 'a-z')
$IP rule list | while read -r line; do
@@ -166,7 +171,8 @@ stop_service() {
# Priority gate
if ! { { [ "$pref" -ge "$iif_min" ] && [ "$pref" -le "$iif_max" ]; } || \
{ [ "$pref" -ge "$fw_min" ] && [ "$pref" -le "$fw_max" ]; } || \
{ [ "$pref" -ge "$ur_min" ] && [ "$pref" -le "$ur_max" ]; }; }; then
{ [ "$pref" -ge "$ur_min" ] && [ "$pref" -le "$ur_max" ]; } || \
{ [ "$pref" -ge "$src_min" ] && [ "$pref" -le "$src_max" ]; }; }; then
continue
fi
# Content gate (a): carries an fwmark masked by mwan3's MMX_MASK
@@ -190,40 +196,9 @@ stop_service() {
done
done
# Flush all mwan3 chains (rules removed, chains stay as skeletons)
for chain_name in $($NFT list chains inet 2>/dev/null | grep "chain mwan3_" | awk '{print $2}'); do
$NFT flush chain inet mwan3 "$chain_name" >/dev/null 2>&1
done
# Delete dynamic per-interface and per-policy chains (not the skeleton ones from 10-mwan3.nft)
for chain_name in $($NFT list chains inet 2>/dev/null | grep "chain mwan3_" | awk '{print $2}'); do
case "$chain_name" in
mwan3_prerouting|mwan3_output|mwan3_postrouting|mwan3_ifaces_in|mwan3_rules|mwan3_connected|mwan3_custom|mwan3_dynamic)
# These are skeleton chains from the static .nft file; keep them
;;
*)
$NFT delete chain inet mwan3 "$chain_name" >/dev/null 2>&1
;;
esac
done
# Final safety flush of skeleton chains (handles any transient failures above)
for chain_name in mwan3_prerouting mwan3_output mwan3_postrouting mwan3_ifaces_in mwan3_rules mwan3_connected mwan3_custom mwan3_dynamic; do
$NFT flush chain inet mwan3 "$chain_name" >/dev/null 2>&1
done
# Flush all mwan3 sets
for setname in $($NFT list sets inet mwan3 2>/dev/null | grep "set mwan3_" | awk '{print $2}'); do
$NFT flush set inet mwan3 "$setname" >/dev/null 2>&1
done
# Delete dynamic maps (sticky maps)
for mapname in $($NFT list maps inet mwan3 2>/dev/null | grep "map mwan3_" | awk '{print $2}'); do
$NFT flush map inet mwan3 "$mapname" >/dev/null 2>&1
$NFT delete map inet mwan3 "$mapname" >/dev/null 2>&1
done
# Remove the table entirely so a stopped mwan3 leaves no nft residue
# Remove the table entirely. The single atomic delete drops every
# chain, set and map mwan3 created in one kernel transaction, so a
# stopped mwan3 leaves no nft residue.
$NFT delete table inet mwan3 >/dev/null 2>&1
rm -rf $MWAN3_STATUS_DIR $MWAN3TRACK_STATUS_DIR
@@ -233,6 +208,23 @@ stop_service() {
reload_service() {
mwan3_init
# The nft table is created only by the skeleton load in start_service,
# which is skipped when the service starts with every interface
# disabled. In that state the table is absent while the status
# directory (created by mwan3_init) exists, so the service reports
# running and a config change drives a reload, not a start. Rebuilding
# the ruleset here would batch every add against a missing table, the
# atomic commit would fail, and this function would return at the commit
# check before the tracker-mismatch restart below. Recover with a full
# stop and start, which reloads the skeleton and recreates the table.
if ! $NFT list table inet mwan3 >/dev/null 2>&1; then
LOG notice "reload escalated to restart as table inet mwan3 not initialised"
stop
start
return
fi
# Atomically rebuild the entire mwan3 nft ruleset in a single kernel
# transaction. mwan3_nft_reload_start opens the outermost batch level
# (MWAN3_BATCH_DEPTH 0->1) so all subsequent batch_start/commit calls
@@ -256,6 +248,7 @@ reload_service() {
# update them after the nft commit so the table is already consistent.
mwan3_update_iface_to_table
mwan3_set_general_rules
mwan3_set_member_backstops
config_foreach mwan3_rebuild_iface_rules interface
# Signal mwan3rtmon to reload UCI config and re-sync the custom sets.
+2 -2
View File
@@ -16,5 +16,5 @@
# $DEVICE Physical device name of the interface the action relates to (e.g. "eth0" or "wwan0").
# Note: On an ifdown event, $DEVICE is not available, use $INTERFACE instead.
#
# Further documentation can be found here:
# https://openwrt.org/docs/guide-user/network/wan/multiwan/mwan3#alertsnotifications
# Further documentation can be found at:
# https://openwrt.org/docs/guide-user/network/wan/multiwan/mwan3-nft#hotplug_events_mwan3user
@@ -4,6 +4,7 @@
import * as rtnl from "rtnl";
import * as uci from "uci";
import * as ubus from "ubus";
import { log_open, log_msg } from 'mwan3.common';
const RTM_GETROUTE = rtnl.const.RTM_GETROUTE;
const RTM_NEWROUTE = rtnl.const.RTM_NEWROUTE;
@@ -56,12 +57,14 @@ let tid = 0;
cur.foreach("mwan3", "interface", function(s) {
tid++;
let fam = s.family ?? "ipv4";
let enabled = +(s.enabled ?? "1");
let enabled = +(s.enabled ?? "0");
if (enabled && fam == family_name)
name_tid[s[".name"]] = tid;
});
cur.unload("mwan3");
log_open("mwan3-create-iface-route");
let dev_table_map = {};
let uconn = ubus.connect();
if (uconn) {
@@ -117,5 +120,5 @@ for (let route in source_routes) {
rtnl.request(RTM_NEWROUTE, NLM_F_CREATE | NLM_F_REPLACE, r);
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-create-iface-route: table %d: %s\n", table_id, err));
log_msg("err", sprintf("table %d: %s", table_id, err));
}
@@ -2,6 +2,7 @@
'use strict';
import * as rtnl from "rtnl";
import { is_default_equivalent } from 'mwan3.common';
const RTM_GETROUTE = rtnl.const.RTM_GETROUTE;
const NLM_F_DUMP = rtnl.const.NLM_F_DUMP;
@@ -18,12 +19,6 @@ function is_cidr_route(route, family_num) {
return (family_num == AF_INET) ? (prefix_len < 32) : (prefix_len < 128);
}
function is_default_route(route) {
return (route.dst == null ||
route.dst == "0.0.0.0/0" ||
route.dst == "::/0");
}
function is_linklocal_route(route) {
return (route.dst != null &&
(match(route.dst, /^fe80::\//) != null ||
@@ -40,7 +35,7 @@ let seen = {};
for (let route in routes) {
if (route.table != table_num) continue;
if (!is_cidr_route(route, family_num)) continue;
if (is_default_route(route)) continue;
if (is_default_equivalent(route)) continue;
if (is_linklocal_route(route)) continue;
if (family_num == AF_INET) {
let first_octet = +split(route.dst, ".")[0];
+74 -29
View File
@@ -2,11 +2,13 @@
'use strict';
import * as rtnl from "rtnl";
import { log_open, log_msg } from 'mwan3.common';
const RTM_GETRULE = rtnl.const.RTM_GETRULE;
const RTM_DELRULE = rtnl.const.RTM_DELRULE;
const RTM_NEWRULE = rtnl.const.RTM_NEWRULE;
const RTM_GETROUTE = rtnl.const.RTM_GETROUTE;
const RTM_GETADDR = rtnl.const.RTM_GETADDR;
const NLM_F_DUMP = rtnl.const.NLM_F_DUMP;
const NLM_F_CREATE = rtnl.const.NLM_F_CREATE;
const AF_INET = rtnl.const.AF_INET;
@@ -22,6 +24,8 @@ function is_default_route(route) {
route.dst == "::/0");
}
log_open("mwan3-manage-rules");
let mode = ARGV[0];
if (mode == "check") {
@@ -59,20 +63,33 @@ if (mode == "check") {
let id = +ARGV[1];
let iif_base = +ARGV[2];
let fwmark_base = +ARGV[3];
let unreachable_base = +ARGV[4];
let mmx_mask = +ARGV[5];
let mmx_mask = +ARGV[4];
let src_prio = +ARGV[5];
let iif_prio = iif_base + id;
let fwmark_prio = fwmark_base + id;
let unreachable_prio = unreachable_base + id;
for (let family in [AF_INET, AF_INET6]) {
let rules = rtnl.request(RTM_GETRULE, NLM_F_DUMP, { family: family }) ?? [];
let fwmark_val = null;
for (let rule in rules) {
if (rule.table != id) continue;
if (rule.action != FR_ACT_TO_TBL) continue;
if (rule.src != null && rule.priority == src_prio) {
rtnl.request(RTM_DELRULE, 0, {
family: family,
priority: rule.priority,
src: rule.src,
table: rule.table,
action: rule.action
});
let err = rtnl.error();
if (err)
log_msg("err", sprintf("delete src rule failed: %s", err));
continue;
}
if (rule.src != null || rule.dst != null) continue;
if (rule.fwmark == null && rule.priority == iif_prio) {
@@ -84,9 +101,8 @@ if (mode == "check") {
});
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-manage-rules: delete iif rule failed: %s\n", err));
log_msg("err", sprintf("delete iif rule failed: %s", err));
} else if (rule.fwmark != null && rule.priority == fwmark_prio && rule.fwmask == mmx_mask) {
fwmark_val = rule.fwmark;
rtnl.request(RTM_DELRULE, 0, {
family: family,
priority: rule.priority,
@@ -97,27 +113,7 @@ if (mode == "check") {
});
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-manage-rules: delete fwmark rule failed: %s\n", err));
}
}
if (fwmark_val != null) {
for (let rule in rules) {
if (rule.fwmark != fwmark_val) continue;
if (rule.action != FR_ACT_UNREACHABLE) continue;
if (rule.priority != unreachable_prio) continue;
if (rule.fwmask != mmx_mask) continue;
if (rule.src != null || rule.dst != null) continue;
rtnl.request(RTM_DELRULE, 0, {
family: family,
priority: rule.priority,
fwmark: rule.fwmark,
fwmask: rule.fwmask,
action: rule.action
});
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-manage-rules: delete unreachable rule failed: %s\n", err));
log_msg("err", sprintf("delete fwmark rule failed: %s", err));
}
}
}
@@ -144,7 +140,7 @@ if (mode == "check") {
});
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-manage-rules: add blackhole rule failed: %s\n", err));
log_msg("err", sprintf("add blackhole rule failed: %s", err));
}
if (!existing[ur_prio]) {
@@ -157,6 +153,55 @@ if (mode == "check") {
});
let err = rtnl.error();
if (err)
warn(sprintf("mwan3-manage-rules: add unreachable rule failed: %s\n", err));
log_msg("err", sprintf("add unreachable rule failed: %s", err));
}
} else if (mode == "add-backstop") {
let family_num = (ARGV[1] == "6") ? AF_INET6 : AF_INET;
let prio = +ARGV[2], mark = +ARGV[3];
let mask = +ARGV[4];
let rules = rtnl.request(RTM_GETRULE, NLM_F_DUMP, { family: family_num }) ?? [];
let existing = {};
for (let r in rules)
if (r.priority != null)
existing[r.priority] = true;
if (!existing[prio]) {
rtnl.request(RTM_NEWRULE, NLM_F_CREATE, {
family: family_num,
priority: prio,
fwmark: mark,
fwmask: mask,
action: FR_ACT_UNREACHABLE
});
let err = rtnl.error();
if (err)
log_msg("err", sprintf("add backstop rule failed: %s", err));
}
} else if (mode == "add-src") {
// IPv4 only
let device = ARGV[1];
let table_id = +ARGV[2];
let prio = +ARGV[3];
let addrs = rtnl.request(RTM_GETADDR, NLM_F_DUMP, { family: AF_INET }) ?? [];
for (let a in addrs) {
if (a.dev != device) continue;
if (a.scope != 0) continue;
let addr = split(a.local ?? a.address ?? "", "/")[0];
if (!addr) continue;
rtnl.request(RTM_NEWRULE, NLM_F_CREATE, {
family: AF_INET,
priority: prio,
src: addr,
table: table_id,
action: FR_ACT_TO_TBL
});
let err = rtnl.error();
if (err)
log_msg("err", sprintf("add src rule failed: %s", err));
}
}
+336 -100
View File
@@ -266,7 +266,8 @@ _mwan3_render_one_ipset()
set_decl="type ${addr_type}; flags interval"
[ "$timeout" -gt 0 ] && set_decl="$set_decl, timeout"
set_decl="$set_decl; auto-merge;"
set_decl="$set_decl;"
[ "$timeout" -eq 0 ] && set_decl="$set_decl auto-merge;"
[ "$counters" -eq 1 ] && set_decl="$set_decl counter;"
[ "$timeout" -gt 0 ] && set_decl="$set_decl timeout ${timeout}s;"
[ "$maxelem" -gt 0 ] && set_decl="$set_decl size ${maxelem};"
@@ -291,14 +292,35 @@ _mwan3_render_one_ipset()
fi
# Collect all elements (inline list + loadfile) before entering batch.
# Every element is validated against the nft set element grammar (IPv4 and
# IPv6 addresses, CIDR prefixes and hyphen ranges) so a crafted value
# cannot close the element braces early and inject nft statements into the
# batch that is later committed with nft -f. Anything carrying a character
# outside that class is dropped.
local elements="" line
_add_entry() { elements="${elements:+$elements, }$1"; }
_add_entry() {
case "$1" in
""|*[!0-9A-Fa-f:./-]*)
LOG warn "config ipset '$name': dropping invalid entry"
return 0
;;
esac
elements="${elements:+$elements, }$1"
}
config_list_foreach "$section" entry _add_entry
if [ -n "$loadfile" ] && [ -f "$loadfile" ]; then
while IFS= read -r line; do
line="${line%%#*}"
line=$(echo "$line" | xargs 2>/dev/null)
[ -n "$line" ] && elements="${elements:+$elements, }$line"
[ -n "$line" ] || continue
case "$line" in
*[!0-9A-Fa-f:./-]*)
LOG warn "config ipset '$name': dropping invalid loadfile element"
continue
;;
esac
elements="${elements:+$elements, }$line"
done < "$loadfile"
fi
@@ -466,6 +488,41 @@ mwan3_set_general_rules()
"$MMX_MASK"
}
# Install a static unreachable ip rule for every configured interface mark, in
# the per-member unreachable priority band. These are created at service start
# and reload and removed only at stop, so a packet carrying a member mark whose
# fwmark rule is currently absent (the member is down, disabled, or briefly
# between a delete and re-add) is rejected instead of leaking to the main
# routing table. One rule per member is required because an fwmark rule matches
# an exact value under the mask, not "any member mark"; the reserved last_resort
# marks are never enumerated here, so last_resort behaviour is unaffected.
mwan3_set_member_backstops()
{
mwan3_add_backstop()
{
local id family fam
config_get family "$1" family ipv4
mwan3_get_iface_id id "$1"
[ -n "$id" ] || return 0
if [ "$family" = "ipv4" ]; then
fam=4
elif [ "$family" = "ipv6" ] && [ $NO_IPV6 -eq 0 ]; then
fam=6
else
return 0
fi
${MWAN3_MANAGE_RULES} add-backstop "$fam" \
"$((id+MWAN3_UNREACHABLE_RULE_BASE))" \
"$(mwan3_id2mask id MMX_MASK)" "$MMX_MASK"
}
config_foreach mwan3_add_backstop interface
}
mwan3_set_general_nft()
{
local chain_exists restore_vmap save_vmap all_marks
@@ -843,16 +900,112 @@ mwan3_delete_iface_map_entries()
# Sticky scheme: one set per (rule, family, iface_id) holding
# only saddrs (no value side). Removing an interface invalidates every
# such set whose name ends in "_<id>"; we flush rather than delete since
# rule chains may still reference the set name.
# rule chains may still reference the set name. The flush lines are pushed
# into the caller's open nft batch so they commit in the same transaction
# as the policy rebuild; the set enumeration reads committed kernel state
# and so stays outside the batch. Call only within an open nft batch.
for setname in $($NFT list sets inet 2>/dev/null | \
awk '$1=="set" && $2 ~ /^mwan3_sticky_v[46]_/ { print $2 }'); do
case "$setname" in
*_"$id") $NFT flush set inet mwan3 "$setname" 2>/dev/null ;;
*_"$id") mwan3_nft_push "flush set inet mwan3 $setname" ;;
esac
done
}
mwan3_flush_worse_metric_sticky()
{
# On recovery of a better-metric member, snap new flows off the worse-metric
# backups it is now preferred over. For each policy the recovered interface
# participates in, flush the per-rule sticky sets of that policy's same-family
# members whose metric is strictly worse, scoped to the rules that use the
# policy, so pinned sources re-evaluate to the recovered member on their next
# new flow while established flows ride their conntrack marks to completion
# undisturbed. The scope is per policy and per rule by construction: each set
# name is built from the rule name and the member id rather than flushing
# every set ending in an id, so a member that is also a deliberate-stickiness
# member of a separate load-balance policy keeps those pins.
local recovered="$1" recovered_id rec_family rec_fam_short
local _snap_pairs=""
mwan3_get_iface_id recovered_id "$recovered"
[ -n "$recovered_id" ] || return 0
# mwan3 runs an independent metric contest per address family, so only
# same-family members compete with the recovered one; resolve its family once
# and snap only that family's backups.
config_get rec_family "$recovered" family ipv4
if [ "$rec_family" = "ipv6" ]; then
rec_fam_short="v6"
else
rec_fam_short="v4"
fi
# Record one "policy=worse-id" pair per strictly-worse-metric same-family
# member of each policy the recovered interface is in. The recovered
# interface's own id is excluded so its own pins are never cleared.
_mwan3_snap_collect() {
local policy="$1" rec_metric=""
# The recovered interface's metric in this policy sets the threshold;
# take the lowest if it appears more than once.
_mwan3_snap_rec_metric() {
local m_iface m_metric
config_get m_iface "$1" interface
[ "$m_iface" = "$recovered" ] || return
config_get m_metric "$1" metric 1
{ [ -z "$rec_metric" ] || [ "$m_metric" -lt "$rec_metric" ]; } && rec_metric="$m_metric"
}
config_list_foreach "$policy" use_member _mwan3_snap_rec_metric
# Policy does not include the recovered interface: nothing to snap.
[ -n "$rec_metric" ] || return
_mwan3_snap_worse() {
local m_iface m_metric m_id m_family
config_get m_iface "$1" interface
[ -n "$m_iface" ] || return
config_get m_family "$m_iface" family ipv4
[ "$m_family" = "$rec_family" ] || return
config_get m_metric "$1" metric 1
[ "$m_metric" -gt "$rec_metric" ] || return
mwan3_get_iface_id m_id "$m_iface"
[ -n "$m_id" ] || return
[ "$m_id" = "$recovered_id" ] && return
case " $_snap_pairs " in
*" ${policy}=${m_id} "*) ;;
*) _snap_pairs="$_snap_pairs ${policy}=${m_id}" ;;
esac
}
config_list_foreach "$policy" use_member _mwan3_snap_worse
}
config_foreach _mwan3_snap_collect policy
[ -n "$_snap_pairs" ] || return 0
# For every rule, flush the worse-metric members' per-rule sticky sets of the
# policy that rule uses, in the recovered member's family. A set name that
# does not exist is a harmless no-op.
_mwan3_snap_flush() {
local r_policy pair p_name p_id
config_get r_policy "$1" use_policy
[ -n "$r_policy" ] || return
for pair in $_snap_pairs; do
p_name="${pair%=*}"
p_id="${pair##*=}"
[ "$p_name" = "$r_policy" ] || continue
$NFT flush set inet mwan3 "mwan3_sticky_${rec_fam_short}_${1}_${p_id}" 2>/dev/null
done
}
config_foreach _mwan3_snap_flush rule
}
mwan3_create_iface_route()
{
local family id source_routing _fam_num
@@ -904,7 +1057,8 @@ mwan3_create_iface_rules()
$IP rule add pref $((id+MWAN3_IIF_RULE_BASE)) iif "$2" lookup "$id" 2>/dev/null
$IP rule add pref $((id+MWAN3_FWMARK_RULE_BASE)) fwmark "$(mwan3_id2mask id MMX_MASK)/$MMX_MASK" lookup "$id" 2>/dev/null
$IP rule add pref $((id+MWAN3_UNREACHABLE_RULE_BASE)) fwmark "$(mwan3_id2mask id MMX_MASK)/$MMX_MASK" unreachable 2>/dev/null
[ "$family" = "ipv4" ] && ${MWAN3_MANAGE_RULES} add-src "$2" "$id" \
"$((id + MWAN3_UNREACHABLE_RULE_BASE + MWAN3_INTERFACE_MAX + 1))"
}
mwan3_delete_iface_rules()
@@ -915,13 +1069,13 @@ mwan3_delete_iface_rules()
[ -n "$id" ] || return 0
${MWAN3_MANAGE_RULES} delete-iface "$id" \
"$MWAN3_IIF_RULE_BASE" "$MWAN3_FWMARK_RULE_BASE" \
"$MWAN3_UNREACHABLE_RULE_BASE" "$MMX_MASK"
"$MWAN3_IIF_RULE_BASE" "$MWAN3_FWMARK_RULE_BASE" "$MMX_MASK" \
"$((id + MWAN3_UNREACHABLE_RULE_BASE + MWAN3_INTERFACE_MAX + 1))"
}
mwan3_set_policy()
{
local id iface family metric weight device is_lowest is_offline
local id iface family metric weight is_lowest is_offline
is_lowest=0
config_get iface "$1" interface
@@ -929,7 +1083,6 @@ mwan3_set_policy()
config_get weight "$1" weight 1
[ -n "$iface" ] || return 0
network_get_device device "$iface"
[ "$metric" -gt $DEFAULT_LOWEST_METRIC ] && LOG warn "Member interface $iface has >$DEFAULT_LOWEST_METRIC metric. Not appending to policy" && return 0
mwan3_get_iface_id id "$iface"
@@ -976,30 +1129,28 @@ mwan3_set_policy()
if [ $is_offline -eq 0 ]; then
# Accumulate members per family: "iface_name:id:weight" tuples
# Accumulate members per family: "iface_name:id:weight" tuples.
# The branches mirror the metric contest above, so a member only
# accumulates through its interface family's contest: an IPv6
# member is excluded on a kernel without IPv6, and an
# unrecognised family value is never accumulated.
if [ "$family" = "ipv4" ]; then
policy_members_v4="$policy_members_v4 $iface:$id:$weight"
else
elif [ "$family" = "ipv6" ] && [ $NO_IPV6 -eq 0 ]; then
policy_members_v6="$policy_members_v6 $iface:$id:$weight"
fi
elif [ -n "$device" ]; then
# Offline interface with device: record for fallback out-device rule
policy_offline_devices="$policy_offline_devices $iface:$device"
fi
}
mwan3_create_policies_nft()
{
local last_resort lowest_metric_v4 lowest_metric_v6 total_weight_v4 total_weight_v6
local policy policy_members_v4 policy_members_v6 policy_offline_devices
local policy policy_members_v4 policy_members_v6
policy="$1"
policy_members_v4=""
policy_members_v6=""
policy_offline_devices=""
config_get last_resort "$1" last_resort unreachable
@@ -1097,22 +1248,6 @@ mwan3_create_policies_nft()
done
fi
# Add offline device fallback rules
local dev_entry offline_iface offline_device
# Only add if no online members
if [ "$total_weight" -eq 0 ]; then
for dev_entry in $policy_offline_devices; do
offline_iface="${dev_entry%%:*}"
offline_device="${dev_entry#*:}"
mwan3_nft_exec add rule inet mwan3 "mwan3_policy_$policy" \
oifname "$offline_device" meta mark \& "$MMX_MASK" == 0 \
"$(mwan3_nft_mark_expr $MMX_DEFAULT $MMX_MASK)"
done
fi
# Add last resort rule
case "$last_resort" in
@@ -1214,7 +1349,7 @@ mwan3_set_user_nft_rule()
local ipset_name ipset_src family proto policy src_ip src_port src_iface src_dev
local sticky dest_ip dest_port use_policy timeout policy
local global_logging rule_logging loglevel rule_policy rule ipv
local enabled fwmark fwmask _check_set _set_info
local enabled fwmark fwmask _check_set _set_info _fam_anchored
config_get_bool enabled "$1" enabled 1
[ "$enabled" -eq 1 ] || return
@@ -1262,18 +1397,30 @@ mwan3_set_user_nft_rule()
[ "$family" = "ipv4" ] && [ "$ipv" = "ipv6" ] && return
[ "$family" = "ipv6" ] && [ "$ipv" = "ipv4" ] && return
# family=any rules whose nft expression has no IP-version-specific element
# (no src_ip/dest_ip/ipset/ipset_src) generate identical output on both the
# ipv4 and ipv6 passes. Skip the ipv6 pass to avoid pushing a duplicate
# rule. The ipv4 pass output already matches IPv6 traffic at runtime
# because the match operates on family-agnostic fields (meta mark, l4proto,
# port, iifname). Exception: proto=icmp requires both passes because ICMP
# (protocol 1) and ICMPv6 (protocol 58) are distinct L4 protocols.
# A family-anchoring element (src_ip/dest_ip/ipset/ipset_src) ties the
# rendered expression to one address family per pass. This list must
# cover every matcher whose nft form differs by address family; the
# de-duplication guard and the sticky block both key on the flag.
_fam_anchored=0
if [ -n "$src_ip" ] || [ -n "$dest_ip" ] || \
[ -n "$ipset_name" ] || [ -n "$ipset_src" ]; then
_fam_anchored=1
fi
# family=any rules with no family-anchoring element render once, on
# the ipv4 pass: the emitted line matches on family-agnostic fields
# (meta mark, l4proto, port, iifname, ether saddr) and carries no
# nfproto guard, so it covers both families at its config position,
# and the sticky block builds the per-rule chain complete for both
# families on that pass. Skip the ipv6 pass to avoid a duplicate
# line. Exception: proto=icmp requires both passes because ICMP
# (protocol 1) and ICMPv6 (protocol 58) are distinct L4 protocols;
# the ipv6 pass then emits its own line but must not rebuild the
# sticky chain.
if [ "$family" = "any" ] && [ "$ipv" = "ipv6" ] && \
[ -z "$src_ip" ] && [ -z "$dest_ip" ] && \
[ -z "$ipset_name" ] && [ -z "$ipset_src" ] && \
[ "$proto" != "icmp" ]; then
[ $_fam_anchored -eq 0 ] && [ "$proto" != "icmp" ]; then
return
fi
@@ -1300,7 +1447,13 @@ mwan3_set_user_nft_rule()
# the src_ip/dest_ip address validation above: a set of type ipv4_addr
# cannot appear in an "ip6 daddr @set" expression, and vice versa.
local _ipset_name_uci_type="" _ipset_src_uci_type=""
local _ipset_name_uci_type="" _ipset_src_uci_type="" _pass_addrtype
if [ "$ipv" = "ipv4" ]; then
_pass_addrtype="ipv4_addr"
else
_pass_addrtype="ipv6_addr"
fi
for _check_set in "$ipset_name" "$ipset_src"; do
[ -z "$_check_set" ] && continue
local _set_info
@@ -1339,6 +1492,25 @@ mwan3_set_user_nft_rule()
[ "$ipv" = "ipv6" ] && [ "$_uci_addrtype" = "ipv4_addr" ] && return
continue
fi
# An earlier rule in this render may already have pre-created
# the set into the open batch, which the kernel query above
# cannot see. Consult the render's own record instead: the
# first pre-creation establishes the set's type for the whole
# render, and every later reference is treated exactly as if
# the kernel held a set of that type.
case " $MWAN3_RENDER_SETS " in
*" ${_check_set}=${_pass_addrtype} "*)
continue
;;
*" ${_check_set}="*)
[ "$family" = "any" ] && return
LOG warn "Rule $rule: set '$_check_set' pre-created as the other address family by an earlier rule, incompatible with family $family"
return
;;
esac
# Not UCI-managed: apply existing guard for external sets.
# For family=any, skip the ipv6 pass; the ipv4 pass will pre-create
# the set as ipv4_addr until an external creator establishes its type.
@@ -1439,15 +1611,20 @@ mwan3_set_user_nft_rule()
# set is missing, which would kill ALL user rules.
# UCI-managed sets are already added to the batch by mwan3_render_config_ipsets;
# skip the kernel check and pre-creation for those.
# A set this render has already pre-created is in the record and is
# never added twice: two adds of one name in a batch are rejected
# when the types differ, and redundant when they match.
if [ -z "$_ipset_name_uci_type" ] && \
! $NFT list set inet mwan3 "$ipset_name" &>/dev/null; then
LOG notice "Creating missing nft set '$ipset_name' for rule $rule"
if [ "$ipv" = "ipv4" ]; then
mwan3_nft_push "add set inet mwan3 $ipset_name { type ipv4_addr; flags interval; auto-merge; }"
else
mwan3_nft_push "add set inet mwan3 $ipset_name { type ipv6_addr; flags interval; auto-merge; }"
fi
case " $MWAN3_RENDER_SETS " in
*" ${ipset_name}=${_pass_addrtype} "*) ;;
*)
LOG notice "Creating missing nft set '$ipset_name' for rule $rule"
mwan3_nft_push "add set inet mwan3 $ipset_name { type ${_pass_addrtype}; flags interval; auto-merge; }"
MWAN3_RENDER_SETS="$MWAN3_RENDER_SETS ${ipset_name}=${_pass_addrtype}"
;;
esac
fi
if [ "$ipv" = "ipv4" ]; then
nft_match="$nft_match ip daddr @$ipset_name"
@@ -1461,12 +1638,14 @@ mwan3_set_user_nft_rule()
if [ -n "$ipset_src" ]; then
if [ -z "$_ipset_src_uci_type" ] && \
! $NFT list set inet mwan3 "$ipset_src" &>/dev/null; then
LOG notice "Creating missing nft set '$ipset_src' for rule $rule"
if [ "$ipv" = "ipv4" ]; then
mwan3_nft_push "add set inet mwan3 $ipset_src { type ipv4_addr; flags interval; auto-merge; }"
else
mwan3_nft_push "add set inet mwan3 $ipset_src { type ipv6_addr; flags interval; auto-merge; }"
fi
case " $MWAN3_RENDER_SETS " in
*" ${ipset_src}=${_pass_addrtype} "*) ;;
*)
LOG notice "Creating missing nft set '$ipset_src' for rule $rule"
mwan3_nft_push "add set inet mwan3 $ipset_src { type ${_pass_addrtype}; flags interval; auto-merge; }"
MWAN3_RENDER_SETS="$MWAN3_RENDER_SETS ${ipset_src}=${_pass_addrtype}"
;;
esac
fi
if [ "$ipv" = "ipv4" ]; then
nft_match="$nft_match ip saddr @$ipset_src"
@@ -1509,7 +1688,7 @@ mwan3_set_user_nft_rule()
# like default_rule (family ipv4, no saddr/daddr) generates a bare
# "meta mark ... jump policy" that matches IPv6 traffic too.
if [ -z "$src_ip" ] && [ -z "$dest_ip" ] && [ -z "$ipset_name" ] && [ -z "$ipset_src" ]; then
if [ $_fam_anchored -eq 0 ]; then
if [ "$family" = "ipv4" ]; then
nft_match="${nft_match:+$nft_match }meta nfproto ipv4"
elif [ "$family" = "ipv6" ]; then
@@ -1549,49 +1728,86 @@ mwan3_set_user_nft_rule()
# bit. The save side mirrors this with per-member "update @set" rules
# guarded on (meta mark & MMX) == <member_mark>.
# The per-rule chain is shared by every mwan3_rules line that
# jumps to it, so it is built exactly once, complete for every
# family whose traffic can reach it. A family=any rule with no
# family-anchoring element serves both families from the single
# line emitted on the ipv4 pass, so that pass builds both
# families' arms; for proto=icmp the ipv6 pass also reaches this
# block and only points its own line at the finished chain. The
# layout is load-bearing: every lookup precedes the single
# fall-through, so a pinned source ORs its member mark into the
# packet before the fall-through can hand it to the policy, and
# the update rules follow.
local _policy_member_marks _entry _m_id _m_mark _setname
local _fam_short _saddr_kw _addr_type
if [ "$ipv" = "ipv4" ]; then
_fam_short="v4"; _saddr_kw="ip saddr"; _addr_type="ipv4_addr"
else
_fam_short="v6"; _saddr_kw="ip6 saddr"; _addr_type="ipv6_addr"
local _fam_short _saddr_kw _addr_type _sticky_families _sfam
_sticky_families="$ipv"
if [ "$family" = "any" ] && [ $_fam_anchored -eq 0 ]; then
if [ "$ipv" = "ipv6" ]; then
_sticky_families=""
elif [ $NO_IPV6 -eq 0 ]; then
_sticky_families="ipv4 ipv6"
fi
fi
mwan3_get_policy_members_for_family "$use_policy" "$ipv"
_sticky_fam_vars() {
if [ "$1" = "ipv4" ]; then
_fam_short="v4"; _saddr_kw="ip saddr"; _addr_type="ipv4_addr"
else
_fam_short="v6"; _saddr_kw="ip6 saddr"; _addr_type="ipv6_addr"
fi
}
# Create sticky rule chain if it doesn't exist yet. The chain was
# already flushed in the preamble of mwan3_set_user_rules, so both
# ipv4 and ipv6 passes can add their rules without interference.
if [ -n "$_sticky_families" ]; then
mwan3_nft_push "add chain inet mwan3 mwan3_rule_$1"
# Create sticky rule chain if it doesn't exist yet. The chain
# was already flushed in the preamble of mwan3_set_user_rules.
# Per-member sticky sets and lookup rules.
mwan3_nft_push "add chain inet mwan3 mwan3_rule_$1"
for _entry in $_policy_member_marks; do
_m_id="${_entry%%:*}"
_m_mark="${_entry##*:}"
_setname="mwan3_sticky_${_fam_short}_${rule}_${_m_id}"
# Per-member sticky sets and lookup rules, every served
# family's lookups ahead of the fall-through.
$NFT list set inet mwan3 "$_setname" &>/dev/null || \
mwan3_nft_push "add set inet mwan3 $_setname { type ${_addr_type}; flags timeout; timeout ${timeout}s; }"
for _sfam in $_sticky_families; do
_sticky_fam_vars "$_sfam"
mwan3_get_policy_members_for_family "$use_policy" "$_sfam"
mwan3_nft_push "add rule inet mwan3 mwan3_rule_$1 ${_saddr_kw} @${_setname} jump mwan3_or_meta_$(mwan3_or_chain_suffix "$_m_mark")"
done
for _entry in $_policy_member_marks; do
_m_id="${_entry%%:*}"
_m_mark="${_entry##*:}"
_setname="mwan3_sticky_${_fam_short}_${rule}_${_m_id}"
# Fall through to policy for new flows (no sticky entry hit -> mark still 0).
$NFT list set inet mwan3 "$_setname" &>/dev/null || \
mwan3_nft_push "add set inet mwan3 $_setname { type ${_addr_type}; flags timeout; timeout ${timeout}s; }"
mwan3_nft_push "add rule inet mwan3 mwan3_rule_$1 meta mark & $MMX_MASK == 0 jump mwan3_policy_$use_policy"
mwan3_nft_push "add rule inet mwan3 mwan3_rule_$1 ${_saddr_kw} @${_setname} jump mwan3_or_meta_$(mwan3_or_chain_suffix "$_m_mark")"
done
done
# After the policy assigns a mark, populate the matching per-member
# sticky set so subsequent packets from this saddr stay on the same WAN.
# Fall through to policy for new flows (no sticky entry hit ->
# mark still 0).
for _entry in $_policy_member_marks; do
_m_id="${_entry%%:*}"
_m_mark="${_entry##*:}"
_setname="mwan3_sticky_${_fam_short}_${rule}_${_m_id}"
mwan3_nft_push "add rule inet mwan3 mwan3_rule_$1 meta mark & $MMX_MASK == 0 jump mwan3_policy_$use_policy"
mwan3_nft_push "add rule inet mwan3 mwan3_rule_$1 meta mark & $MMX_MASK == $_m_mark update @${_setname} { ${_saddr_kw} timeout ${timeout}s }"
done
# After the policy assigns a mark, populate the matching
# per-member sticky set so subsequent packets from this saddr
# stay on the same WAN.
for _sfam in $_sticky_families; do
_sticky_fam_vars "$_sfam"
mwan3_get_policy_members_for_family "$use_policy" "$_sfam"
for _entry in $_policy_member_marks; do
_m_id="${_entry%%:*}"
_m_mark="${_entry##*:}"
_setname="mwan3_sticky_${_fam_short}_${rule}_${_m_id}"
mwan3_nft_push "add rule inet mwan3 mwan3_rule_$1 meta mark & $MMX_MASK == $_m_mark update @${_setname} { ${_saddr_kw} timeout ${timeout}s }"
done
done
fi
policy_action="jump mwan3_rule_$1"
fi
@@ -1638,14 +1854,21 @@ mwan3_set_user_iface_rules()
mwan3_set_user_rules()
{
local ipv
# Record of the external sets this render has pre-created into the
# batch, as space-delimited "name=addrtype" tokens, read and appended
# to by mwan3_set_user_nft_rule through dynamic scoping. Kernel
# queries inside an open batch see pre-batch state, so the renderer
# carries its own shadow of what the batch already holds. Reset per
# render, so repeated renders emit identical batches.
local MWAN3_RENDER_SETS=""
mwan3_nft_batch_start
mwan3_nft_push "flush chain inet mwan3 mwan3_rules"
# Pre-create and flush per-rule chains for all enabled UCI rules, before
# the per-family loop. Sourcing names from UCI (not the kernel) prevents
# the render pass. Sourcing names from UCI (not the kernel) prevents
# chains for deleted rules from being recreated after reload. "add chain"
# is idempotent: it recreates a chain deleted by mwan3_nft_reload_start or
# is a no-op if the chain already exists (hotplug path).
@@ -1659,10 +1882,22 @@ mwan3_set_user_rules()
}
config_foreach _init_rule_chain rule
for ipv in ipv4 ipv6; do
[ "$ipv" = "ipv6" ] && [ $NO_IPV6 -ne 0 ] && continue
config_foreach mwan3_set_user_nft_rule rule "$ipv"
done
# Render the rules in one pass over the config, in config order,
# emitting each rule's IPv4 and IPv6 variants at that rule's position.
# The chain is evaluated first-match, so a rule's position in the chain
# must equal its position in the config; rendering per family instead
# would place every IPv6-specific rule after every IPv4-pass rule,
# letting a family-agnostic rule shadow IPv6 rules the config places
# before it.
_render_rule() {
local _rr_ipv
for _rr_ipv in ipv4 ipv6; do
[ "$_rr_ipv" = "ipv6" ] && [ $NO_IPV6 -ne 0 ] && continue
mwan3_set_user_nft_rule "$1" "$_rr_ipv"
done
}
config_foreach _render_rule rule
mwan3_nft_batch_commit
}
@@ -2018,12 +2253,13 @@ mwan3_flush_conntrack()
config_list_foreach "$interface" flush_conntrack handle_flush "$action"
fi
# On ifdown, selectively flush conntrack entries for this interface's mark.
# This forces flows that were using the failed WAN to immediately re-establish
# via the new policy rather than waiting for a TCP retransmit timeout.
# More targeted than the UCI flush_conntrack mechanism which flushes everything.
# On a teardown transition (a hard ifdown or a soft tracker disconnect),
# selectively flush conntrack entries carrying this interface's mark. This
# forces flows that were using the failed WAN to immediately re-establish via
# the new policy rather than waiting for a TCP retransmit timeout. More
# targeted than the UCI flush_conntrack mechanism which flushes everything.
if [ "$action" = "ifdown" ] && [ -e "$CONNTRACK_FILE" ]; then
if { [ "$action" = "ifdown" ] || [ "$action" = "disconnected" ]; } && [ -e "$CONNTRACK_FILE" ]; then
local iface_id iface_mark
mwan3_get_iface_id iface_id "$interface"
if [ -n "$iface_id" ]; then
+128 -71
View File
@@ -87,14 +87,43 @@ let mwan3_cfg = null;
let pub6_seen = {};
let pub4_seen = {};
let lit_alias = {};
// Register one public address. The placeholder map is keyed and numbered on
// normalised forms only; when the literal spelling differs from the
// normalised form it is recorded as an alias so free-form text carrying
// that spelling is still replaced.
function register(addr_str) {
if (!addr_str) return;
let lit = split(addr_str, '/')[0];
let norm = norm_addr(addr_str);
if (!norm) return;
let sa = sock.sockaddr(split(addr_str, '/')[0]);
let sa = sock.sockaddr(lit);
if (sa.family == 10 && is_public_v6(norm)) pub6_seen[norm] = 1;
else if (sa.family == 2 && is_public_v4(norm)) pub4_seen[norm] = 1;
else return;
if (lit != norm) lit_alias[lit] = norm;
}
// Sweep a block of free-form text for address literals. Splitting on any
// run of characters that cannot occur inside an address or prefix keeps
// every address-bearing token intact; a token that is not a valid public
// address fails validation inside register() and drops out. A colon-less
// token must be a full dotted quad: the address parser also accepts
// shorthand forms like "1.0", which free-form text produces as ordinary
// decimal fragments, and registering one would alias that fragment to a
// placeholder everywhere in the report.
function scan_text(text) {
if (!text) return;
for (let tok in split(text, /[^0-9a-fA-F:.\/]+/)) {
if (!length(tok)) continue;
if (index(tok, ':') == -1 &&
!match(tok, /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(\/[0-9]+)?$/))
continue;
register(tok);
}
}
for (let n in nb_all) { register(n.dst); }
@@ -110,57 +139,8 @@ if (mwan3_st?.interfaces)
// Register any addresses appearing in the mwan3 UCI config (e.g. track_ip
// values not yet reflected in routes/status).
if (mwan3_cfg)
for (let line in split(mwan3_cfg, '\n'))
for (let tok in split(replace(line, "'", ' '), ' '))
register(trim(tok));
// ---------------------------------------------------------------------------
// Phase 3: build deterministic placeholder map
// Sort addresses so the same address always gets the same placeholder across
// multiple runs (e.g. v3.4 run vs v3.5.1 run).
// ---------------------------------------------------------------------------
let pub6_list = sort(keys(pub6_seen));
let pub4_list = sort(keys(pub4_seen));
let addr_map = {};
for (let i = 0; i < length(pub6_list); i++)
addr_map[pub6_list[i]] = sprintf('PUB6_%d', i + 1);
for (let i = 0; i < length(pub4_list); i++)
addr_map[pub4_list[i]] = sprintf('PUB4_%d', i + 1);
// ---------------------------------------------------------------------------
// Anonymisation helpers
// ---------------------------------------------------------------------------
// Anonymise a single address string (preserves any /prefix-length suffix).
function anon(addr_str) {
if (!addr_str) return addr_str;
let parts = split(addr_str, '/');
let norm = norm_addr(parts[0]);
let ph = norm ? (addr_map[norm] ?? null) : null;
if (ph) return ph + (length(parts) > 1 ? '/' + parts[1] : '');
return addr_str;
}
// Anonymise a block of free-form text (nft output, log lines, etc.).
// Sorted longest-first so longer addresses are replaced before any shorter
// prefix they might share (prevents partial substitutions).
let _sorted_addrs = sort(keys(addr_map), (a, b) => length(b) - length(a));
function anon_text(text) {
if (!text) return '(no output)';
for (let addr in _sorted_addrs) {
let ph = addr_map[addr];
let pos = 0;
while (true) {
let found = index(text, addr, pos);
if (found == -1) break;
text = substr(text, 0, found) + ph + substr(text, found + length(addr));
pos = found + length(ph);
}
}
return text;
}
scan_text(mwan3_cfg);
// Strip elements from user-defined nft sets, replacing with { ... }.
// mwan3 infrastructure sets (names starting with mwan3_) are left intact:
@@ -197,13 +177,102 @@ function strip_set_elements(text) {
return join('\n', out);
}
// Run a shell command and return its anonymised output.
function pcmd(cmd) {
// Run a shell command and return its raw output.
function rcmd(cmd) {
let f = fs.popen(cmd);
if (!f) return '(failed to run: ' + cmd + ')\n';
let out = f.read('all');
f.close();
return anon_text(out || '(no output)\n');
return out || '(no output)\n';
}
// ---------------------------------------------------------------------------
// Detect which table mwan3 is using (v3.4 = inet fw4, v3.5+ = inet mwan3)
// ---------------------------------------------------------------------------
let mwan3_tbl = 'inet mwan3';
{
let f = fs.popen('nft list chain inet mwan3 mwan3_prerouting 2>/dev/null | head -1');
let l = f?.read('line');
f?.close();
if (!l || !length(trim(l))) mwan3_tbl = 'inet fw4';
}
// ---------------------------------------------------------------------------
// Capture the free-form text sections the report prints and sweep them for
// address literals. Some public addresses exist only in this text, e.g. NAT
// map elements derived from no route or interface address, or logged
// addresses no longer present in live state, and are registered here so
// they receive placeholders. Each section is captured exactly once and the
// captured text itself is printed later, so no literal can appear in the
// printed copy without having been swept. The mwan3 table is stripped of
// user-set elements before the sweep so redacted addresses are not
// assigned placeholders.
// ---------------------------------------------------------------------------
let nft_mwan3_txt = strip_set_elements(rcmd(sprintf('nft list table %s 2>/dev/null', mwan3_tbl)));
let fw4_mangle_pre_txt = rcmd('nft list chain inet fw4 mangle_prerouting 2>/dev/null');
let fw4_mangle_out_txt = rcmd('nft list chain inet fw4 mangle_output 2>/dev/null');
let fw4_output_txt = rcmd('nft list chain inet fw4 output 2>/dev/null');
let log_txt = rcmd('logread 2>/dev/null | grep -E "mwan3track\\[|mwan3-hotplug\\[|mwan3-init\\[|mwan3\\[|mwan3rtmon\\[|mwan3-manage-rules\\[|mwan3-create-iface-route\\[" | tail -200');
scan_text(nft_mwan3_txt);
scan_text(fw4_mangle_pre_txt);
scan_text(fw4_mangle_out_txt);
scan_text(fw4_output_txt);
scan_text(log_txt);
// ---------------------------------------------------------------------------
// Phase 3: build deterministic placeholder map
// Sort addresses so the same address always gets the same placeholder across
// multiple runs (e.g. v3.4 run vs v3.5.1 run).
// ---------------------------------------------------------------------------
let pub6_list = sort(keys(pub6_seen));
let pub4_list = sort(keys(pub4_seen));
let addr_map = {};
for (let i = 0; i < length(pub6_list); i++)
addr_map[pub6_list[i]] = sprintf('PUB6_%d', i + 1);
for (let i = 0; i < length(pub4_list); i++)
addr_map[pub4_list[i]] = sprintf('PUB4_%d', i + 1);
// A literal spelling that normalises to a registered address receives the
// registered form's placeholder, so both spellings are replaced in text.
for (let lit, norm in lit_alias)
if (addr_map[norm]) addr_map[lit] = addr_map[norm];
// ---------------------------------------------------------------------------
// Anonymisation helpers
// ---------------------------------------------------------------------------
// Anonymise a single address string (preserves any /prefix-length suffix).
function anon(addr_str) {
if (!addr_str) return addr_str;
let parts = split(addr_str, '/');
let norm = norm_addr(parts[0]);
let ph = norm ? (addr_map[norm] ?? null) : null;
if (ph) return ph + (length(parts) > 1 ? '/' + parts[1] : '');
return addr_str;
}
// Anonymise a block of free-form text (nft output, log lines, etc.).
// Sorted longest-first so longer addresses are replaced before any shorter
// prefix they might share (prevents partial substitutions).
let _sorted_addrs = sort(keys(addr_map), (a, b) => length(b) - length(a));
function anon_text(text) {
if (!text) return '(no output)';
for (let addr in _sorted_addrs) {
let ph = addr_map[addr];
let pos = 0;
while (true) {
let found = index(text, addr, pos);
if (found == -1) break;
text = substr(text, 0, found) + ph + substr(text, found + length(addr));
pos = found + length(ph);
}
}
return text;
}
// ---------------------------------------------------------------------------
@@ -224,18 +293,6 @@ function tbl_label(n) {
return name ? sprintf('%s (%d)', name, n) : sprintf('%d', n);
}
// ---------------------------------------------------------------------------
// Detect which table mwan3 is using (v3.4 = inet fw4, v3.5+ = inet mwan3)
// ---------------------------------------------------------------------------
let mwan3_tbl = 'inet mwan3';
{
let f = fs.popen('nft list chain inet mwan3 mwan3_prerouting 2>/dev/null | head -1');
let l = f?.read('line');
f?.close();
if (!l || !length(trim(l))) mwan3_tbl = 'inet fw4';
}
// ---------------------------------------------------------------------------
// mwan3 version
// ---------------------------------------------------------------------------
@@ -407,20 +464,20 @@ else
// --- full mwan3 nft table ---
printf('=== mwan3 nft table (%s) ===\n', mwan3_tbl);
printf('%s\n', strip_set_elements(pcmd(sprintf('nft list table %s 2>/dev/null', mwan3_tbl))));
printf('%s\n', anon_text(nft_mwan3_txt));
// --- fw4 mangle chains (relevant to mwan3 mark interactions) ---
printf('=== fw4 mangle_prerouting chain ===\n');
printf('%s\n', pcmd('nft list chain inet fw4 mangle_prerouting 2>/dev/null'));
printf('%s\n', anon_text(fw4_mangle_pre_txt));
printf('=== fw4 mangle_output chain ===\n');
printf('%s\n', pcmd('nft list chain inet fw4 mangle_output 2>/dev/null'));
printf('%s\n', anon_text(fw4_mangle_out_txt));
printf('=== fw4 output chain ===\n');
printf('%s\n', pcmd('nft list chain inet fw4 output 2>/dev/null'));
printf('%s\n', anon_text(fw4_output_txt));
// --- recent mwan3 log ---
printf('=== Recent mwan3 Log (last 200 lines) ===\n');
printf('%s\n', pcmd('logread 2>/dev/null | grep -E "mwan3track\\[|mwan3-hotplug\\[|mwan3-init\\[|mwan3\\[" | tail -200'));
printf('%s\n', anon_text(log_txt));
printf('=== End of Report ===\n');
+178 -7
View File
@@ -5,7 +5,8 @@ import * as rtnl from "rtnl";
import * as uloop from "uloop";
import * as uci from "uci";
import * as ubus from "ubus";
import { open, readfile, popen, stat } from "fs";
import { open, readfile, popen, stat, mkdir, rename, unlink } from "fs";
import { log_open, log_verbose, log_msg as mlog, ucibool, is_default_equivalent } from 'mwan3.common';
const RTM_NEWROUTE = rtnl.const.RTM_NEWROUTE;
const RTM_DELROUTE = rtnl.const.RTM_DELROUTE;
@@ -32,6 +33,7 @@ let iface_list = []; // [ { name, family, tid, enabled, has_tracking } ]
let active_chains = {}; // { chain_name: true } cached from nft
let connected_timer = null; // debounce timer for connected set rebuilds
let last_connected_fingerprint = ""; // sorted element list from last populate call
let route_state_timer = null; // debounce timer for ROUTE_STATUS refresh
let ubus_conn;
// ---------------------------------------------------------------------------
@@ -40,7 +42,7 @@ let ubus_conn;
function log_msg(level, msg) {
warn(`mwan3rtmon[${family_name}] ${level}: ${msg}\n`);
mlog(level, `[${family_name}] ${msg}`);
}
// ---------------------------------------------------------------------------
@@ -52,6 +54,10 @@ function load_config() {
let cur = uci.cursor();
cur.load("mwan3");
// Verbose logging gate
log_verbose(ucibool(cur.get("mwan3", "globals", "verbose_logging") ?? "0"));
// MMX mask
mmx_mask = cur.get("mwan3", "globals", "mmx_mask") ?? "0x3F00";
@@ -219,9 +225,17 @@ function nft_exec(cmd) {
return true;
}
// Run a list of nft commands as a single nft -f transaction. The batch
// file is per-family: one mwan3rtmon instance runs per address family
// and each instance is single-threaded, so the family suffix alone
// keeps the two instances' batch files apart without locking. The file
// is removed on every exit path after creation. nft runs through a
// read-mode popen with stderr folded into stdout so the failure log
// carries nft's own error text.
function nft_batch(cmds) {
let tmpfile = "/var/run/mwan3/mwan3rtmon_nft_batch";
let tmpfile = "/var/run/mwan3/mwan3rtmon_nft_batch." + family_name;
let f = open(tmpfile, "w", 0600);
if (!f) {
log_msg("warn", "cannot open nft batch file");
@@ -232,10 +246,13 @@ function nft_batch(cmds) {
f.close();
let p = popen("nft -f " + tmpfile + " 2>&1");
if (!p)
if (!p) {
unlink(tmpfile);
return false;
}
let out = p.read("all");
let rc = p.close();
unlink(tmpfile);
if (rc != 0) {
log_msg("warn", "nft batch failed: " + (out ?? ""));
return false;
@@ -323,6 +340,126 @@ function is_linklocal_route(route) {
match(route.dst, /^169\.254\./) != null));
}
// Does this interface's own routing table hold a usable default route?
//
// This is the datapath truth: the interface's fwmark ip rule looks up
// exactly this table, so an interface whose table N holds no default route
// black-holes the traffic mwan3 marks to it, even when the main table still
// has a default via the same device. This mirrors the predicate the rpcd
// backend uses for has_default and the one mwan3-manage-rules.uc check-route
// uses. 'routes' is a single full route dump for this family (all tables).
function table_has_default(routes, tid, dev) {
for (let r in routes)
if (r.table == tid && is_default_route(r) && r.oif == dev)
return true;
return false;
}
// The interfaces this rtmon instance is responsible for: enabled, tracked,
// of this family, and currently bound to an l3 device. iface_list is built
// by load_config() and already carries name, family, tid, enabled, and
// has_tracking. The device comes from the same ubus network.interface dump
// build_dev_table_map() uses. An interface with no l3_device yet is skipped;
// it gets evaluated later when its device appears and routes change.
function tracked_interfaces() {
let ifaces = [];
if (!ubus_conn)
return ifaces;
let dump = ubus_conn.call("network.interface", "dump");
if (!dump || !dump.interface)
return ifaces;
let dev_by_name = {};
for (let intf in dump.interface)
if (intf.l3_device)
dev_by_name[intf.interface] = intf.l3_device;
for (let iface in iface_list) {
if (iface.family != family_name || !iface.enabled || !iface.has_tracking)
continue;
let dev = dev_by_name[iface.name];
if (dev == null)
continue;
push(ifaces, { name: iface.name, tid: iface.tid, dev: dev });
}
return ifaces;
}
// Write the interface's ROUTE_STATUS file, only when the value changes, and
// log the transition. The write is atomic (temp file + rename within the
// same tmpfs directory) so the tracker never reads a torn value. The
// tracker creates these directories, but rtmon may evaluate an interface
// before its tracker has, so mkdir best-effort first; it fails harmlessly
// if the directory already exists.
function write_route_state(ifname, state) {
let path = MWAN3TRACK_STATUS_DIR + "/" + ifname + "/ROUTE_STATUS";
let cur = trim(readfile(path) ?? "");
if (cur == state)
return;
mkdir(MWAN3TRACK_STATUS_DIR, 0700);
mkdir(MWAN3TRACK_STATUS_DIR + "/" + ifname, 0755);
let tmp = path + ".tmp";
let f = open(tmp, "w", 0644);
if (!f) {
log_msg("warn", "cannot open ROUTE_STATUS for " + ifname);
return;
}
f.write(state + "\n");
f.close();
if (!rename(tmp, path)) {
unlink(tmp);
log_msg("warn", "cannot commit ROUTE_STATUS for " + ifname);
return;
}
// A missing default route is abnormal, not routine failover, so log the
// loss at warn and the return to normal at notice.
log_msg(state == "down" ? "warn" : "notice", ifname + " default route " + state);
}
// Recompute and persist every responsible interface's route state from one
// route dump. The dump spans all tables for this family; table_has_default
// filters to each interface's own table. Called at startup, on SIGHUP, and
// debounced from the route-event handler.
function refresh_route_states() {
let ifaces = tracked_interfaces();
if (length(ifaces) == 0)
return;
let routes = rtnl.request(RTM_GETROUTE, NLM_F_DUMP, { family: family_num }) ?? [];
for (let iface in ifaces)
write_route_state(iface.name,
table_has_default(routes, iface.tid, iface.dev) ? "up" : "down");
}
// Arm the debounced refresh. Bursts of route events (PD renewal, reconnects,
// the boot-time route flood) collapse into a single sweep, exactly as the
// connected-set rebuild is debounced.
function arm_route_state_refresh() {
if (!route_state_timer)
route_state_timer = uloop.timer(DEBOUNCE_MS, () => {
refresh_route_states();
route_state_timer = null;
});
else
route_state_timer.set(DEBOUNCE_MS);
}
// Get all routes from main table (and extra lookup tables) via netlink
function get_all_routes() {
@@ -370,8 +507,18 @@ function populate_connected_set() {
let seen = {};
let elements = [];
// Main-table routes only. get_all_routes() also returns the extra lookup
// tables for the per-interface mirroring, but their routes belong to
// mwan3_custom_v4/v6, which grants the identical bypass and is maintained
// on those tables' own route events. Sweeping them in here as well would
// strand them: extra-table events never arm this rebuild, so a route
// deleted from its table would stay in the connected set indefinitely.
for (let route in routes) {
if (is_cidr_route(route) && !is_default_route(route) && !is_linklocal_route(route) && !seen[route.dst]) {
if (route.table != RT_TABLE_MAIN)
continue;
if (is_cidr_route(route) && !is_default_equivalent(route) && !is_linklocal_route(route) && !seen[route.dst]) {
push(elements, route.dst);
seen[route.dst] = true;
}
@@ -456,7 +603,7 @@ function repopulate_custom_sets() {
if (!extra_table_set[r.table])
continue;
let dst = r.dst;
if (dst == null || is_default_route(r) || is_linklocal_route(r))
if (dst == null || is_default_equivalent(r) || is_linklocal_route(r))
continue;
if (!seen[dst]) {
push(cmds, "add element inet mwan3 " + setname + " { " + dst + " }");
@@ -478,7 +625,7 @@ function repopulate_custom_sets() {
function handle_custom_set_event(route, is_new) {
let dst = route.dst;
if (dst == null || is_default_route(route) || is_linklocal_route(route))
if (dst == null || is_default_equivalent(route) || is_linklocal_route(route))
return;
let setname = (family_num == AF_INET) ? "mwan3_custom_v4" : "mwan3_custom_v6";
@@ -536,6 +683,10 @@ function handle_route_event(event) {
connected_timer.set(DEBOUNCE_MS);
}
// Re-evaluate every interface's default-route state on any routing change.
arm_route_state_refresh();
// -- Update per-interface routing tables --
build_dev_table_map();
@@ -594,6 +745,7 @@ function main() {
// Parse args
family_name = ARGV[0] ?? "ipv4";
log_open("mwan3rtmon");
if (family_name == "ipv6") {
if (stat("/proc/sys/net/ipv6") == null) {
@@ -610,6 +762,7 @@ function main() {
}
load_config();
log_msg("notice", "started");
// Connect to ubus
@@ -653,6 +806,9 @@ function main() {
log_msg("debug", "populating initial per-interface routes");
populate_iface_routes();
log_msg("debug", "initial route-state sweep");
refresh_route_states();
// Signal handlers for clean shutdown
uloop.signal("SIGTERM", () => {
@@ -669,8 +825,23 @@ function main() {
log_msg("debug", "received SIGHUP, reloading config");
load_config();
repopulate_custom_sets();
refresh_route_states();
});
// Slow periodic re-sweep as defense in depth: bounds recovery if a
// route-state change ever arrives without an accompanying main-table
// route event (a coalesced or missed netlink multicast). It is cheap
// and silent when nothing moves, since write_route_state only writes
// on an actual change.
function schedule_periodic_route_sweep() {
uloop.timer(20000, () => {
refresh_route_states();
schedule_periodic_route_sweep();
});
}
schedule_periodic_route_sweep();
log_msg("debug", "entering event loop");
uloop.run();
+22 -2
View File
@@ -299,7 +299,7 @@ main() {
trap config_reload HUP
load_tracking_config
local sleep_time result ping_status loss latency
local sleep_time result ping_status loss latency route_state route_state_prev probe_ips
mwan3_list_track_ips()
{
track_ips="$track_ips $1"
@@ -352,6 +352,7 @@ main() {
local lost=0
local turn=0
local failed_hosts=""
route_state_prev=""
firstconnect
while true; do
@@ -391,7 +392,26 @@ main() {
continue
fi
sleep_time=$interval
for track_ip in $track_ips; do
# An interface can be reachable to its tracking target yet hold no
# usable default route in its own table, in which case mwan3 marks
# traffic to it and the per-interface backstop drops it. mwan3rtmon
# writes the interface's default-route state to ROUTE_STATUS; treat
# "down" as a failed round and skip probing so the existing score
# machinery carries the interface offline, and back online when the
# route returns. An absent file means no route monitor has evaluated
# this interface, so probe exactly as before.
route_state=$(cat "$MWAN3TRACK_STATUS_DIR/$INTERFACE/ROUTE_STATUS" 2>/dev/null)
probe_ips=$track_ips
if [ "$route_state" = "down" ]; then
probe_ips=""
[ "$route_state_prev" != "down" ] && LOG warn "Interface $INTERFACE ($DEVICE) has no usable default route in its table; treating tracking round as failed"
elif [ "$route_state_prev" = "down" ]; then
LOG notice "Interface $INTERFACE ($DEVICE) default route restored"
fi
route_state_prev=$route_state
for track_ip in $probe_ips; do
if [ $host_up_count -lt $reliability ]; then
case "$track_method" in
ping)
+65 -7
View File
@@ -75,6 +75,16 @@ function parse_elem_val(v) {
return null;
}
// Allowlist for nft set elements written back to the kernel: IPv4 and IPv6
// addresses, CIDR prefixes and hyphen-written ranges only, which is exactly
// what the apply path emits. These values are interpolated into an nft add
// element command line built with popen, so anything carrying a character
// outside this class is dropped before it can break out of the element list.
function valid_nft_element(v) {
return match(v, /^[0-9A-Fa-f:.\/-]+$/) != null;
}
function count_nftset_elements(setname) {
if (!match(setname, /^[a-zA-Z0-9_-]+$/))
return 0;
@@ -216,6 +226,14 @@ function get_table_routes(table_id) {
}
function nslookup_resolve(host, type) {
// Reject any host outside the hostname character class before it is
// interpolated into the /bin/sh -c command line below. This is the single
// chokepoint for both resolve_host and the nftset_resolve domain path,
// whose UCI-supplied domain values are otherwise unvalidated.
if (!match(host, /^[a-zA-Z0-9._-]+$/))
return [];
const p = popen(sprintf('/bin/busybox nslookup -type=%s %s 127.0.0.1 2>/dev/null', type, host), 'r');
if (!p) return [];
const raw = p.read('all');
@@ -233,7 +251,6 @@ function nslookup_resolve(host, type) {
function routing_health() {
const uci = cursor();
const mmx_mask = int(uci.get('mwan3', 'globals', 'mmx_mask')) || 0x3F00;
let bitcnt = 0;
for (let m = mmx_mask; m; m >>= 1) bitcnt += m & 1;
@@ -250,34 +267,47 @@ function routing_health() {
UNREACH_BASE = 3000;
}
const SRC_BASE = UNREACH_BASE + iface_max + 1;
// Build ordered interface list with 1-based index (same ordering mwan3 uses)
const iface_order = [];
const iface_index = {};
const iface_family = {};
uci.foreach('mwan3', 'interface', intf => {
push(iface_order, intf['.name']);
iface_index[intf['.name']] = length(iface_order);
iface_family[intf['.name']] = intf['family'] || 'ipv4';
});
// Fetch all ip rules and index by priority for O(1) lookup
const all_rules = get_ip_rules();
const rule_by_priority = {};
const v4_rule_by_priority = {};
for (let rule in all_rules) {
if (rule?.priority != null)
if (rule?.priority != null) {
rule_by_priority[rule.priority] = rule;
if (rule.family == 2)
v4_rule_by_priority[rule.priority] = rule;
}
}
// Pre-build the set of priorities that correspond to current UCI interfaces
const valid_priorities = {};
for (let ifname in iface_order) {
const id = iface_index[ifname];
valid_priorities[IIF_BASE + id] = true;
valid_priorities[FWMARK_BASE + id] = true;
valid_priorities[UNREACH_BASE + id] = true;
valid_priorities[UNREACH_BASE + iface_max + 1 + id] = true;
}
valid_priorities[FWMARK_BASE + mmdefault - 2] = true;
valid_priorities[FWMARK_BASE + mmdefault - 1] = true;
// Per-interface health report
const result = {};
for (let ifname in iface_order) {
const id = iface_index[ifname];
@@ -319,17 +349,38 @@ function routing_health() {
})),
},
};
// The source band is IPv4-only, so test presence against the IPv4
// rule index alone. A device holds one source rule per global IPv4
// address, all sharing this priority, so a populated slot means the
// band exists for this interface, not that every address is covered.
if (iface_family[ifname] == 'ipv4') {
result[ifname].src_rule = {
present: v4_rule_by_priority[SRC_BASE + id] != null,
priority: SRC_BASE + id,
};
}
}
// Stale rules: priorities in mwan3 ranges but not matching any current UCI interface
const stale = [];
for (let rule in all_rules) {
const p = rule?.priority;
if (p == null) continue;
// The iif and fwmark windows are mmdefault wide (the fwmark band
// carries the global blackhole and unreachable rules above
// iface_max). The unreachable band holds only ids 1..iface_max;
// the slot above it is unused and the source band follows at
// iface_max + 1 + id.
const in_iif_range = (p > IIF_BASE && p <= IIF_BASE + mmdefault);
const in_fwmark_range = (p > FWMARK_BASE && p <= FWMARK_BASE + mmdefault);
const in_unreach_range = (p > UNREACH_BASE && p <= UNREACH_BASE + mmdefault);
if ((in_iif_range || in_fwmark_range || in_unreach_range) && !valid_priorities[p])
const in_unreach_range = (p > UNREACH_BASE && p <= UNREACH_BASE + iface_max);
const in_src_range = (p > UNREACH_BASE + iface_max + 1 && p <= UNREACH_BASE + 2 * iface_max + 1);
if ((in_iif_range || in_fwmark_range || in_unreach_range || in_src_range) && !valid_priorities[p])
push(stale, { priority: p });
}
@@ -339,7 +390,7 @@ function routing_health() {
interfaces: result,
stale_rules: stale,
mwan3_active: mwan3_active,
rule_bases: { iif: IIF_BASE, fwmark: FWMARK_BASE, unreach: UNREACH_BASE },
rule_bases: { iif: IIF_BASE, fwmark: FWMARK_BASE, unreach: UNREACH_BASE, src: SRC_BASE },
};
}
@@ -391,12 +442,14 @@ function get_policies(version) {
const family_filter = (version == '6') ? 'ipv6' : 'ipv4';
// Build interface family map: iface_name -> 'ipv4'/'ipv6'
const iface_family = {};
uci.foreach('mwan3', 'interface', intf => {
iface_family[intf['.name']] = intf['family'] || 'ipv4';
});
// Build member info map: member_name -> { interface, metric, weight }
const member_info = {};
uci.foreach('mwan3', 'member', m => {
member_info[m['.name']] = {
@@ -415,6 +468,7 @@ function get_policies(version) {
if (type(use_members) != 'array') use_members = [use_members];
// Collect members matching the requested address family
const by_metric = {};
for (let mname in use_members) {
const m = member_info[mname];
@@ -431,6 +485,7 @@ function get_policies(version) {
if (!length(keys(by_metric))) return;
// Find the lowest metric with at least one online member
const sorted_metrics = sort(keys(by_metric), (a, b) => int(a) - int(b));
let active_metric = null;
for (let metric in sorted_metrics) {
@@ -444,6 +499,7 @@ function get_policies(version) {
}
// Sum weights of online members at the active metric for percent calculation
let total_weight = 0;
if (active_metric != null) {
for (let m in by_metric[active_metric]) {
@@ -498,10 +554,12 @@ function interfaces_status(request) {
'packetloss': get_int(ifname, sprintf('LOSS_%s', ip)),
});
}
// Derive check_quality from LATENCY file content rather than UCI.
// UCI may be stale if check_quality was changed without restarting mwan3track.
// mwan3track only writes LATENCY_* files when check_quality=1, so file
// presence with content is the authoritative indicator of actual state.
let check_quality = false;
for (let tip in track_ips) {
const lat = get_str_raw(ifname, sprintf('LATENCY_%s', tip.ip));
@@ -694,7 +752,7 @@ const methods = {
if (type(entries) != 'array') entries = [entries];
for (let e in entries) {
const ev = ltrim(rtrim(e, ' \t\r\n'), ' \t\r\n');
if (length(ev) > 0) push(elements, ev);
if (length(ev) > 0 && valid_nft_element(ev)) push(elements, ev);
}
if (cfg.loadfile) {
const content = readfile(cfg.loadfile);
@@ -703,7 +761,7 @@ const methods = {
const ci = index(line, '#');
if (ci >= 0) line = substr(line, 0, ci);
line = ltrim(rtrim(line, ' \t\r\n'), ' \t\r\n');
if (length(line) > 0) push(elements, line);
if (length(line) > 0 && valid_nft_element(line)) push(elements, line);
}
}
}
@@ -0,0 +1,107 @@
'use strict';
// common.uc: the shared mwan3 ucode library, the ucode analogue of common.sh.
// Each shared ucode routine is defined here so it lives, and is tested, in one
// place.
import { openlog, syslog, LOG_PID, LOG_DAEMON } from 'log';
// ---- Logging ---------------------------------------------------------------
//
// All mwan3 ucode logs through these functions so the sink, tag format and
// verbose gate live in one place. Output goes to syslog, the same destination
// logger(1) reaches, so it appears in logread regardless of how procd handles
// the process stderr.
let verbose = false;
// Map the level names mwan3 uses (the shell's warn/error vocabulary and the
// canonical syslog names) onto the exact tokens parse_priority() accepts. It
// rejects "warn" and "error", so an unmapped name would be dropped; an unknown
// level falls back to notice so a mislabelled message still logs.
const PRIO = {
emerg: "emerg", alert: "alert", crit: "crit",
err: "err", error: "err",
warn: "warning", warning: "warning",
notice: "notice", info: "info", debug: "debug",
};
// Open the syslog connection with the given identity. LOG_PID appends the pid
// so the tag reads ident[pid], matching the shell's "${SCRIPTNAME}[$$]"
// convention. Facility daemon suits these long-running and service-invoked
// components.
function log_open(ident) {
openlog(ident, LOG_PID, LOG_DAEMON);
}
// Set the verbose gate. The caller reads mwan3.globals.verbose_logging and
// pushes the parsed bool here; debug is suppressed unless verbose, exactly as
// common.sh:LOG() gates the debug facility behind MWAN3_VERBOSE_LOGGING.
function log_verbose(enable) {
verbose = enable;
}
// Emit one message. debug is dropped unless verbose; every other level always
// logs. Message content is passed as a %s argument so it is never treated as a
// format string.
function log_msg(level, msg) {
if (level == "debug" && !verbose)
return;
syslog(PRIO[level] ?? "notice", "%s", msg);
}
// ---- Config helpers --------------------------------------------------------
// Parse a UCI bool the way the shell's config_get_bool does: 1/on/true/yes/
// enabled are true, everything else is false. Kept here so every ucode
// consumer interprets a UCI bool identically to the shell. It reads no config
// itself, so it does not couple the module to uci.
function ucibool(val) {
switch (val) {
case 'yes':
case 'on':
case 'true':
case 'enabled':
return true;
default:
return !!int(val);
}
}
// ---- Route classification --------------------------------------------------
// The width classifier for the bypass sweeps: a route is default-equivalent
// when its destination is absent (a literal default) or its prefix is wide
// enough that it can only exist as a component of an in-substance default
// route - /2 or wider for IPv4, /3 or wider for IPv6, which covers the
// split-half pairs VPN clients install and the 2000::/3 global-unicast
// aggregate. Each route is judged alone, so a half-installed pair is still
// excluded. The family comes from iptoarr, the inet_pton wrapper, so no
// address text is hand-parsed; a destination it cannot parse is not
// default-equivalent. The strict literal-default predicate the mirroring and
// route-state paths use is deliberately not widened and lives on beside this.
function is_default_equivalent(route) {
let dst = route.dst;
if (dst == null)
return true;
let slash = index(dst, "/");
if (slash < 0)
return false;
let a = iptoarr(substr(dst, 0, slash));
if (a == null)
return false;
return +substr(dst, slash + 1) <= (length(a) == 16 ? 3 : 2);
}
// ---- Exports ---------------------------------------------------------------
// The module's public interface. Declared as an export list rather than inline
// on each function, which this ucode build does not accept.
export { log_open, log_verbose, log_msg, ucibool, is_default_equivalent };
File diff suppressed because it is too large. Load diff