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** > Frozen requirements spec for the **uBridge** project. Status: **delivered**
> (uBridge `e2e3155` — `link l2only` plus the creators that apply it) and > (uBridge `e2e3155` — `link l2only` plus the creators that apply it;
> verified from the server side: anchors and per-link bridges report > `fb72758` — the fifth creator: the TAP `bridge add_nio_tap` creates for a
> `addrgenmode none`, `ip -4/-6 addr` are empty, and an idle anchor is silent > free name) and verified from the server side: anchors and per-link bridges
> once the one-shot membership burst of §0 has settled. > 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 > **Residual, accepted (measured after delivery):** enslaving a port still
> makes the *bridge device* announce its multicast memberships — one IGMPv3 > 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 | | `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 | | `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 | | `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 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. 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`) ## C. Implementation notes (netlink, not `/proc`)
- Use `RTM_SETLINK` with `IFLA_AF_SPEC{ AF_INET6, IFLA_INET6_ADDR_GEN_MODE = - 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) ## gns3-server alignment (informational — not uBridge scope)
- **Call sites unchanged**: the four creators harden the devices; the server does - **Call sites**: the creators harden the devices; the server issues no
not issue `link l2only`, and no NIO/JSON schema, filter or MCP tool description `link l2only` itself, and no NIO/JSON schema, filter or MCP tool
changes. This is host-side hygiene only. description changes. This is host-side hygiene only.
- **e2e assertions to add** (kernel-datapath suites, alongside the existing - **The fifth creator was found by these servers' e2e assertions**: on a
capture/marker/filter checks): build predating `fb72758`, the Ethernet switch's relay port TAPs
- anchors and per-link bridges have no IPv4/IPv6 addresses (§E.1); (`gns3{id}-N`, created implicitly by `bridge add_nio_tap`) carried a live
- an idle anchor captures zero frames in 5 s (§E.2); `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 - a silent-failure guard found while validating this spec: after attach, every
bridge port reports `brport/state == 3` (forwarding). A bridge device left bridge port reports `brport/state == 3` (forwarding) (`assert_forwarding`).
DOWN keeps its ports `DISABLED` (`state == 0`) and forwards nothing — with A bridge device left DOWN keeps its ports `DISABLED` (`state == 0`) and
no error anywhere, so the assertion is the only signal. 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 - **Rollout**: with a uBridge that lacks the command the anchors keep today's
behaviour (noise present). The e2e assertions skip when `link l2only` answers 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); `GNS3_UNIX_SOCKET_NIO` containers);
* cross-compute links, non-Ethernet ports: the common rules. * 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 ## 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) ubridge_bridge = self._ubridge_bridge_name(port_number)
tap = self._tap_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 create {ubridge_bridge}")
await self._ubridge_send(f'bridge add_nio_tap {ubridge_bridge} "{tap}"') 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) # enslave the same TAP to the kernel bridge (the cloud.py::_add_linux_ethernet move)