Commit Graph
70 Commits
Author SHA1 Message Date
d00f 64906ad135 stp: name front panel ports on the console, and add "stp status"
Two things from the review, both about the console being where you end up when
the tree is not what you expected.

The six messages that name a port were printing the internal index. On a board
whose map is not the identity that is a different number from the one written
next to the socket, which is worse than no number at all. They go through
machine.log_to_phys_port now, in a small helper that also swallows the newline
each of them repeated.

"stp status" prints the bridge and root IDs, the root port and path cost, the
topology change count, the failsafe setting, and a line per port with state,
role and operational edge. Everything it shows is state the module already
keeps, apart from the port states, which come from one read of MSTP_STATES.

660 bytes of BANK2, which leaves 3366 free. No internal RAM, no xdata.
2026-08-18 23:29:17 +02:00
d00f 4d7a2e28c7 stp: keep the designated bridge recording out of internal RAM
The report on the PR is that it will not link for KP_9000_6XHML_X2, with
"?ASlink-Error-Could not get N consecutive bytes in internal RAM for area OSEG"
five times over. It builds here on sdcc 4.2.0 and 4.5.0 for that same machine
and the same commit, so something in the toolchain differs, but the pressure it
is complaining about is mine and it costs little to give back.

stp_in() is __banked, so its temporaries get exclusive DSEG instead of
overlaying with anything else. Recording the designated bridge put four more
live values across a memcpy in the middle of it and the register allocator
answered with five spill locations. The module went from 5 bytes of DSEG to 12,
and from 17 sloc references to 49.

Moving that block into a __reentrant helper puts its temporaries on the stack
instead. The module now claims no DSEG at all, 5 bytes better than before the
recording was added, and the image sits at 95 bytes of DSEG against 101 on main.
It costs 170 bytes of BANK2, where there is room.
2026-08-18 23:29:16 +02:00
d00f a6c5f558bf stp: do not arm the management failsafe while the config replays
The failsafe is a commit confirm window for an interactive change: turn STP on,
and if management goes quiet for stp_failsafe_s seconds the switch undoes it.
The three places that arm it sit in the command parser, and execute_config()
drives that same parser at boot, so a saved "stp on" arms the window too. A
switch that reboots with nobody watching then turns its own STP back off.

Measured on a SWTGW218AS with "stp failsafe 180" in the saved config: cold boot,
no HTTP and no console for four minutes, and "STP failsafe: disabling" arrives
on time, with stp.json reporting on:0 and fsT:1.

execute_config() already clears save_cmd while it replays and sets it again at
the end, so the three parser sites can just test it. The two on the protocol
side, the loop latch and the root guard, stay unconditional. They react to what
arrived on the wire, which is the case the failsafe exists for, and they only
run once the replay is long finished.

BANK2 grows 24 bytes. Nothing else moves.
2026-08-18 23:29:16 +02:00
d00f 5a6b854c5d stp: record the designated bridge, port and cost from received BPDUs
stp_dbridge, stp_dpid and stp_dcost were declared and read by the status page,
but nothing ever wrote them, so they stayed zero for the life of the firmware.
The page's validity test then always failed, and the Designated Bridge,
Designated Port ID and Designated Cost columns reported our own values on every
port, including the root port where the answer is the upstream neighbour. The
three arrays reserved 140 bytes of xdata and never used any of it.

They are filled now, right after the loop check, so a frame that came back from
one of our own ports is not mistaken for a neighbour.

The validity test moves from the last byte of the stored MAC to the stored Port
ID. A Port ID is 1-based on the wire and cannot be zero, while a neighbour whose
MAC happens to end in 0x00 would have failed the old test.

The root path cost byte swap happens once now, and the root port branch reuses
the value instead of repeating the shifts.

BANK2 grows 148 bytes, BANK1 loses 9, and xdata does not move.
2026-08-18 23:29:15 +02:00
d00f d31980eb9e stp: sort the port rows, show this switch's bridge ID, react to enabling
Three things from the page feedback.

The rows came out in logical order while carrying the physical port number,
so on the six-port boards the first row is labelled 5. Sorting the rows by
that number in JS puts every board back into front-panel order. I walked all
25 machine definitions and each one now yields a clean 1..N.

The Designated Bridge column is hard to read without knowing this switch's
own bridge ID, so the status line shows it in the same priority and MAC shape
as the cells use. On the test switch that reads 61440-06:05:16:1E:F9:24 and
matches the Designated Bridge of every locally designated port, which is the
comparison that was missing. The root bridge and the path cost now use the
same formatting as the columns instead of raw hex.

Enabling STP printed nothing until the next poll, and because the ports start
blocked, management can stay quiet for the whole listening and learning
period, so the page had no chance to say anything later. It now writes what
is about to happen before the command goes out, and how long the ports need.

Page data only. Both banks, xdata and the common bank are unchanged.
2026-08-18 23:29:14 +02:00
d00f eb4a6ac275 stp: drop the bounds on the L2 flush and L2MC table waits
The review asked for these to come out and the reasoning holds. A guard that
gives up mid-transaction lets the code carry on with whatever the engine left
behind, and that is what cost me an SPI clip twice. An unbounded wait on a
wedged engine still hangs, but it hangs in a known place instead of writing
garbage into the L2 table.

BANK1 loses 45 bytes and xdata one. BANK2 and the common bank do not move.
2026-08-18 23:29:13 +02:00
d00f 1ba1224644 stp: trim the comments, and put one back on the variable it describes
Review asked for this across the other commits too. Gone are the blocks
that restate what doc/stp.md already says, the ones that explain what an
embedded programmer already knows, and one that had gone stale inside this
very branch: the CLI summary above stp_parse still described "cost <0-255>
(x1000)" while the parser has taken the raw 0 to 200000000 for some time,
and it never learned about p2p or trk at all. A usage list next to the
parser is the kind of thing that rots first, so it is out rather than
updated.

The review flagged one comment saying a variable is in xdata because the
internal RAM overlay is full, on the grounds that it may stop being true.
Four more of the same kind were in these files and are out as well, one of
them pointing at a file that does not exist in this branch at all. The
declarations still say __xdata, which is the part a reader needs.

Also out: the note on why three helpers are __reentrant, which was really
a paragraph about two bytes of DSEG, and the measurement story behind the
tick divider, which belongs with the other timer numbers in doc/stp.md.

One comment was not stale but simply wrong. "max BPDUs per port per second"
sat on stp_failsafe_tripped, having slid down two lines when the two
failsafe variables were inserted above it. It describes stp_txhold and is
back there now.

Short factual labels stay: they sit next to the magic number they explain
and the codebase uses them throughout. The generated code is byte for byte
what it was before this commit, both banks and xdata unchanged.
2026-08-18 23:29:11 +02:00
d00f 4a78b2dc8c doc: move the L2 multicast and tag word details out of the code
Review asked for this directly: the hardware layout above port_l2mc_set()
would be better as documentation than as a comment, keeping only the two
lines that say what the function does.

doc/l2.md gains a section on static multicast entries, why delivery uses
the forward action rather than the trap, and the SMI layout of the entry.
doc/CpuPort.md gains the layout of the tag's flags and pmask words, with
the byte order trap that cost an afternoon: writing the flags constant raw
instead of through HTONS puts 0x0020 on the wire as 0x2000, which is EFID
rather than LEARN_DIS, and the ASIC then leaves the 0x8899 header on the
frame.

The comments those paragraphs came from are replaced by a pointer to the
file that now holds them.
2026-08-18 23:29:10 +02:00
d00f 0160b430f4 doc: correct what a blocked port does with frames
The note said a blocked port drops frames the CPU injects into it and so
cannot send BPDUs of its own. Hardware says otherwise, and it matters,
because that sentence is the reason one would go looking for a way to let
control frames out of a blocked port when there is nothing to fix there.

Held a two port group in blocking and watched from the neighbour. Our
BPDUs kept leaving it, 27 of them with a largest gap of 2.00 s, which is
the hello interval with nothing missed. Pings across the same port stopped
dead for 11.63 s in one unbroken gap, so data really is held. In the same
window the neighbour sent 74 frames with a largest gap of 1.04 s while our
receive counter for them moved by 2, and the port stayed in its trunk
throughout, so nothing in the aggregation code was discarding them.
2026-08-18 23:29:09 +02:00
d00f c72d36af36 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.
2026-08-18 23:29:08 +02:00
d00f 7ebb420e7d stp: warn when an enabled port cannot receive BPDUs
A port set to admit tagged frames only will never see a BPDU, because
delivery rides the forward action and the ingress pipeline drops untagged
frames before the L2 lookup. The failure is silent and looks like a dead
receive path: the port turns edge after three seconds, the bridge elects
itself root, and nothing hints at the ingress setting. Diagnosing exactly
that cost most of a day on a live switch, with the neighbour provably
transmitting the whole time.

stp_setup() now prints one line per affected port, so the hint lands at
"stp on" and at every config replay on boot. The check runs in its own
loop after the MSTP write: port_ingress_filter_get() reads a register
into sfr_data, which the state-building loop above is still using. The
port number in the message is physical, matching what the ingress
command takes.

doc/stp.md explains why this can happen here and not on a normal bridge,
where BPDUs are consumed before any VLAN classification.

stp.rel stays at DSEG 5 with no OSEG and the image at 10498 bytes of
XDATA.
2026-08-18 23:28:39 +02:00
d00f 2cf60e177b stp: derive the BPDU flags from the port state
Every RST BPDU we sent carried flags 0x3c - designated, learning, forwarding -
whatever the port was actually doing. A blocked port kept announcing itself as
forwarding, and the root port would have called itself designated. Nothing on
this bench acted on it, but it is a lie in the protocol frame and the kind
that surfaces in somebody else's mixed network.

Derive the flags instead: the root port reports the root role, every other
transmitting port is designated (alternates do not transmit at all), and the
learning and forwarding bits mirror the ASIC state, so a listening port now
sends 0x0c. TC and TCA stay dynamic as before. Legacy Config BPDUs are
unchanged - their flags only ever carried TC and TCA.

Costs nothing in internal RAM; the state comes from the register scratch that
is already there.
2026-08-18 23:28:38 +02:00
d00f 0ce5fb4af3 stp: latch the block on a looped port
Two of our own ports on one segment blocked each other in turn instead
of settling. The guard on the loop path only acted when port_timers[]
had already run out, so a BPDU arriving while the port was blocked did
nothing: the timer expired, the port went forwarding, the loop reopened
and the pair started over. The comment above the code claimed the
opposite - "if the loop persists the BPDUs keep arriving and the port
stays blocked" - but nothing implemented it.

Measured on a SWTGW218AS with a patch cord between two free ports: both
ports blocked, both returned to forwarding one forward delay later, and
the topology-change counter reached 0x51 in 5.5 minutes - 15.6 changes
per minute for as long as the cable was in.

Let the better Port ID decide for both. That port is forwarding by
construction, so it goes on hearing the loop and re-arms the other
port's timer on every BPDU, which is what turns the block into a latch;
the held port only has to keep transmitting, which the send path already
allows in any MSTP state. Nothing here depends on a blocked port still
receiving - that was never established. Having one writer also removes a
race: while both ends decided for themselves, the winner's re-arm could
land in the loser's port_timers[] first, the loser read it as "already
blocked" and skipped its own state change, and the loop stayed open.

802.1D compares the priority before the number and stp_cnf_send() puts
stp_pprio[] on the wire next to it, so compare that first - otherwise
"stp port N prio" would quietly not influence which end of a looped pair
keeps forwarding.

The port number arrives in a frame and our bridge MAC is public in every
BPDU we send, so bound it to the ports this module manages before
indexing anything. Outside that range nothing would release the block
either: stp_timers() walks min_port..max_port and skips ports that are
not STP-enabled, so their port_timers[] never counts down.

Equal Port IDs mean the frame came back on the port it left - a loop
further out, behind an unmanaged switch. There is no pair to choose
from, so that port holds itself down; since it can only re-arm while it
is receiving, that case stays the forward-delay pulse it was before
rather than becoming a real latch.

The work sits in a __reentrant helper on purpose, like the two functions
above it: parameters and locals then live on the stack. Inlined into
stp_in(), which is __banked and whose temporaries cannot be overlaid,
the same code costs two more bytes of DSEG - enough to stop an image
that also carries LACP from linking at all.

Verified on hardware: with the loop in place for 1 h 36 min exactly one
port blocked, the other kept forwarding, and the topology-change counter
moved four times in total - three of them the link event and the
promotion in the first minute.
2026-08-18 23:28:37 +02:00
d00f 4b5bf09c83 doc: separate the trap action from CPU-port delivery
The wording read as a claim about the CPU interface in general, which is
wrong and misleading: the 8051 sits behind an ordinary port of the internal
switch and is an ordinary member of a forwarding mask - which is exactly
what this implementation relies on.

Say what is actually broken instead: the trap action, a separate mechanism
whose destination is an external CPU port these boards do not populate.
Record the measurements behind it, including the widened CPU_PMSK and both
external-CPU destinations, and add the ACL trap result - a rule matching the
group intercepts frames but does not deliver them either, which is a second,
independent path to the same conclusion.

Reported-by: vDorst
2026-08-18 23:28:37 +02:00
d00f be520d6716 stp: accept BPDUs with a protocol version above 2
We only recognised RST BPDUs when the Protocol Version Identifier was
exactly 2, which silently drops every MST BPDU: 802.1s uses version 3
with type 2 and a prefix deliberately laid out to be identical to an RST
BPDU, precisely so that an RSTP bridge can parse it.

802.1D-2004 14.4 spells the rule out - a bridge shall accept a version
identifier of 2 or greater and treat the BPDU as RST, ignoring anything
beyond what it understands. Compare with >= instead of ==. The receive
path already length-checks before touching the body and only reads the
fields common to both formats, so a longer MST body needs no other care.

The two fields are deliberately asymmetric: the Protocol Identifier must
be exactly zero (it is a sanity check), while the version is an extension
point that has to tolerate the future.
2026-08-18 23:28:35 +02:00
d00f f3c1b7f3ac stp: react to a port losing carrier
The state machine never looked at link state, so a port whose cable was
pulled stayed in forwarding: it kept being announced, kept its learned
entries, and the most ordinary topology change there is went unnoticed.
Observed on hardware - a non-edge port with the link administratively
down still reported forwarding and left the topology-change counter at
zero for the whole observation window.

Sample the carrier bitmap once per second, alongside the tx-budget refill
(the 50 Hz tick has no business doing register reads). On carrier loss put
the port back to blocking and run the normal topology-change path, which
flushes just that port's entries. On carrier return re-run the listen
period rather than forwarding immediately - the segment may have been
rewired while we were down - and clear the operational edge flag so a port
that was auto-edged has to earn it again.

stp_setup() seeds the bitmap from the hardware so enabling STP does not
report every already-down port as a fresh topology change.
2026-08-18 23:28:34 +02:00
d00f 7a790b32b1 README: STP is no longer missing
Point at doc/stp.md instead, with the caveat that the implementation is a
simplified one and that enabling it remotely deserves a read first.
2026-08-18 23:28:34 +02:00
d00f 293a196c91 doc: describe the Spanning Tree support
Covers enabling it, the bridge and per-port settings, how BPDUs are
delivered on this hardware (RMA forward constrained by a CPU-only static
L2 entry, since trap-to-CPU targets an external CPU these boards do not
have), the timer base, and the management failsafe - enabling STP over
the network can block the very port the management VLAN rides on, so
that part is spelled out rather than left to be discovered.
2026-08-18 23:28:33 +02:00
d00f 6b4d2b9cfa stp: apply an edge-port change immediately
"stp port N edge off" cleared only the admin and auto flags, not the
operational one - and that is the flag the engine actually consults: it
exempts the port from topology changes and lets it skip the listen
period. A port therefore stayed an edge port until the next "stp off" /
"stp on", silently ignoring the new setting. Clear it with the others,
and mark an admin edge operational right away, as stp_setup() does.
2026-08-18 23:28:33 +02:00
d00f 3c4fb679bd stp: correct the timer tick rate (50 Hz, measured)
The timers assumed stp_timers() runs at 64 Hz. It does not: the main loop
idles on the 200 Hz system tick and calls us every fourth pass, i.e.
50 Hz. Measured on hardware - with hello configured to 2 s the BPDUs left
the port 2.560 s apart, exactly the 28 % overshoot the wrong constant
implies, and every other timer (forward delay, max age, tx-hold refill)
was stretched the same way.

Move the constant to the header with the arithmetic spelled out, and use
it in the status page too, which had the 64 hardcoded and therefore aged
the same counters differently than the engine.
2026-08-18 23:28:32 +02:00
d00f f1f69e26fd stp: do not carry a dropped BPDU's flags over to the next one
The Topology Change Acknowledgment is staged in a one-shot variable and
consumed when the BPDU is built - but stp_cnf_send() can return before
that, when the port is filtered/tripped or its tx-hold budget for this
second is spent. The flag then survived and was OR-ed into the next BPDU
this switch sent, on whatever port that happened to be. Clear it with
the frame it belonged to.
2026-08-18 23:28:31 +02:00
d00f b51ed71bb1 stp: announce topology changes and flush the port that changed
A port entering forwarding, or being blocked because its own BPDU came
back, changes where MAC addresses live - but the counter was bumped and
nothing else happened: our forwarding table kept the stale entries and
the neighbours were never told.

Flush the affected port's dynamic entries (bounded single-port flush)
and set the Topology Change flag in our BPDUs for max age + forward
delay, so neighbours age their tables out as well. Edge ports are
exempt: a host coming or going is not a topology change.
2026-08-18 23:28:30 +02:00
d00f 65a41eaddd port: add a bounded single-port L2 flush
port_l2_forget() flushes the whole table and polls the flush engine
without a bound. A topology change only needs to age out the port that
changed, and the STP tick cannot afford an unbounded poll: add
port_l2_forget_port() with a single-port mask and the same bounded wait
the static-entry helper uses.
2026-08-18 23:28:30 +02:00
d00f 5361c85d30 stp: keep the root port quiet and relay the message age
Two protocol-correctness fixes on the information we advertise:

Only designated ports announce periodically. The root port is where our
root information arrives; sending it back there feeds the upstream
bridge its own data and makes us look like a competing designated
bridge on that segment.

Relay the message age instead of always claiming zero. A bridge
increments the received age by one second per hop, so downstream
neighbours can age the information out; advertising 0 forever made our
BPDUs look eternally fresh no matter how stale the root information was.
Age stays 0 while we are the root ourselves.
2026-08-18 23:28:29 +02:00
d00f a31be093e7 stp: answer TCN BPDUs and validate the received BPDU length
Accept legacy Topology Change Notification BPDUs (v0, type 0x80): reply
on the ingress port with a Config BPDU carrying Topology Change
Acknowledgment so the sender stops repeating, and count the change.

Also stop reading fields past the end of short frames: require the
header through bpdu_type (33 bytes with the CPU/VLAN prefix) before
classifying, and the full 35-byte body before the election logic -
truncated or fuzzed BPDUs are dropped instead of parsed as garbage.
2026-08-18 23:28:28 +02:00
d00f f39eefa90c stp: carry the version-1 length field in RST BPDUs
An RST BPDU body is 36 bytes: the Config-BPDU fields plus a trailing
version-1 length octet (zero - there is no version-1 information).
Ours was 35 - strict 802.1w parsers treat such a BPDU as malformed and
drop it. Add the field, keep legacy Config BPDUs at 35 bytes, and set
the 802.3 length accordingly (0x27 with LLC for RST, 0x26 for Config).
2026-08-18 23:28:27 +02:00
d00f eaff9537ef stp: vendor-style per-port config + status (path cost, p2p, designated info)
Bring the Spanning Tree page in line with a typical managed switch's
per-port panel. Configuration gains the full-range path cost (raw
0..200000000, 0 = auto, replacing the old 1000x-scaled byte), a
point-to-point admin control (auto/on/off), and the priority is now a
0..240 step-16 dropdown. A new status table shows, per port, the Port
State, Role, Designated Bridge / Port ID / Cost (learned from received
BPDUs, kept per port and aged via the BPDU age), Operational Edge and
Operational Point-to-Point.

The designated fields fall back to presenting this switch as the
segment's designated bridge when no fresh BPDU has been heard (so a
quiet port shows our own bridge-id, as the vendor UIs do). /stp.json
carries the packed hex fields plus our own MAC for that fallback.

Space: reclaim BANK2 for the above by moving rtl837x_pins to HOME and
compacting leds_dump into a register-address table (~800B); bandwidth
returns to BANK1. No BANK3 - hardware-verified that PSBANK > 2 crashes
this SoC at boot (a bricked unit and an SPI-programmer recovery earlier
today); a warning to that effect is now in rtl837x_lldp.c.

Hardware-verified: cost 200000000 and p2p off round-trip through the CLI
and JSON, the status table populates correctly with STP enabled (all
ports Forwarding/Designated, oper-edge and oper-p2p True), LACP 3f/3f
and the LAN unaffected.

(cherry picked from commit 2ec62072f061dc9e78bc821ba1c297cb6819e206)
2026-08-18 23:28:27 +02:00
d00f 2110cf128a stp: management failsafe (commit-confirm) + bounded NIC waits
Enabling STP on a bridge whose management rides an in-band VLAN can cut
off that very management - and not only by our own blocking: on this
network the upstream TP-Link Easy Smart switch's "loop prevention"
reacted to our BPDU hellos by blocking ITS port towards us while our
ASIC was all-forwarding, isolating the whole segment until a power
cycle. Recoverable only by going quiet.

Add a commit-confirm watchdog: while STP is enabled, any HTTP request
re-arms a countdown ("stp failsafe <seconds>", default 180, 0 disables);
if management stays silent for the whole window, STP disables itself,
which also stops BPDU TX so a neighbour's loop protection can release
its block. The web UI polls /stp.json every 2 s, so an open browser
naturally keeps the watchdog re-armed. The trip is reported via
/stp.json (fs, fsT) and as a warning on the Spanning Tree page.

Deliberately not conditioned on our own MSTP port states - the incident
above proves the uplink can be dead while every local port forwards.

Also bound the NIC DMA busy-waits (nic_tx_packet, nic_rx_header,
nic_rx_packet): an unbounded spin on SFR_NIC_CTRL freezes the entire
main loop (timers, HTTP, ARP) if the ASIC ever fails to consume a
transfer; give up after ~65k polls and drop the frame instead.

Hardware-verified end to end: with priority 15 against a live RSTP
bridge the uplink died 6 s after "stp on" and the network recovered BY
ITSELF 66 s later (trip at 45 s + neighbour release), fsT=1, LACP and
LAN intact. Telemetry via syslog-to-edge-port host confirmed the full
chain: countdown 44->4, trip, hello TX stopping at the trip.

(cherry picked from commit 1fa9775156fd6d7ebfdda2382f73430b86601230)
2026-08-18 23:28:26 +02:00
d00f 267e371d3c stp: BPDUs finally reach the wire (CPU tag flags + management VLAN)
Two TX bugs meant our BPDUs NEVER left the switch as valid STP frames -
on the wire they appeared as ethertype 0x8899 (the raw Realtek CPU tag)
and were flooded to all ports instead of directed. Every earlier root
election was a solo act: no other bridge ever saw us. Both are the same
bug classes fixed for LACP earlier:

- rtl_tag.flags was written raw (0x0020); like every other tag field it
  must go through HTONS, otherwise the bits land in the wrong byte
  (0x2000 = EFID), the ASIC fails to parse the tag and floods the frame
  with the 0x8899 header still attached.
- With a management VLAN set, tcpip_output() splices an 802.1Q tag after
  the SA, again shifting the CPU tag out of the parsed position. BPDUs are
  link-local and must egress untagged: suppress the VLAN insert per frame,
  exactly as lacp_send() does.

Hardware note discovered while fixing this: RTL_TAG_KEEP on an LLC/802.3
(length-field) frame makes the ASIC drop it entirely - the same flag works
fine on ethertype frames (LACP). So BPDUs use LEARN_DIS only.

Verified on the wire (tcpdump on the peer): clean "802.3 ... LLC, dsap STP
0x42 ... Rapid STP, bridge-id 8000.<our mac>" at the hello interval, sent
directed (no flood), management HTTP unaffected, LAN at 0% loss throughout.

(cherry picked from commit 4a41a292a9ab88d4fb05a8481ad28f8ffcfd9bc4)
2026-08-18 23:28:25 +02:00
d00f 9702f8e4c4 stp: reject port sub-commands with a missing argument
"stp port 7 edge" (no value) passed the cmd_words_len < 4 check and then
cmd_compare(4, ...) read a stale word left over from the PREVIOUS command
line - cmd_words_b is not cleared between commands - so the sub-command
could randomly match whatever was typed before. Require 5 words for every
per-port sub-command that carries an argument (everything except on/off).

(cherry picked from commit 1210f4f9257b14c31ad653fc7616ef403a494d28)
2026-08-18 23:28:24 +02:00
d00f 524c37d7c7 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)
2026-08-18 23:28:23 +02:00
d00f b5387016fa gui: Spanning Tree page (config + live status)
Add a Spanning Tree page: an on/off toggle driving the existing "stp"
command over /cmd, and a live status section fed by a new /stp.json
endpoint - the elected root bridge (priority + MAC), our path cost,
whether we are the root, and the per-port STP state read live from the
ASIC's MSTP register (same 2-bit encoding stp_setup() writes). Ports are
reported by their physical numbers.

Recovered-from: 3132319, 9365c86
2026-08-18 23:27:09 +02:00
d00f 5c881da4fe stp: stop forcing the SFP port to forwarding in the CPU-port mask
The "do not block the CPU port" mask 0x0f covers bits 3:0 of MSTP_STATES
byte 1, which is ports 8 AND 9 - so stp_setup unconditionally forced
port 8 (a real front port, the SFP uplink on SWTGW218AS) into forwarding
and it could never be blocked. The CPU port alone is bits 3:2 = 0x0c.
2026-08-18 23:27:08 +02:00
d00f 0df699659a stp: actually promote ports out of blocking; calibrate timers
"stp on" put every port into blocking (stp_setup, port_timers = "10 s") but
nothing ever counted those timers down: stp_timers() only sent hello BPDUs.
On a network with no other (R)STP bridge - i.e. nobody sends us BPDUs - every
port therefore stayed blocking FOREVER and enabling STP took the whole
network down until "stp off".

- stp_timers(): count port_timers down; when a port's listen period expires
  with no better root heard, promote it to forwarding in MSTP_STATES (we are
  the designated bridge on that port).
- Calibrate the tick constants to the real stp_timers() rate (~64 Hz: main
  loop ~256 Hz / (STP_TICK_DIVIDER+1)): TIME_HELLO 0x200->0x80 is an actual
  2 s hello, port_timers 0xa00->0x280 an actual 10 s listen period. Measured
  before the fix, ports converged only after ~40 s.
- Move struct bridge into rtl837x_stp.h and export root_bridge/-_cost for
  the web UI status endpoint.

Verified on hardware: "stp on" -> ports report Blocking, after the 10 s
listen period all ports promote to Forwarding and LAN connectivity returns;
"stp off" restores forwarding immediately. We elect ourselves root (weRoot)
with no other bridge present.

(cherry picked from commit 8537a15ca254b2122272b20bec7a66426e86df4b)
2026-08-18 23:27:08 +02:00
d00f 032305ad3a stp: move the STP module to code bank 2
The always-mapped common area is nearly full (349 bytes free before this
change), and the STP state machine that follows does not fit there. Move
the module to BANK2 next to the other protocol code; its public entry
points are already __banked, and cmpMAC/stp_cnf_send have no callers
outside the file.
2026-08-18 23:27:07 +02:00
d00f b16c7d9723 common: define the RTL frame-tag flag bits shared by STP and LACP
The rtl_tag `flags` word (LEARN_DIS, KEEP) and the `pmask` ALLOW-bit
semantics are properties of the RTL8_4 CPU tag, not of any one protocol:
STP injects BPDUs with LEARN_DIS set and LACP emits slow-protocol frames
the same way. Define them once in the shared header, with the HTONS
byte-order caveat documented, so every feature that hand-builds a CPU
tag frame uses the same constants.

(cherry picked from commit 7f905b3e90f9bc5df586a5138723d97edf3d6aaf)
2026-08-18 23:27:06 +02:00
d00f 6e1f199571 stp: contain BPDUs to the CPU while STP runs
With STP enabled the switch is a participating bridge, so BPDUs must be
consumed, not relayed - yet the reserved group 01:80:C2:00:00:00 was
flooded across the VLAN just like any multicast, leaking every BPDU to
all ports (the same defect class as the LACPDU flood addressed in the
LACP branch, PR #299).

On stp on, write a CPU-only static L2 multicast entry for the BPDU group
per VLAN: BPDUs can arrive VLAN-tagged and classify into the tag's VID,
so cover every VLAN present in the VLAN table plus every port's PVID for
the untagged case.

On stp off the same entries are retargeted to all ports + CPU, restoring
the previous flood behaviour: an unmanaged switch is expected to be
transparent to BPDUs so the surrounding spanning tree can span through
it, and dropping them instead would partition that topology.

Note: with STP enabled the ports start out blocking, which also stops
egress of CPU-originated LACPDUs, so an active LACP aggregate drops
until the ports reach forwarding - a pre-existing interaction, not
changed here.
2026-08-18 23:27:06 +02:00
d00f 5329193987 port: add a helper to steer a link-local group via a static L2 entry
port_l2mc_set() writes a static L2 multicast entry for a reserved group
01:80:C2:00:00:<last> in a given VLAN with a given member portmask.

Slow-protocol frames must reach the management CPU without being flooded
to other ports, but the RMA "trap" action cannot deliver to the internal
NIC on this hardware - its destination is an external CPU attached to a
physical port. The working alternative is to keep the RMA action at
"forward" and constrain the egress with a static entry: the forward
lookup then hits the entry's member mask instead of the VLAN flood mask.
Hardware-verified on a SWTGW218AS in both directions: a mask without the
CPU bit stops delivery to the CPU, a CPU-only mask delivers with no port
egress. Lookups are IVL, so callers add one entry per VID they care
about; rewriting the same MAC+VID replaces the entry in place.

Used by the BPDU containment in the next commit; the pending LACP branch
adopts it for 01:80:C2:00:00:02 the same way.
2026-08-18 23:27:05 +02:00
d00f 56fa96c494 igmp: only hand reports to the CPU while snooping is on
handle_rx() dispatched to igmp_packet_handler() on the destination
address alone, so an ordinary IGMPv3 report off the wire reached the
handler and could write a table entry whether or not anyone had asked
for snooping. The STP branch right above it is gated on stpEnabled;
this brings the IGMP branch in line.

Snooping state lived only in the per-port registers, and the receive
path cannot afford to read one per packet, so the flag shadows it:
igmp_enable() sets it, igmp_setup() clears it, and igmp_setup() runs
from both the boot path and "igmp off".

While here, igmp off becomes an explicit subcommand instead of the
fall-through, and an unrecognised igmp subcommand prints the usage
line rather than silently turning snooping off.

Six bytes of BANK1 and one of xdata, no internal RAM.
2026-08-16 23:42:46 +02:00
d00f 3fda9ccd86 html: handle an empty MAC table reply and fix the entry cap
l2.js reads the last index of a reply to know where the next page starts.
With the firmware side of this branch an empty table answers [], and
s[s.length-1] then throws on undefined. It used to answer commas with
nothing between them and throw in JSON.parse instead, so this is the same
case reaching a different line rather than a new one. An empty reply now renders what
has been collected and starts the next pass from zero.

The 4096 entry cap compared the array against the number instead of its
length, so it never fired: an empty array and a 5000 element one both
compare false. Comparing the length restores what the check was for.
2026-08-15 23:14:49 +02:00
d00f a3c586ef38 port: make the trunk hash default reachable again, and per group
Wrapping REG_SET restored the guard in front of the hash default, and
that exposed three things about the line it guards.

The test was against zero. The register does not read zero: it comes out
of reset holding source port number plus both MAC fields, both IP fields
and the L4 source port, which the header now names LAG_HASH_RESET.
Measured on an SWTGW218AS, where all four groups read 0x3f after a cold
boot and a value written before a power cycle is gone afterwards. With
the guard working and the test unreachable, the default would never be
installed, where before it was installed on every call. Testing against
the reset value restores the intent, and zero is still accepted in case
another device does reset that way.

The write went to the base address while the read that decides it used
the group offset, so a group other than zero was tested and group zero
was written. Both ends use the offset now.

The range check printed a complaint and carried on. It returns, which
matters more now that the hash write also uses the group number to build
an address.

Thirty bytes of BANK1.
2026-08-15 22:01:08 +02:00
d00f 0503e7952d httpd: check the port index /counters.json is given
The handler took one raw character of the request line and passed it to
send_counters(), which uses it to index machine.phys_to_log_port. That
array has nine entries and the character is whatever the client sent, so
the read ran up to 246 entries past the end and the result went on to
STAT_GET as a port number. is_word() accepts any request whose name is
followed by a question mark, so nothing constrained the byte to a digit.

Bounding it where it is read keeps the check beside the assumption it
protects and needs nothing from the machine description. sdcc leaves
plain char unsigned and the subtraction wraps in eight bits, so a byte
below '0' comes out above 200 and one upper test covers both ends:
exactly '0' to '8' now reach send_counters. The compiled test is
add a,#0xf7 followed by jnc, which I read back out of the assembly rather
than assuming.

Out of range answers 400 by the path the other malformed requests already
take, rather than an empty array. An empty array would have been worse
than useless here, since the statistics page calls BigInt on the first
element before it looks at the length. The page asks only for index zero
to the port count minus one, so nothing that answered before stops
answering, and a non-200 reply makes its handler do nothing at all.

11 bytes of BANK1, nothing in the common segment, BANK2, xdata or
internal RAM. Built for SWTGW218AS and KP_9000_6XHML_X2 on sdcc 4.5.0.
2026-08-15 00:49:21 +02:00
d00f 5c5dcb5209 igmp: drop the duplicate port configuration loop
igmp_setup() writes the per-port IGMP configuration twice, once with the
value spelled out and once with the same number assembled from the
constants: IGMP_MAX_GROUP | IGMP_PROTOCOL_ENABLE | IGMP_FLOOD is exactly
0x00ff7c15. The second loop carries the comment block explaining the bit
layout, so the literal one is the one to drop.

Today the cost is one redundant register write, because REG_SET is not a
single statement and the unbraced loop body only ever reaches index
machine.max_port + 1. Once the macro is wrapped it becomes one redundant
write per port on every boot and on every "igmp off", which is what makes
this worth removing rather than leaving.

Fifty nine bytes of BANK1 on SWTGW218AS, nothing anywhere else.
2026-08-14 07:39:24 +02:00
d00f be6d47a3ab regs: wrap REG_SET and REG_WRITE in do { } while (0)
Both macros expand to a run of statements joined by backslashes with
nothing around them, so as the unbraced body of an if or a for only the
first assignment belongs to that body. The other three and the
reg_write() call sit after it and run once, unconditionally, with
whatever the loop counter ended on. Wrapping each macro into a single
statement is what every call site already assumes it to be.

This hands the compiler no new room around the SFR writes, which is worth
showing rather than asserting. Building the whole image before and after
and comparing the generated assembly module by module, with label
numbering, block scope suffixes and the version string normalised away,
three modules differ: rtl837x_igmp, rtl837x_port and rtl837x_leds. Ten
modules call these macros, so the other seven come out identical, and so
does everything else in the image.

Those three differ because they hold the five unbraced uses. Two of them
want a fix rather than only the brace, and that is left to the commits
that follow.

Six bytes of BANK1 on SWTGW218AS, nothing anywhere else.
2026-08-14 07:34:42 +02:00
d00f 43845b911b httpd: stop the GET request line walk at the end of the buffer
The POST path tests for a NUL before it looks at a byte. The GET path did not,
and is_separator() counts only space, tab, question mark and equals, so a request
line carrying none of those walks past the end of uip_buf and writes its
terminator into whatever xdata it happens to stop on.

Everything that is not a POST reaches that walk. The pointer advances past the
method before anything checks that the method was GET, so a TLS record sent to
port 80 by a browser trying https first is enough on its own, as is a port
scanner or a malformed line. The stop is wherever the first space, tab, question
mark or equals turns up in memory, which is why the symptoms are erratic.

Two bytes of BANK1. BANK2 and xdata do not move.
2026-08-12 03:22:01 +02:00
d00f e30976be76 httpd: move the management VLAN into /vlanlist
Review feedback on #306. The value used to ride in /information.json and
the picker fetched /vlanlist separately, so the page needed both requests
to mean anything. /vlanlist now answers {"mgmt":N,"vlan":[...]} and the
picker reads both from the one response. Both consumers in vlan.js were
taught the new shape, and /information.json no longer carries mgmt_vlan.

The truncation guard now reserves 141 bytes instead of 139: the closing
grew to two bytes with the wrapping object, and the comma in front of a
non-first entry was never counted, so the worst case could land one byte
past outbuf even before this change. char_to_html() does not check.

The comments added on this branch are gone as well, style.css and
system.js both, since these files are served byte for byte.

page_impl.rel stays at DSEG 5, OSEG 0, BSEG 3 and the image reports the
same 10183 bytes of XDATA before and after.
2026-08-08 18:26:49 +02:00
d00f 1a55a134c7 system: pick the management VLAN from System Settings
`vlan <id> mgmt` existed on the CLI, but nothing reported which VLAN
currently carries management, so the setting was invisible from the web UI -
the only way to find out was to read the startup config.

Report it in information.json and offer it where the other switch-wide
addressing settings live, between Gateway and Language, as a picker filled
from the configured VLANs. When management is untagged there is no VLAN to
select, so that state shows as a disabled entry rather than inventing an id
the switch would reject.

Confirm before applying, and say what will happen rather than echoing the
action back: the switch starts tagging its own frames, and if the port you
came in on does not carry that VLAN the page becomes unreachable and the way
back is the console. Cancelling puts the picker back where it was.
2026-08-08 18:26:49 +02:00
d00f 23f0ba995c css: align read-only values with the input fields
The System Settings rows all pair a label with a form control, except Model,
which is a bare span. The inputs carry padding and a left margin, so their
text starts about 32px further in than the model name did - close enough to
look like a mistake and far enough to see it.

Give read-only values a class with the same metrics instead of styling the
model specifically; any other value shown without a control gets the
alignment for free.
2026-08-08 18:26:15 +02:00
d00f 22a09bd06a l2: drop the comments and define the column list once
Review feedback on #307: these files are served from flash byte for byte,
so comments ride along on every page load. The three added in this branch
are gone, 357 bytes across l2.js and style.css. The column list existed in
three copies inside renderL2() and is now a single const.
2026-08-08 17:55:27 +02:00
d00f 930a08ec52 cmd: validate the port argument of mtu and pvid
Both handlers turned the first character of the port word into an index
with no check at all. "mtu 0 100" computes '0' - '1' = 255, reads far past
the end of phys_to_log_port[9], and writes the size to whatever register
0x1250 plus that garbage points at. "pvid 0 2" walks the same path into
port_pvid_set(). lag and vlan already validate their port arguments; these
two just did not.

The port now has to be a single digit 1 to 9, which is exactly the range
the mapping table holds. A second digit or a stray letter falls to the
usage message.

"mtu show" also gained the return it was missing: after printing the table
it fell through, derived a port from the word "show" and printed the
garbage byte before the length check stopped it.
2026-08-08 17:46:24 +02:00
d00f 1701d4dc53 cmd: refuse a management VLAN that cannot exist
"vlan 5000 mgmt" parked the management interface on a VLAN the table
cannot hold, which quietly cuts management off. IDs above 4094 now fall
through to the usage message. 0 still switches the management VLAN off,
which is the documented way to disable it.
2026-08-08 17:38:10 +02:00
d00f 1e19a9abe2 cmd: bound the PVID and say something when it is refused
"pvid 1 5000" packed 5000 into the 12-bit PVID field and truncated on the
way, so the port ended up with a PVID nobody chose. 0 and anything above
4094 are refused now, matching what the VLAN table can hold. A failed
parse used to be dropped without a word; both cases print the usage line.
2026-08-08 17:38:10 +02:00
d00f 1098e73337 cmd: stop "mtu" from acting on a value it failed to parse
The handler threw away atoi_short()'s return and leant on the range test
alone. The range test cannot tell a failed parse from a small number, so
"mtu 1 99999" stopped at the partial 9999 and went through as a number
nobody typed. With the parse result checked, a failure is rejected with
the same message as an out-of-range value.
2026-08-08 17:38:10 +02:00
d00f 99b0fc4b32 cmd: give mtu a lower bound as well as an upper one
"mtu 1 0" was accepted. The chip takes it verbatim, the port reports 0x0000
back, and it stops passing frames: on a live 2.5G LAG member the LACPDU
receives moved by 4 in fifteen seconds against 21 on the sibling port, and
the partner went expired. Restoring the size brought both back.

Nothing shorter than a minimum Ethernet frame is a usable maximum, so the
range is now 64 to 16383. The upper end is unchanged and still comes from
the width of the field the value is written into.

This also covers most of #312 by accident: "mtu 1 abc" leaves the parse
result at 0 and now gets rejected on the bound rather than reaching the
register. It does not cover all of it. The handler still ignores what
atoi_short() returns, so "mtu 1 99999" stops on a partial 9999 and goes
through as a number nobody typed.

The GUI is not affected either way, it offers a fixed list of sizes.
2026-08-08 17:30:25 +02:00
d00f 10d472d8a6 vlan: reject VLAN IDs the table cannot hold
vlan_create() and vlan_delete() wrote the ID straight into the table index
register. vlan_get() has refused anything >= 0xfff for a while, so reads were
guarded and writes were not: "vlan 4095 1 2" built an entry that no read path
can see, and IDs above that either miss the table or alias onto another VLAN.

Both writers now enforce the range vlan_get() already did, and parse_vlan()
rejects the same values with the usage message so the CLI says why. The check
sits after the "vlan 0 mgmt" branch, which legitimately takes 0 to switch the
management VLAN off.

Costs nothing in RAM: rtl837x_port.rel stays at DSEG 0, OSEG 5, and the image
still reports 10207 bytes of XDATA in use.
2026-08-08 17:13:28 +02:00
d00f 6254f44001 cmd: reject out-of-range numeric arguments instead of wrapping
atoi_short() accumulated into a uint16_t without checking, so "vlan 65540"
wrapped to 4 and edited VLAN 4 instead of failing. atoi_byte() had the same
hole with "300" landing on 44. Both now refuse the digit that would push the
value past its type, before it lands.

The partial result is deliberately left alone rather than zeroed. Zeroing
would give the function one tidy rule, every failure leaves 0, but a caller
that ignores the return would then write that 0, and 0 is not a harmless
number everywhere. Set as a port MTU it stops the port taking frames: I put
0 on a live 2.5G LAG member and its LACPDU receives moved by 4 in fifteen
seconds against 21 on the sibling port, with the partner going expired.
Putting the size back recovered both. A wrong number does less damage than
that, and the real fix belongs in the callers that ignore the return anyway.

The test sits inside the loop rather than after it, so no wider accumulator
is needed and the parser stays off the internal RAM budget.

056a30a on the branch in #303 fixes atoi_byte a different way, by widening
the accumulator. Whichever lands first, the other hunk should go.
2026-08-08 17:13:08 +02:00
d00f ccb90ed16f l2: refresh the type column when a row is reused
The paint loop reuses existing rows and rewrites port, MAC, VLAN and the
delete button, but never the type cell - only the insert path set it. With a
fixed row order that stayed invisible, since a row usually landed back where
it was. Sorting moves rows, so every other column followed the data while
type kept the previous row's value, which made sorting by type look broken
when the sort itself was correct.
2026-08-05 21:42:54 +02:00
d00f 4389cfcd8b l2: show which column sorts and in which direction
Clicking a heading sorted the table but nothing said so afterwards - the
only feedback was the rows moving, which is no help when the sort key is a
column you are not looking at.

Give every sortable heading a permanent marker: a neutral double arrow when
it is not the sort key, up or down when it is. The marker sits in its own
span so the i18n pass, which replaces the heading text, does not wipe it.
2026-08-05 21:35:54 +02:00
d00f 5e901fb5c2 l2: sort and filter the forwarding table from its header
The table lists every learned and static entry in one flat block, which is
fine with a handful and unusable with a few hundred: finding out where one
MAC sits, or what a port has learned, meant reading the whole thing.

Make the column headings sort and give each one a filter box. Filters are
substring matches combined with AND, so "port 8 + static" is two keystrokes.
A counter above the table shows matched out of total, so a filter that hides
everything is obvious rather than looking like an empty table.

Sorting keeps the CPU entry from comparing as a number - it sorts last
instead of landing between ports 8 and 9, where a string-vs-number compare
would otherwise put it.

The filter inputs live inside the existing header cells rather than in a
second row: the paint loop addresses data rows as rows[i+1], and a second
header row would have shifted every one of them.
2026-08-05 21:21:03 +02:00
d00f 2974e4b66a l2: stop labelling the SFP port as CPU
The port column was mapped from logical to physical numbering first and the
CPU label applied afterwards, testing for port 9. On an 8+1 board the SFP
port maps to physical 9 as well, so every entry learned on the SFP was shown
as CPU - on this switch that was 26 of 30 entries.

Label the CPU while the number is still logical, before the mapping, so the
two cannot collide.
2026-08-05 21:21:03 +02:00
d00f 108ac9b8fc system: derive the default hostname after the startup config
Move the MAC-derived default name out of main() into its own function and
call it after execute_config(), returning early when the config already
set a name - a configured switch then does no work for it at all.

The body deliberately has no local variables. Locals here - counters and
pointers alike - land in the 8051's internal-RAM overlay, and on an image
with LACP and STP both enabled that overlay is exhausted: a loop makes the
linker fail with "Could not get 8 consecutive bytes in internal RAM for
area OSEG". Moving the code into its own function does not help, since the
overlay is shared across the whole image, and hoisting the locals to xdata
does not either, because itohex() is inline and brings its own frame. This
only shows up in an integrated build; the branch on its own links fine.

Suggested-by: vDorst
2026-08-05 16:48:36 +02:00
d00f 1194823cd5 system: drop the defensive checks around the System page fields
The hostname and model fields were populated through element-existence
checks and empty-string fallbacks that the other fields on the page do
without. Assign them the same way.

Suggested-by: vDorst
2026-08-04 05:43:05 +02:00
d00f 94716ceb5d system: derive the default hostname from the MAC address
Every switch came up as "RTLPlayground", so several of them on one
network were indistinguishable until someone configured a name. Append
the last three MAC octets (e.g. RTLPlayground-1ef924) - unique in
practice, still recognisable, and any "hostname ..." line in the startup
config overrides it as before.

Suggested-by: plaes
2026-08-04 05:43:05 +02:00
d00f 13120127df cmd: make "hostname" report the name and reject stray arguments
Typing "hostname" on its own cleared the name: with no argument the copy
loop never ran and the terminating NUL landed at index 0. Report the
current name instead, accept exactly one argument to set it, and reject
anything longer - a name with spaces tokenizes into several words, and
silently keeping only the first one is worse than an error. Walk the
buffer with a pointer, which the compiler codes better than indexing.

Suggested-by: vDorst
2026-08-04 05:43:05 +02:00
d00f e6cd362a1e system: show the hardware model on the System page
machine.machine_name is already reported in /information.json as hw_ver;
surface it read-only on the System Settings page so the exact build
target is visible in the UI, without adding a redundant JSON field.
2026-07-25 13:21:13 +02:00
d00f f21b3a32bd system: configurable hostname (device identity)
Add a device hostname settable from the CLI (`hostname <text>`) and the
System Settings page. The value is sanitized on ingest to JSON-safe
printable ASCII (<=23 chars), stored in a shared __xdata buffer, seeded
to "RTLPlayground" at boot, persisted through the startup-config, and
reported in /information.json. It lives in the common header so other
modules can advertise it (LLDP uses it as the System Name TLV).
2026-07-25 13:07:09 +02:00
d00f 0f257d38df httpd: shorten comments per review
Keep only the non-obvious bits; the rationale for each change already
lives in the respective commit messages.
2026-07-25 10:39:32 +02:00
d00f 3fd9cdfa7e httpd: parse the session cookie by name, not fixed offset
Root cause of the "browser login always bounces back with Wrong password!
while curl works": scan_header() read the session id from a fixed offset
into the Cookie header (p + 17), assuming "session=" is the first and only
cookie. Browsers keep stale cookies for a long time - e.g. an "admin" cookie
left over from this switch's VENDOR firmware - so the header can arrive as
"Cookie: admin=..; session=..", the fixed offset then points into the admin
value, authentication silently fails and every page bounces to login although
the password had been accepted. curl sends only "session=", which is why
command-line tests passed while a real browser (with that stale cookie) failed.

- scan_header(): scan the Cookie header for the actual "session=" key
  (matched as "session" - is_word() requires a separator after the pattern
  and '=' is on its list, the first value byte is not).
- is_word_x(): accept ';' as a terminating separator so the session value
  also matches when it is not the last cookie in the header.

Verified on hardware end-to-end in a real browser WITH the stale "admin"
cookie present: login -> index.html, all pages and JSON endpoints work.
2026-07-21 06:48:18 +02:00
d00f 3189820ba0 httpd: send Connection: close (single-connection uIP mitigation)
Real root cause of the "web login fails from a browser but works from curl":
the uIP httpd is built with UIP_CONF_MAX_CONNECTIONS = 1 and uses global
response state (outbuf/slen/session), i.e. it serves exactly one TCP connection
at a time and closes it after each response - but never advertises that via
the Connection header. A browser's HTTP/1.1 client therefore assumes the
connection may be persistent and can park it in its keep-alive pool for reuse;
a later request sent on that pooled connection hits one the server has already
closed, and a POST (unlike a GET) is never retried by the browser, so it can
be silently lost this way.

This adds "Connection: close" to every response so the browser does not pool
and reuse a connection the server is about to drop. On its own this did not
fully explain the reported login failures - the actual authentication bug is
fixed in the next commit (the Cookie header parsed at a fixed offset) - but it
is correct behaviour for a server that only ever handles one connection, and
removes one source of dropped requests.
2026-07-21 06:47:44 +02:00
d00f ed74ec1e97 httpd/login: complete CSP + password autocomplete hint
Two hygiene fixes for the web UI, prompted by a login that appeared to fail
under privacy shields (Brave Shields / NoScript-family extensions):

- httpd: replace the partial "style-src 'self' 'unsafe-inline'" CSP with a
  complete, first-party policy (default-src 'self'; script-src 'self'
  'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
  connect-src 'self'; form-action 'self'). Everything the UI needs is
  same-origin; the explicit policy stops shields injecting their own
  restrictive report-only probes (the noisy script-src-elem 'none' console
  spam) and passes a strict-CSP audit. Verified: no CSP violations in-browser.
- login.html: add autocomplete="current-password" so password managers
  recognise the field (they showed "unknown password" without it).

NOTE: these do NOT bypass a browser's LAN-device protection (NoScript "lan" /
Brave Shields), which strips the POST body of requests to a LAN address and is
why the login can fail in-browser while the same credentials work over curl.
That is a deliberate browser security feature; the user must allow the site in
their shields to log in. The backend password (default 1234) is unchanged and
correct.
2026-07-21 06:47:10 +02:00