From 422292c384cfe429f141f3eac527f30409999168 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 04:43:34 +0200 Subject: [PATCH 1/3] toyos-net-node: a datagram socket's broadcast permission, a logged refusal out of the send that met it, and the name announced again when the link returns Three rules of the node stage of the move to ToyOS's own stack, each of which the track said the move does not land without. The node ships in nothing yet; toyos-mdns ships, and gains one call netstack on smoltcp does not make. The broadcast permission. Node::udp_set_broadcast hands a client's permission through Stack and Shard to Udp::set_broadcast, and udp.broadcast-not-permitted is answered Refused::PermissionDenied where it was invalid input: the request was well formed. US-21, decision U-4, by the owner's ruling that the permission is carried. A logged refusal. Only receive, fire and link passed over the stack's reports, so a line [udp] logged against a client's send waited for the next frame or deadline. udp_send_to now ends by taking the log lines out of what the shard reported; a lease report among them keeps its turn, since only the node's pass reads one against the state. No other client call of a datagram socket meets a logged rule. The name on a link that returns. The node keeps its lease across the link, so the responder saw no new address and announced nothing, where RFC 6762 section 8 asks for the announcing step on a link change. Responder::link_returned owes the record to the group twice, a second apart (section 8.3), the first no sooner than a second after the record's last multicast (section 6); Node::link calls it. A new address is announced through the same two-step, so the second announcement is one rule and not two. The track's three lines say what now holds. The line on a logged refusal is closed and deleted; the line on the name loses its clause that netstack announces again today, which it does not: it restarts DHCP on link-up only with no lease held. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- issues/toyos-has-its-own-network-stack.md | 7 +- toyos-net-shard/src/lib.rs | 5 ++ toyos-net-udp/src/lib.rs | 2 +- userland/netstack/mdns/src/lib.rs | 104 +++++++++++++++++---- userland/netstack/node/src/datagram.rs | 24 ++++- userland/netstack/node/src/lease.rs | 62 +++++++++++++ userland/netstack/node/src/lib.rs | 6 +- userland/netstack/node/src/name.rs | 7 ++ userland/netstack/node/tests/datagram.rs | 105 ++++++++++++++++++---- userland/netstack/node/tests/name.rs | 43 +++++++++ 10 files changed, 323 insertions(+), 42 deletions(-) diff --git a/issues/toyos-has-its-own-network-stack.md b/issues/toyos-has-its-own-network-stack.md index be7074354fb..f6a2d9fa779 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: 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: 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 pipe ABI has no request that permits it, and std's `set_broadcast` (`sdk/std/sys/net/connection.rs`) answers `Ok` to a permission nothing grants. No program in the tree sends to a broadcast address or sets the option; the sources of the third-party C programs an image builds from a recipe have not been searched. 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 move of netd onto the node does not land with this open. Exit, both before the move, as a stage of their own: that search, for `SO_BROADCAST` and a `sendto` to a broadcast address, is posted with its command and output; and the pipe ABI carries the permission from std's and libc's setters to `Udp::set_broadcast`, a send refused for want of it answered in a word that says permission and not input. -- 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 node's half is built: `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. What is open is the way to it: the pipe ABI has no request that permits it and no word for the refusal, and std's `set_broadcast` (`sdk/std/sys/net/connection.rs`) answers `Ok` to a permission nothing grants. No program in the tree sends to a broadcast address or sets the option; the sources of the third-party C programs an image builds from a recipe have not been searched. The move of netd onto the node does not land with this open. Exit, before the move, as a stage of its own: that search, for `SO_BROADCAST` and a `sendto` to a broadcast address, is posted with its command and output; and the pipe ABI carries the permission from std's and libc's setters and has a word that says permission and not input. Exit, at the move: netd answers the option's request by `Node::udp_set_broadcast` and writes that word for `Refused::PermissionDenied`. Exit, after the move, since no test can read the refusal on smoltcp: a guest test, `udp_broadcast_needs_its_permission`, sends to the limited broadcast address before and after `set_broadcast(true)` and is refused `PermissionDenied` only before. - 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), as `toyos-mdns` records of itself and `issues/netstack-answers-for-its-name-without-claiming-it.md` of netd. 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. Exit: that issue's, and `toyos-mdns` probes again on the node's `link`. - A query that does not reach its resolver is waited out for `toyos_dns::WAIT_MS` like one nobody answered, whether [udp] refused it for want of a route (`node.query-unsent`) or the network reported it back, its resolver unreachable or its port refused (`node.query-failed`): `toyos_dns::Lookup` has no word for either. Exit, before the move: `Lookup` is told and asks the next resolver at once, `a_query_udp_refuses_is_counted_holds_no_port_and_is_waited_out` finds its answer at 0 ms, and `a_query_the_network_reports_unreachable_is_counted_and_waited_out` finds its lookup ended at 3 s, [ip]'s first report of its one resolver, and not at `ROUNDS` × `WAIT_MS`. - Lookups started together at a resolver whose link address is not yet known lose all but the newest `PENDING_PER_NEIGHBOUR` queries to [ip]'s queue for an unresolved next hop (`nb.pending-overflow`, RFC 4861 §7.2.2), and each lost one costs its lookup a `WAIT_MS`; smoltcp kept each query in its own socket until the neighbour answered. `a_burst_at_a_resolver_not_yet_resolved_leaves_ips_queue_of_queries_and_the_rest_a_wait_later` holds it as it stands. It is a regression against what ships and would ship at the move. Exit, before the move: that test finds every query of the burst on the wire once the resolver answers ARP. - `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. 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-udp/src/lib.rs b/toyos-net-udp/src/lib.rs index a5fa0f73a40..55737e4e095 100644 --- a/toyos-net-udp/src/lib.rs +++ b/toyos-net-udp/src/lib.rs @@ -371,7 +371,7 @@ 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. pub fn set_broadcast(&mut self, id: SocketId, permitted: bool) -> Result<(), Error> { self.socket(id).map(|s| s.broadcast = permitted) } 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..b5968061994 100644 --- a/userland/netstack/node/src/datagram.rs +++ b/userland/netstack/node/src/datagram.rs @@ -11,8 +11,12 @@ //! 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`]. +//! +//! A refusal [udp] logs is in [`Node::drain_events`] when the send that met it returns; no +//! other call of a client's meets one. use core::net::Ipv4Addr; @@ -36,6 +40,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 +76,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 +118,18 @@ 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); + let events = &mut self.events; + self.stack.refusals(|refusal, suppressed| events.push(crate::Event::Stack { refusal, suppressed })); + 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..23d957a7018 100644 --- a/userland/netstack/node/src/lease.rs +++ b/userland/netstack/node/src/lease.rs @@ -161,6 +161,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,6 +175,19 @@ impl Stack { // ---- the lease ---- + /// The log lines among what the shard has reported, taken ahead of the reports before them: + /// none is read against the state. + pub(crate) fn refusals(&mut self, mut line: impl FnMut(Refusal, u64)) { + self.inbox.extend(self.shard.drain_events()); + self.inbox.retain(|event| match *event { + Event::Refused { refusal, suppressed } => { + line(refusal, suppressed); + false + } + Event::Verified(_) | Event::Conflict { .. } | Event::Lost { .. } | Event::NotVerified(_) => true, + }); + } + /// The shard's next event, read against the state. pub(crate) fn report(&mut self) -> Option { if self.inbox.is_empty() { @@ -255,3 +272,48 @@ 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()); + } +} diff --git a/userland/netstack/node/src/lib.rs b/userland/netstack/node/src/lib.rs index 60b4e21cec7..62aeabd85f4 100644 --- a/userland/netstack/node/src/lib.rs +++ b/userland/netstack/node/src/lib.rs @@ -158,10 +158,14 @@ impl Node { } /// 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); } diff --git a/userland/netstack/node/src/name.rs b/userland/netstack/node/src/name.rs index 0591302ce86..5691c1b8184 100644 --- a/userland/netstack/node/src/name.rs +++ b/userland/netstack/node/src/name.rs @@ -6,6 +6,8 @@ //! - 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`]). //! - Every response leaves with TTL 255 (§11). @@ -59,6 +61,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..7b8f755124b 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. 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::{terms, A, BROADCAST, MAC, MAC_B, MAC_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,82 @@ 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"); +} + +/// 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..65c0722a510 100644 --- a/userland/netstack/node/tests/name.rs +++ b/userland/netstack/node/tests/name.rs @@ -174,6 +174,49 @@ 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 §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. From 436570ec9a4a1b467320c6be5216e9ca39087f3f Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 06:05:09 +0200 Subject: [PATCH 2/3] toyos-net-node: a datagram keeps the broadcast permission of its call to the route it leaves by, and a log line has one way out of the stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The broadcast permission was asked once, in [udp]'s submit, against the prefixes held then, and the datagram was routed again when it left, against the prefixes held then. `Ip::add_address` rewrites a held address's prefix in place, with whatever prefix a DHCP server's acknowledgement of a renewal names, so a datagram a socket without the permission had accepted for one host left as a link broadcast once the address was renewed with a prefix whose directed broadcast that host's address is; a widened prefix did the same to a datagram accepted for the router. The value that opened it is a server's, off the wire. What the socket held at the call now goes with the datagram: [udp]'s `Queued` carries it, a closed socket's datagrams included, `UdpOut` hands it to [ip], and `Ip::send_udp` refuses a link broadcast to a datagram without it: `ip.broadcast-not-permitted`, counted, logged against the destination, the flow told it is unreachable as for any refusal there. `UdpOut` is a struct every field of which its maker names, so no transport hands [ip] a datagram without saying. `toyos-net-shard/tests/udp.rs` holds it across a renewal that narrows the prefix, with the socket open and closed, across one that widens it, and for the permission of the call against the permission of the moment; run at 07fbe3c7b with its counter assertions taken out, all three are red on a frame to ff:ff:ff:ff:ff:ff. The node's test reaches it from a server's ACK, and [ip]'s test holds the rule at `send_udp`. `Node::transmit` now ends in the pass over the stack's log lines, since a datagram can be refused as it leaves. One way out of `Stack` for a log line: `Stack::refusals` is the one drain of the shard, `Report::Refused` and `report`'s own drain are gone, and the inbox holds [ip]'s reports on the address and nothing else. An announcement of the name that falls due with the link down is refused by [udp] for want of a route and counted `node.name-unsent`, and the responder holds it sent; the link's return owes both again. RFC 6762 §6 is a least interval, which that only lengthens, and §8.3's two leave after the return, so it stays, said at `name.rs`'s header and held by a test. `issues/netstack-answers-for-its-name-without-claiming-it.md` described `userland/netd/src/mdns.rs`, which the tree no longer has: it now says what `toyos-mdns` does, what a returning link multiplies, its owner and an exit a test can fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- ...nswers-for-its-name-without-claiming-it.md | 42 +++++-- issues/toyos-has-its-own-network-stack.md | 7 +- toyos-net-ip/src/counters.rs | 1 + toyos-net-ip/src/egress.rs | 14 ++- toyos-net-ip/tests/addr.rs | 21 ++++ toyos-net-ip/tests/clk.rs | 4 +- toyos-net-ip/tests/common/mod.rs | 11 +- toyos-net-ip/tests/igmp.rs | 1 + toyos-net-shard/tests/udp.rs | 104 +++++++++++++++++- toyos-net-udp/src/lib.rs | 9 +- userland/netstack/node/src/datagram.rs | 12 +- userland/netstack/node/src/lease.rs | 55 ++++----- userland/netstack/node/src/lib.rs | 14 ++- userland/netstack/node/src/name.rs | 3 + userland/netstack/node/tests/datagram.rs | 37 ++++++- userland/netstack/node/tests/name.rs | 27 +++++ 16 files changed, 302 insertions(+), 60 deletions(-) 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 1efd8a70cee..abad2b340d3 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 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: 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, in this order: the two lines below whose exits read "before the move", a lookup told of a query that never reached its resolver and datagram senders that wait for their next hop where a burst is lost to [ip]'s queue today, each its own stage; then the move. What the node does not yet meet: @@ -36,9 +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 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; 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 sequence without `bind` waits on `issues/libc-sends-from-a-datagram-socket-it-never-bound.md`. +- 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 sequence without `bind` waits on `issues/libc-sends-from-a-datagram-socket-it-never-bound.md`. - 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 and `issues/netstack-answers-for-its-name-without-claiming-it.md` of netd. 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. Exit: that issue's, and `toyos-mdns` probes again 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 that does not reach its resolver is waited out for `toyos_dns::WAIT_MS` like one nobody answered, whether [udp] refused it for want of a route (`node.query-unsent`) or the network reported it back, its resolver unreachable or its port refused (`node.query-failed`): `toyos_dns::Lookup` has no word for either. Exit, before the move: `Lookup` is told and asks the next resolver at once, `a_query_udp_refuses_is_counted_holds_no_port_and_is_waited_out` finds its answer at 0 ms, and `a_query_the_network_reports_unreachable_is_counted_and_waited_out` finds its lookup ended at 3 s, [ip]'s first report of its one resolver, and not at `ROUNDS` × `WAIT_MS`. - Lookups started together at a resolver whose link address is not yet known lose all but the newest `PENDING_PER_NEIGHBOUR` queries to [ip]'s queue for an unresolved next hop (`nb.pending-overflow`, RFC 4861 §7.2.2), and each lost one costs its lookup a `WAIT_MS`; smoltcp kept each query in its own socket until the neighbour answered. `a_burst_at_a_resolver_not_yet_resolved_leaves_ips_queue_of_queries_and_the_rest_a_wait_later` holds it as it stands. It is a regression against what ships and would ship at the move. Exit, before the move: that test finds every query of the burst on the wire once the resolver answers ARP. - `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. @@ -52,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: `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. - 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..273329f36c7 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 { @@ -296,6 +300,10 @@ impl Ip { }; let source = if out.source.is_unspecified() { Source::Unspecified } else { Source::Bound(out.source) }; let sent = self.route(out.destination, source, None).and_then(|route| { + if route.next_hop == NextHop::Broadcast && !out.broadcast { + self.log.refuse(Counter::IpBroadcastNotPermitted, route.iface, Peer::Ip(out.destination)); + return Err(Counter::IpBroadcastNotPermitted); + } let builder = Ipv4Builder { source: Ipv4Source::new(route.source).map_err(|_| Counter::RouteNoSourceAddress)?, destination: out.destination, 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/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/tests/udp.rs b/toyos-net-shard/tests/udp.rs index a7058b15ac8..c302d15292c 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::Unreachable))); } + +/// 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 55737e4e095..88c38d21622 100644 --- a/toyos-net-udp/src/lib.rs +++ b/toyos-net-udp/src/lib.rs @@ -181,6 +181,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, } @@ -371,7 +374,8 @@ impl Udp { self.socket(id).map(|s| s.rx_full) } - /// POSIX's `SO_BROADCAST`: without it a send to a broadcast address is refused. + /// 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) } @@ -520,7 +524,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)); @@ -719,6 +723,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/node/src/datagram.rs b/userland/netstack/node/src/datagram.rs index b5968061994..b40ad1f5cbd 100644 --- a/userland/netstack/node/src/datagram.rs +++ b/userland/netstack/node/src/datagram.rs @@ -13,10 +13,13 @@ //! //! **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`]. +//! [`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; no -//! other call of a client's meets one. +//! 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; @@ -127,8 +130,7 @@ impl Node { /// 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> { let sent = self.stack.send_to(now, id.0, destination, port, payload).map_err(refused); - let events = &mut self.events; - self.stack.refusals(|refusal, suppressed| events.push(crate::Event::Stack { refusal, suppressed })); + self.log(); sent } diff --git a/userland/netstack/node/src/lease.rs b/userland/netstack/node/src/lease.rs index 23d957a7018..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. @@ -175,37 +182,33 @@ impl Stack { // ---- the lease ---- - /// The log lines among what the shard has reported, taken ahead of the reports before them: - /// none is read against the state. + /// 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)) { - self.inbox.extend(self.shard.drain_events()); - self.inbox.retain(|event| match *event { - Event::Refused { refusal, suppressed } => { - line(refusal, suppressed); - false - } - Event::Verified(_) | Event::Conflict { .. } | Event::Lost { .. } | Event::NotVerified(_) => true, - }); + 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 shard's next event, read against the state. + /// The next report [`Self::refusals`] took, read against the state. pub(crate) fn report(&mut self) -> Option { - if self.inbox.is_empty() { - self.inbox.extend(self.shard.drain_events()); - } 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) } }) } @@ -315,5 +318,7 @@ mod tests { 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 62aeabd85f4..3a2ca566cb3 100644 --- a/userland/netstack/node/src/lib.rs +++ b/userland/netstack/node/src/lib.rs @@ -153,6 +153,8 @@ 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 } @@ -194,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), @@ -215,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 5691c1b8184..329db3b1aba 100644 --- a/userland/netstack/node/src/name.rs +++ b/userland/netstack/node/src/name.rs @@ -10,6 +10,9 @@ //! 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. diff --git a/userland/netstack/node/tests/datagram.rs b/userland/netstack/node/tests/datagram.rs index 7b8f755124b..d89c8ea944d 100644 --- a/userland/netstack/node/tests/datagram.rs +++ b/userland/netstack/node/tests/datagram.rs @@ -1,7 +1,7 @@ //! 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. What is not ours: `etherparse` reads +//! 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. @@ -11,7 +11,7 @@ mod lan; use std::net::Ipv4Addr; use std::time::Duration; -use common::{terms, A, BROADCAST, 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; @@ -195,6 +195,39 @@ fn a_broadcast_needs_its_permission() { 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()); diff --git a/userland/netstack/node/tests/name.rs b/userland/netstack/node/tests/name.rs index 65c0722a510..f219c3ce10d 100644 --- a/userland/netstack/node/tests/name.rs +++ b/userland/netstack/node/tests/name.rs @@ -217,6 +217,33 @@ fn a_held_name_is_announced_again_when_the_link_returns() { 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. From e8a806cc8ee75c56684f84b315c39f02b5a00ca1 Mon Sep 17 00:00:00 2001 From: japabu Date: Fri, 9 Oct 2026 06:59:56 +0200 Subject: [PATCH 3/3] toyos-net-ip: the link's broadcast address is refused where it is chosen, and [ip]'s own messages never carry the permission The broadcast permission was asked in `send_udp`, one call above the arm of `datagram` that writes the link's broadcast address, and `own`, the path of [ip]'s ICMP messages, reached that arm unasked: only `admit`'s source rule, read when the datagram arrived, kept an echo reply or an error from it. The refusal is now `datagram`'s `NextHop::Broadcast` arm itself, with the permission its argument. `send_udp` passes the datagram's and `own` passes `false`, so no sender inside [ip] picks that address without saying so. `datagram` takes a transport's flow and frame as one argument, which they always were together. An error is routed when [udp] asks for it, after its datagram was admitted: a prefix that becomes usable in between can make the datagram's source its directed broadcast. `an_error_whose_next_hop_became_a_broadcast_is_refused_and_counted` builds that state through the public calls and finds the error counted, logged and not sent. The track's departure line said the readers' list of logged rules has none for a datagram; it has none for one that leaves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RvnWQFcMuGqTHYhvSnTe8A --- issues/toyos-has-its-own-network-stack.md | 2 +- toyos-net-ip/src/egress.rs | 21 ++++++++++++--------- toyos-net-ip/tests/icmp.rs | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/issues/toyos-has-its-own-network-stack.md b/issues/toyos-has-its-own-network-stack.md index 3f88452c0e9..589e0c387f1 100644 --- a/issues/toyos-has-its-own-network-stack.md +++ b/issues/toyos-has-its-own-network-stack.md @@ -52,7 +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: `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 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/egress.rs b/toyos-net-ip/src/egress.rs index 273329f36c7..bbc49fdfb3d 100644 --- a/toyos-net-ip/src/egress.rs +++ b/toyos-net-ip/src/egress.rs @@ -300,10 +300,6 @@ impl Ip { }; let source = if out.source.is_unspecified() { Source::Unspecified } else { Source::Bound(out.source) }; let sent = self.route(out.destination, source, None).and_then(|route| { - if route.next_hop == NextHop::Broadcast && !out.broadcast { - self.log.refuse(Counter::IpBroadcastNotPermitted, route.iface, Peer::Ip(out.destination)); - return Err(Counter::IpBroadcastNotPermitted); - } let builder = Ipv4Builder { source: Ipv4Source::new(route.source).map_err(|_| Counter::RouteNoSourceAddress)?, destination: out.destination, @@ -312,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)); @@ -321,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); @@ -341,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) => { @@ -357,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/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) }]); +}