mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-13 20:04:45 +08:00
914 lines
36 KiB
Lua
Executable File
914 lines
36 KiB
Lua
Executable File
#!/usr/bin/env lua
|
|
|
|
-- Copyright 2018-2023 Ycarus (Yannick Chabanois) <ycarus@zugaina.org>
|
|
-- Licensed to the public under the Apache License 2.0.
|
|
--
|
|
-- rpcd script exposing MPTCP bandwidth and diagnostic data as UBUS/RPC methods,
|
|
-- replacing the legacy Lua HTTP controller handlers.
|
|
|
|
local jsonc = require "luci.jsonc"
|
|
local uci = require "luci.model.uci".cursor()
|
|
|
|
-- -------------------------------------------------------------------------
|
|
-- Helpers
|
|
-- -------------------------------------------------------------------------
|
|
|
|
local function get_device(interface)
|
|
if not interface or interface == "" then return "" end
|
|
local dump = require("luci.util").ubus("network.interface." .. interface, "status", {})
|
|
if dump and dump["l3_device"] then
|
|
return dump["l3_device"]
|
|
end
|
|
return ""
|
|
end
|
|
|
|
local function exec_output(cmd)
|
|
local lines = {}
|
|
local f = io.popen(cmd)
|
|
if f then
|
|
for line in f:lines() do
|
|
lines[#lines + 1] = line
|
|
end
|
|
f:close()
|
|
end
|
|
return table.concat(lines, "\n")
|
|
end
|
|
|
|
-- Returns an array of [ts, rx_bytes, rx_pkts, tx_bytes, tx_pkts] rows from luci-bwc.
|
|
-- luci-bwc outputs one "[ ts, rx, rxp, tx, txp ]," entry per line (no outer brackets).
|
|
-- The trailing comma is determined by file position, not by whether the next entry is
|
|
-- non-zero: when the circular buffer is partially filled the last valid row still gets
|
|
-- a trailing comma. Strip it before wrapping with "[...]" to produce valid JSON.
|
|
local function bwc_rows(dev)
|
|
local f = io.popen(string.format("luci-bwc -i %q 2>/dev/null", dev))
|
|
if not f then return {} end
|
|
local output = f:read("*all")
|
|
f:close()
|
|
if not output or output:match("^%s*$") then return {} end
|
|
output = output:gsub(",%s*$", "")
|
|
local ok, rows = pcall(jsonc.parse, "[" .. output .. "]")
|
|
if ok and type(rows) == "table" then return rows end
|
|
return {}
|
|
end
|
|
|
|
local IP_BIN = "/sbin/ip"
|
|
|
|
local function trim(s)
|
|
return (tostring(s or ""):gsub("^%s+", ""):gsub("%s+$", ""))
|
|
end
|
|
|
|
local function read_proc(path)
|
|
local f = io.open(path, "r")
|
|
if not f then return nil end
|
|
local v = f:read("*l")
|
|
f:close()
|
|
if v == nil then return nil end
|
|
return trim(v)
|
|
end
|
|
|
|
-- First field of /proc/uptime (seconds since boot), used to turn raw
|
|
-- cumulative MPTcpExt counters into an "events/hour since boot" rate --
|
|
-- a bare cumulative count (e.g. "7021 resets") doesn't say whether that's
|
|
-- alarming or a trickle, since it doesn't say over what timespan.
|
|
local function uptime_seconds()
|
|
local raw = read_proc("/proc/uptime")
|
|
local secs = raw and raw:match("^(%S+)")
|
|
return secs and tonumber(secs) or nil
|
|
end
|
|
|
|
-- -------------------------------------------------------------------------
|
|
-- MPTCP Diagnostics: explain *why* MPTCP might not be aggregating WANs
|
|
-- correctly -- kernel-level MPTCP counters (fallback to plain TCP,
|
|
-- blackhole/stale-subflow detection, resets, checksum errors, JOIN
|
|
-- failures, ...), the live endpoint/limits state, and a per-WAN cross-check
|
|
-- of whether each multipath-enabled interface actually has a registered
|
|
-- MPTCP endpoint -- turned into a plain-English issue list.
|
|
--
|
|
-- All the underlying commands (multipath -k/-m/-c, ip mptcp ...) are
|
|
-- read-only/local and near-instant, so none of them are timeout-wrapped.
|
|
-- NOTE: never wrap "ip" in "timeout" here -- on this router's busybox
|
|
-- userland, "timeout N ip ..." has been seen to silently dispatch
|
|
-- busybox's own crippled "ip" applet instead of the real /sbin/ip, which
|
|
-- has no "mptcp" subcommand at all. Always call /sbin/ip directly.
|
|
-- -------------------------------------------------------------------------
|
|
|
|
-- Kernel MPTCP sysctl snapshot (works across the legacy out-of-tree sysctl
|
|
-- layout AND the current in-kernel net.mptcp.* layout -- whichever files
|
|
-- actually exist on this kernel).
|
|
function kernel_settings()
|
|
local settings = {}
|
|
settings.enabled = read_proc("/proc/sys/net/mptcp/mptcp_enabled")
|
|
or read_proc("/proc/sys/net/mptcp/enabled")
|
|
settings.checksum = read_proc("/proc/sys/net/mptcp/mptcp_checksum")
|
|
or read_proc("/proc/sys/net/mptcp/checksum_enabled")
|
|
settings.path_manager = read_proc("/proc/sys/net/mptcp/mptcp_path_manager")
|
|
or read_proc("/proc/sys/net/mptcp/pm_type")
|
|
settings.scheduler = read_proc("/proc/sys/net/mptcp/mptcp_scheduler")
|
|
or read_proc("/proc/sys/net/mptcp/scheduler")
|
|
settings.syn_retries = read_proc("/proc/sys/net/mptcp/mptcp_syn_retries")
|
|
settings.stale_loss_cnt = read_proc("/proc/sys/net/mptcp/stale_loss_cnt")
|
|
settings.add_addr_timeout = read_proc("/proc/sys/net/mptcp/add_addr_timeout")
|
|
settings.close_timeout = read_proc("/proc/sys/net/mptcp/close_timeout")
|
|
settings.blackhole_timeout = read_proc("/proc/sys/net/mptcp/blackhole_timeout")
|
|
settings.allow_join_initial_addr_port = read_proc("/proc/sys/net/mptcp/allow_join_initial_addr_port")
|
|
return settings
|
|
end
|
|
|
|
-- The kernel's MPTcpExt* MIB counters, either as the "nstat -z" layout
|
|
-- (name, value, rate -- one counter per line, what "multipath -m" prints)
|
|
-- or the SNMP-style two-line layout (a header line of field names followed
|
|
-- by a value line, both prefixed "MPTcpExt:", found in /proc/net/netstat
|
|
-- and the legacy /proc/net/mptcp_net/snmp). Parse both.
|
|
function parse_counters(raw)
|
|
local counters = {}
|
|
local header
|
|
|
|
for line in (raw or ""):gmatch("[^\n]+") do
|
|
-- The nstat-style counter name always continues with a letter right
|
|
-- after the "MPTcpExt" prefix (e.g. "MPTcpExtMPCapableSYNTX"); the
|
|
-- SNMP-style header/value lines start "MPTcpExt:" instead. Requiring
|
|
-- a letter here keeps the two forms from being confused with each
|
|
-- other (a bare "MPTcpExt:" line's second "word" can itself be a
|
|
-- number, which would otherwise wrongly match as a counter).
|
|
local name, value = line:match("^%s*(MPTcpExt%a%w*)%s+(%-?%d+)")
|
|
if name and value then
|
|
counters[name:gsub("^MPTcpExt", "")] = tonumber(value)
|
|
elseif line:match("^MPTcpExt:") then
|
|
-- SNMP-style: first "MPTcpExt:" line is field names, the next is values
|
|
if not header then
|
|
header = {}
|
|
for field in line:gmatch("%S+") do header[#header + 1] = field end
|
|
else
|
|
local i = 0
|
|
for field in line:gmatch("%S+") do
|
|
i = i + 1
|
|
local key = header[i]
|
|
if key and key ~= "MPTcpExt:" then
|
|
counters[key] = tonumber(field)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return counters
|
|
end
|
|
|
|
-- Where to read the MPTcpExt* counters from for Diagnostics. Deliberately
|
|
-- NOT "multipath -m" here: that wraps "nstat -z", and nstat (without -a)
|
|
-- reports the delta since the *previous* nstat invocation by anyone on the
|
|
-- box (it keeps a shared per-uid history file) -- confirmed live: two
|
|
-- consecutive calls a couple seconds apart came back ~0, while "nstat -a -z"
|
|
-- (ignore history) matched /proc/net/netstat exactly. A page that polls
|
|
-- every 15s and calls that would show "since last poll" numbers while
|
|
-- labelling them "since boot", and would race with anything else on the
|
|
-- router that also calls nstat. Reading the kernel's own procfs SNMP-style
|
|
-- counters directly sidesteps all of that -- always the true cumulative
|
|
-- count, no shared state with other callers.
|
|
function mptcp_counters_raw()
|
|
local raw = exec_output("cat /proc/net/mptcp_net/snmp 2>/dev/null")
|
|
if raw == "" then
|
|
raw = exec_output("grep '^MPTcpExt:' /proc/net/netstat 2>/dev/null")
|
|
end
|
|
return raw
|
|
end
|
|
|
|
-- "ip mptcp endpoint show": one line per registered endpoint, e.g.
|
|
-- 10.0.0.2 id 1 subflow dev eth0
|
|
-- 88.1.2.3 id 2 signal dev wwan0
|
|
-- 10.0.0.5 id 3 subflow backup fullmesh dev eth1
|
|
function parse_endpoints(raw)
|
|
local endpoints = {}
|
|
for line in (raw or ""):gmatch("[^\n]+") do
|
|
local id = line:match("id%s+(%d+)")
|
|
local dev = line:match("dev%s+(%S+)")
|
|
local addr = line:match("^%s*(%S+)")
|
|
if id and addr and addr ~= "id" then
|
|
endpoints[#endpoints + 1] = {
|
|
id = tonumber(id),
|
|
address = addr,
|
|
dev = dev or "",
|
|
signal = line:match("%f[%a]signal%f[%A]") ~= nil,
|
|
subflow = line:match("%f[%a]subflow%f[%A]") ~= nil,
|
|
backup = line:match("%f[%a]backup%f[%A]") ~= nil,
|
|
fullmesh = line:match("%f[%a]fullmesh%f[%A]") ~= nil,
|
|
}
|
|
end
|
|
end
|
|
return endpoints
|
|
end
|
|
|
|
-- "ip mptcp limits show" -> "add_addr_accepted 8 subflows 8"
|
|
function parse_limits(raw)
|
|
local limits = {}
|
|
for key, value in (raw or ""):gmatch("(%a[%w_]*)%s+(%d+)") do
|
|
limits[key] = tonumber(value)
|
|
end
|
|
return limits
|
|
end
|
|
|
|
-- Live per-subflow TCP metrics via "ss -tin" (no per-WAN "src <ip>" filter --
|
|
-- one call for every established connection on the box, then each
|
|
-- connection's local address is looked up against the endpoints list to
|
|
-- attribute it to a WAN and pick up its id/backup/fullmesh flags). This
|
|
-- mirrors the shell/awk field parsing in omr-metrics' 040-metrics
|
|
-- post-tracking hook, reimplemented natively in Lua here so the Diagnostics
|
|
-- page stays self-contained: no dependency on omr-metrics being installed,
|
|
-- and always a live read rather than whatever omr-tracker last cached for a
|
|
-- WAN that may not have cycled recently.
|
|
--
|
|
-- Known limitation: a connection is only attributed to a WAN if its local
|
|
-- address matches a registered MPTCP endpoint. In practice every
|
|
-- multipath-enabled interface gets one (multipath's "on"/"backup" hotplug
|
|
-- path always registers an endpoint for it), so this only misses a WAN that
|
|
-- somehow never got one -- which "wan_endpoint_status" below already flags
|
|
-- as its own issue.
|
|
local function tobps(s)
|
|
local v = tonumber((s or ""):match("^[%d%.]+")) or 0
|
|
if s:find("Gbps") then return math.floor(v * 1000000000)
|
|
elseif s:find("Mbps") then return math.floor(v * 1000000)
|
|
elseif s:find("[Kk]bps") then return math.floor(v * 1000)
|
|
end
|
|
return math.floor(v)
|
|
end
|
|
|
|
-- ss prints "ip:port", or "[v6addr]:port" for IPv6.
|
|
local function split_hostport(s)
|
|
local ip, port = (s or ""):match("^(.-):(%d+)$")
|
|
if not ip then return s, nil end
|
|
return (ip:gsub("^%[", ""):gsub("%]$", "")), tonumber(port)
|
|
end
|
|
|
|
function parse_ss_subflows(raw, endpoints)
|
|
local by_addr = {}
|
|
for _, ep in ipairs(endpoints) do by_addr[ep.address] = ep end
|
|
|
|
local subflows = {}
|
|
local cur
|
|
for line in (raw or ""):gmatch("[^\n]+") do
|
|
local state, lhost, rhost = line:match("^(%S+)%s+%d+%s+%d+%s+(%S+)%s+(%S+)")
|
|
if state == "ESTAB" then
|
|
if cur then subflows[#subflows + 1] = cur end
|
|
cur = nil
|
|
local lip, lport = split_hostport(lhost)
|
|
local rip, rport = split_hostport(rhost)
|
|
local ep = by_addr[lip]
|
|
if ep then
|
|
cur = {
|
|
dev = ep.dev, endpoint_id = ep.id, backup = ep.backup,
|
|
local_ip = lip, local_port = lport,
|
|
remote_ip = rip, remote_port = rport,
|
|
}
|
|
end
|
|
elseif cur then
|
|
for token in line:gmatch("%S+") do
|
|
if token:match("^cwnd:") then cur.cwnd = tonumber(token:match(":(%d+)"))
|
|
elseif token:match("^ssthresh:") then cur.ssthresh = tonumber(token:match(":(%d+)"))
|
|
elseif token:match("^rtt:") then
|
|
local r, rv = token:match(":([%d%.]+)/([%d%.]+)")
|
|
cur.rtt = tonumber(r); cur.rttvar = tonumber(rv)
|
|
elseif token:match("^retrans:") then
|
|
local c, t = token:match(":(%d+)/(%d+)")
|
|
cur.retrans = tonumber(c); cur.retrans_total = tonumber(t)
|
|
elseif token:match("^bytes_sent:") then cur.bytes_sent = tonumber(token:match(":(%d+)"))
|
|
elseif token:match("^bytes_acked:") then cur.bytes_acked = tonumber(token:match(":(%d+)"))
|
|
elseif token:match("^bytes_retrans:") then cur.bytes_retrans = tonumber(token:match(":(%d+)"))
|
|
elseif token:match("^bytes_received:") then cur.bytes_received = tonumber(token:match(":(%d+)"))
|
|
elseif token:match("^minrtt:") then cur.min_rtt = tonumber(token:match(":([%d%.]+)"))
|
|
end
|
|
end
|
|
-- pacing_rate/delivery_rate are separate space-joined tokens
|
|
-- ("pacing_rate 6.4Mbps"), not "key:value" -- match on the whole
|
|
-- line instead of a single token.
|
|
local pr = line:match("pacing_rate%s+(%S+)")
|
|
if pr then cur.pacing_rate = tobps(pr) end
|
|
local dr = line:match("delivery_rate%s+(%S+)")
|
|
if dr then cur.delivery_rate = tobps(dr) end
|
|
end
|
|
end
|
|
if cur then subflows[#subflows + 1] = cur end
|
|
return subflows
|
|
end
|
|
|
|
-- Per-WAN endpoint cross-check: for every interface the router considers
|
|
-- multipath-enabled, is its device actually registered as an MPTCP
|
|
-- endpoint? If not, that WAN can never carry an MPTCP subflow no matter
|
|
-- what the schedulers/counters say.
|
|
function wan_endpoint_status(endpoints)
|
|
local wans = {}
|
|
uci:foreach("network", "interface", function(s)
|
|
local name = s[".name"]
|
|
if name == "loopback" then return end
|
|
|
|
local multipath = s["multipath"]
|
|
if multipath == nil or multipath == "" then
|
|
multipath = uci:get("openmptcprouter", name, "multipath")
|
|
end
|
|
multipath = multipath or "off"
|
|
if multipath == "off" then return end
|
|
|
|
local dev = get_device(name)
|
|
if dev == "" then dev = s["device"] or s["ifname"] or "" end
|
|
|
|
local has_endpoint = false
|
|
local endpoint_flags = ""
|
|
for _, ep in ipairs(endpoints) do
|
|
if dev ~= "" and ep.dev == dev then
|
|
has_endpoint = true
|
|
local flags = {}
|
|
if ep.signal then flags[#flags + 1] = "signal" end
|
|
if ep.subflow then flags[#flags + 1] = "subflow" end
|
|
if ep.backup then flags[#flags + 1] = "backup" end
|
|
if ep.fullmesh then flags[#flags + 1] = "fullmesh" end
|
|
endpoint_flags = table.concat(flags, ",")
|
|
break
|
|
end
|
|
end
|
|
|
|
wans[#wans + 1] = {
|
|
name = name,
|
|
device = dev,
|
|
multipath = multipath,
|
|
has_endpoint = has_endpoint,
|
|
endpoint_flags = endpoint_flags,
|
|
}
|
|
end)
|
|
return wans
|
|
end
|
|
|
|
-- Turn everything above into a plain-English issue list.
|
|
local function add_issue(issues, severity, code, message, detail)
|
|
issues[#issues + 1] = {
|
|
severity = severity,
|
|
code = code,
|
|
message = message,
|
|
detail = detail or "",
|
|
}
|
|
end
|
|
|
|
function build_issues(settings, counters, wans)
|
|
local issues = {}
|
|
|
|
if settings.enabled == "0" then
|
|
add_issue(issues, "error", "mptcp_disabled",
|
|
"MPTCP is disabled in the kernel.",
|
|
"No connection can use more than one WAN until MPTCP is re-enabled.")
|
|
end
|
|
|
|
for _, wan in ipairs(wans) do
|
|
if wan.device == "" then
|
|
add_issue(issues, "warning", "wan_no_device",
|
|
string.format("%s: multipath is enabled but the interface has no device yet.", wan.name),
|
|
"The interface may still be coming up (no IP/link yet).")
|
|
elseif not wan.has_endpoint then
|
|
add_issue(issues, "error", "wan_no_endpoint",
|
|
string.format("%s (%s): no MPTCP endpoint registered for this WAN.", wan.name, wan.device),
|
|
"This WAN will never be used for an MPTCP subflow, regardless of scheduler/weight settings. " ..
|
|
"Usually fixed by a hotplug/interface restart (which re-runs 'multipath <dev> on|backup'), " ..
|
|
"or by checking that the interface actually has a global (non-private-only, non-CGNAT) IP.")
|
|
end
|
|
end
|
|
|
|
local fallback = (counters.MPCapableFallbackSYNACK or 0) + (counters.MPCapableFallbackACK or 0)
|
|
if fallback > 0 then
|
|
add_issue(issues, "warning", "mptcp_fallback",
|
|
string.format("MPTCP fell back to plain TCP %d time(s).", fallback),
|
|
"A device on the path (firewall/middlebox/some ISP CGNAT boxes) is stripping the MPTCP TCP option " ..
|
|
"from the SYN or SYN/ACK, so the kernel gave up and used regular single-path TCP for those " ..
|
|
"connections. Use the MPTCP Support Check page (tracebox) per-WAN to confirm which WAN(s) strip it.")
|
|
end
|
|
|
|
local stale, recover = counters.SubflowStale or 0, counters.SubflowRecover or 0
|
|
if stale > 0 then
|
|
local stuck = stale - recover
|
|
if stuck > 0 then
|
|
add_issue(issues, "error", "mptcp_blackhole",
|
|
string.format("%d subflow(s) currently marked stale (possible blackhole) out of %d total.", stuck, stale),
|
|
"A path stopped acknowledging data for one or more subflows; the kernel is avoiding it. " ..
|
|
"If this keeps climbing on one specific WAN, that WAN's path is likely being blackholed.")
|
|
else
|
|
add_issue(issues, "warning", "mptcp_blackhole_recovered",
|
|
string.format("%d subflow(s) were marked stale but all recovered.", stale),
|
|
"Transient -- a path briefly stopped acking data and the kernel recovered it automatically.")
|
|
end
|
|
end
|
|
|
|
local hmac_fail = (counters.MPJoinSynAckHMacFailure or 0) + (counters.MPJoinAckHMacFailure or 0)
|
|
if hmac_fail > 0 then
|
|
add_issue(issues, "warning", "mptcp_join_hmac_failure",
|
|
string.format("%d MPTCP JOIN handshake authentication failure(s).", hmac_fail),
|
|
"A new subflow's JOIN handshake failed HMAC verification -- either packets were altered in " ..
|
|
"transit or the two ends disagree on the connection's key/token.")
|
|
end
|
|
|
|
if (counters.MPJoinNoTokenFound or 0) > 0 then
|
|
add_issue(issues, "warning", "mptcp_join_no_token",
|
|
string.format("%d subflow JOIN attempt(s) referenced an unknown token.", counters.MPJoinNoTokenFound),
|
|
"The peer (usually the VPS) no longer recognizes the MPTCP connection -- often because its proxy " ..
|
|
"process restarted and lost state. A new subflow can't attach to a connection the peer forgot.")
|
|
end
|
|
|
|
if (counters.DataCsumErr or 0) > 0 then
|
|
add_issue(issues, "warning", "mptcp_checksum_error",
|
|
string.format("%d MPTCP data checksum error(s).", counters.DataCsumErr),
|
|
"Check that 'mptcp_checksum' matches on both the router and the VPS -- a mismatch (or a path " ..
|
|
"that corrupts data) triggers this.")
|
|
end
|
|
|
|
-- RcvPruned counts data the kernel already received on an MPTCP subflow
|
|
-- but then had to drop from the receive queue under socket memory
|
|
-- pressure -- unlike the other counters here, this points at a local
|
|
-- resource problem on this router (or the VPS, if it's the one pruning),
|
|
-- not the network path: the data arrived fine and was thrown away anyway.
|
|
if (counters.RcvPruned or 0) > 0 then
|
|
add_issue(issues, "warning", "mptcp_rcv_pruned",
|
|
string.format("%d segment(s) pruned from an MPTCP receive queue (memory pressure).", counters.RcvPruned),
|
|
"Data that was already received had to be dropped because the socket ran out of receive buffer " ..
|
|
"space -- a local resource problem, not a network issue. Usually means something is reading from " ..
|
|
"the proxied connection too slowly (a slow/blocked application) or overall memory is tight. If " ..
|
|
"this keeps climbing, check free memory and whether any proxy process is stalled.")
|
|
end
|
|
|
|
-- Split by direction, not just summed: which side is doing the closing
|
|
-- matters. Confirmed live on the bench (2026-08-16) -- a steady climb
|
|
-- here traced back to OMR's own per-WAN shadowsocks health-check loop
|
|
-- (omr-tracker-ss) opening a fresh MPTCP connection through the
|
|
-- mptcp-enabled sslocal proxy every ~10s and closing it abortively, NOT
|
|
-- to path interference or the VPS dropping the tunnel -- confirmed by
|
|
-- MPRstRx staying ~0 while MPRstTx climbed, and by reproducing an
|
|
-- MPRstTx increment live by firing that exact health-check request.
|
|
local reset_tx = (counters.MPRstTx or 0) + (counters.MPFastcloseTx or 0)
|
|
local reset_rx = (counters.MPRstRx or 0) + (counters.MPFastcloseRx or 0)
|
|
local resets = reset_tx + reset_rx
|
|
if resets > 0 then
|
|
local rate_suffix = ""
|
|
local uptime = uptime_seconds()
|
|
if uptime and uptime >= 60 then
|
|
rate_suffix = string.format(" (~%.1f/hour since boot)", resets / (uptime / 3600))
|
|
end
|
|
if reset_rx == 0 or reset_tx >= reset_rx * 3 then
|
|
-- Tx-heavy: this router is doing almost all the closing and the
|
|
-- peer is barely ever resetting back. That's the signature of a
|
|
-- local abortive close (e.g. a health-check script tearing down
|
|
-- its own short-lived proxied connection), not the peer/path
|
|
-- forcing the connection shut.
|
|
add_issue(issues, "warning", "mptcp_resets",
|
|
string.format("%d MPTCP-level reset/fastclose event(s) sent by this router%s.", reset_tx, rate_suffix),
|
|
"Almost all of these were sent by this router, not the peer (received resets are near zero) -- " ..
|
|
"that points at something local doing an abortive close of its own MPTCP connections (a common " ..
|
|
"cause: a health-check/keepalive script that opens a proxied connection, makes one quick request, " ..
|
|
"then closes it hard) rather than active interference on the path or the VPS dropping the tunnel. " ..
|
|
"Only worth escalating if it's paired with rising SubflowStale/Blackhole counters above.")
|
|
else
|
|
add_issue(issues, "warning", "mptcp_resets",
|
|
string.format("%d MPTCP-level reset/fastclose event(s)%s.", resets, rate_suffix),
|
|
"One side force-closed the whole MPTCP connection (all subflows at once) rather than a single " ..
|
|
"subflow -- can indicate active interference or the proxy/VPN tunnel dropping abruptly.")
|
|
end
|
|
end
|
|
|
|
local addr_drop = (counters.AddAddrDrop or 0) + (counters.RmAddrDrop or 0)
|
|
if addr_drop > 0 then
|
|
-- A handful of drops only matters relative to how much ADD_ADDR/RM_ADDR
|
|
-- traffic there's actually been -- against a large total it's usually a
|
|
-- one-off from an interface flap or brief restart, already self-healed
|
|
-- by the next successful signal, not an ongoing problem. Only surface
|
|
-- this as an issue once drops are a meaningful share of that traffic
|
|
-- (>1%); with no successful signals at all to compare against, treat
|
|
-- it as 100% (nothing has gotten through), which is always worth
|
|
-- surfacing.
|
|
local addr_total = (counters.AddAddr or 0) + (counters.AddAddrTx or 0)
|
|
+ (counters.RmAddr or 0) + (counters.RmAddrTx or 0)
|
|
local addr_drop_pct = addr_total > 0 and (100 * addr_drop / addr_total) or 100
|
|
if addr_drop_pct > 1 then
|
|
local ratio_suffix = ""
|
|
if addr_total > 0 then
|
|
ratio_suffix = string.format(" (%d of %d ADD_ADDR/RM_ADDR signals, %.3g%%)",
|
|
addr_drop, addr_total, addr_drop_pct)
|
|
end
|
|
add_issue(issues, "warning", "mptcp_addr_signal_dropped",
|
|
string.format("%d ADD_ADDR/RM_ADDR signal(s) dropped%s.", addr_drop, ratio_suffix),
|
|
"A WAN address announcement (or removal) didn't make it to the peer, so the peer may never " ..
|
|
"attempt a subflow toward that WAN. A handful of drops against a much larger signal volume is " ..
|
|
"usually a one-off from an interface flap or a brief proxy restart, and self-heals on the next " ..
|
|
"successful signal -- only worth chasing further if this keeps climbing.")
|
|
end
|
|
end
|
|
|
|
if (counters.MPFailTx or 0) > 0 or (counters.MPFailRx or 0) > 0 then
|
|
add_issue(issues, "warning", "mptcp_mapping_failure",
|
|
"MPTCP data mapping failure signalled (MP_FAIL).",
|
|
"One side couldn't map received data to the right place in the byte stream on a subflow -- " ..
|
|
"that subflow gets torn down and traffic continues on the others, if any remain.")
|
|
end
|
|
|
|
if #issues == 0 then
|
|
add_issue(issues, "ok", "no_issues_detected",
|
|
"No MPTCP fallback, blackhole, reset or checksum issues detected right now.",
|
|
"This only reflects counters since boot/last reset -- a healthy snapshot doesn't rule out an " ..
|
|
"intermittent issue on a specific path; keep this page open while reproducing the problem.")
|
|
end
|
|
|
|
return issues
|
|
end
|
|
|
|
-- -------------------------------------------------------------------------
|
|
-- RPC methods
|
|
-- -------------------------------------------------------------------------
|
|
|
|
local methods = {
|
|
|
|
-- -----------------------------------------------------------------------
|
|
-- Settings: global MPTCP (network.globals)
|
|
-- -----------------------------------------------------------------------
|
|
|
|
-- Returns all configurable global MPTCP settings from network.globals.
|
|
get_settings = {
|
|
call = function()
|
|
local result = {}
|
|
local string_fields = {
|
|
"multipath", "mptcp_checksum", "mptcp_debug",
|
|
"mptcp_path_manager", "mptcp_scheduler", "mptcp_syn_retries",
|
|
"mptcp_version", "congestion", "mptcp_pm_type",
|
|
"mptcp_disable_initial_config", "mptcp_force_multipath",
|
|
"mptcpd_enable", "mptcp_subflows", "mptcp_stale_loss_cnt",
|
|
"mptcp_add_addr_accepted", "mptcp_add_addr_timeout",
|
|
"mptcp_blackhole_timeout", "mptcp_close_timeout",
|
|
"mptcp_syn_retrans_before_tcp_fallback",
|
|
"mptcp_fullmesh_num_subflows", "mptcp_fullmesh_create_on_err",
|
|
"mptcp_ndiffports_num_subflows", "mptcp_rr_cwnd_limited",
|
|
"mptcp_rr_num_segments"
|
|
}
|
|
local list_fields = {
|
|
"mptcpd_path_manager", "mptcpd_plugins",
|
|
"mptcpd_addr_flags", "mptcpd_notify_flags"
|
|
}
|
|
for _, field in ipairs(string_fields) do
|
|
local val = uci:get("network", "globals", field)
|
|
if val ~= nil then result[field] = val end
|
|
end
|
|
for _, field in ipairs(list_fields) do
|
|
local val = uci:get_list("network", "globals", field)
|
|
result[field] = val or {}
|
|
end
|
|
return result
|
|
end
|
|
},
|
|
|
|
-- Writes one or more global MPTCP settings to network.globals and commits.
|
|
-- All parameters are optional; only provided (non-empty) ones are written.
|
|
-- List fields (mptcpd_path_manager, mptcpd_plugins, mptcpd_addr_flags,
|
|
-- mptcpd_notify_flags) must be passed as JSON arrays.
|
|
set_settings = {
|
|
args = {
|
|
mptcp_checksum = "",
|
|
mptcp_debug = "",
|
|
mptcp_path_manager = "",
|
|
mptcp_scheduler = "",
|
|
mptcp_syn_retries = "",
|
|
mptcp_version = "",
|
|
congestion = "",
|
|
mptcp_pm_type = "",
|
|
mptcp_disable_initial_config = "",
|
|
mptcp_force_multipath = "",
|
|
mptcpd_enable = "",
|
|
mptcpd_path_manager = {},
|
|
mptcpd_plugins = {},
|
|
mptcpd_addr_flags = {},
|
|
mptcpd_notify_flags = {},
|
|
mptcp_subflows = "",
|
|
mptcp_stale_loss_cnt = "",
|
|
mptcp_add_addr_accepted = "",
|
|
mptcp_add_addr_timeout = "",
|
|
mptcp_blackhole_timeout = "",
|
|
mptcp_close_timeout = "",
|
|
mptcp_syn_retrans_before_tcp_fallback = "",
|
|
mptcp_fullmesh_num_subflows = "",
|
|
mptcp_fullmesh_create_on_err = "",
|
|
mptcp_ndiffports_num_subflows = "",
|
|
mptcp_rr_cwnd_limited = "",
|
|
mptcp_rr_num_segments = ""
|
|
},
|
|
call = function(args)
|
|
local string_fields = {
|
|
"mptcp_checksum", "mptcp_debug", "mptcp_path_manager",
|
|
"mptcp_scheduler", "mptcp_syn_retries", "mptcp_version",
|
|
"congestion", "mptcp_pm_type", "mptcp_disable_initial_config",
|
|
"mptcp_force_multipath", "mptcpd_enable", "mptcp_subflows",
|
|
"mptcp_stale_loss_cnt", "mptcp_add_addr_accepted",
|
|
"mptcp_add_addr_timeout", "mptcp_blackhole_timeout",
|
|
"mptcp_close_timeout", "mptcp_syn_retrans_before_tcp_fallback",
|
|
"mptcp_fullmesh_num_subflows", "mptcp_fullmesh_create_on_err",
|
|
"mptcp_ndiffports_num_subflows", "mptcp_rr_cwnd_limited",
|
|
"mptcp_rr_num_segments"
|
|
}
|
|
local list_fields = {
|
|
"mptcpd_path_manager", "mptcpd_plugins",
|
|
"mptcpd_addr_flags", "mptcpd_notify_flags"
|
|
}
|
|
for _, field in ipairs(string_fields) do
|
|
if args[field] ~= nil and args[field] ~= "" then
|
|
uci:set("network", "globals", field, args[field])
|
|
end
|
|
end
|
|
for _, field in ipairs(list_fields) do
|
|
if args[field] ~= nil and type(args[field]) == "table" then
|
|
uci:set_list("network", "globals", field, args[field])
|
|
end
|
|
end
|
|
uci:save("network")
|
|
uci:commit("network")
|
|
return { result = "ok" }
|
|
end
|
|
},
|
|
|
|
-- -----------------------------------------------------------------------
|
|
-- Settings: per-interface MPTCP (network.interface)
|
|
-- -----------------------------------------------------------------------
|
|
|
|
-- Returns multipath settings for all interfaces (or a single one when
|
|
-- the optional "iface" argument is provided).
|
|
get_interface_settings = {
|
|
args = { iface = "" },
|
|
call = function(args)
|
|
local iface = args.iface or ""
|
|
if iface ~= "" then
|
|
local section = uci:get_all("network", iface)
|
|
if not section or section[".type"] ~= "interface" then
|
|
return { error = "Interface not found" }
|
|
end
|
|
return {
|
|
multipath = section["multipath"] or "off",
|
|
multipath_weight = section["multipath_weight"] or "100"
|
|
}
|
|
end
|
|
local result = {}
|
|
uci:foreach("network", "interface", function(s)
|
|
local name = s[".name"]
|
|
result[name] = {
|
|
multipath = s["multipath"] or "off",
|
|
multipath_weight = s["multipath_weight"] or "100"
|
|
}
|
|
end)
|
|
return result
|
|
end
|
|
},
|
|
|
|
-- Sets multipath and/or multipath_weight for the given interface.
|
|
-- "iface" is required; "multipath" and "multipath_weight" are optional.
|
|
-- Valid values for multipath: "on", "off", "master", "backup".
|
|
set_interface_settings = {
|
|
args = { iface = "", multipath = "", multipath_weight = "" },
|
|
call = function(args)
|
|
local iface = args.iface or ""
|
|
if iface == "" then
|
|
return { error = "Interface name required" }
|
|
end
|
|
local section = uci:get_all("network", iface)
|
|
if not section or section[".type"] ~= "interface" then
|
|
return { error = "Interface not found" }
|
|
end
|
|
if args.multipath ~= nil and args.multipath ~= "" then
|
|
uci:set("network", iface, "multipath", args.multipath)
|
|
end
|
|
if args.multipath_weight ~= nil and args.multipath_weight ~= "" then
|
|
uci:set("network", iface, "multipath_weight", args.multipath_weight)
|
|
end
|
|
uci:save("network")
|
|
uci:commit("network")
|
|
return { result = "ok" }
|
|
end
|
|
},
|
|
|
|
-- -----------------------------------------------------------------------
|
|
-- Bandwidth / diagnostics (existing methods below)
|
|
-- -----------------------------------------------------------------------
|
|
|
|
-- Returns per-interface bandwidth arrays plus a "total" entry.
|
|
-- Each value is an array of [ts, rx_bytes, rx_pkts, tx_bytes, tx_pkts].
|
|
multipath_bandwidth = {
|
|
call = function()
|
|
local result = {}
|
|
local iface_rows = {} -- interface -> rows (only non-empty)
|
|
|
|
uci:foreach("network", "interface", function(s)
|
|
local intname = s[".name"]
|
|
local dev = get_device(intname)
|
|
if dev == "" then dev = s["device"] or s["ifname"] or "" end
|
|
|
|
local multipath = s["multipath"] or ""
|
|
if dev ~= "lo" and dev ~= "" then
|
|
if multipath == "" then
|
|
multipath = uci:get("openmptcprouter", intname, "multipath") or ""
|
|
end
|
|
if multipath == "" then multipath = "off" end
|
|
|
|
if multipath == "on" or multipath == "master" or
|
|
multipath == "backup" or multipath == "handover" then
|
|
local rows = bwc_rows(dev)
|
|
result[intname] = rows
|
|
if #rows > 0 then
|
|
iface_rows[intname] = rows
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
|
|
-- Build a correctly aligned cumulative total using last-known-value
|
|
-- interpolation. The old approach summed raw cumulative counters at
|
|
-- each timestamp, but different interfaces record data at different
|
|
-- (interleaved) timestamps. When consecutive total entries came from
|
|
-- different interfaces, rate = (total[i] - total[i-1]) / dt could be
|
|
-- negative or zero even under real traffic.
|
|
--
|
|
-- Fix: for every timestamp in the union of all interface timestamps,
|
|
-- use the latest known cumulative bytes from EACH interface (carry the
|
|
-- last seen value forward). This keeps the total monotonically
|
|
-- increasing so JS rate computation stays correct.
|
|
local ts_set = {}
|
|
local all_ts = {}
|
|
for _, rows in pairs(iface_rows) do
|
|
for _, row in ipairs(rows) do
|
|
local ts = row[1]
|
|
if not ts_set[ts] then
|
|
ts_set[ts] = true
|
|
all_ts[#all_ts + 1] = ts
|
|
end
|
|
end
|
|
end
|
|
table.sort(all_ts)
|
|
|
|
-- One forward-scan pointer per interface
|
|
local iface_ptrs = {}
|
|
for itf, rows in pairs(iface_rows) do
|
|
iface_ptrs[itf] = { idx = 1, rows = rows }
|
|
end
|
|
|
|
local iface_last = {} -- latest row seen so far per interface
|
|
local total_rows = {}
|
|
for _, ts in ipairs(all_ts) do
|
|
-- Advance each pointer up to (and including) this timestamp
|
|
for itf, ptr in pairs(iface_ptrs) do
|
|
while ptr.idx <= #ptr.rows and ptr.rows[ptr.idx][1] <= ts do
|
|
iface_last[itf] = ptr.rows[ptr.idx]
|
|
ptr.idx = ptr.idx + 1
|
|
end
|
|
end
|
|
-- Sum latest known cumulative bytes from every interface
|
|
local total = { ts, 0, 0, 0, 0 }
|
|
for _, last_row in pairs(iface_last) do
|
|
for j = 2, 5 do
|
|
total[j] = total[j] + (last_row[j] or 0)
|
|
end
|
|
end
|
|
total_rows[#total_rows + 1] = total
|
|
end
|
|
|
|
result["total"] = total_rows
|
|
return result
|
|
end
|
|
},
|
|
|
|
-- Returns bandwidth rows for a single device as { data: [...] }.
|
|
interface_bandwidth = {
|
|
args = { iface = "" },
|
|
call = function(args)
|
|
return { data = bwc_rows(args.iface or "") }
|
|
end
|
|
},
|
|
|
|
-- Runs "multipath -f" and returns its output.
|
|
mptcp_fullmesh = {
|
|
call = function()
|
|
return { output = exec_output("multipath -f 2>/dev/null") }
|
|
end
|
|
},
|
|
|
|
-- Runs "multipath -m" and returns its output.
|
|
mptcp_monitor = {
|
|
call = function()
|
|
return { output = exec_output("multipath -m 2>/dev/null") }
|
|
end
|
|
},
|
|
|
|
-- Runs "multipath -c" and returns its output.
|
|
mptcp_connections = {
|
|
call = function()
|
|
return { output = exec_output("multipath -c 2>/dev/null") }
|
|
end
|
|
},
|
|
|
|
-- Runs tracebox against the configured VPN server via the given interface.
|
|
mptcp_check_trace = {
|
|
args = { iface = "" },
|
|
call = function(args)
|
|
local iface = args.iface or ""
|
|
local interface = get_device(iface)
|
|
local server = uci:get("shadowsocks-libev", "sss0", "server") or ""
|
|
if server == "" then return { output = "" } end
|
|
local cmd
|
|
if interface == "" then
|
|
cmd = "tracebox -s /usr/share/tracebox/omr-mptcp-trace.lua " .. server
|
|
else
|
|
cmd = "tracebox -s /usr/share/tracebox/omr-mptcp-trace.lua -i " .. interface .. " " .. server
|
|
end
|
|
return { output = exec_output(cmd) }
|
|
end
|
|
},
|
|
|
|
-- Diagnostics: kernel MPTCP counters/endpoints/limits + a per-WAN
|
|
-- endpoint cross-check, turned into a plain-English issue list. See the
|
|
-- helpers above ("MPTCP Diagnostics" section) for the actual logic.
|
|
diagnose = {
|
|
call = function()
|
|
local settings = kernel_settings()
|
|
local counters = parse_counters(mptcp_counters_raw())
|
|
local endpoints = parse_endpoints(exec_output(IP_BIN .. " mptcp endpoint show 2>/dev/null"))
|
|
local limits = parse_limits(exec_output(IP_BIN .. " mptcp limits show 2>/dev/null"))
|
|
local wans = wan_endpoint_status(endpoints)
|
|
local issues = build_issues(settings, counters, wans)
|
|
|
|
local connections_raw = exec_output("multipath -c 2>/dev/null")
|
|
local established_count = 0
|
|
for _ in connections_raw:gmatch("[Ee][Ss][Tt][Aa][Bb]") do
|
|
established_count = established_count + 1
|
|
end
|
|
|
|
-- Attribute each subflow's device to its UCI interface name
|
|
-- (endpoints only carry the device, e.g. "eth1", not "wan1").
|
|
local dev_to_name = {}
|
|
for _, w in ipairs(wans) do
|
|
if w.device ~= "" then dev_to_name[w.device] = w.name end
|
|
end
|
|
local subflows = parse_ss_subflows(exec_output("ss -tin 2>/dev/null"), endpoints)
|
|
for _, sf in ipairs(subflows) do
|
|
sf.wan = dev_to_name[sf.dev] or sf.dev
|
|
end
|
|
|
|
return {
|
|
settings = settings,
|
|
counters = counters,
|
|
endpoints = endpoints,
|
|
limits = limits,
|
|
wans = wans,
|
|
issues = issues,
|
|
established_count = established_count,
|
|
subflows = subflows,
|
|
}
|
|
end
|
|
},
|
|
}
|
|
|
|
-- -------------------------------------------------------------------------
|
|
-- rpcd dispatch (same boilerplate used by all rpcd Lua scripts in this repo)
|
|
-- -------------------------------------------------------------------------
|
|
|
|
local function parseInput()
|
|
local parse = jsonc.new()
|
|
local done, err
|
|
while true do
|
|
local chunk = io.read(4096)
|
|
if not chunk then break end
|
|
if not done and not err then
|
|
done, err = parse:parse(chunk)
|
|
end
|
|
end
|
|
if not done then
|
|
print(jsonc.stringify({ error = err or "Incomplete input" }))
|
|
os.exit(1)
|
|
end
|
|
return parse:get()
|
|
end
|
|
|
|
local function validateArgs(func, uargs)
|
|
local method = methods[func]
|
|
if not method then
|
|
print(jsonc.stringify({ error = "Method not found" }))
|
|
os.exit(1)
|
|
end
|
|
if type(uargs) ~= "table" then
|
|
print(jsonc.stringify({ error = "Invalid arguments" }))
|
|
os.exit(1)
|
|
end
|
|
uargs.ubus_rpc_session = nil
|
|
local margs = method.args or {}
|
|
for k, v in pairs(uargs) do
|
|
if margs[k] == nil or (v ~= nil and type(v) ~= type(margs[k])) then
|
|
print(jsonc.stringify({ error = "Invalid arguments" }))
|
|
os.exit(1)
|
|
end
|
|
end
|
|
return method
|
|
end
|
|
|
|
if arg[1] == "list" then
|
|
local rv = {}
|
|
for name, method in pairs(methods) do rv[name] = method.args or {} end
|
|
print((jsonc.stringify(rv):gsub(":%[%]", ":{}")))
|
|
elseif arg[1] == "call" then
|
|
local args = parseInput()
|
|
local method = validateArgs(arg[2], args)
|
|
local result, code = method.call(args)
|
|
print((jsonc.stringify(result):gsub("^%[%]$", "{}")))
|
|
os.exit(code or 0)
|
|
end
|