🛸 Sync 2026-08-18 08:57:58

This commit is contained in:
github-actions[bot] committed 2026-08-18 08:57:58 +08:00
1 parent a9264dc426
commit 0b567b4be0
56 files changed
+8850 -2138

No files matched your search

@@ -0,0 +1,93 @@
/* ── CSS custom properties (same palette as wanstatus.css, duplicated so
this page doesn't depend on wanstatus.css having been loaded first) ── */
:root {
--diag-bg: #ffffff;
--diag-border: #e2e6ea;
--diag-text: #1a2332;
--diag-muted: #6b7a8d;
--diag-ok: #22c55e;
--diag-warn: #f59e0b;
--diag-err: #ef4444;
--diag-ok-bg: #f0fdf4;
--diag-warn-bg: #fffbeb;
--diag-err-bg: #fef2f2;
--diag-ok-border: #bbf7d0;
--diag-warn-border:#fde68a;
--diag-err-border: #fecaca;
--diag-radius: 10px;
}
.omrdiag-summary {
display: flex;
flex-direction: column;
gap: 10px;
margin: 10px 0 20px 0;
}
.omrdiag-issue {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 14px;
border-radius: var(--diag-radius);
border: 1px solid var(--diag-border);
background: var(--diag-bg);
}
.omrdiag-issue.ok { border-color: var(--diag-ok-border); background: var(--diag-ok-bg); }
.omrdiag-issue.warning { border-color: var(--diag-warn-border); background: var(--diag-warn-bg); }
.omrdiag-issue.error { border-color: var(--diag-err-border); background: var(--diag-err-bg); }
.omrdiag-issue .omrdiag-dot {
flex: 0 0 10px;
width: 10px;
height: 10px;
border-radius: 50%;
margin-top: 5px;
background: var(--diag-muted);
}
.omrdiag-issue.ok .omrdiag-dot { background: var(--diag-ok); }
.omrdiag-issue.warning .omrdiag-dot { background: var(--diag-warn); }
.omrdiag-issue.error .omrdiag-dot { background: var(--diag-err); }
.omrdiag-issue .omrdiag-body { flex: 1; min-width: 0; }
.omrdiag-issue .omrdiag-message { font-weight: 700; color: var(--diag-text); }
.omrdiag-issue .omrdiag-detail { font-size: 12px; color: var(--diag-muted); margin-top: 3px; line-height: 1.5; }
.omrdiag-wan-table,
.omrdiag-counters-table {
border-collapse: collapse;
width: 100%;
margin: 8px 0 18px 0;
}
.omrdiag-wan-table th,
.omrdiag-wan-table td,
.omrdiag-counters-table th,
.omrdiag-counters-table td {
border: 1px solid var(--diag-border);
padding: 6px 10px;
text-align: left;
font-size: 12.5px;
}
.omrdiag-wan-table th,
.omrdiag-counters-table th {
background: rgba(0,0,0,.03);
font-weight: 700;
}
.omrdiag-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.omrdiag-badge.ok { background: var(--diag-ok-bg); color: #15803d; border: 1px solid var(--diag-ok-border); }
.omrdiag-badge.warning { background: var(--diag-warn-bg); color: #b45309; border: 1px solid var(--diag-warn-border); }
.omrdiag-badge.error { background: var(--diag-err-bg); color: #b91c1c; border: 1px solid var(--diag-err-border); }
.omrdiag-counters-table td.omrdiag-nonzero { font-weight: 700; color: #b45309; }
.omrdiag-updated {
font-size: 11px;
color: var(--diag-muted);
margin-bottom: 6px;
}
@@ -0,0 +1,305 @@
'use strict';
'require view';
'require poll';
'require rpc';
var callDiagnose = rpc.declare({
object: 'luci.mptcp',
method: 'diagnose',
expect: { '': {} }
});
var COUNTER_LABELS = {
/* Initial MP_CAPABLE handshake (does this connection become MPTCP at all?) */
MPCapableSYNTX: _('MPTCP handshake attempts sent (SYN)'),
MPCapableSYNRX: _('MPTCP handshake attempts received (SYN)'),
MPCapableSYNACKRX: _('MPTCP handshake accepted by peer (SYN/ACK)'),
MPCapableACKRX: _('MPTCP handshake completed (ACK)'),
MPCapableFallbackSYNACK: _('Fallback to TCP (SYN/ACK stripped)'),
MPCapableFallbackACK: _('Fallback to TCP (ACK stripped)'),
MPCapableSYNTXDrop: _('MPTCP handshake dropped client-side, fell back to TCP'),
MPCapableSYNTXDisabled: _('MPTCP handshake disabled client-side after past issues'),
MPCapableEndpAttempt: _('MPTCP rejected on a port-only endpoint'),
MPFallbackTokenInit: _('Could not allocate an MPTCP token, fell back to TCP'),
/* MP_JOIN handshake (does a WAN succeed in adding a subflow?) */
MPJoinSynTx: _('Subflow JOIN attempts sent (SYN)'),
MPJoinSynTxCreatSkErr: _('Subflow JOIN failed: could not create socket'),
MPJoinSynTxBindErr: _('Subflow JOIN failed: could not bind address'),
MPJoinSynTxConnectErr: _('Subflow JOIN failed: could not connect'),
MPJoinSynRx: _('Subflow JOIN attempts received (SYN)'),
MPJoinSynBackupRx: _('Subflow JOIN attempts received (SYN, backup)'),
MPJoinSynAckRx: _('Subflow JOIN accepted by peer (SYN/ACK)'),
MPJoinSynAckBackupRx: _('Subflow JOIN accepted by peer (SYN/ACK, backup)'),
MPJoinSynAckHMacFailure: _('JOIN SYN/ACK authentication failures'),
MPJoinAckRx: _('Subflow JOIN completed (ACK)'),
MPJoinAckHMacFailure: _('JOIN ACK authentication failures'),
MPJoinNoTokenFound: _('JOIN attempts with unknown token'),
MPJoinRejected: _('JOIN request rejected by the path manager'),
MPJoinPortSynRx: _('Subflow JOIN SYN received on a different port'),
MPJoinPortSynAckRx: _('Subflow JOIN SYN/ACK received on a different port'),
MPJoinPortAckRx: _('Subflow JOIN ACK received on a different port'),
MismatchPortSynRx: _('Subflow JOIN SYN with mismatched port'),
MismatchPortAckRx: _('Subflow JOIN ACK with mismatched port'),
/* Data-sequence mapping / data path integrity */
MPTCPRetrans: _('MPTCP-level retransmissions'),
DSSNotMatching: _('Data sequence mapping did not match the previous one'),
DSSCorruptionFallback: _('DSS corruption detected, fell back to plain TCP'),
DSSCorruptionReset: _('DSS corruption detected, subflow reset'),
DSSNoMatchTCP: _('Data sequence mapping did not match TCP sequence numbers'),
InfiniteMapTx: _('Infinite mapping sent (fell back to plain TCP semantics)'),
InfiniteMapRx: _('Infinite mapping received (peer fell back to plain TCP semantics)'),
DataCsumErr: _('Data checksum errors'),
NoDSSInWindow: _('Segments received outside the MPTCP mapping window'),
DuplicateData: _('Duplicate data segments discarded'),
OFOQueueTail: _('Out-of-order segments queued at tail'),
OFOQueue: _('Out-of-order segments queued'),
OFOMerge: _('Out-of-order segments merged'),
/* Subflow lifecycle: stale/blackhole detection and removal */
Blackhole: _('Blackhole detected on the path'),
SubflowStale: _('Subflows marked stale (possible blackhole)'),
SubflowRecover: _('Stale subflows recovered'),
RmSubflow: _('Subflows removed'),
/* ADD_ADDR / RM_ADDR signalling (extra WAN addresses advertised to the peer) */
AddAddr: _('ADD_ADDR signals received'),
AddAddrTx: _('ADD_ADDR signals sent'),
AddAddrTxDrop: _('ADD_ADDR signals failed to send'),
AddAddrDrop: _('ADD_ADDR signals dropped'),
EchoAdd: _('ADD_ADDR echo received'),
EchoAddTx: _('ADD_ADDR echo sent'),
EchoAddTxDrop: _('ADD_ADDR echo failed to send'),
PortAdd: _('ADD_ADDR received with a port number'),
RmAddr: _('RM_ADDR signals received'),
RmAddrTx: _('RM_ADDR signals sent'),
RmAddrTxDrop: _('RM_ADDR signals failed to send'),
RmAddrDrop: _('RM_ADDR signals dropped'),
/* Subflow priority (MP_PRIO: backup/non-backup flag changes) */
MPPrioTx: _('Subflow priority change sent (MP_PRIO)'),
MPPrioRx: _('Subflow priority change received (MP_PRIO)'),
/* Connection teardown */
MPFailTx: _('Data mapping failures sent (MP_FAIL)'),
MPFailRx: _('Data mapping failures received (MP_FAIL)'),
MPFastcloseTx: _('Fastclose sent'),
MPFastcloseRx: _('Fastclose received'),
MPRstTx: _('MPTCP resets sent'),
MPRstRx: _('MPTCP resets received'),
/* Receive-window sharing across subflows */
SndWndShared: _('Send window shared across subflows'),
RcvWndShared: _('Receive window shared across subflows'),
RcvWndConflictUpdate: _('Receive window conflict resolved on update'),
RcvWndConflict: _('Receive window update conflicts'),
/* Misc: whole-connection fallback reasons, gauges */
MPCurrEstab: _('Established MPTCP connections (current)'),
MPCapableDataFallback: _('Fell back to TCP: missing data on first established packet'),
MD5SigFallback: _('Fell back to TCP: conflicting TCP MD5SIG option'),
DssFallback: _('Fell back to TCP: bad or missing data sequence signal'),
SimultConnectFallback: _('Fell back to TCP: simultaneous connect'),
FallbackFailed: _("Couldn't fall back to TCP (connection state didn't allow it)"),
WinProbe: _('MPTCP-level zero-window probes')
};
return view.extend({
load: function() {
return callDiagnose().catch(function() { return null; });
},
pollData: function(container) {
poll.add(L.bind(function() {
return callDiagnose().then(L.bind(function(data) {
this.renderAll(container, data);
}, this)).catch(function() {
/* Ignore transient poll errors -- keep showing the last good snapshot */
});
}, this), 15);
},
render: function(initialData) {
if (!document.querySelector('link[href*="mptcp_diagnostics.css"]')) {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = L.resource('mptcp/css/mptcp_diagnostics.css');
document.head.appendChild(link);
}
var container = E('div', { 'id': 'omrdiag-container' }, [
E('img', { 'src': L.resource('spinner.gif') })
]);
var seeAlso = E('div', { 'class': 'cbi-section' }, [
E('legend', {}, [ _('Related pages') ]),
E('ul', {}, [
E('li', {}, [ E('a', { 'href': L.url('admin/system/openmptcprouter/status') }, [ _('OpenMPTCProuter Network overview / Status') ]) ]),
E('li', {}, [ E('a', { 'href': L.url('admin/network/mptcp/mptcp') }, [ _('MPTCP Settings') ]) ]),
E('li', {}, [ E('a', { 'href': L.url('admin/network/mptcp/mptcp_fullmesh') }, [ _('MPTCP Fullmesh (subflow map)') ]) ]),
E('li', {}, [ E('a', { 'href': L.url('admin/network/mptcp/mptcp_connections') }, [ _('MPTCP Established connections') ]) ]),
E('li', {}, [ E('a', { 'href': L.url('admin/network/mptcp/mptcp_monitor') }, [ _('MPTCP monitoring (raw kernel counters)') ]) ])
])
]);
var page = E('div', {}, [
E('h2', {}, [ _('MPTCP Diagnostics') ]),
E('div', { 'class': 'cbi-map-descr' }, [
_('Helps explain why MPTCP might not be aggregating your WANs correctly: fallback to plain TCP, ' +
'blackholed/stale subflows, resets, checksum errors, and WANs that never got an MPTCP endpoint ' +
'registered at all. Refreshes automatically every 15 seconds.')
]),
container,
seeAlso
]);
this.renderAll(container, initialData);
this.pollData(container);
return page;
},
renderAll: function(container, data) {
if (!container) return;
var d = data || {};
container.textContent = '';
container.appendChild(E('div', { 'class': 'omrdiag-updated' },
[ _('Last updated:') + ' ' + (new Date()).toLocaleTimeString() ]));
container.appendChild(this.renderIssues(d.issues || []));
container.appendChild(this.renderWanTable(d.wans || []));
container.appendChild(this.renderSettings(d.settings || {}, d.limits || {}, d.established_count));
container.appendChild(this.renderCounters(d.counters || {}));
},
renderIssues: function(issues) {
var box = E('div', { 'class': 'omrdiag-summary' });
if (!issues.length) {
box.appendChild(E('div', { 'class': 'omrdiag-issue' }, [
E('div', { 'class': 'omrdiag-dot' }),
E('div', { 'class': 'omrdiag-body' }, [
E('div', { 'class': 'omrdiag-message' }, [ _('No diagnostic data available yet.') ])
])
]));
return box;
}
issues.forEach(function(issue) {
var sev = issue.severity || 'warning';
box.appendChild(E('div', { 'class': 'omrdiag-issue ' + sev }, [
E('div', { 'class': 'omrdiag-dot' }),
E('div', { 'class': 'omrdiag-body' }, [
E('div', { 'class': 'omrdiag-message' }, [ issue.message || '' ]),
issue.detail ? E('div', { 'class': 'omrdiag-detail' }, [ issue.detail ]) : ''
])
]));
});
return box;
},
renderWanTable: function(wans) {
var wrap = E('div', {}, [ E('h3', {}, [ _('Per-WAN MPTCP endpoint status') ]) ]);
if (!wans.length) {
wrap.appendChild(E('p', {}, [ _('No multipath-enabled WAN interfaces found.') ]));
return wrap;
}
var table = E('table', { 'class': 'omrdiag-wan-table' }, [
E('tr', {}, [
E('th', {}, [ _('WAN') ]),
E('th', {}, [ _('Device') ]),
E('th', {}, [ _('Mode') ]),
E('th', {}, [ _('MPTCP endpoint') ]),
E('th', {}, [ _('Endpoint flags') ])
])
]);
wans.forEach(function(wan) {
var ok = !!wan.has_endpoint;
table.appendChild(E('tr', {}, [
E('td', {}, [ wan.name || '' ]),
E('td', {}, [ wan.device || '-' ]),
E('td', {}, [ wan.multipath || 'off' ]),
E('td', {}, [
E('span', { 'class': 'omrdiag-badge ' + (ok ? 'ok' : 'error') },
[ ok ? _('registered') : _('missing') ])
]),
E('td', {}, [ wan.endpoint_flags || '-' ])
]));
});
wrap.appendChild(table);
return wrap;
},
renderSettings: function(settings, limits, establishedCount) {
// "1"/"0" mean enabled/disabled for the enabled and checksum sysctls --
// but NOT for path_manager: on the in-kernel MPTCP sysctl layout,
// net.mptcp.pm_type is a selector between two path manager
// *implementations* (0 = in-kernel, 1 = userspace/mptcpd), not an
// on/off switch -- "0" here is the normal default, not "disabled".
// On the legacy out-of-tree sysctl layout path_manager is already a
// name (e.g. "fullmesh"), not a 0/1 code, so leave those untouched.
var formatBool = function(v) {
if (v === '1') return _('Enabled') + ' (1)';
if (v === '0') return _('Disabled') + ' (0)';
return v;
};
var formatPathManager = function(v) {
if (v === '0') return _('In-kernel') + ' (0)';
if (v === '1') return _('Userspace') + ' (1)';
return v;
};
var rows = [
[ _('MPTCP enabled'), formatBool(settings.enabled) ],
[ _('Path manager'), formatPathManager(settings.path_manager) ],
[ _('Scheduler'), settings.scheduler ],
[ _('Checksum'), formatBool(settings.checksum) ],
[ _('Stale loss count threshold'), settings.stale_loss_cnt ],
[ _('Blackhole timeout (s)'), settings.blackhole_timeout ],
[ _('Add-addr timeout (s)'), settings.add_addr_timeout ],
[ _('Close timeout (s)'), settings.close_timeout ],
[ _('Subflow limit'), limits.subflows ],
[ _('Accepted ADD_ADDR limit'), limits.add_addr_accepted ],
[ _('Established MPTCP connections (right now)'), establishedCount ]
];
var wrap = E('div', {}, [ E('h3', {}, [ _('Kernel MPTCP configuration') ]) ]);
var table = E('table', { 'class': 'omrdiag-wan-table' });
rows.forEach(function(r) {
if (r[1] === undefined || r[1] === null || r[1] === '') return;
table.appendChild(E('tr', {}, [ E('th', {}, [ r[0] ]), E('td', {}, [ String(r[1]) ]) ]));
});
wrap.appendChild(table);
return wrap;
},
renderCounters: function(counters) {
var keys = Object.keys(counters || {});
var wrap = E('div', {}, [ E('h3', {}, [ _('MPTCP kernel counters (since boot)') ]) ]);
if (!keys.length) {
wrap.appendChild(E('p', {}, [ _('No counters available (nstat/multipath -m returned nothing).') ]));
return wrap;
}
keys.sort(function(a, b) {
var av = counters[a] || 0, bv = counters[b] || 0;
if (av !== bv) return bv - av;
return a < b ? -1 : (a > b ? 1 : 0);
});
var table = E('table', { 'class': 'omrdiag-counters-table' }, [
E('tr', {}, [ E('th', {}, [ _('Counter') ]), E('th', {}, [ _('Value') ]) ])
]);
keys.forEach(function(key) {
var value = counters[key] || 0;
var label = COUNTER_LABELS[key] ? (COUNTER_LABELS[key] + ' (' + key + ')') : key;
table.appendChild(E('tr', {}, [
E('td', {}, [ label ]),
E('td', { 'class': value > 0 ? 'omrdiag-nonzero' : '' }, [ String(value) ])
]));
});
wrap.appendChild(table);
return wrap;
},
handleSave: null,
handleSaveApply: null,
handleReset: null
});
@@ -41,6 +41,15 @@ function isEligibleIface(name) {
name !== 'omrvpn' && name !== 'OWVPN' && name !== 'omr6in4';
}
// Same "multipath actually turned on" whitelist as the bandwidth page's
// _getMultipathIfaces (multipath.js) and the rpcd backend's
// multipath_bandwidth: "off"/unset doesn't count.
function isMultipathEnabledIface(name) {
var multipath = uci.get('network', name, 'multipath');
return multipath === 'on' || multipath === 'master' ||
multipath === 'backup' || multipath === 'handover';
}
function fillIfaceOptions(option) {
option.value('', _('-- not pinned --'));
uci.sections('network', 'interface', function(ifc) {
@@ -140,7 +149,9 @@ return L.view.extend({
if (vpsSyncEnabled) {
o = s.option(form.ListValue, 'download_interface', _('Download interface'),
_('Gateway (VPS) → router. Only takes effect for MPTCP’s bpf_dscp scheduler (pins the VPS’s own send-side choice via dscp_remote_id) and can be a different WAN than Upload. mqvpn has no equivalent: its downlink always mirrors whichever WAN carries the class on upload, so this field has no effect on mqvpn.'));
_('Gateway (VPS) → router. Only takes effect for MPTCP’s bpf_dscp scheduler (pins the VPS’s own send-side choice via dscp_remote_id) and can be a different WAN than Upload.') + ' ' +
_('Leave empty to just mirror this row’s Upload interface to the gateway instead -- the same "download always follows upload" behavior mqvpn already has built in natively.') + ' ' +
_('mqvpn has no equivalent: its downlink always mirrors whichever WAN carries the class on upload, so this field has no effect on mqvpn.'));
o.load = function(section_id) {
fillIfaceOptions(this);
return this.super('load', [section_id]);
@@ -150,9 +161,10 @@ return L.view.extend({
if (showWeight) {
s = m.section(form.TypedSection, 'interface', _('WAN weight'),
_('Used by the *weight MPTCP schedulers (bpf_weight/bpf_weight_rr) and by mqvpn’s "Weighted RTT"/"Weighted Round Robin" schedulers -- same value, applies to both upload and download (mirrored to the gateway automatically). Unlike the DSCP pins above, weight is a single value per WAN, not per traffic class.'));
_('Used by the *weight MPTCP schedulers (bpf_weight/bpf_weight_rr) and by mqvpn’s "Weighted RTT"/"Weighted Round Robin" schedulers -- same value, applies to both upload and download (mirrored to the gateway automatically). Unlike the DSCP pins above, weight is a single value per WAN, not per traffic class.') + ' ' +
_('Only WANs with Multipath TCP enabled (the “Multipath TCP” option on the MPTCP page’s Interfaces Settings) are listed below.'));
s.filter = function(section) {
return isEligibleIface(section);
return isEligibleIface(section) && isMultipathEnabledIface(section);
};
o = s.option(form.Value, 'multipath_weight', _('Weight'),
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -51,6 +51,369 @@ local function bwc_rows(dev)
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
-- 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
-- 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
-- -------------------------------------------------------------------------
@@ -354,6 +717,36 @@ local methods = {
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
return {
settings = settings,
counters = counters,
endpoints = endpoints,
limits = limits,
wans = wans,
issues = issues,
established_count = established_count,
}
end
},
}
-- -------------------------------------------------------------------------
@@ -21,6 +21,17 @@
"acl": [ "luci-app-mptcp" ]
}
},
"admin/network/mptcp/mptcp_diagnostics": {
"title": "Diagnostics",
"order": 12,
"action": {
"type": "view",
"path": "mptcp/mptcp_diagnostics"
},
"depends": {
"acl": [ "luci-app-mptcp" ]
}
},
"admin/network/mptcp/mptcp_dscp_routing": {
"title": "DSCP / Weight Routing",
"order": 15,
@@ -22,7 +22,8 @@
"get_settings",
"set_settings",
"get_interface_settings",
"set_interface_settings"
"set_interface_settings",
"diagnose"
]
}
},