stp: turn the management failsafe into a one-shot window

The failsafe used to watch management traffic for as long as STP ran, so
three minutes of nobody looking at the web UI took the tree down on any
quiet network. That made a standing STP config impractical, which is the
problem raised in the review of the original PR.

Enabling STP arms a window of stp_failsafe_s seconds. One HTTP request
inside it confirms that management survived the new tree and disarms the
watchdog; a silent window disables STP and restores forwarding. Both
outcomes print to the console and the syslog.

The window re-arms on any later event that newly takes a port out of
forwarding: a port rejoining via "stp port N on", root guard firing, the
loop latch. Those were covered by the old always-on surveillance and a
disarmed window would have left them able to cut management off for good.
If management traffic keeps flowing past the new block, the next request
confirms straight away, which is the correct verdict, the block did not
cut it. The arming deliberately does not refresh an already armed window:
root guard can re-fire on every hello, and refreshing the countdown on
each one would keep a cut-off window from ever expiring. A stable network
with nothing newly blocked never re-arms, which is the reviewed-for
behaviour.

The request or console command that causes the arming never counts as its
own confirmation: mgmt_alive is cleared when a command arms, and the
console hook only disarms when the window predates the command. Without
that, enabling from the web UI or the console would confirm the window
before the new tree had any chance to cut management off.

A command on the serial console confirms like HTTP does. An operator at
the console has out-of-band access that no tree can cut, so the automatic
restore only takes STP away from someone equipped to deal with the
situation. The hook sits on the interactive console path only, identified
by cmd_available, so neither the config replay at boot nor HTTP commands
pass through it.

After a confirmation STP runs unsupervised until something new blocks.
Headless installs where nobody will confirm should set stp failsafe 0;
doc/stp.md says so.

Costs two bytes of XDATA, the armed flag and the console-path snapshot;
stp.rel and rtlplayground.rel keep their segment sizes.
This commit is contained in:
d00f
2026-08-18 23:29:08 +02:00
committed by d00f
parent 7ebb420e7d
commit c72d36af36
3 changed files with 76 additions and 30 deletions
+22 -6
View File
@@ -142,16 +142,32 @@ Enabling STP on a switch you administer over the network is a genuine risk: the
management VLAN rides a port that STP may put into blocking, and once that management VLAN rides a port that STP may put into blocking, and once that
happens the way back is a power cycle. happens the way back is a power cycle.
The firmware therefore runs a commit-confirm watchdog. While STP is enabled, The firmware therefore runs a commit-confirm watchdog. Enabling STP, by hand or
any HTTP request re-arms a countdown; if management stays silent for from the startup config, arms a one-shot window of `stp failsafe <seconds>`
`stp failsafe <seconds>` (default 180, 0 disables it), STP disables itself and (default 180). One HTTP request inside the window confirms that management
restores forwarding. Keeping the web UI open on the Spanning Tree page is survived the new tree and disarms the watchdog until the next enable; a window
enough to hold it off, since the page polls for status. with no management activity disables STP and restores forwarding. After the
confirmation STP runs unsupervised, so a quiet network no longer loses its
tree to three minutes of nobody looking at the web UI.
``` ```
stp failsafe 180 # seconds of silence before STP gives up (0 = never) stp failsafe 180 # length of the armed window after enabling (0 = never armed)
``` ```
Any later event that newly takes a port out of forwarding arms the window
again: a port rejoining via `stp port <n> on`, root guard firing, the loop
latch. If management traffic keeps flowing past the new block, the very next
request confirms and disarms; if the block cut it, the silent window restores
forwarding as above. A stable network with nothing newly blocked never re-arms.
A command executed on the serial console also confirms, on the grounds that an
operator with out-of-band access does not need the automatic restore; the
command that enabled STP does not count, only activity after it.
A headless switch that nobody confirms over HTTP should set `stp failsafe 0`,
otherwise a reboot with STP in the startup config disables it again three
minutes later. Setting a new value while STP runs arms a fresh window.
The status page shows whether the failsafe has tripped since STP was last The status page shows whether the failsafe has tripped since STP was last
enabled. enabled.
+47 -24
View File
@@ -62,13 +62,15 @@ __xdata uint8_t stp_fwddelay_s;
__xdata uint8_t stp_rstp; __xdata uint8_t stp_rstp;
__xdata uint8_t stp_txhold; __xdata uint8_t stp_txhold;
/* Management failsafe: if any port is held out of Forwarding while no HTTP /* Management failsafe, commit-confirm: enabling STP arms a one-shot window of
* request has been seen for stp_failsafe_s seconds, assume STP just cut off * stp_failsafe_s seconds. One HTTP request inside the window confirms that
* in-band management (mgmt VLAN rides a blockable front port!) and disable * management survived the new tree and disarms it until the next enable; a
* itself, restoring forwarding. Commit-confirm pattern; hardware lockout of * window with no management activity disables STP and restores forwarding
* 2026-07-20 is the motivating incident. 0 disables the watchdog. */ * (mgmt VLAN rides a blockable front port; lockout of 2026-07-20 is the
* motivating incident). 0 never arms. Headless installs should set 0. */
__xdata uint8_t stp_failsafe_s; __xdata uint8_t stp_failsafe_s;
__xdata uint8_t stp_failsafe_cnt; /* seconds left before the trip */ __xdata uint8_t stp_failsafe_cnt; /* seconds left of the armed window */
__xdata uint8_t stp_failsafe_armed;
__xdata uint8_t stp_failsafe_tripped; __xdata uint8_t stp_failsafe_tripped;
extern volatile __xdata uint8_t mgmt_alive; /* set by httpd on any request */ extern volatile __xdata uint8_t mgmt_alive; /* set by httpd on any request */
@@ -232,6 +234,10 @@ static void stp_loop_hold_peer(uint8_t port) __reentrant
print_string("STP: loop detected, blocking port "); print_string("STP: loop detected, blocking port ");
print_byte(port); write_char('\n'); print_byte(port); write_char('\n');
stp_state_set(port, 0b01); stp_state_set(port, 0b01);
if (stp_failsafe_s && !stp_failsafe_armed) {
stp_failsafe_armed = 1;
stp_failsafe_cnt = stp_failsafe_s;
}
stp_pflags[port] &= ~STP_PF_OPEREDGE; stp_pflags[port] &= ~STP_PF_OPEREDGE;
stp_topology_change(port); stp_topology_change(port);
} }
@@ -486,6 +492,10 @@ void stp_in(void) __banked
print_string("STP: root guard blocking port "); print_string("STP: root guard blocking port ");
print_byte(port); write_char('\n'); print_byte(port); write_char('\n');
stp_state_set(port, 0b01); stp_state_set(port, 0b01);
if (stp_failsafe_s && !stp_failsafe_armed) {
stp_failsafe_armed = 1;
stp_failsafe_cnt = stp_failsafe_s;
}
port_timers[port] = (uint16_t)stp_fwddelay_s * STP_HZ; port_timers[port] = (uint16_t)stp_fwddelay_s * STP_HZ;
stp_pflags[port] &= ~STP_PF_OPEREDGE; stp_pflags[port] &= ~STP_PF_OPEREDGE;
return; return;
@@ -523,25 +533,28 @@ void stp_timers(void) __banked
for (stp_i = machine.min_port; stp_i <= machine.max_port; stp_i++) for (stp_i = machine.min_port; stp_i <= machine.max_port; stp_i++)
stp_tx_budget[stp_i] = stp_txhold; stp_tx_budget[stp_i] = stp_txhold;
/* Management failsafe: plain commit-confirm. While STP is on, ANY /* Management failsafe: armed as a one-shot window by "stp on".
* HTTP request re-arms the countdown (the web UI polls /stp.json * The first HTTP request inside the window proves management
* every 2 s, so an open browser keeps it alive); stp_failsafe_s * survived the new tree and disarms it; a silent window disables
* seconds of management silence disable STP and restore the * STP. Deliberately NOT conditioned on our own MSTP states:
* pre-STP state. Deliberately NOT conditioned on our own MSTP * hardware incident 2026-07-21 showed a NEIGHBOR (TP-Link Easy
* states: hardware incident 2026-07-21 showed a NEIGHBOR (TP-Link * Smart loop prevention) cutting our uplink in reaction to our
* Easy Smart loop prevention) cutting our uplink in reaction to * BPDUs while our ASIC was all-forwarding - only going fully
* our BPDUs while our ASIC was all-forwarding - only going fully
* quiet (no BPDU TX) lets such a neighbor recover. */ * quiet (no BPDU TX) lets such a neighbor recover. */
if (mgmt_alive) { if (stp_failsafe_armed) {
mgmt_alive = 0; if (mgmt_alive) {
stp_failsafe_cnt = stp_failsafe_s; stp_failsafe_armed = 0;
} else if (stp_failsafe_s && stp_failsafe_cnt && --stp_failsafe_cnt == 0) { print_string("STP failsafe: management confirmed - disarmed\n");
print_string("STP failsafe: no management activity - disabling STP\n"); } else if (--stp_failsafe_cnt == 0) {
stp_off(); print_string("STP failsafe: no management activity - disabling STP\n");
stpEnabled = 0; stp_failsafe_armed = 0;
stp_failsafe_tripped = 1; stp_off();
return; stpEnabled = 0;
stp_failsafe_tripped = 1;
return;
}
} }
mgmt_alive = 0;
/* Link supervision. Without this the state machine never learns /* Link supervision. Without this the state machine never learns
* that a port lost carrier: it keeps the port in forwarding, keeps * that a port lost carrier: it keeps the port in forwarding, keeps
@@ -778,6 +791,8 @@ void stp_parse(void) __banked __reentrant
print_string("STP enabled\n"); print_string("STP enabled\n");
stp_failsafe_tripped = 0; stp_failsafe_tripped = 0;
stp_failsafe_cnt = stp_failsafe_s; stp_failsafe_cnt = stp_failsafe_s;
stp_failsafe_armed = stp_failsafe_s ? 1 : 0;
mgmt_alive = 0;
stpEnabled = 1; stpEnabled = 1;
stp_setup(); stp_setup();
return; return;
@@ -786,6 +801,7 @@ void stp_parse(void) __banked __reentrant
print_string("STP disabled\n"); print_string("STP disabled\n");
stp_off(); stp_off();
stpEnabled = 0; stpEnabled = 0;
stp_failsafe_armed = 0;
return; return;
} }
if (cmd_words_len < 3) if (cmd_words_len < 3)
@@ -809,6 +825,11 @@ void stp_parse(void) __banked __reentrant
if (stpEnabled) { /* (re)join: listen first */ if (stpEnabled) { /* (re)join: listen first */
stp_state_set(port, 0b01); stp_state_set(port, 0b01);
port_timers[port] = (uint16_t)stp_fwddelay_s * STP_HZ; port_timers[port] = (uint16_t)stp_fwddelay_s * STP_HZ;
if (stp_failsafe_s) {
stp_failsafe_armed = 1;
stp_failsafe_cnt = stp_failsafe_s;
mgmt_alive = 0;
}
} }
} else if (cmd_compare(3, "off")) { } else if (cmd_compare(3, "off")) {
stp_pflags[port] &= ~STP_PF_ENABLED; stp_pflags[port] &= ~STP_PF_ENABLED;
@@ -911,9 +932,11 @@ void stp_parse(void) __banked __reentrant
goto err; goto err;
stp_txhold = stp_scratch; stp_txhold = stp_scratch;
} else if (cmd_compare(1, "failsafe")) { } else if (cmd_compare(1, "failsafe")) {
/* 0 disables the management watchdog; otherwise seconds to trip */ /* 0 never arms; otherwise the length of the armed window */
stp_failsafe_s = stp_scratch; stp_failsafe_s = stp_scratch;
stp_failsafe_cnt = stp_scratch; stp_failsafe_cnt = stp_scratch;
stp_failsafe_armed = (stp_scratch && stpEnabled) ? 1 : 0;
mgmt_alive = 0;
} else { } else {
goto err; goto err;
} }
+7
View File
@@ -122,6 +122,8 @@ __xdata uint8_t tx_seq;
__xdata uint8_t stpEnabled; __xdata uint8_t stpEnabled;
__xdata uint8_t igmpEnabled; __xdata uint8_t igmpEnabled;
extern __xdata uint8_t stp_failsafe_armed;
__xdata uint8_t fs_was_armed;
__xdata char hostname[24]; /* device hostname, default set at boot, see rtl837x_common.h */ __xdata char hostname[24]; /* device hostname, default set at boot, see rtl837x_common.h */
__code uint16_t bit_mask[16] = { __code uint16_t bit_mask[16] = {
@@ -1518,9 +1520,14 @@ void idle(void)
// Check whether a command is waiting in the cmd_buffer and execute // Check whether a command is waiting in the cmd_buffer and execute
if (cmd_available) { if (cmd_available) {
cmd_available = 0; cmd_available = 0;
fs_was_armed = stp_failsafe_armed;
cmd_tokenize(); cmd_tokenize();
if (err_status == ERR_OK) if (err_status == ERR_OK)
cmd_parser(); cmd_parser();
if (fs_was_armed && stp_failsafe_armed) {
stp_failsafe_armed = 0;
print_string("STP failsafe: console activity - disarmed\n");
}
print_cmd_prompt(); print_cmd_prompt();
} }
} }