Skip to content
42 changes: 32 additions & 10 deletions issues/netstack-answers-for-its-name-without-claiming-it.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<host>.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.
8 changes: 4 additions & 4 deletions issues/toyos-has-its-own-network-stack.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions toyos-net-ip/src/counters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
27 changes: 19 additions & 8 deletions toyos-net-ip/src/egress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>,
}

Expand Down Expand Up @@ -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<Sent, Counter> {
let now = self.clock(now);
let flow = Flow {
Expand All @@ -304,7 +308,7 @@ impl Ip {
options: &[],
payload: out.datagram,
};
self.datagram(now, &route, &builder, FrameKind::Datagram, Some(flow), Some(frame))
self.datagram(now, &route, &builder, FrameKind::Datagram, out.broadcast, Some((flow, frame)))
});
if sent.is_err() {
self.log.event(Event::Unreachable(flow));
Expand All @@ -313,26 +317,32 @@ 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<P: Payload>(&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<P: Payload>(
&mut self,
now: Instant,
route: &Route,
builder: &Ipv4Builder<'_, P>,
kind: FrameKind,
flow: Option<Flow>,
out: Option<&mut [u8; FRAME]>,
broadcast: bool,
transport: Option<(Flow, &mut [u8; FRAME])>,
) -> Result<Sent, Counter> {
if builder.length().map_or(true, |len| len > MTU) {
self.log.count(Counter::IpExceedsMtu);
return Err(Counter::IpExceedsMtu);
}
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) => {
Expand All @@ -349,6 +359,7 @@ impl Ip {
}
};
let mac = i.mac;
let (flow, out) = transport.unzip();
if let Some(addr) = hold {
let frame = build_vec(mac, destination, builder).ok_or(Counter::IpExceedsMtu)?;
nud::hold(i, &mut cx, addr, Held { frame, kind, flow });
Expand Down
21 changes: 21 additions & 0 deletions toyos-net-ip/tests/addr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions toyos-net-ip/tests/clk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
}
Expand Down Expand Up @@ -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(_))));
Expand Down
11 changes: 9 additions & 2 deletions toyos-net-ip/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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<Option<Vec<u8>>, 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 {
Expand Down
19 changes: 19 additions & 0 deletions toyos-net-ip/tests/icmp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>(), [], "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) }]);
}
1 change: 1 addition & 0 deletions toyos-net-ip/tests/igmp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions toyos-net-shard/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
104 changes: 102 additions & 2 deletions toyos-net-shard/tests/udp.rs
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -52,3 +59,96 @@ fn s_ip_icd_012_shard_a_resolution_failure_reaches_the_connected_socket() {
net.advance(Duration::from_millis(1));
assert_eq!(net.nodes[a].shard.recv_from(socket, &mut buf), Err(Error::Failed(SocketError::NextHopFailed)));
}

/// One host of 192.0.2.0/24, and the directed broadcast address of 192.0.2.0/25 (RFC 922 §7).
const EDGE_OF_25: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 127);
/// The directed broadcast address of 192.0.2.0/24, which under 192.0.2.0/25 is a host off the link.
const EDGE_OF_24: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 255);
/// A router both prefixes hold.
const ROUTER_IN_25: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 126);

/// One transmit opportunity with all the credit the shard wants: each frame's link destination
/// and, of a UDP datagram, its destination and source port; any other frame by its name.
fn opportunity(shard: &mut Shard, now: Instant) -> Vec<(MacAddr, String)> {
let mut left = Vec::new();
shard.transmit(now, usize::MAX, |bytes| {
let frame = Frame::parse(bytes).expect("a frame");
let udp = Ipv4Packet::parse(frame.body()).ok().and_then(|ip| UdpDatagram::parse(&ip).ok().map(|udp| format!("UDP {} from port {}", ip.destination(), udp.source_port().map_or(0, |p| p.get()))));
left.push((frame.destination(), udp.unwrap_or_else(|| name(bytes))));
});
left
}

/// [ip] counted `count` datagrams refused a link broadcast, and the shard's one line for the
/// rule names `destination`.
fn refused_a_broadcast(shard: &mut Shard, destination: Ipv4Addr, count: u64) {
let rule = toyos_net_ip::Counter::IpBroadcastNotPermitted;
assert_eq!(shard.ip().counters().get(rule), count);
let lines: Vec<Event> = 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);
}
Loading
Loading