docs(ubridge): the fifth L2-only creator, and the call site that used it

The spec's B table grows its fifth row: bridge add_nio_tap mints a
transient TAP when the name is free and hardens it at creation, while an
attach to an existing device (possibly a user-owned TAP with addresses)
is left untouched. The rule paragraph spells out attach-is-not-a-creation.
Delivery: uBridge fb72758, closing the gap the new e2e assertions found
(live: the switch's gns3*-0 relay TAP carried fe80::). The server issues
no link l2only of its own -- the switch call site now documents that the
TAP is minted-and-hardened by add_nio_tap, and the ESW feature doc
describes the uBridge-side close instead of a server workaround.
This commit is contained in:
YueGuobin 2026-10-02 14:36:58 +08:00
parent 30cb7119e5
commit f13da59f23
No known key found for this signature in database
3 changed files with 53 additions and 16 deletions

View File

@ -4,10 +4,11 @@ See LICENSE file for licensing information.
-->
> Frozen requirements spec for the **uBridge** project. Status: **delivered**
> (uBridge `e2e3155` — `link l2only` plus the creators that apply it) and
> verified from the server side: anchors and per-link bridges report
> `addrgenmode none`, `ip -4/-6 addr` are empty, and an idle anchor is silent
> once the one-shot membership burst of §0 has settled.
> (uBridge `e2e3155` — `link l2only` plus the creators that apply it;
> `fb72758` — the fifth creator: the TAP `bridge add_nio_tap` creates for a
> free name) and verified from the server side: anchors and per-link bridges
> report `addrgenmode none`, `ip -4/-6 addr` are empty, and an idle anchor is
> silent once the one-shot membership burst of §0 has settled.
>
> **Residual, accepted (measured after delivery):** enslaving a port still
> makes the *bridge device* announce its multicast memberships — one IGMPv3
@ -141,10 +142,16 @@ directly, and a caller cannot forget it:
| `docker create_veth <host> <guest>` | the **host** end only | the guest end moves into a container netns and is deliberately untouched |
| `link veth <name> <peer>` | both ends | both ends are host-side |
| `brctl create <bridge>` | the bridge device itself | the fabric the anchors are enslaved to; the bridge's own link-local floods to every port |
| `bridge add_nio_tap <br> <name>` | the TAP it **creates** (name free) | by-name `TUNSETIFF` creates a transient device when the name is free — cloud's bridge interfaces, and the relay swap's create-if-missing path. Hardened at creation; an attach to an existing device is left untouched (the caller may name a user-owned TAP that has addresses). |
Rule for future creators: any command that creates a host-side device for the
data plane hardens it, and the acceptance checks in §E apply to it.
An **attach** is not a creation: `bridge add_nio_tap` (and every other open
of a TAP that already exists) must leave that device's addresses alone. The
transient TAP `bridge add_nio_tap` creates for a free name is a creation and
is hardened like the rest — best-effort, before the NIO is handed back.
## C. Implementation notes (netlink, not `/proc`)
- Use `RTM_SETLINK` with `IFLA_AF_SPEC{ AF_INET6, IFLA_INET6_ADDR_GEN_MODE =
@ -198,17 +205,35 @@ data plane hardens it, and the acceptance checks in §E apply to it.
## gns3-server alignment (informational — not uBridge scope)
- **Call sites unchanged**: the four creators harden the devices; the server does
not issue `link l2only`, and no NIO/JSON schema, filter or MCP tool description
changes. This is host-side hygiene only.
- **e2e assertions to add** (kernel-datapath suites, alongside the existing
capture/marker/filter checks):
- anchors and per-link bridges have no IPv4/IPv6 addresses (§E.1);
- an idle anchor captures zero frames in 5 s (§E.2);
- **Call sites**: the creators harden the devices; the server issues no
`link l2only` itself, and no NIO/JSON schema, filter or MCP tool
description changes. This is host-side hygiene only.
- **The fifth creator was found by these servers' e2e assertions**: on a
build predating `fb72758`, the Ethernet switch's relay port TAPs
(`gns3{id}-N`, created implicitly by `bridge add_nio_tap`) carried a live
`fe80::` — the §E.1 assertion failed on exactly that device, live. The
close is uBridge-side (§B table row 5): the transient TAP is hardened at
creation, attaches stay untouched. The server side needed no change for
it — the assertion alone caught it, and it now guards the fixed build.
- **e2e assertions** (landed in `tests/e2e/harness.py`, asserted by the
kernel-datapath suites alongside the existing capture/marker/filter checks):
- anchors and per-link bridges have no IPv4/IPv6 addresses, addrgenmode
`none` (`assert_pure_l2`, §E.1) — on the kernel scenarios' anchors and
bridges, and on relay-mode anchors/switch TAPs alike (the hardening is a
creation-time property, not a datapath one). The switch relay-TAP
assertion is what exercises the fifth creator end-to-end (the same
uBridge creation path cloud's transient TAPs take);
- an idle link stays silent for 5 s after the settle window
(`assert_idle_silence`, §E.2) — measured with the guest interfaces shut
(silencing IOS's own CDP/keepalives) over the anchors and the bridge;
- a silent-failure guard found while validating this spec: after attach, every
bridge port reports `brport/state == 3` (forwarding). A bridge device left
DOWN keeps its ports `DISABLED` (`state == 0`) and forwards nothing — with
no error anywhere, so the assertion is the only signal.
bridge port reports `brport/state == 3` (forwarding) (`assert_forwarding`).
A bridge device left DOWN keeps its ports `DISABLED` (`state == 0`) and
forwards nothing — with no error anywhere, so the assertion is the only
signal.
- **Rollout**: with a uBridge that lacks the command the anchors keep today's
behaviour (noise present). The e2e assertions skip when `link l2only` answers
`202-Unknown command`, mirroring the existing tc-capability degradation.
`202-Unknown command` (`harness.l2only_supported()` probes a throwaway
uBridge once), mirroring the existing tc-capability degradation. A build
that has the command but predates `fb72758` fails the switch relay-TAP
assertion instead of skipping: the gap is exactly what it exists to catch.

View File

@ -41,6 +41,15 @@ on the relay:
`GNS3_UNIX_SOCKET_NIO` containers);
* cross-compute links, non-Ethernet ports: the common rules.
**Relay port TAPs.** The switch's own relay-path port TAPs (the
`gns3{id}-N` devices) are created *implicitly* by uBridge's
`bridge add_nio_tap` — a name that is free is minted as a transient TAP,
and uBridge hardens it L2-only at creation (the fifth creator of
`docs/design/ubridge-l2-anchor-spec.md` §B, uBridge `fb72758`). The server
issues nothing extra: without that hardening the device would take a
kernel-default IPv6 identity whose DAD/MLD noise floods the segment, which
is exactly what the e2e's relay-control §E.1 assertion guards against.
## The wire format: one NIO names the other end's interface
```

View File

@ -386,7 +386,10 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode):
ubridge_bridge = self._ubridge_bridge_name(port_number)
tap = self._tap_name(port_number)
# per-port uBridge relay -- uBridge holds the TAP fd
# per-port uBridge relay -- uBridge holds the TAP fd. Nothing created
# this TAP before: add_nio_tap mints it (transient, a free name) and
# hardens it L2-only at creation (ubridge-l2-anchor-spec §B, the fifth
# creator — the server issues no link l2only of its own).
await self._ubridge_send(f"bridge create {ubridge_bridge}")
await self._ubridge_send(f'bridge add_nio_tap {ubridge_bridge} "{tap}"')
# enslave the same TAP to the kernel bridge (the cloud.py::_add_linux_ethernet move)