stp: full RSTP configuration (bridge + per-port), CLI + GUI + persistence

Implements the standard 802.1D-2004/802.1w configuration surface:

Bridge:  priority (0-15 x4096), hello time, max age, forward delay,
         force-version (RSTP v2 / STP-compatible v0 Config BPDUs), tx hold
         count (per-port per-second BPDU budget).
Per port: enable, admin edge (forwarding immediately - no listen gap),
         auto edge (forwarding after 3 s of BPDU silence; DEFAULT, so
         host-facing ports no longer take the full forward delay),
         path cost (0=auto/20000), port priority, BPDU guard (port disabled
         on BPDU receipt), root guard (never accept a better root on the
         port), BPDU filter (no BPDUs in or out).

Engine additions: root max-age expiry (reclaim the tree when the root goes
silent), root path cost accounting (rx cost + root-port cost, advertised in
our BPDUs), loop detection (our own BPDU coming back blocks the port for a
listen period), topology-change counter, approximated per-port roles
(Root/Designated/Alternate) for diagnostics.

CLI: "stp prio|hello|maxage|fwd|txhold|version ..." and
"stp port <n> on|off|edge|cost|prio|guard|filter ..." (stp_parse, delegated
from cmd_parser); all forms accepted by the startup-config validator so the
whole configuration persists. /stp.json now reports config + status; the
Spanning Tree page exposes everything with immediate-apply controls and live
state/role columns (edit-in-flight guard against the 2 s refresh).

8051 memory: the module moves to code BANK2; internal-RAM pressure from
cross-bank calls resolved by xdata loop iterators/scratch, __reentrant on
the small helpers, and moving httpd's header-pointer globals to xdata.

Verified on hardware (SWTGW218AS): defaults land per standard; priority and
hello change live; admin-edge ports (the LACP bond uplinks) keep the LAN at
0% loss THROUGH "stp on"; auto-edge ports forward after 3 s; a port that
heard real BPDUs (a VM bridge behind physical port 6) correctly declined
auto-edge, sat out the full listen period and became Designated; tc counts
promotions; we win the root election at priority 16384 vs 32768.

(cherry picked from commit 09a34dc6acdc81ab9cab0727d2f4a59c68131a3e)
This commit is contained in:
d00f
2026-08-04 03:25:10 +02:00
parent 89cc9828c3
commit 531bbbea5d
9 changed files with 641 additions and 158 deletions
+10 -1
View File
@@ -22,6 +22,14 @@ const conf_cmds = [
/^laghash\s+\d(\s+\w+)+$/,
/^isolate\s+\d{1,2}(\s+(off|\d{1,2}))+$/,
/^stp\s+(on|off)$/,
/^stp\s+(prio|hello|maxage|fwd|txhold)\s+\d{1,2}$/,
/^stp\s+version\s+(rstp|stp)$/,
/^stp\s+port\s+\d{1,2}\s+(on|off)$/,
/^stp\s+port\s+\d{1,2}\s+edge\s+(on|off|auto)$/,
/^stp\s+port\s+\d{1,2}\s+cost\s+\d{1,3}$/,
/^stp\s+port\s+\d{1,2}\s+prio\s+\d{1,3}$/,
/^stp\s+port\s+\d{1,2}\s+guard\s+(none|bpdu|root)$/,
/^stp\s+port\s+\d{1,2}\s+filter\s+(on|off)$/,
/^igmp\s+(on|off)$/,
/^mtu\s+\d{1,2}\s+\d+$/,
/^bw\s+(in|out)\s+\d{1,2}\s+\S+$/,
@@ -45,7 +53,8 @@ const conf_overwrite = [
/^lag\s+\d+\b/,
/^laghash\b/,
/^isolate\s+\d{1,2}\b/,
/^stp\b/,
/^stp\s+(prio|hello|maxage|fwd|txhold|version)\b/,
/^stp\s+port\s+\d{1,2}\s+(edge|cost|prio|guard|filter)\b/,
/^igmp\b/,
/^mtu\s+\d{1,2}\b/,
/^bw\s+(in|out)\s+\d{1,2}\b/,
+21 -1
View File
@@ -13,7 +13,27 @@
<h2>Mode: <select id="stpMode"><option value="off">Disabled</option><option value="on">Enabled</option></select>
<input style="width:15%;margin-left: 3em;" class="action" id="stp_sub" onclick="stpSub();" type="button" value="Apply"></h2>
<div id="stpStat" style="font-family:monospace;"></div>
<div id="stpPorts" style="font-family:monospace;white-space:pre;"></div>
<h2>Bridge settings</h2>
<table id="stpBridge">
<tr>
<th>Priority</th><th>Version</th><th>Hello [s]</th><th>Max age [s]</th><th>Fwd delay [s]</th><th>Tx hold</th>
</tr>
<tr>
<td><select id="bPrio"></select></td>
<td><select id="bVer"><option value="rstp">RSTP</option><option value="stp">STP compat</option></select></td>
<td><input id="bHello" type="number" min="1" max="10" style="width:4em"></td>
<td><input id="bMaxage" type="number" min="6" max="40" style="width:4em"></td>
<td><input id="bFwd" type="number" min="4" max="30" style="width:4em"></td>
<td><input id="bTxhold" type="number" min="1" max="10" style="width:4em"></td>
</tr>
</table>
<p style="font-size:small">Changes apply immediately. Edge ports skip the listen period; guard/filter act on received BPDUs.</p>
<h2>Ports</h2>
<table id="stpPortsTbl">
<tr>
<th>Port</th><th>State</th><th>Role</th><th>STP</th><th>Edge</th><th>Cost [k]</th><th>Priority</th><th>Guard</th><th>Filter</th>
</tr>
</table>
<script src="/stp.js"></script>
</div>
</body>
+120 -23
View File
@@ -1,12 +1,78 @@
/* ---- Spanning Tree (RSTP) section ---- */
/* Spanning Tree page: full RSTP configuration + live status.
*
* Every control applies IMMEDIATELY on change (POST /cmd "stp ...") - there is
* no per-row Apply. The refresh (2 s) repopulates controls from /stp.json;
* a global dirty flag suppresses that between a change and its confirmation
* so the refresh never reverts an edit in flight (same lesson as the LAG page).
*/
// STP port states as encoded in the ASIC's MSTP register (2 bits per port)
const STP_STATES = ["Disabled", "Blocking", "Learning", "Forwarding"];
const STP_ROLES = ["-", "Root", "Designated", "Alternate"];
// stp_pflags bits (keep in sync with rtl837x_stp.h)
const PF_ENABLED = 1, PF_ADMEDGE = 2, PF_AUTOEDGE = 4, PF_BPDUGUARD = 8,
PF_ROOTGUARD = 16, PF_FILTER = 32, PF_OPEREDGE = 64, PF_TRIPPED = 128;
// "user is editing" flag: while set, the periodic refresh must not overwrite
// the mode dropdown (same pattern as the LAG page - without it the 2 s refresh
// silently reverts the user's choice before Apply).
var stpDirty = false;
var stpRows = 0; // ports table built?
async function stpCmd(cmd) {
stpDirty = true;
try {
await fetch('/cmd', { method: 'POST', body: cmd });
} catch(err) {
console.error(`Error: ${err}`);
}
stpDirty = false;
fetchStp();
}
function sel(id, opts, onch) {
const s = document.createElement("select");
s.id = id;
for (const [v, label] of opts) {
const o = document.createElement("option");
o.value = v; o.textContent = label;
s.appendChild(o);
}
s.addEventListener("change", onch);
return s;
}
function num(id, min, max, onch) {
const n = document.createElement("input");
n.type = "number"; n.id = id; n.min = min; n.max = max; n.style.width = "4em";
n.addEventListener("change", onch);
return n;
}
function buildPortsTable(ports) {
const tbl = document.getElementById("stpPortsTbl");
for (const p of ports) {
const tr = tbl.insertRow();
tr.insertCell().textContent = p.p; // Port
tr.insertCell().id = "st_" + p.p; // State
tr.insertCell().id = "role_" + p.p; // Role
tr.insertCell().appendChild(sel("en_" + p.p,
[["on","on"],["off","off"]],
e => stpCmd("stp port " + p.p + " " + e.target.value)));
tr.insertCell().appendChild(sel("edge_" + p.p,
[["auto","auto"],["on","edge"],["off","off"]],
e => stpCmd("stp port " + p.p + " edge " + e.target.value)));
tr.insertCell().appendChild(num("cost_" + p.p, 0, 255,
e => stpCmd("stp port " + p.p + " cost " + e.target.value)));
tr.insertCell().appendChild(num("prio_" + p.p, 0, 240,
e => stpCmd("stp port " + p.p + " prio " + e.target.value)));
tr.insertCell().appendChild(sel("guard_" + p.p,
[["none","none"],["bpdu","BPDU"],["root","Root"]],
e => stpCmd("stp port " + p.p + " guard " + e.target.value)));
tr.insertCell().appendChild(sel("filt_" + p.p,
[["off","off"],["on","on"]],
e => stpCmd("stp port " + p.p + " filter " + e.target.value)));
}
stpRows = ports.length;
}
function fetchStp() {
var xhttp = new XMLHttpRequest();
@@ -15,21 +81,42 @@ function fetchStp() {
const s = JSON.parse(xhttp.responseText);
// textContent throughout: rootMac comes from received BPDUs
// (remote-controlled), never render it as HTML
if (!stpDirty)
document.getElementById("stpMode").value = s.on ? "on" : "off";
if (!stpRows)
buildPortsTable(s.ports);
document.getElementById("stpStat").textContent = s.on
? (s.weRoot
? "This switch is the root bridge (priority 0x" + s.rootPrio + ")"
? "This switch is the root bridge (priority 0x" + s.rootPrio + ") — topology changes: " + parseInt(s.tc, 16)
: "Root bridge: 0x" + s.rootPrio + " / " + s.rootMac
+ " \u2014 path cost: 0x" + s.cost)
+ " via port " + s.rootPort + " — path cost: 0x" + s.cost
+ " — topology changes: " + parseInt(s.tc, 16))
: "";
let t = "";
if (s.on) {
t = "port state\n";
for (const p of s.ports)
t += String(p.p).padEnd(6) + STP_STATES[p.st] + "\n";
// live status columns always refresh
for (const p of s.ports) {
const trip = (p.f & PF_TRIPPED) ? " (guard!)" : "";
document.getElementById("st_" + p.p).textContent =
s.on ? STP_STATES[p.st] + trip : "-";
document.getElementById("role_" + p.p).textContent =
s.on ? STP_ROLES[p.role] + ((p.f & PF_OPEREDGE) ? " edge" : "") : "-";
}
if (stpDirty) // an edit is in flight - do not revert controls
return;
document.getElementById("stpMode").value = s.on ? "on" : "off";
document.getElementById("bPrio").value = s.prio;
document.getElementById("bVer").value = s.rstp ? "rstp" : "stp";
document.getElementById("bHello").value = s.hello;
document.getElementById("bMaxage").value = s.maxage;
document.getElementById("bFwd").value = s.fwd;
document.getElementById("bTxhold").value = s.txhold;
for (const p of s.ports) {
document.getElementById("en_" + p.p).value = (p.f & PF_ENABLED) ? "on" : "off";
document.getElementById("edge_" + p.p).value =
(p.f & PF_ADMEDGE) ? "on" : ((p.f & PF_AUTOEDGE) ? "auto" : "off");
document.getElementById("cost_" + p.p).value = p.cost;
document.getElementById("prio_" + p.p).value = p.prio;
document.getElementById("guard_" + p.p).value =
(p.f & PF_BPDUGUARD) ? "bpdu" : ((p.f & PF_ROOTGUARD) ? "root" : "none");
document.getElementById("filt_" + p.p).value = (p.f & PF_FILTER) ? "on" : "off";
}
document.getElementById("stpPorts").textContent = t;
}
};
xhttp.open("GET", `/stp.json`, true);
@@ -38,21 +125,31 @@ function fetchStp() {
async function stpSub() {
const on = document.getElementById("stpMode").value === "on";
try {
await fetch('/cmd', { method: 'POST', body: on ? "stp on" : "stp off" });
} catch(err) {
console.error(`Error: ${err}`);
}
stpDirty = false; // editing done - let the refresh show the truth
fetchStp();
await stpCmd(on ? "stp on" : "stp off");
}
window.addEventListener("load", function() {
// bridge priority: 0-15 (x4096)
const bp = document.getElementById("bPrio");
for (let i = 0; i < 16; i++) {
const o = document.createElement("option");
o.value = i; o.textContent = (i * 4096) + (i === 8 ? " (default)" : "");
bp.appendChild(o);
}
bp.addEventListener("change", e => stpCmd("stp prio " + e.target.value));
document.getElementById("bVer")
.addEventListener("change", e => stpCmd("stp version " + e.target.value));
document.getElementById("bHello")
.addEventListener("change", e => stpCmd("stp hello " + e.target.value));
document.getElementById("bMaxage")
.addEventListener("change", e => stpCmd("stp maxage " + e.target.value));
document.getElementById("bFwd")
.addEventListener("change", e => stpCmd("stp fwd " + e.target.value));
document.getElementById("bTxhold")
.addEventListener("change", e => stpCmd("stp txhold " + e.target.value));
document.getElementById("stpMode")
.addEventListener("change", () => { stpDirty = true; });
});
window.addEventListener("load", function() {
update( () => {
fetchStp();
const interval = setInterval(update, 2000);