diff --git a/issues/netstack-answers-for-its-name-without-claiming-it.md b/issues/netstack-answers-for-its-name-without-claiming-it.md index a722b1d7a1a..9cb223e1c9c 100644 --- a/issues/netstack-answers-for-its-name-without-claiming-it.md +++ b/issues/netstack-answers-for-its-name-without-claiming-it.md @@ -4,17 +4,39 @@ kind: defect opened: 2026-09-24 --- -# netd answers for its name without first claiming it on the network +# netstack answers for its name without first claiming it on the network -`userland/netd/src/mdns.rs` answers multicast DNS for `toyos-t14.local` and -announces it on every new lease, and skips what RFC 6762 asks before a -responder uses a name: probing for it (§8.1) and defending it against another -host's answer (§9). Every machine this tree boots asks its network for the one -name `toyos-t14` (`dhcp::HOSTNAME`), so two of them on one network both answer -for it, and a resolver may reach either. +`toyos-mdns` (`userland/netstack/mdns/src/lib.rs`) announces `.local` +and answers for it, and does neither of the things RFC 6762 asks of a +responder before it uses a name: probing for it (§8.1) and defending it against +another host's answer (§9). §8.3 announces a unique record that has "completed +the probing step"; this one has not, and each announcement and each multicast +answer carries the cache-flush bit (§10.2), which tells every cache on the link +to drop any other record of the name. + +Every machine this tree boots is named `toyos-t14` (`dhcp::HOSTNAME`, +`userland/netstack/src/dhcp.rs`), so two of them on one network both answer for +that name, each flushes the other's record, and a resolver reaches whichever +spoke last. + +The record is announced, unprobed, on every new address (the responder that +ships, called by `userland/netstack/src/mdns.rs`) and, in `toyos-net-node`, +which ships in nothing yet, also on every return of the link under a held lease +(`Responder::link_returned`): §8 asks for both steps there, probing first, and +only the announcing is built. What a returning link multiplies is the +occasions: each one is two more multicasts of the unprobed record, a second +apart, so two machines named alike each flush the other's record whenever +either's link returns, where on what ships it happens once an address. + +**Owner**: whoever holds `issues/toyos-has-its-own-network-stack.md`; probing +is the next stage of `toyos-mdns`, not part of the node's stages. ## Exit condition -netd probes its name before it answers for it and gives it up — saying so in -its log — when another host holds it; or each machine's name is its own, and a -test with two guests on one network finds each by its name. +`toyos-mdns` probes for its name before it announces it, at start-up and when +its link returns (§8, §8.1), defends it (§9), and gives it up, saying so in its +log, when another host holds it; its tests hold the three probes 250 ms apart, +the announcement only after them, a conflicting answer under the probe taking +the name away, and a link's return probing again before it announces. Or each +machine's name is its own, and a test with two guests on one network finds each +by its name. diff --git a/issues/toyos-has-its-own-network-stack.md b/issues/toyos-has-its-own-network-stack.md index 763e8e8f1cc..6af94f946c7 100644 --- a/issues/toyos-has-its-own-network-stack.md +++ b/issues/toyos-has-its-own-network-stack.md @@ -17,7 +17,7 @@ netd runs smoltcp. Its replacement is ToyOS's own stack, built clean-room: reade - Then multi-core, netring (blocked on the owner's ABI ruling), TCP and IP hardening, IPv6, offloads, soak. - This track owns the T14's outbound rows, the machine reaching its router and the internet on the I219, judged from the stick: they arrive on this stack and not on smoltcp (owner: "no smoltcp."), and until they do no T14 row reads the wired card (`issues/the-host-cannot-reach-the-t14-while-it-runs-toyos.md`). -Stage 5 lands in slices, the orchestrator's cut under the owner's words "i want smoltcp out as fast as possible": netd's decisions go into `toyos-net-node` (`userland/netstack/node`), pure and host-tested and shipped in nothing, and then one change moves netd onto it and deletes smoltcp. In the tree: the node, its DHCP lease, its clients' datagram sockets, its mDNS name, its resolver, streams, listeners and the one bound on its clients' streams, listeners and datagram sockets (`userland/netstack/node/src/places.rs`); a lookup's sockets stand outside that bound, under `toyos_dns::MAX_LOOKUPS` and its rounds. Still to build on it: datagram senders that wait on the hop, which is [udp]'s way to [ip] through the node's `lib.rs`, `lease.rs` and `toyos-net-shard/src/lib.rs`; the datagram sockets' broadcast permission; then the move. +Stage 5 lands in slices, the orchestrator's cut under the owner's words "i want smoltcp out as fast as possible": netd's decisions go into `toyos-net-node` (`userland/netstack/node`), pure and host-tested and shipped in nothing, and then one change moves netd onto it and deletes smoltcp. In the tree: the node, its DHCP lease, its clients' datagram sockets and their broadcast permission, its mDNS name, its resolver, streams, listeners and the one bound on its clients' streams, listeners and datagram sockets (`userland/netstack/node/src/places.rs`); a lookup's sockets stand outside that bound, under `toyos_dns::MAX_LOOKUPS` and its rounds. Still to build on it: datagram senders that wait on the hop, which is [udp]'s way to [ip] through the node's `lib.rs`, `lease.rs` and `toyos-net-shard/src/lib.rs`; then the move. What the node does not yet meet: @@ -36,10 +36,9 @@ What the node does not yet meet: - A client that is gone with nothing left in its pipe is finished by [tcp]'s rules for a user who let go: a reset once it has been idle 60 s, where netd on smoltcp reset it 100 s after the client left whatever the peer said. A peer that acknowledges a byte a minute holds such a connection for as long as it has bytes to acknowledge. Exit: the T14 or a guest shows such a peer holding one, and [tcp]'s rule takes a bound from the client's leaving; or the owner rules the idle bound is the one. - `Tcp::ready`, `Tcp::orphans` and `Tcp::options` are additions to [tcp] the specifications do not hold, made for the node's wakes, its places and the copy a stream keeps of its connection's options; their tests (`toyos-net-shard/tcp/tests/held.rs`) name no scenario id. `Shard::listen` refuses an address [ip] does not hold usable, by the rule [udp]'s bind refuses by (`udp.bind-address-not-local`), and counts nothing for it; the node's test of it (`a_listener_is_at_the_address_it_named_and_only_one_the_machine_holds`) names no scenario either. A listener whose address the machine loses stands as a datagram socket bound to it does, with its port and its place and its owner told nothing, and answers again once the address is back (`a_listener_stands_while_its_address_is_lost_and_answers_when_it_is_back`, `userland/netstack/node/tests/lease.rs`); no host was read for it. Exit: the specifications hold the three calls and the refusal, and the tests name their scenarios. - The word each of [udp]'s refusals is answered in on the pipe ABI is `Refused` in `userland/netstack/node/src/datagram.rs`, tested by `each_refusal_is_answered_in_the_pipes_word_for_it` against no reader's scenario; `udp.no-ephemeral-port` has a word and no test that reaches it. Exit: the scenario owed below exists, and the test names its id. -- A client's datagram to a broadcast address is refused `udp.broadcast-not-permitted` and answered invalid input, where smoltcp sends it. The owner's ruling: "Of course carry the permission why would you even ask. The premise is and always has been to do things properly". The pipe ABI carries the permission as far as netd: `MsgType::UdpSetOption` with `OPT_BROADCAST` (`toyos/src/net.rs`), sent by std's `set_broadcast` (`sdk/std/sys/net/connection.rs`) and by libc's `setsockopt` of `SO_BROADCAST` (`userland/libc/src/socket.rs`), which keeps it for a socket not yet bound and sends it at `bind`; the word for a send refused for want of it is `ERR_PERMISSION_DENIED`, `ErrorKind::PermissionDenied` in std and `EACCES` in libc. Nothing enforces it yet: netd on smoltcp answers the request done and sends to a broadcast address for every socket, and the node has no call that takes the permission and answers the refusal invalid input. The search is run. `grep -rnI -E 'SO_BROADCAST|INADDR_BROADCAST|255\.255\.255\.255|sendto|setsockopt|sys/socket\.h|socket\(' userland/doom/doomgeneric`, the one third-party C program an image builds, at the commit `userland/doom/build.rs` pins, exits 1 with no line; the same without `sys/socket\.h` over `tests/testcases` exits 1 with no line; `SO_BROADCAST|INADDR_BROADCAST|255\.255\.255\.255` over the trees of `src/libcxx.rs`'s `SOURCES` finds LLVM libc's own macro and its documentation and no caller; and in ToyOS's tree outside the stack's crates the option is named only by the two setters. The socket2 fork's setter reaches nobody: `issues/the-socket2-forks-so-broadcast-setter-does-nothing.md`. Exit, before the move, and the move does not land without it: a slice of its own on the node's datagram sockets gives the node a call that hands the permission to `Udp::set_broadcast` and gives `Refused` (`userland/netstack/node/src/datagram.rs`) the word for `ERR_PERMISSION_DENIED`, and in `each_refusal_is_answered_in_the_pipes_word_for_it` (`userland/netstack/node/tests/datagram.rs`) the two `BroadcastNotPermitted` sends turn from invalid input to that word and the same two sends leave once the socket holds the permission. Exit, after the move, which keeps this line open until both are green on netd on the node, since no test can read the refusal on smoltcp: a guest test, `udp_broadcast_needs_its_permission`, reads `broadcast()` false, then true after `set_broadcast(true)`, on the socket and on a `try_clone` of it, which `broadcast()` answering a constant or a duplicate holding a value of its own turns red, and sends to the limited broadcast address before and after the set and is refused `PermissionDenied` only before, which std's setter reverted to `Ok(())` turns red; and a guest C case reads libc's setter, `socket`, `setsockopt(SO_BROADCAST)`, `bind`, `sendto` to the limited broadcast address, refused `EACCES` without the option and sent with it, and with the option set before `bind`, which `bind` no longer handing the kept option over turns red. The case runs the sequence without `bind` too, where the first `sendto` binds the socket and hands the kept option over; `tests/netcase/sendto_unbound.c` holds that send for a socket permitted to make it. -- A refusal [udp] logs against a client's call waits in the stack until the node's next `receive`, `fire` or `link`, because only those pass over the stack's reports. Exit, at the move and not after it: the datagram calls end in that pass, or netd drains it once per turn of its loop. +- A client's datagram to a broadcast address needs its socket's permission, where smoltcp sends it for every socket. The owner's ruling: "Of course carry the permission why would you even ask. The premise is and always has been to do things properly". The pipe ABI carries the permission as far as netd: `MsgType::UdpSetOption` with `OPT_BROADCAST` (`toyos/src/net.rs`), sent by std's `set_broadcast` (`sdk/std/sys/net/connection.rs`) and by libc's `setsockopt` of `SO_BROADCAST` (`userland/libc/src/socket.rs`), which keeps it for a socket not yet bound and sends it at `bind`; the word for a send refused for want of it is `ERR_PERMISSION_DENIED`, `ErrorKind::PermissionDenied` in std and `EACCES` in libc. The node enforces it and nothing that ships does: `Node::udp_set_broadcast` hands the permission to `Udp::set_broadcast`, and a send without it is refused `udp.broadcast-not-permitted` and answered `Refused::PermissionDenied` (`userland/netstack/node/src/datagram.rs`), which `a_broadcast_needs_its_permission` (US-21, `userland/netstack/node/tests/datagram.rs`) holds, with both broadcast addresses read off the wire in link-broadcast frames once the socket holds it and refused again once it is taken back, and `each_refusal_is_answered_in_the_pipes_word_for_it` holds the word. The permission of the call goes with the datagram to the route it leaves by: [udp] queues it with what its socket held, a closed socket's included, and `Ip::send_udp` refuses a link broadcast to one without it, `ip.broadcast-not-permitted`, counted and logged, so a prefix a DHCP server renews under a held address between the call and the frame makes no broadcast of a datagram accepted for a host (`s_udp_us_021_shard_a_datagram_accepted_for_a_host_is_no_broadcast_after_a_renewal_narrows_the_prefix` and its two neighbours in `toyos-net-shard/tests/udp.rs`, and from a server's ACK `a_datagram_accepted_for_a_host_is_no_broadcast_after_the_server_renews_a_narrower_prefix` in the node's tests); netd on smoltcp answers the request done and sends to a broadcast address for every socket. The search is run. `grep -rnI -E 'SO_BROADCAST|INADDR_BROADCAST|255\.255\.255\.255|sendto|setsockopt|sys/socket\.h|socket\(' userland/doom/doomgeneric`, the one third-party C program an image builds, at the commit `userland/doom/build.rs` pins, exits 1 with no line; the same without `sys/socket\.h` over `tests/testcases` exits 1 with no line; `SO_BROADCAST|INADDR_BROADCAST|255\.255\.255\.255` over the trees of `src/libcxx.rs`'s `SOURCES` finds LLVM libc's own macro and its documentation and no caller; and in ToyOS's tree outside the stack's crates the option is named only by the two setters. The socket2 fork's setter reaches nobody: `issues/the-socket2-forks-so-broadcast-setter-does-nothing.md`. Exit, at the move: netd answers `MsgType::UdpSetOption`'s `OPT_BROADCAST` by `Node::udp_set_broadcast` and writes `ERR_PERMISSION_DENIED` for `Refused::PermissionDenied`. Exit, after the move, which keeps this line open until both are green on netd on the node, since no test can read the refusal on smoltcp: a guest test, `udp_broadcast_needs_its_permission`, reads `broadcast()` false, then true after `set_broadcast(true)`, on the socket and on a `try_clone` of it, which `broadcast()` answering a constant or a duplicate holding a value of its own turns red, and sends to the limited broadcast address before and after the set and is refused `PermissionDenied` only before, which std's setter reverted to `Ok(())` turns red; and a guest C case reads libc's setter, `socket`, `setsockopt(SO_BROADCAST)`, `bind`, `sendto` to the limited broadcast address, refused `EACCES` without the option and sent with it, and with the option set before `bind`, which `bind` no longer handing the kept option over turns red. The case runs the sequence without `bind` too, where the first `sendto` binds the socket and hands the kept option over; `tests/netcase/sendto_unbound.c` holds that send for a socket permitted to make it. - The machine's name is told to the node twice, as `toyos_dhcp::HostName` in `Node::new` and as `toyos_mdns::Host` in `Node::answer_as`: the first owns its bytes and the second borrows them. Exit: one type carries the label to both; due at the move if handing `answer_as` its `&'static str` takes a leak. -- The node's name is not probed for or defended (RFC 6762 §8.1, §9), as `toyos-mdns` records of itself. It is not announced again when the link comes back, which §8.3 asks and netd does today, where link-up resets its DHCP client and the address is new again: here the lease outlives the link. Exit: `toyos-mdns` does both, on the node's `link`. +- The node's name is not probed for or defended (RFC 6762 §8.1, §9): `issues/netstack-answers-for-its-name-without-claiming-it.md`, which is `toyos-mdns`'s and so the node's and the shipped netstack's alike. A link that returns under a held lease is announced on (§8.3, `a_held_name_is_announced_again_when_the_link_returns`, `userland/netstack/node/tests/name.rs`) and not probed on, which §8 asks first, so each return is two more multicasts of an unprobed record. Exit: that issue's. - A query [ip] refuses at the transmit opportunity that would have carried it, its next hop held failed or its table full, is reported to its socket inside `Node::transmit`, which carries no lookup on: the resolver reads the report at the node's next `receive`, `fire` or `link`, which at the latest is the end of the query's wait, where a query [udp] refuses in the resolver's own call, or one reported in a frame or at a deadline, ends its wait at once. `a_query_ip_refuses_at_its_transmit_opportunity_is_read_at_the_nodes_next_wake` holds it as it stands. It is no regression: what ships waits every such query out. Until that read, an ICMP error that quotes the query's addresses and ports takes [ip]'s word's place on the socket, [udp] holding one pending error a socket, the latest: the query is then waited out and not let go, which costs the early end and never an answer. Owner: the slice in which datagram senders wait on the hop (`Node::transmit` is the node's `lib.rs`). Exit: the node carries its lookups on after the stack's transmit, or [udp] refuses in the call a datagram whose next hop [ip] holds failed, so that no report waits on a socket to be displaced, and that test finds its lookup ended at the 4 s it started. - [ip]'s queue for a next hop it is resolving is every sender's: `PENDING_PER_NEIGHBOUR` is 16, a stopgap which the node's resolver asserts is no fewer than `toyos_dns::MAX_LOOKUPS`, so every held lookup's first query waits there whole, but a datagram a client sends to the same next hop in that window, which for everything off the link is the router, takes the oldest one's place (`nb.pending-overflow`, RFC 4861 §7.2.2), and a query lost so costs its lookup a `WAIT_MS`; smoltcp kept each datagram in its own socket until the neighbour answered. `a_clients_datagram_behind_the_burst_takes_the_oldest_querys_place_in_ips_queue` holds it as it stands. It is a regression against what ships for more than 16 datagrams at one next hop not yet resolved, and would ship at the move; and it sizes a limit of [ip]'s by a constant of the resolver's, which leaves four unresolved next hops filling `PENDING_TOTAL` where eight did. Owner: the slice in which datagram senders wait on the hop, where [udp]'s datagram calls already go through `Stack` and `Shard`. Its design: [udp]'s `serve` asks [ip] the next-hop question before it hands a datagram over, as [tcp]'s exit does with `Hop` (NUD-25, IP-D5); a socket whose oldest datagram's hop is pending keeps it in its own queue, which `TX_DATAGRAMS` and `TX_BYTES` already bound and refuse in the call, and is offered again when the hop resolves and told when it fails; [ip] then queues only its own datagrams. Exit, before the move: that test finds every query on the wire once the resolver answers ARP; `PENDING_PER_NEIGHBOUR` is `limits.ip.nud.pending_per_neighbour`'s 8 again; and the resolver's compile-time assertion, the change to `s_ip_nud_004_overflow_drops_the_oldest` and stage 3's departure line about it are deleted. - `toyos_dns::Lookup::on_datagram` still takes a reply's source address and port, which the node's connected sockets have already matched and the node hands it from its own record of the query: the two parameters are read only by netd's resolver on smoltcp. Exit, at the move: they go with that resolver. @@ -53,6 +52,7 @@ What stage 3 departs from its specifications: - US-43 expects `ip.not-for-us` for a datagram to an unjoined group in a frame to that group's MAC; the frame filter refuses it first, as `eth.not-for-us` (ETH-23). Exit: the scenario names the frame filter. - DH-64 expects an announce request from the client on link-up with a lease held; `toyos-net-ip` announces the held address itself on link-up, by the owner's ruling to announce rather than probe again, so the client asks nothing. Exit: the scenario drops the request, or [ip] stops announcing on its own. +- The readers' UDP specification asks the broadcast permission at the call alone (US-21), and their IP specification's list of logged rules has none for a datagram that leaves: `UdpOut`'s permission and `ip.broadcast-not-permitted` are additions, and `s_ip_mod_002_ordinary_refusals_are_not_logged` lists the rule beside the readers'. The tests that hold it carry US-21's id, whose rule it is, and no scenario of the readers' has a prefix that changes under a queued datagram. Exit: the specifications name the carried permission and its counter, and a scenario renews a prefix between a send and its frame. - The readers' UDP specification lets every datagram a closed socket had accepted leave; `toyos-net-udp` holds at most one socket's queue of them together and refuses the rest `udp.tx-discarded-on-close`, because the readers' stage 4 design holds nothing in the shard without a bound. Exit: `s_udp_us_053_accepted_datagrams_outlive_close` finds `udp.tx-discarded-on-close` at 0, every datagram a closed socket accepted leaving. - NUD-04 sends ten datagrams and finds the third to the tenth, two overflowed, under `limits.ip.nud.pending_per_neighbour`, 8; `PENDING_PER_NEIGHBOUR` is 16, so that a burst of every lookup the node holds waits whole for its resolver's link address, and `s_ip_nud_004_overflow_drops_the_oldest` sends two more than the constant: it holds the rule, the oldest dropped and counted, and no longer the scenario's numbers. NUD-05 passes unchanged only because its eight neighbours of eight datagrams are `PENDING_TOTAL`, which did not move. Exit: that of the node's residual on [ip]'s queue above, in the slice in which datagram senders wait on the hop: the constant is 8, and the test sends NUD-04's ten. - The readers' IP specification has no scenario for 169.254/16 on an interface that holds a routable address: its routing scenarios are RTE-01 to RTE-17 and its ARP reception scenarios NBR-01 to NBR-18, none names that prefix, and NBR-12's `arp.sender-off-link` is expected of a sender in no prefix at all, which its 198.51.100.9 still is. Its input scenarios are IN-01 to IN-09, of which none names 169.254 or a link-local source: IN-05, the nearest, refuses a source that is no host or an edge of a held prefix, and [ip]'s refusal of a source at an edge of 169.254/16 on every interface, `ip.invalid-source`, is an addition to that step's list. It has none either for two next hops of which one never answers: NUD-04 overflows one neighbour's queue and NUD-05 fills the interface's, and in neither does a second neighbour answer. The tests that hold the two rules therefore name their RFC section and carry no id: the `rfc_3927_…` tests in `toyos-net-ip/tests/addr.rs` and `nbr.rs`, and `rfc_4861_7_2_2_a_silent_next_hop_holds_up_no_other` in `tests/nud.rs`. Exit: the readers add a scenario for each, and each test carries its id. diff --git a/toyos-net-ip/src/counters.rs b/toyos-net-ip/src/counters.rs index 8029f1d12b8..62e458d2aba 100644 --- a/toyos-net-ip/src/counters.rs +++ b/toyos-net-ip/src/counters.rs @@ -46,6 +46,7 @@ toyos_net_wire::counters! { IpProtocol = "ip.protocol"; IpTcpNotUnicast = "ip.tcp-not-unicast"; IpExceedsMtu = "ip.exceeds-mtu"; + IpBroadcastNotPermitted = "ip.broadcast-not-permitted", logged; IpControlQueueFull = "ip.control-queue-full"; IpAcquisitionAdmitted = "ip.acquisition-admitted"; IpEventOverflow = "ip.event-overflow"; diff --git a/toyos-net-ip/src/egress.rs b/toyos-net-ip/src/egress.rs index 6db7b2716e8..bbc49fdfb3d 100644 --- a/toyos-net-ip/src/egress.rs +++ b/toyos-net-ip/src/egress.rs @@ -25,7 +25,7 @@ use crate::iface::Cx; use crate::limits::{nud::PENDING_TOTAL, CONTROL_QUEUE, ECHO_REPLIES}; use crate::nud::{self, Held, Link, Nud}; use crate::route::{NextHop, Route, Source}; -use crate::{acd, igmp, Event, Flow, IfIndex, Ip, MTU}; +use crate::{acd, igmp, Event, Flow, IfIndex, Ip, Peer, MTU}; /// The largest frame [ip] builds. pub const FRAME: usize = toyos_net_wire::ethernet::HEADER_LEN + MTU; @@ -168,6 +168,9 @@ pub struct UdpOut<'a> { pub source: Ipv4Addr, pub destination: Ipv4Addr, pub ttl: Ttl, + /// Whether its socket held the broadcast permission when it accepted the datagram: one + /// without it never leaves in a link broadcast, whatever the prefixes have become since. + pub broadcast: bool, pub datagram: UdpBuilder<'a>, } @@ -284,8 +287,9 @@ impl Ip { } /// A UDP datagram at a transmit opportunity: written into `frame` when its link - /// destination is known, or held here for its next hop, which spends no credit. A refusal is - /// counted and the datagram's flow is told it is unreachable. + /// destination is known, or held here for its next hop, which spends no credit. The route is + /// the one of this moment, so a link broadcast is refused a datagram that does not carry the + /// permission. A refusal is counted and the datagram's flow is told it is unreachable. pub fn send_udp(&mut self, now: Instant, out: &UdpOut<'_>, frame: &mut [u8; FRAME]) -> Result { let now = self.clock(now); let flow = Flow { @@ -304,7 +308,7 @@ impl Ip { options: &[], payload: out.datagram, }; - self.datagram(now, &route, &builder, FrameKind::Datagram, Some(flow), Some(frame)) + self.datagram(now, &route, &builder, FrameKind::Datagram, out.broadcast, Some((flow, frame))) }); if sent.is_err() { self.log.event(Event::Unreachable(flow)); @@ -313,19 +317,21 @@ impl Ip { } /// Sends one of [ip]'s own ICMP messages along `route`, into the control queue or held for - /// its next hop. + /// its next hop, and never in a link broadcast. pub(crate) fn own(&mut self, now: Instant, route: &Route, builder: &Ipv4Builder<'_, P>, kind: FrameKind) { - let _ = self.datagram(now, route, builder, kind, None, None); + let _ = self.datagram(now, route, builder, kind, false, None); } + /// The one place a datagram's link destination is chosen: the link's broadcast address only + /// with `broadcast`, and a transport's datagram comes with its flow and its frame. fn datagram( &mut self, now: Instant, route: &Route, builder: &Ipv4Builder<'_, P>, kind: FrameKind, - flow: Option, - out: Option<&mut [u8; FRAME]>, + broadcast: bool, + transport: Option<(Flow, &mut [u8; FRAME])>, ) -> Result { if builder.length().map_or(true, |len| len > MTU) { self.log.count(Counter::IpExceedsMtu); @@ -333,6 +339,10 @@ impl Ip { } let Some((i, mut cx)) = self.split(now, route.iface) else { return Err(Counter::UnknownInterface) }; let (destination, hold) = match route.next_hop { + NextHop::Broadcast if !broadcast => { + cx.log.refuse(Counter::IpBroadcastNotPermitted, route.iface, Peer::Ip(builder.destination)); + return Err(Counter::IpBroadcastNotPermitted); + } NextHop::Broadcast => (MacAddr::BROADCAST, None), NextHop::Multicast(group) => (MacAddr::multicast(group), None), NextHop::Neighbour(addr) => { @@ -349,6 +359,7 @@ impl Ip { } }; let mac = i.mac; + let (flow, out) = transport.unzip(); if let Some(addr) = hold { let frame = build_vec(mac, destination, builder).ok_or(Counter::IpExceedsMtu)?; nud::hold(i, &mut cx, addr, Held { frame, kind, flow }); diff --git a/toyos-net-ip/tests/addr.rs b/toyos-net-ip/tests/addr.rs index a937698afa1..10d4901db2a 100644 --- a/toyos-net-ip/tests/addr.rs +++ b/toyos-net-ip/tests/addr.rs @@ -276,6 +276,27 @@ fn s_ip_rte_009_subnet_broadcast_needs_no_arp() { assert!(h.out().is_empty()); } +// US-21 at the route that is taken: a datagram whose socket did not hold the broadcast permission +// when it accepted it leaves in no link broadcast, to the limited address or to a held prefix's +// directed one, and is counted, logged against its destination and reported to its flow. No +// request is sent for either. The same datagram to a host leaves. +#[test] +fn s_udp_us_021_ip_no_link_broadcast_without_the_datagrams_permission() { + let mut h = H::fixture_i(); + let rule = Counter::IpBroadcastNotPermitted; + for (n, destination) in [(1, ip4(255, 255, 255, 255)), (2, ip4(192, 0, 2, 255))] { + assert_eq!(h.send_as(A, destination, 5001, 5001, b"hi", Ttl::DEFAULT, false), Err(rule), "{destination}"); + assert_eq!(h.count(rule), n); + let refusal = toyos_net_ip::Refusal { rule, iface: h.if0, peer: toyos_net_ip::Peer::Ip(destination) }; + let port = toyos_net_wire::Port::new(5001).unwrap(); + let flow = toyos_net_ip::Flow { source: A, source_port: port, destination, destination_port: port }; + assert_eq!(h.events.split_off(0), [toyos_net_ip::Event::Refused(refusal), toyos_net_ip::Event::Unreachable(flow)]); + } + assert!(h.out().is_empty(), "nothing left, and nothing was asked"); + assert_eq!(h.send_as(A, B, 5001, 5001, b"hi", Ttl::DEFAULT, false), Ok(None), "a host's datagram waits for its link address"); + assert_eq!(h.count(rule), 2); +} + #[test] fn s_ip_rte_010_the_source_of_the_next_hops_prefix() { let mut h = H::raw(); diff --git a/toyos-net-ip/tests/clk.rs b/toyos-net-ip/tests/clk.rs index c5e59546d21..e0ac8de475c 100644 --- a/toyos-net-ip/tests/clk.rs +++ b/toyos-net-ip/tests/clk.rs @@ -118,7 +118,7 @@ fn trace(secret: [u8; 16], seed: u64) -> Trace { destination: toyos_net_wire::Port::new(5001).unwrap(), data: b"x", }; - let out = toyos_net_ip::UdpOut { source: A, destination, ttl: toyos_net_wire::ipv4::Ttl::DEFAULT, datagram }; + let out = toyos_net_ip::UdpOut { source: A, destination, ttl: toyos_net_wire::ipv4::Ttl::DEFAULT, broadcast: false, datagram }; if let Ok(toyos_net_ip::Sent::Frame(n)) = ip.send_udp(at, &out, &mut frame) { frames.push((now, frame[..n].to_vec())); } @@ -223,7 +223,7 @@ fn s_ip_clk_010_a_deadline_saturates() { destination: toyos_net_wire::Port::new(5001).unwrap(), data: b"hi", }; - let out = toyos_net_ip::UdpOut { source: A, destination: B, ttl: toyos_net_wire::ipv4::Ttl::DEFAULT, datagram }; + let out = toyos_net_ip::UdpOut { source: A, destination: B, ttl: toyos_net_wire::ipv4::Ttl::DEFAULT, broadcast: false, datagram }; assert_eq!(h.ip.send_udp(late, &out, &mut frame), Ok(toyos_net_ip::Sent::Held)); assert_eq!(h.ip.transmit(late, usize::MAX, |_, _| {}), 1); assert!(matches!(h.state(B), Some(Nud::Incomplete(_)))); diff --git a/toyos-net-ip/tests/common/mod.rs b/toyos-net-ip/tests/common/mod.rs index 023c513cb65..389bdca362b 100644 --- a/toyos-net-ip/tests/common/mod.rs +++ b/toyos-net-ip/tests/common/mod.rs @@ -716,15 +716,22 @@ c0 00 02 01 13 88 13 89 00 20 7b b4 30 31 32 33 34 35 36 37"; impl H { - /// A UDP datagram handed to [ip] now: the frame when it left at once, `None` when held. + /// A UDP datagram handed to [ip] now, from a socket that holds the broadcast permission: the + /// frame when it left at once, `None` when held. pub fn send(&mut self, source: Ipv4Addr, destination: Ipv4Addr, sport: u16, dport: u16, data: &[u8]) -> Result>, Counter> { self.send_ttl(source, destination, sport, dport, data, Ttl::DEFAULT) } pub fn send_ttl(&mut self, source: Ipv4Addr, destination: Ipv4Addr, sport: u16, dport: u16, data: &[u8], ttl: Ttl) -> Result>, Counter> { + self.send_as(source, destination, sport, dport, data, ttl, true) + } + + /// [`Self::send_ttl`] for a socket that holds the broadcast permission, or does not. + #[allow(clippy::too_many_arguments)] + pub fn send_as(&mut self, source: Ipv4Addr, destination: Ipv4Addr, sport: u16, dport: u16, data: &[u8], ttl: Ttl, broadcast: bool) -> Result>, Counter> { let mut frame = [0u8; FRAME]; let datagram = UdpBuilder { source: Port::new(sport).unwrap(), destination: Port::new(dport).unwrap(), data }; - let out = UdpOut { source, destination, ttl, datagram }; + let out = UdpOut { source, destination, ttl, broadcast, datagram }; let sent = self.ip.send_udp(self.clock(), &out, &mut frame); self.collect(); sent.map(|s| match s { diff --git a/toyos-net-ip/tests/icmp.rs b/toyos-net-ip/tests/icmp.rs index 4e0fe80fb74..1483839e218 100644 --- a/toyos-net-ip/tests/icmp.rs +++ b/toyos-net-ip/tests/icmp.rs @@ -614,3 +614,22 @@ fn s_icmp_046_no_error_to_a_source_that_is_no_host() { } assert_eq!(h.count(Counter::IpInvalidSource), 4); } + +// An error of [ip]'s own is routed when it is generated, which is after its datagram was admitted: +// a prefix that becomes usable in between can make the datagram's source its directed broadcast. +// The error then leaves in no link broadcast: it is counted and logged against that address. +#[test] +fn an_error_whose_next_hop_became_a_broadcast_is_refused_and_counted() { + let mut h = H::fixture_i(); + let source = ip4(192, 0, 2, 127); + let Some(Delivery::Udp(arrival, _)) = h.datagram(&hi_from(source)) else { panic!("a host's datagram is admitted") }; + h.ip.add_address(h.clock(), h.if0, ip4(192, 0, 2, 3), 25).unwrap(); + h.run(3_000); + assert_eq!(h.ip.address(h.if0, ip4(192, 0, 2, 3)), Some(toyos_net_ip::AddrState::Assigned)); + h.rebase(); + h.ip.port_unreachable(h.clock(), &arrival); + let rule = Counter::IpBroadcastNotPermitted; + assert_eq!(h.out().iter().map(Out::to).collect::>(), [], "nothing left"); + assert_eq!((h.count(rule), h.count(Counter::IcmpErrorSuppressed), h.count(Counter::IcmpErrorsSent)), (1, 0, 0)); + assert_eq!(h.refusals(rule), [toyos_net_ip::Refusal { rule, iface: h.if0, peer: Peer::Ip(source) }]); +} diff --git a/toyos-net-ip/tests/igmp.rs b/toyos-net-ip/tests/igmp.rs index ba1f2f4d1d3..5f2eff3231c 100644 --- a/toyos-net-ip/tests/igmp.rs +++ b/toyos-net-ip/tests/igmp.rs @@ -547,6 +547,7 @@ fn s_ip_mod_002_ordinary_refusals_are_not_logged() { let mut expected = [ "ip.source-route", "ip.fragment", + "ip.broadcast-not-permitted", "icmp.redirect", "icmp.timestamp-request", "icmp.source-quench", diff --git a/toyos-net-shard/src/lib.rs b/toyos-net-shard/src/lib.rs index d7ee5fb50b2..678f1f50ac9 100644 --- a/toyos-net-shard/src/lib.rs +++ b/toyos-net-shard/src/lib.rs @@ -605,6 +605,11 @@ impl Shard { self.udp.set_ttl(id, unicast, multicast) } + /// Whether a socket's datagrams may go to a broadcast address. + pub fn udp_set_broadcast(&mut self, id: SocketId, permitted: bool) -> Result<(), toyos_net_udp::Error> { + self.udp.set_broadcast(id, permitted) + } + /// Closes a socket: its port is free at once, and what it had accepted leaves as [udp]'s /// closed sender, in the round from this call. pub fn udp_close(&mut self, now: Instant, id: SocketId) -> Result<(), toyos_net_udp::Error> { diff --git a/toyos-net-shard/tests/udp.rs b/toyos-net-shard/tests/udp.rs index 345ee1ef3cc..e10162cbcd6 100644 --- a/toyos-net-shard/tests/udp.rs +++ b/toyos-net-shard/tests/udp.rs @@ -1,13 +1,20 @@ -//! UDP through the shard: what nobody takes is answered by [ip], and what the network says about -//! a connected socket's datagrams reaches it. +//! UDP through the shard: what nobody takes is answered by [ip], what the network says about a +//! connected socket's datagrams reaches it, and a datagram leaves under the broadcast permission +//! it was accepted with, whatever prefix the address has been renewed with since. mod common; +use std::net::Ipv4Addr; use std::time::Duration; use common::*; +use toyos_net_shard::{Event, Refusal, Shard}; use toyos_net_testnet::Fate; use toyos_net_udp::{Error, SocketError}; +use toyos_net_wire::ethernet::{Frame, MacAddr}; +use toyos_net_wire::ipv4::Ipv4Packet; +use toyos_net_wire::udp::UdpDatagram; +use toyos_net_wire::Instant; #[test] fn s_udp_us_035_shard_no_socket_is_a_port_unreachable() { @@ -52,3 +59,96 @@ fn s_ip_icd_012_shard_a_resolution_failure_reaches_the_connected_socket() { net.advance(Duration::from_millis(1)); assert_eq!(net.nodes[a].shard.recv_from(socket, &mut buf), Err(Error::Failed(SocketError::NextHopFailed))); } + +/// One host of 192.0.2.0/24, and the directed broadcast address of 192.0.2.0/25 (RFC 922 §7). +const EDGE_OF_25: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 127); +/// The directed broadcast address of 192.0.2.0/24, which under 192.0.2.0/25 is a host off the link. +const EDGE_OF_24: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 255); +/// A router both prefixes hold. +const ROUTER_IN_25: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 126); + +/// One transmit opportunity with all the credit the shard wants: each frame's link destination +/// and, of a UDP datagram, its destination and source port; any other frame by its name. +fn opportunity(shard: &mut Shard, now: Instant) -> Vec<(MacAddr, String)> { + let mut left = Vec::new(); + shard.transmit(now, usize::MAX, |bytes| { + let frame = Frame::parse(bytes).expect("a frame"); + let udp = Ipv4Packet::parse(frame.body()).ok().and_then(|ip| UdpDatagram::parse(&ip).ok().map(|udp| format!("UDP {} from port {}", ip.destination(), udp.source_port().map_or(0, |p| p.get())))); + left.push((frame.destination(), udp.unwrap_or_else(|| name(bytes)))); + }); + left +} + +/// [ip] counted `count` datagrams refused a link broadcast, and the shard's one line for the +/// rule names `destination`. +fn refused_a_broadcast(shard: &mut Shard, destination: Ipv4Addr, count: u64) { + let rule = toyos_net_ip::Counter::IpBroadcastNotPermitted; + assert_eq!(shard.ip().counters().get(rule), count); + let lines: Vec = shard.drain_events().filter(|event| matches!(event, Event::Refused { .. })).collect(); + let refusal = toyos_net_ip::Refusal { rule, iface: shard.iface(), peer: toyos_net_ip::Peer::Ip(destination) }; + assert_eq!(lines, [Event::Refused { refusal: Refusal::Ip(refusal), suppressed: 0 }]); +} + +// US-21 (decision U-4) across a renewal: the prefix is a DHCP server's value, and a renewal +// rewrites it under a held address. A datagram a socket without the permission had accepted for +// one host of the /24 is, once the address is renewed as a /25, addressed to that prefix's +// directed broadcast: it leaves in no frame, to the link's broadcast address or any other, and is +// counted and logged. A socket closed with the datagram accepted changes nothing: the closed +// sender's datagrams carry what their socket was permitted. +#[test] +fn s_udp_us_021_shard_a_datagram_accepted_for_a_host_is_no_broadcast_after_a_renewal_narrows_the_prefix() { + for closed in [false, true] { + let (mut net, a, _) = segment(); + let now = net.now(); + let shard = &mut net.nodes[a].shard; + shard.drain_events().for_each(drop); + let socket = shard.bind(Ipv4Addr::UNSPECIFIED, Some(port(5000)), || 0).unwrap(); + assert_eq!(shard.send_to(now, socket, EDGE_OF_25, 5001, b"hi"), Ok(()), "one host of the /24"); + if closed { + shard.udp_close(now, socket).unwrap(); + } + assert_eq!(shard.add_address(now, A, 25), Ok(()), "the renewal"); + assert_eq!(opportunity(shard, now), [], "closed: {closed}"); + refused_a_broadcast(shard, EDGE_OF_25, 1); + assert_eq!(opportunity(shard, now), [], "and it is gone, not waiting"); + } +} + +// The same across a renewal that widens the prefix: under the /25 192.0.2.255 is a host behind +// the router, and under the /24 it is the directed broadcast. +#[test] +fn s_udp_us_021_shard_a_datagram_accepted_for_the_router_is_no_broadcast_after_a_renewal_widens_the_prefix() { + let (mut net, a, _) = segment(); + let now = net.now(); + let shard = &mut net.nodes[a].shard; + shard.add_address(now, A, 25).unwrap(); + shard.set_gateways(now, &[ROUTER_IN_25]).unwrap(); + shard.drain_events().for_each(drop); + let socket = shard.bind(Ipv4Addr::UNSPECIFIED, Some(port(5000)), || 0).unwrap(); + assert_eq!(shard.send_to(now, socket, EDGE_OF_24, 5001, b"hi"), Ok(()), "a host behind the router"); + assert_eq!(shard.add_address(now, A, 24), Ok(()), "the renewal"); + assert_eq!(opportunity(shard, now), []); + refused_a_broadcast(shard, EDGE_OF_24, 1); +} + +// The permission is the one the socket held at the call (POSIX's `SO_BROADCAST` is read by the +// send): given after a datagram was accepted it covers nothing accepted before, and taken back +// it still covers what was accepted under it. +#[test] +fn s_udp_us_021_shard_a_datagram_carries_the_permission_of_its_call() { + let (mut net, a, _) = segment(); + let now = net.now(); + let shard = &mut net.nodes[a].shard; + shard.drain_events().for_each(drop); + let permitted = shard.bind(Ipv4Addr::UNSPECIFIED, Some(port(5000)), || 0).unwrap(); + let unpermitted = shard.bind(Ipv4Addr::UNSPECIFIED, Some(port(5002)), || 0).unwrap(); + shard.udp_set_broadcast(permitted, true).unwrap(); + for socket in [permitted, unpermitted] { + assert_eq!(shard.send_to(now, socket, EDGE_OF_25, 5001, b"hi"), Ok(())); + } + shard.udp_set_broadcast(permitted, false).unwrap(); + shard.udp_set_broadcast(unpermitted, true).unwrap(); + assert_eq!(shard.add_address(now, A, 25), Ok(()), "the renewal"); + assert_eq!(opportunity(shard, now), [(MacAddr::BROADCAST, "UDP 192.0.2.127 from port 5000".to_string())]); + refused_a_broadcast(shard, EDGE_OF_25, 1); +} diff --git a/toyos-net-udp/src/lib.rs b/toyos-net-udp/src/lib.rs index f94fecbc81e..edeb93d6fc5 100644 --- a/toyos-net-udp/src/lib.rs +++ b/toyos-net-udp/src/lib.rs @@ -187,6 +187,9 @@ struct Queued { destination: Ipv4Addr, port: Port, ttl: Ttl, + /// The socket's broadcast permission at the call: what [ip] may do with the datagram when it + /// leaves is decided by this, not by what the socket holds then. + broadcast: bool, payload: Vec, } @@ -377,7 +380,8 @@ impl Udp { self.socket(id).map(|s| s.rx_full) } - /// Only netstack's own sockets: POSIX's `SO_BROADCAST`. + /// POSIX's `SO_BROADCAST`: without it a send to a broadcast address is refused, and a datagram + /// accepted without it is refused by [ip] if a broadcast is where it would leave. pub fn set_broadcast(&mut self, id: SocketId, permitted: bool) -> Result<(), Error> { self.socket(id).map(|s| s.broadcast = permitted) } @@ -526,7 +530,7 @@ impl Udp { return self.refuse(Counter::TxQueueFull, me, peer); } let ttl = if destination.is_multicast() { multicast_ttl } else { ttl }; - socket.tx.push_back(Queued { source, destination, port, ttl, payload: payload.to_vec() }); + socket.tx.push_back(Queued { source, destination, port, ttl, broadcast: broadcast_permitted, payload: payload.to_vec() }); socket.tx_bytes = bytes; if !core::mem::replace(&mut socket.offered, true) { self.eligible.push(Sender::Socket(id)); @@ -725,6 +729,7 @@ impl Udp { source: queued.source, destination: queued.destination, ttl: queued.ttl, + broadcast: queued.broadcast, datagram: UdpBuilder { source: port, destination: queued.port, data: &queued.payload }, }); if last { diff --git a/userland/netstack/mdns/src/lib.rs b/userland/netstack/mdns/src/lib.rs index d6890ccdfca..66b16f21031 100644 --- a/userland/netstack/mdns/src/lib.rs +++ b/userland/netstack/mdns/src/lib.rs @@ -175,10 +175,11 @@ const GROUP_EVERY_MS: u64 = 1_000; const ANNOUNCE_AGAIN_MS: u64 = 1_000; /// This host's one record on its link, on the caller's monotonic clock in -/// milliseconds: announced on every new address and a second later (§8.3), -/// answered to whoever asks, and multicast at most once a second (§6), so no -/// host can turn the queries it sends into a multicast to every host on the -/// link at its own rate. A query §6 holds back is answered when the second +/// milliseconds: announced on every new address and whenever the link comes +/// back ([`Responder::link_returned`]), each time twice a second apart (§8, +/// §8.3), answered to whoever asks, and multicast at most once a second (§6), +/// so no host can turn the queries it sends into a multicast to every host on +/// the link at its own rate. A query §6 holds back is answered when the second /// ends, not dropped: the caller wakes at [`Responder::owed_at`]. #[derive(Debug)] pub struct Responder<'a> { @@ -186,39 +187,61 @@ pub struct Responder<'a> { /// The link the record was last announced on. link: Option, last_group_ms: Option, - /// When the record is next owed to the group: the second announcement, - /// or an answer §6 delayed. One multicast of the record answers every - /// query it held. + /// When the record is next owed to the group: an announcement, or an + /// answer §6 delayed. One multicast of the record answers every query it + /// held. owed_ms: Option, + /// The multicast owed at `owed_ms` is the first of §8.3's two: the second + /// is owed a second after it. + again: bool, } impl<'a> Responder<'a> { pub const fn new(host: Host<'a>) -> Self { - Self { host, link: None, last_group_ms: None, owed_ms: None } + Self { host, link: None, last_group_ms: None, owed_ms: None, again: false } } /// This host is on `link` at `now_ms`, or on none while it holds no - /// address: the multicast the record is owed now, if any — a new - /// address's announcement, its second, or an answer §6 delayed. + /// address: the multicast the record is owed now, if any — an + /// announcement of a new address or of a link that came back, its + /// second, or an answer §6 delayed. pub fn on(&mut self, link: Option, now_ms: u64) -> Option> { let Some(link) = link else { self.link = None; self.owed_ms = None; return None; }; - let new = self.link.is_none_or(|was| was.addr != link.addr); + if self.link.is_none_or(|was| was.addr != link.addr) { + self.owed_ms = Some(now_ms); + self.again = true; + } self.link = Some(link); - if new { - self.owed_ms = Some(now_ms.saturating_add(ANNOUNCE_AGAIN_MS)); - } else if self.owed_ms.is_some_and(|at| now_ms >= at) { - self.owed_ms = None; - } else { + if !self.owed_ms.is_some_and(|at| now_ms >= at) { return None; } + self.owed_ms = core::mem::take(&mut self.again).then(|| now_ms.saturating_add(ANNOUNCE_AGAIN_MS)); self.last_group_ms = Some(now_ms); Some(announcement(self.host, link.addr)) } + /// The link this host's address is on came back at `now_ms`: its + /// connectivity may have changed, so the record is announced again as a + /// new address's is (§8), the first time as soon as §6 lets the record + /// be multicast. With no address held nothing is owed: the one that + /// comes is new. + pub fn link_returned(&mut self, now_ms: u64) { + if self.link.is_some() { + self.owed_ms = Some(self.group_free_ms(now_ms)); + self.again = true; + } + } + + /// When §6 next lets the record be multicast: `now_ms`, or the end of + /// the second its last multicast began. + fn group_free_ms(&self, now_ms: u64) -> u64 { + self.last_group_ms.map_or(now_ms, |last| last.saturating_add(GROUP_EVERY_MS)).max(now_ms) + } + /// When the record is next owed to the group, on the caller's clock. pub fn owed_at(&self) -> Option { self.owed_ms @@ -267,7 +290,7 @@ impl<'a> Responder<'a> { return Some(Answer { to: To::Asker, bytes: out }); } if !unicast { - let free_ms = self.last_group_ms.map_or(now_ms, |last| last.saturating_add(GROUP_EVERY_MS)); + let free_ms = self.group_free_ms(now_ms); if now_ms < free_ms { self.owed_ms = Some(free_ms); return None; @@ -485,6 +508,53 @@ mod tests { assert_eq!(announced[announced.len() - 4..], [192, 168, 1, 50]); } + /// RFC 6762 §8: "Whenever a Multicast DNS responder starts up, wakes up + /// from sleep, receives an indication of a network interface "Link Change" + /// event, or has any other reason to believe that its network connectivity + /// may have changed in some relevant way, it MUST perform the two startup + /// steps below: Probing (Section 8.1) and Announcing (Section 8.3)." §8.3: + /// "at least two unsolicited responses, one second apart." §6 still holds: + /// a link back inside the second of the record's last multicast is + /// announced when that second ends. With no address there is no record, + /// and the address that comes is announced as new, twice and not four + /// times. + #[test] + fn a_link_that_returns_is_announced_twice_a_second_apart_and_no_sooner_than_a_second_after_the_last_multicast() { + let mut r = announced(); + let record = r.on(Some(LINK), 1_000).expect("the first address's second announcement"); + assert_eq!((r.on(Some(LINK), 60_000), r.owed_at()), (None, None), "nothing is owed a link that stayed"); + + r.link_returned(60_000); + assert_eq!(r.owed_at(), Some(60_000)); + assert_eq!(r.on(Some(LINK), 60_000).as_ref(), Some(&record), "announced as the link returns"); + assert_eq!(r.owed_at(), Some(61_000), "§8.3: and a second later"); + assert_eq!(r.on(Some(LINK), 60_999), None); + assert_eq!(r.on(Some(LINK), 61_000).as_ref(), Some(&record)); + assert_eq!((r.on(Some(LINK), 62_000), r.owed_at()), (None, None), "twice"); + + r.link_returned(61_300); + assert_eq!(r.owed_at(), Some(62_000), "§6: the record was multicast 300 ms ago"); + assert_eq!(r.on(Some(LINK), 61_300), None); + assert_eq!(r.on(Some(LINK), 62_000).as_ref(), Some(&record)); + assert_eq!(r.owed_at(), Some(63_000)); + assert_eq!(r.on(Some(LINK), 63_000).as_ref(), Some(&record)); + assert_eq!(r.owed_at(), None); + + r.link_returned(70_000); + assert_eq!(r.on(None, 70_000), None, "the address went with the link's return"); + assert_eq!(r.owed_at(), None); + assert!(r.on(Some(LINK), 71_000).is_some(), "an address after none is new"); + assert_eq!(r.owed_at(), Some(72_000)); + assert!(r.on(Some(LINK), 72_000).is_some()); + assert_eq!((r.on(Some(LINK), 73_000), r.owed_at()), (None, None), "its two announcements and no third"); + + let mut unaddressed = Responder::new(host()); + unaddressed.link_returned(5_000); + assert_eq!(unaddressed.owed_at(), None, "no address, no record to announce"); + assert!(unaddressed.on(Some(LINK), 9_000).is_some()); + assert_eq!(unaddressed.owed_at(), Some(10_000)); + } + /// RFC 6762 §11: a query whose source is not on this link is ignored; a /// link-local source (RFC 3927) is on every link. RFC 1122 §3.2.1.3: a /// loopback source is never on a wire, and a datagram carrying one is diff --git a/userland/netstack/node/src/datagram.rs b/userland/netstack/node/src/datagram.rs index b24dead14e8..b40ad1f5cbd 100644 --- a/userland/netstack/node/src/datagram.rs +++ b/userland/netstack/node/src/datagram.rs @@ -11,8 +11,15 @@ //! is its two queues, `limits::RX_DATAGRAMS` received and `limits::TX_DATAGRAMS` accepted. A bind //! with no place left is answered [`Refused::ResourceExhausted`] with nothing made. //! -//! A refusal [udp] logs comes out of [`Node::drain_events`] after the node's next `receive`, -//! `fire` or `link`. +//! **A send to a broadcast address needs the socket's permission** ([`Node::udp_set_broadcast`], +//! POSIX's `SO_BROADCAST`), which a socket is bound without: one sent without it is answered +//! [`Refused::PermissionDenied`]. The permission a datagram was accepted under goes with it: one +//! accepted for a host leaves in no link broadcast, whatever prefix a server has renewed the +//! address with since, and is dropped, counted `ip.broadcast-not-permitted` and logged instead. +//! +//! A refusal [udp] logs is in [`Node::drain_events`] when the send that met it returns, and one +//! [ip] logs as a datagram leaves when that [`Node::transmit`] returns; no other call of a +//! client's meets one. use core::net::Ipv4Addr; @@ -36,6 +43,9 @@ pub enum Refused { /// The call names what no datagram of this socket may carry: an address that is not this /// machine's to bind, a destination nothing is sent to, port 0, more than one frame holds. InvalidInput, + /// The call was well formed and the socket lacks the permission it needs: a datagram to a + /// broadcast address. + PermissionDenied, /// The socket holds all it may until some of it has left, or the node holds all it has /// places for: the same call succeeds later. ResourceExhausted, @@ -69,8 +79,8 @@ fn refused(error: Error) -> Refused { | Counter::SendInvalidDestination | Counter::SendPortZero | Counter::SendUnspecifiedDestination - | Counter::BroadcastNotPermitted | Counter::ExceedsMtu => Refused::InvalidInput, + Counter::BroadcastNotPermitted => Refused::PermissionDenied, Counter::TxQueueFull => Refused::ResourceExhausted, Counter::ConnectUnspecified | Counter::ConnectGroup @@ -111,9 +121,17 @@ impl Node { Ok((DatagramId(id), port)) } + /// Whether the socket's datagrams may go to a broadcast address, the limited one or the + /// directed one of a prefix the machine holds, from here on. + pub fn udp_set_broadcast(&mut self, id: DatagramId, permitted: bool) -> Result<(), Refused> { + self.stack.set_broadcast(id.0, permitted).map_err(refused) + } + /// Queues `payload` for `destination:port`; it leaves in a later [`Node::transmit`]. pub fn udp_send_to(&mut self, now: Instant, id: DatagramId, destination: Ipv4Addr, port: u16, payload: &[u8]) -> Result<(), Refused> { - self.stack.send_to(now, id.0, destination, port, payload).map_err(refused) + let sent = self.stack.send_to(now, id.0, destination, port, payload).map_err(refused); + self.log(); + sent } /// The oldest datagram that reached the socket, in `out`, or `None` when none waits. One diff --git a/userland/netstack/node/src/lease.rs b/userland/netstack/node/src/lease.rs index 70af5c6b884..9398ca4d322 100644 --- a/userland/netstack/node/src/lease.rs +++ b/userland/netstack/node/src/lease.rs @@ -39,9 +39,8 @@ enum State { /// Proof that [ip] finished conflict detection for the address under probe. pub(crate) struct Verified(Ipv4Addr); -/// What the shard said, one event at a time. +/// What [ip] said became of an address, one event at a time. pub(crate) enum Report { - Refused { refusal: Refusal, suppressed: u64 }, Verified(Verified), /// Another host holds the address, at this MAC: [ip] removed it, and the state is /// `Unaddressed`. @@ -56,7 +55,15 @@ pub(crate) struct Stack { /// The DHCP client's socket. socket: SocketId, state: State, - inbox: VecDeque, + /// What [ip] reported of an address and [`Stack::report`] has not read: never a log line. + inbox: VecDeque, +} + +/// [ip]'s report on the address this module added. +enum Fate { + Verified(Ipv4Addr), + /// Removed by [ip]: for the host at this MAC, or with none, for a link that went down. + Taken(Ipv4Addr, Option), } /// [ip] refuses nothing about the one interface and the one address this module gave it. @@ -161,6 +168,10 @@ impl Stack { self.shard.udp_close(now, id) } + pub(crate) fn set_broadcast(&mut self, id: SocketId, permitted: bool) -> Result<(), toyos_net_udp::Error> { + self.shard.udp_set_broadcast(id, permitted) + } + pub(crate) fn set_ttl(&mut self, id: SocketId, unicast: Ttl, multicast: Ttl) -> Result<(), toyos_net_udp::Error> { self.shard.udp_set_ttl(id, unicast, multicast) } @@ -171,24 +182,33 @@ impl Stack { // ---- the lease ---- - /// The shard's next event, read against the state. - pub(crate) fn report(&mut self) -> Option { - if self.inbox.is_empty() { - self.inbox.extend(self.shard.drain_events()); + /// Takes what the shard has reported: each log line goes to `line`, ahead of the reports + /// before it since none is read against the state, and each report on the address waits its + /// turn for [`Self::report`]. + pub(crate) fn refusals(&mut self, mut line: impl FnMut(Refusal, u64)) { + for event in self.shard.drain_events() { + self.inbox.push_back(match event { + Event::Refused { refusal, suppressed } => { + line(refusal, suppressed); + continue; + } + Event::Verified(addr) => Fate::Verified(addr), + Event::Conflict { addr, mac } | Event::Lost { addr, mac } => Fate::Taken(addr, Some(mac)), + Event::NotVerified(addr) => Fate::Taken(addr, None), + }); } + } + + /// The next report [`Self::refusals`] took, read against the state. + pub(crate) fn report(&mut self) -> Option { Some(match self.inbox.pop_front()? { - Event::Refused { refusal, suppressed } => Report::Refused { refusal, suppressed }, - Event::Verified(addr) => match self.state { + Fate::Verified(addr) => match self.state { State::Probing(address) if address == addr => Report::Verified(Verified(addr)), _ => unreachable!("[ip] verified {addr}, which is not under probe"), }, - Event::Conflict { addr, mac } | Event::Lost { addr, mac } => { - self.taken(addr); - Report::Conflict(mac) - } - Event::NotVerified(addr) => { + Fate::Taken(addr, by) => { self.taken(addr); - Report::NotVerified + by.map_or(Report::NotVerified, Report::Conflict) } }) } @@ -255,3 +275,50 @@ impl Stack { refused } } + +#[cfg(test)] +mod tests { + use alloc::vec::Vec; + + use toyos_net_shard::Secrets; + use toyos_net_udp::Counter; + use toyos_net_wire::ethernet::IndividualMac; + + use super::*; + + const A: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 1); + + // No id, and no call of the node's reaches it: [ip] reports an address only at a frame, a + // deadline or a link change, each of which ends in the node's pass. The order is `Stack`'s to + // keep all the same: a log line taken while the verification of the address under probe + // waits takes nothing else, and the verification is still the next report. + #[test] + fn the_log_lines_are_taken_and_the_report_before_them_keeps_its_turn() { + let secrets = Secrets { + ip: [1; 16], + resets: [2; 16], + tcp: toyos_net_tcp::Secrets { isn: [3; 16], timestamp: [4; 16], port_offset: [5; 16], port_index: [6; 16], port_table: [0; 16] }, + }; + let Some(mac) = IndividualMac::new(MacAddr([2, 0, 0, 0, 0, 0x0a])) else { unreachable!("an individual address") }; + let mut now = Instant::from_millis(3_600_000); + let Ok(mut stack) = Stack::new(now, Config { mac, receive_buffer: 65_535, send_buffer: 65_535, secrets }) else { unreachable!("a configuration [tcp] takes") }; + stack.link(now, true); + assert_eq!(stack.probe(now, A, 24), Ok(())); + while stack.shard.ip().address(stack.shard.iface(), A) == Some(toyos_net_ip::AddrState::Tentative) { + stack.transmit(now, usize::MAX, |_| {}); + let Some(next) = stack.next_deadline() else { unreachable!("conflict detection is still running") }; + now = next; + stack.fire(now); + } + let Ok((socket, _)) = stack.bind(Ipv4Addr::UNSPECIFIED, Port::new(4_000), || 0) else { unreachable!("a free port") }; + assert_eq!(stack.send_to(now, socket, Ipv4Addr::UNSPECIFIED, 7, b"x"), Err(toyos_net_udp::Error::Refused(Counter::SendUnspecifiedDestination))); + + let mut lines = Vec::new(); + stack.refusals(|refusal, _| lines.push(refusal)); + assert!(matches!(lines.as_slice(), [Refusal::Udp(refusal)] if refusal.rule == Counter::SendUnspecifiedDestination), "{lines:?}"); + assert!(matches!(stack.report(), Some(Report::Verified(Verified(A)))), "the verification is still to be read"); + assert!(stack.report().is_none()); + stack.refusals(|refusal, _| lines.push(refusal)); + assert_eq!((lines.len(), stack.report().is_none()), (1, true), "each was handed over once"); + } +} diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs index 60b4e21cec7..3a2ca566cb3 100644 --- a/userland/netstack/node/src/lib.rs +++ b/userland/netstack/node/src/lib.rs @@ -153,15 +153,21 @@ impl Node { /// Returns how many left. pub fn transmit(&mut self, now: Instant, credit: usize, sink: impl FnMut(&[u8])) -> usize { let sent = self.stack.transmit(now, credit, sink); + // A datagram [ip] refused as it left is a line of this opportunity. + self.log(); self.pass(now, true); sent } /// The link came up or went down; the caller reports a change, not a state. Down, a held - /// lease stays; up, the client verifies a lease it holds and otherwise starts over. + /// lease stays; up, the client verifies a lease it holds and otherwise starts over, and the + /// machine's name is announced again for a lease that stayed. pub fn link(&mut self, now: Instant, up: bool, mut draw: impl FnMut() -> u32) { self.stack.link(now, up); if up { + if let Some(name) = &mut self.name { + name.link_returned(now); + } let out = self.client.link_up(now, &mut draw); self.carry_out(now, out, None, &mut draw); } @@ -190,12 +196,9 @@ impl Node { /// carried on, against the lease as that left it. fn settle(&mut self, now: Instant, draw: &mut impl FnMut() -> u32) { loop { + self.log(); let (out, verified) = if let Some(report) = self.stack.report() { match report { - Report::Refused { refusal, suppressed } => { - self.events.push(Event::Stack { refusal, suppressed }); - continue; - } Report::Verified(verified) => (self.client.verified(now, &mut *draw), Some(verified)), Report::Conflict(mac) => (self.client.conflict(now, mac, &mut *draw), None), Report::NotVerified => (self.client.not_verified(now, &mut *draw), None), @@ -211,6 +214,13 @@ impl Node { self.resolver.pass(now, &mut self.stack, &mut self.counters, draw); } + /// The stack's log lines so far, into [`Self::drain_events`]: the one way a line leaves the + /// stack. + fn log(&mut self) { + let events = &mut self.events; + self.stack.refusals(|refusal, suppressed| events.push(Event::Stack { refusal, suppressed })); + } + /// What one call of the client's asked for: the lease first, so a message leaves from the /// state its call left behind. fn carry_out(&mut self, now: Instant, out: Output, verified: Option, draw: &mut impl FnMut() -> u32) { diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs index 0591302ce86..329db3b1aba 100644 --- a/userland/netstack/node/src/name.rs +++ b/userland/netstack/node/src/name.rs @@ -6,8 +6,13 @@ //! - The record is the held lease's: its address and prefix are what the responder is told, and //! it is told there is none from the moment the lease goes. Nothing is announced or answered //! for an address still under probe. +//! - A link that comes back under a held lease is the responder's to hear of: the lease outlives +//! the link, so no new address would tell it (§8). //! - What the record is owed later, the second announcement (§8.3) or an answer §6 held back, //! is a deadline of the node's ([`Name::next_deadline`]). +//! - An announcement that falls due with the link down is [udp]'s to refuse and is counted +//! unsent, and the responder is not told: the link's return owes both announcements again +//! (§8), and holding the unsent one a multicast only ever delays the first of them (§6). //! - Every response leaves with TTL 255 (§11). //! - An answer goes where the responder says: to the group on its port, or to the asker's own //! address and port. One [udp] refuses is counted and dropped: the asker asks again. @@ -59,6 +64,11 @@ impl Name { Ok(Self { socket, record: Responder::new(host), query: vec![0; toyos_net_udp::limits::MAX_PAYLOAD] }) } + /// The link came back: the record of a lease that outlived it is owed to the group again. + pub(crate) fn link_returned(&mut self, now: Instant) { + self.record.link_returned(millis(now)); + } + pub(crate) fn next_deadline(&self) -> Option { self.record.owed_at().map(Instant::from_millis) } diff --git a/userland/netstack/node/tests/datagram.rs b/userland/netstack/node/tests/datagram.rs index f4992bd072f..d89c8ea944d 100644 --- a/userland/netstack/node/tests/datagram.rs +++ b/userland/netstack/node/tests/datagram.rs @@ -1,8 +1,9 @@ -//! A client's datagram sockets on the node. No scenario ids: [udp]'s rules are its own crate's -//! scenarios; these are the node's calls over them, and the word each of [udp]'s refusals is -//! answered in, which the readers' specifications owe a scenario for and do not have. What is -//! not ours: `etherparse` reads every frame the node emits (`lan::outside`), and each test names -//! the RFC its expectation is read from. +//! A client's datagram sockets on the node. [udp]'s rules are its own crate's scenarios; these +//! are the node's calls over them, and the word each of [udp]'s refusals is answered in, which +//! the readers' specifications owe a scenario for and do not have. The one id here is US-21's, +//! the broadcast permission, which the node hands a client and a datagram keeps. What is not ours: `etherparse` reads +//! every frame the node emits (`lan::outside`), and each test names the RFC its expectation is +//! read from. mod common; mod lan; @@ -10,7 +11,7 @@ mod lan; use std::net::Ipv4Addr; use std::time::Duration; -use common::{terms, A, MAC, MAC_B, MAC_R}; +use common::{from_server, message_of, terms, xid, A, ACK, BROADCAST, DNS, MAC, MAC_B, MAC_R, R}; use lan::{udp, Lan, Seen, Udp, B, OFF_LINK}; use toyos_net_node::{Datagram, Event, Refused}; use toyos_net_shard::Refusal; @@ -18,6 +19,8 @@ use toyos_net_udp::Counter; use toyos_net_wire::Port; const ANY: Ipv4Addr = Ipv4Addr::UNSPECIFIED; +/// The directed broadcast address of the leased 192.0.2.0/24 (RFC 922 §7). +const SUBNET_BROADCAST: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 255); fn port(port: u16) -> Option { Some(Port::new(port).expect("not port 0")) @@ -102,7 +105,7 @@ fn refused_send(lan: &mut Lan, id: toyos_net_node::DatagramId, to: Ipv4Addr, por } // The pipe ABI's words, `toyos::net`'s `ERR_*`: an address in use, not connected, invalid input, -// resource exhausted. Each refusal is reached here by the call a client makes. +// resource exhausted, permission denied. Each refusal is reached here by the call a client makes. #[test] fn each_refusal_is_answered_in_the_pipes_word_for_it() { let mut lan = Lan::new(); @@ -117,19 +120,20 @@ fn each_refusal_is_answered_in_the_pipes_word_for_it() { assert_eq!(lan.counted(Counter::PortInUse), 2); let (held, _) = lan.node.udp_bind(A, port(4_001), undrawn).unwrap(); - let invalid: [(Ipv4Addr, u16, &[u8], Counter); 8] = [ + let invalid: [(Ipv4Addr, u16, &[u8], Counter); 6] = [ (B, 0, b"x", Counter::SendPortZero), (Ipv4Addr::UNSPECIFIED, 7, b"x", Counter::SendUnspecifiedDestination), (Ipv4Addr::LOCALHOST, 7, b"x", Counter::SendLoopback), (A, 7, b"x", Counter::SendToSelf), (Ipv4Addr::new(240, 0, 0, 1), 7, b"x", Counter::SendInvalidDestination), - (Ipv4Addr::BROADCAST, 7, b"x", Counter::BroadcastNotPermitted), - (Ipv4Addr::new(192, 0, 2, 255), 7, b"x", Counter::BroadcastNotPermitted), (B, 7, &[0; 1_473], Counter::ExceedsMtu), ]; for (to, port, payload, rule) in invalid { refused_send(&mut lan, any, to, port, payload, Refused::InvalidInput, rule); } + for to in [Ipv4Addr::BROADCAST, SUBNET_BROADCAST] { + refused_send(&mut lan, any, to, 7, b"x", Refused::PermissionDenied, Counter::BroadcastNotPermitted); + } lan.node.udp_send_to(lan.now, any, B, 7, &[0; 1_472]).expect("1,472 octets fill one frame"); lan.pump(); @@ -140,11 +144,6 @@ fn each_refusal_is_answered_in_the_pipes_word_for_it() { lan.pump(); lan.node.udp_send_to(lan.now, any, B, 7, b"later").expect("the same call, once the queue has left"); - // What [udp] logs waits for the node's next pass over the stack's reports. - lan.fire(lan.now); - let logged = lan.node.drain_events().any(|event| matches!(event, Event::Stack { refusal: Refusal::Udp(refusal), .. } if refusal.rule == Counter::BroadcastNotPermitted)); - assert!(logged, "a refused broadcast is a line for the log"); - assert!(lan.run_until(Duration::from_secs(700), |lan| lan.node.lease().is_none()), "the lease runs out"); refused_send(&mut lan, any, B, 7, b"late", Refused::NotConnected, Counter::NoSourceAddress); refused_send(&mut lan, held, B, 7, b"late", Refused::NotConnected, Counter::SourceAddressNotAssigned); @@ -155,6 +154,115 @@ fn each_refusal_is_answered_in_the_pipes_word_for_it() { refused_send(&mut routerless, id, OFF_LINK, 7, b"x", Refused::NotConnected, Counter::NoRoute); } +// US-21 (decision U-4), POSIX's `SO_BROADCAST`: a send to the limited broadcast address or to the +// held prefix's directed one is refused without the socket's permission, in the word that says +// permission, and with it leaves in a frame to the link's broadcast address with the socket's +// TTL, no link address asked for (RFC 919 §7, RFC 922 §7: an IP broadcast is a link broadcast; +// RFC 1122 §3.3.6). The permission is the one socket's, and is taken back as it was given. +#[test] +fn a_broadcast_needs_its_permission() { + let mut lan = Lan::new(); + lan.lease(3_600); + let (id, _) = lan.node.udp_bind(ANY, port(5_000), undrawn).unwrap(); + let (other, _) = lan.node.udp_bind(ANY, port(5_002), undrawn).unwrap(); + let frames = lan.sent.len(); + for to in [Ipv4Addr::BROADCAST, SUBNET_BROADCAST] { + refused_send(&mut lan, id, to, 5_001, b"hi", Refused::PermissionDenied, Counter::BroadcastNotPermitted); + } + lan.pump(); + assert_eq!(lan.sent.len(), frames, "nothing left for either"); + + assert_eq!(lan.node.udp_set_broadcast(id, true), Ok(())); + for to in [Ipv4Addr::BROADCAST, SUBNET_BROADCAST] { + assert_eq!(lan.node.udp_send_to(lan.now, id, to, 5_001, b"hi"), Ok(())); + } + lan.pump(); + let left: Vec = lan.sent[frames..].iter().map(|(_, seen)| seen.clone()).collect(); + let broadcast = |destination| Seen::Udp(Udp { to: BROADCAST, source: A, source_port: 5_000, destination, port: 5_001, ttl: 64, payload: b"hi".to_vec() }); + assert_eq!(left, [broadcast(Ipv4Addr::BROADCAST), broadcast(SUBNET_BROADCAST)], "each in a link broadcast, and no ARP request before it"); + + refused_send(&mut lan, other, Ipv4Addr::BROADCAST, 5_001, b"hi", Refused::PermissionDenied, Counter::BroadcastNotPermitted); + assert_eq!(lan.node.udp_set_broadcast(id, false), Ok(())); + for to in [Ipv4Addr::BROADCAST, SUBNET_BROADCAST] { + refused_send(&mut lan, id, to, 5_001, b"hi", Refused::PermissionDenied, Counter::BroadcastNotPermitted); + } + lan.node.udp_send_to(lan.now, id, B, 5_001, b"hi").expect("one host needs no permission"); + lan.pump(); + let destinations: Vec = lan.datagrams().iter().map(|(_, udp)| udp.destination).collect(); + assert_eq!(destinations, [Ipv4Addr::BROADCAST, SUBNET_BROADCAST, B], "and no broadcast since the permission went"); + + lan.node.udp_close(lan.now, id).unwrap(); + assert_eq!(lan.node.udp_set_broadcast(id, true), Err(Refused::NotConnected), "a closed socket is given nothing"); +} + +// US-21 from the wire: the prefix a datagram's destination is read against is a DHCP server's +// value (RFC 2131 §4.4.5: a renewal's ACK carries the parameters again, RFC 2132 §3.3 the mask). +// A socket without the permission has a datagram accepted for 192.0.2.127, one host of the leased +// /24; the server's ACK of the renewal then names 255.255.255.128, under which that address is +// the directed broadcast (RFC 922 §7). The datagram leaves in no frame, and the drop is counted +// and is the node's line for the log when the opportunity that met it returns. +#[test] +fn a_datagram_accepted_for_a_host_is_no_broadcast_after_the_server_renews_a_narrower_prefix() { + let edge = Ipv4Addr::new(192, 0, 2, 127); + let mut lan = Lan::new(); + lan.lease(600); + let (id, _) = lan.node.udp_bind(ANY, port(5_000), undrawn).unwrap(); + let requests = |lan: &Lan| lan.udp().into_iter().filter(|udp| udp.port == 67).count(); + let sent = requests(&lan); + assert!(lan.run_until(Duration::from_secs(400), |lan| requests(lan) > sent), "the renewal leaves"); + let renewal = xid(&lan.udp().into_iter().rfind(|udp| udp.port == 67).unwrap().payload); + assert_eq!(lan.node.drain_events().count(), 0, "nothing is owed the log before the send"); + + let frames = lan.sent.len(); + lan.node.udp_send_to(lan.now, id, edge, 5_001, b"hi").expect("one host of the /24"); + let narrower = [(54, R.octets().to_vec()), (51, 600u32.to_be_bytes().to_vec()), (1, vec![255, 255, 255, 128]), (6, DNS.octets().to_vec())]; + lan.deliver(&from_server(MAC, A, &message_of(ACK, renewal, &narrower))); + assert_eq!(lan.node.lease().map(|lease| lease.prefix_len), Some(25), "the server's prefix is the lease's"); + assert_eq!(lan.sent[frames..], [], "nothing left for it: no broadcast, and no request for a link address"); + + let rule = toyos_net_ip::Counter::IpBroadcastNotPermitted; + let shard = lan.node.shard(); + assert_eq!(shard.ip().counters().get(rule), 1); + let refusal = toyos_net_ip::Refusal { rule, iface: shard.iface(), peer: toyos_net_ip::Peer::Ip(edge) }; + let logged: Vec = lan.node.drain_events().collect(); + assert_eq!(logged, [Event::Stack { refusal: Refusal::Ip(refusal), suppressed: 0 }]); +} + +/// The line the log gets for a send [udp] refused under `rule`, from port 4000 bound to any. +fn line(rule: Counter, peer: (Ipv4Addr, u16), suppressed: u64) -> Event { + let local = (ANY, port(4_000).unwrap()); + Event::Stack { refusal: Refusal::Udp(toyos_net_udp::Refusal { rule, local, peer }), suppressed } +} + +// A refusal [udp] logs (US-20's 0.0.0.0, US-21's broadcast) is the node's line for the log when +// the send that met it returns: no frame, deadline or link change comes between. The shard lets +// one line a rule through in 10 s, and the next after them says how many it stood for. +#[test] +fn a_refusal_is_logged_by_the_call_that_met_it() { + let mut lan = Lan::new(); + lan.lease(3_600); + let (id, _) = lan.node.udp_bind(ANY, port(4_000), undrawn).unwrap(); + assert_eq!(lan.node.drain_events().count(), 0, "nothing is owed the log before the send"); + + assert_eq!(lan.node.udp_send_to(lan.now, id, Ipv4Addr::BROADCAST, 7, b"x"), Err(Refused::PermissionDenied)); + let logged: Vec = lan.node.drain_events().collect(); + assert_eq!(logged, [line(Counter::BroadcastNotPermitted, (Ipv4Addr::BROADCAST, 7), 0)]); + + assert_eq!(lan.node.udp_send_to(lan.now, id, ANY, 7, b"x"), Err(Refused::InvalidInput)); + let logged: Vec = lan.node.drain_events().collect(); + assert_eq!(logged, [line(Counter::SendUnspecifiedDestination, (ANY, 7), 0)]); + + assert_eq!(lan.node.udp_send_to(lan.now, id, SUBNET_BROADCAST, 7, b"x"), Err(Refused::PermissionDenied)); + assert_eq!(lan.node.udp_send_to(lan.now, id, B, 0, b"x"), Err(Refused::InvalidInput)); + lan.node.udp_send_to(lan.now, id, B, 7, b"x").unwrap(); + assert_eq!(lan.node.drain_events().count(), 0, "a second inside 10 s, a refusal that is no line, and a datagram accepted"); + + let later = lan.now.after(Duration::from_secs(10)); + assert_eq!(lan.node.udp_send_to(later, id, Ipv4Addr::BROADCAST, 9, b"x"), Err(Refused::PermissionDenied)); + let logged: Vec = lan.node.drain_events().collect(); + assert_eq!(logged, [line(Counter::BroadcastNotPermitted, (Ipv4Addr::BROADCAST, 9), 1)]); +} + // A closed socket's port is free at once, and what it had accepted still leaves: in the first // opportunity after the close, not one later. #[test] diff --git a/userland/netstack/node/tests/name.rs b/userland/netstack/node/tests/name.rs index 96e0681c9ba..f219c3ce10d 100644 --- a/userland/netstack/node/tests/name.rs +++ b/userland/netstack/node/tests/name.rs @@ -174,6 +174,76 @@ fn a_held_lease_is_announced_at_once_and_a_second_later() { assert_eq!(lan.datagrams().len(), 2, "owed twice, sent twice"); } +// RFC 6762 §8: "Whenever a Multicast DNS responder starts up, wakes up from sleep, receives an +// indication of a network interface "Link Change" event, or has any other reason to believe that +// its network connectivity may have changed in some relevant way, it MUST perform the two startup +// steps below: Probing (Section 8.1) and Announcing (Section 8.3)." §8.3: "at least two +// unsolicited responses, one second apart." The node keeps its lease across the link, so the +// address is not new and only the link's return says the name is owed again. §6: a return inside +// the second of the record's last multicast is announced when that second ends. +#[test] +fn a_held_name_is_announced_again_when_the_link_returns() { + let mut lan = settled(); + let from = lan.datagrams().len(); + lan.link(false); + let down = lan.now.after(Duration::from_secs(30)); + lan.fire(down); + assert!(since(&lan, from).is_empty(), "a link that went owes the name nothing"); + + lan.link(true); + let back = lan.now; + assert!(lan.node.lease().is_some(), "the lease outlived the link"); + assert_eq!(lan.datagrams()[from..], [(back, &multicast(response()))], "announced as the link returns"); + let owed = lan.node.next_deadline().expect("the second announcement is owed"); + assert!(owed <= back.after(Duration::from_secs(1)), "and is a deadline of the node's"); + assert!(lan.run_until(Duration::from_secs(2), |lan| lan.datagrams().len() == from + 2), "the second announcement"); + let (second, again) = lan.datagrams()[from + 1]; + assert_eq!(again, &multicast(response())); + // The responder's clock is whole milliseconds. + let apart = second.since(back); + assert!(apart <= Duration::from_secs(1) && apart > Duration::from_millis(999), "{apart:?} apart"); + let quiet = lan.now.after(Duration::from_secs(10)); + lan.fire(quiet); + assert_eq!(lan.datagrams().len(), from + 2, "owed twice, sent twice"); + + let asked = lan.now; + lan.deliver(&to_group(MAC_B, (B, MDNS), &ours(0, CLASS_IN))); + lan.link(false); + lan.link(true); + assert_eq!(lan.datagrams()[from + 2..], [(asked, &multicast(response()))], "the answer, and no announcement in its second"); + assert!(lan.run_until(Duration::from_secs(3), |lan| lan.datagrams().len() == from + 5), "both announcements"); + let after: Vec = lan.datagrams()[from + 3..].iter().map(|(at, _)| at.since(asked)).collect(); + assert!(after[0] <= Duration::from_secs(1) && after[0] > Duration::from_millis(999), "{after:?}"); + assert!(after[1] <= Duration::from_secs(2) && after[1] > Duration::from_millis(1_999), "{after:?}"); +} + +// RFC 6762 §8.3's second announcement falling due with the link down: [udp] has no route for it, +// so it is counted and gone, and the responder holds it sent. The link's return owes both again +// (§8, §8.3: two, a second apart), the first when the second of the unsent one ends: §6's +// interval is kept with time to spare and never cut short. +#[test] +fn an_announcement_due_with_the_link_down_is_counted_and_the_links_return_owes_both() { + let mut lan = named(); + lan.lease(3_600); + let held = lan.now; + assert_eq!(lan.datagrams(), [(held, &multicast(response()))], "the first announcement"); + lan.link(false); + let due = held.after(Duration::from_secs(1)); + lan.fire(due); + assert_eq!((lan.datagrams().len(), lan.node.counters().get(Counter::NameUnsent)), (1, 1), "the second has no link to leave on"); + + lan.now = due.after(Duration::from_millis(300)); + lan.link(true); + assert_eq!(lan.datagrams().len(), 1, "300 ms after the unsent one"); + assert!(lan.run_until(Duration::from_secs(3), |lan| lan.datagrams().len() == 3), "both announcements"); + let after: Vec = lan.datagrams()[1..].iter().map(|(at, _)| at.since(due)).collect(); + assert!(after[0] <= Duration::from_secs(1) && after[0] > Duration::from_millis(999), "{after:?}"); + assert!(after[1] <= Duration::from_secs(2) && after[1] > Duration::from_millis(1_999), "{after:?}"); + let quiet = lan.now.after(Duration::from_secs(10)); + lan.fire(quiet); + assert_eq!((lan.datagrams().len(), lan.node.counters().get(Counter::NameUnsent)), (3, 1), "owed twice, sent twice"); +} + // RFC 6762 §6.7: a query from a port other than 5353 is a legacy resolver's, and its answer goes // to its own address and port. §5.4: one from port 5353 with the unicast-response bit is // answered at its own address, with the response a multicast would carry.