From 6f112f71f8085ab89497523b7e5d0d2171039f8c Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 12 Oct 2026 14:58:28 +0200 Subject: [PATCH 1/7] issues: what the per-program DNS cache leaves open, filed with the design that leaves it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues the design of #883 owes, written as the tree stands once its build lands, which is the only way they reach main: - netstack-replaces-one-programs-cached-answers-to-keep-anothers: when MAX_VIEWS views hold live answers, a new program's first lookup replaces the least recently used view whole, and that program learns a count of others. The cache adds this channel; no bound on a pool whose tenants anyone can multiply avoids it (the design review's BLOCKER 2). - the-lease-resolvers-cache-tells-a-program-what-this-machine-asked: a stub cannot partition the upstream resolver's cache, and only an iterative resolver per view removes the channel, which is the owner's to decide (the review's NOTE on §1). - nothing-shows-netstack-keys-a-lookups-cached-answers-by-its-program: after the T14's K4 reading, no test guards the badge wiring; the exit is the one the review named (its NOTE on Checks). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- ...rograms-cached-answers-to-keep-anothers.md | 35 ++++++++++++++++++ ...a-lookups-cached-answers-by-its-program.md | 23 ++++++++++++ ...tells-a-program-what-this-machine-asked.md | 36 +++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 issues/netstack-replaces-one-programs-cached-answers-to-keep-anothers.md create mode 100644 issues/nothing-shows-netstack-keys-a-lookups-cached-answers-by-its-program.md create mode 100644 issues/the-lease-resolvers-cache-tells-a-program-what-this-machine-asked.md diff --git a/issues/netstack-replaces-one-programs-cached-answers-to-keep-anothers.md b/issues/netstack-replaces-one-programs-cached-answers-to-keep-anothers.md new file mode 100644 index 0000000000..9c17bdcaab --- /dev/null +++ b/issues/netstack-replaces-one-programs-cached-answers-to-keep-anothers.md @@ -0,0 +1,35 @@ +--- +status: open +kind: defect +opened: 2026-10-12 +--- + +# netstack replaces one program's cached answers to keep another's + +netstack's DNS cache keeps each program's answers in a view of its own, named +by the row and session its `netstack` connector was minted for, and holds at +most `toyos_dns::MAX_VIEWS` views. When a program with no view looks a name up +and every view holds a live answer, the least recently used view is replaced +whole. That view's program finds its next lookup of a name it held sent to the +resolver, and so learns that at least `MAX_VIEWS` other programs kept answers +since its own last lookup: a count of programs, never a name. Anyone can +multiply the tenants, since every ssh login opens a session, so a program can +also replace another's view on purpose, or keep views of its own and watch the +oldest go to count other programs' first lookups. A view whose answers have all +expired is replaced before a live one, which tells its program nothing. + +No bound on one pool whose tenants anyone can multiply avoids this. The lookup +slots (`issues/netstack-lookup-slots-have-no-per-client-share.md`) and the +node's places (`issues/netstacks-places-are-one-number-for-every-client.md`) +share its root: what netstack holds for a program is charged to netstack, not +to the program. + +Evidence: `toyos-dns`'s host test +`a_new_view_replaces_an_expired_view_first_and_else_the_least_recently_used`. + +Exit condition: a view is charged to the program it serves, so netstack never +replaces one program's view to keep another's, and a test of the node in which +`MAX_VIEWS` + 1 programs each keep an answer finds the first program's next +lookup of its name answered with no query. + +Owner: whoever holds `issues/toyos-has-its-own-network-stack.md`. diff --git a/issues/nothing-shows-netstack-keys-a-lookups-cached-answers-by-its-program.md b/issues/nothing-shows-netstack-keys-a-lookups-cached-answers-by-its-program.md new file mode 100644 index 0000000000..a884e93df5 --- /dev/null +++ b/issues/nothing-shows-netstack-keys-a-lookups-cached-answers-by-its-program.md @@ -0,0 +1,23 @@ +--- +status: open +kind: tooling +opened: 2026-10-12 +--- + +# Nothing shows that netstack keys a lookup's cached answers by its program + +netstack's DNS cache answers a program only from its own view, which +`system/netstack/src/serve.rs` names by the badge on the lookup's connection, +and the supervisor mints each program its own `netstack` connector. Host tests +hold the cache's rules, and none reaches either wiring. A change that passed +one view for every lookup, or minted one badge for every program, would turn +no test red: no guest test resolves a name (`https_get` dials an address), and +under QEMU's slirp the lease's resolver forwards to the host's, which reaches +the internet. The T14 read the wiring once, in #883's metal measurement. + +Exit condition: a netcase guest test against a resolver the harness answers +(`issues/there-is-no-network-gate.md`'s `-netdev socket`) shows that a second +row's lookup of a name the first row holds sends a query. + +Owner: `issues/there-is-no-network-gate.md`, whose `-netdev socket` the test +needs. diff --git a/issues/the-lease-resolvers-cache-tells-a-program-what-this-machine-asked.md b/issues/the-lease-resolvers-cache-tells-a-program-what-this-machine-asked.md new file mode 100644 index 0000000000..a253aa52a8 --- /dev/null +++ b/issues/the-lease-resolvers-cache-tells-a-program-what-this-machine-asked.md @@ -0,0 +1,36 @@ +--- +status: owner +kind: question +opened: 2026-10-12 +--- + +# The lease's resolver's cache tells a program what this machine asked + +netstack asks the resolvers the held lease names, and keeps each program's +answers to that program (`toyos-dns`'s views). The resolver upstream keeps one +cache for every client it serves, and netstack cannot partition a cache it +does not hold. A program learns whether anyone asked that resolver for a name +within the name's TTL in two ways: + +- it times its own lookup, which comes back sooner when the resolver holds the + name than when the resolver has to ask the name's servers; +- it sends a query with recursion not desired (RD clear, RFC 1035 §4.1.1) from + a datagram socket of its own, which the resolver answers from its cache if + at all. + +On a network where this machine is the resolver's only client, that is what +this machine's other programs asked. Only a resolver that iterates from the +root itself, for each view, and asks no shared recursive resolver removes it, +at a round trip per delegation per view. + +## The question + +Does netstack resolve iteratively for each view, or does ToyOS accept this +channel? + +## Exit condition + +The owner's answer. If netstack is to close the channel, this file becomes a +`track` whose exit is a test of the node in which one view's lookup of a name +another view's lookup fetched sends no query to the lease's resolver. If the +channel is accepted, this file becomes a `defect` that stays open. From 804ccd7fcd99f27e1109d296217b5ca885137f72 Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 12 Oct 2026 15:59:26 +0200 Subject: [PATCH 2/7] netstack keeps each program's DNS answers for their TTL, in one cache for the machine that empties with the lease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design accepted on #883, with round 2's named changes. Every lookup asked the lease's resolver across the WAN; on the T14 that round trip was 6.31 ms of the 8.65 ms by which ToyOS's TCP setup trailed Ubuntu's (#861's scout). toyos-dns: the reader reads each record's TTL unsigned and caps it at an hour (RFC 8767 §4), and for a negative answer the alias chain of an NXDOMAIN reply and the authority section's SOA, which counts only where it is owned by the denied name or an ancestor (RFC 2308 §5). A lookup keeps one clock: every TTL it reads counts from its first query's send, so neither a retried query nor an alias asked again stretches what an earlier reply said. `Cache` keeps an answer per view, under the name asked only, bounded at 128 names a view and 32 views; a full view drops its expired entries and then its least recently used, and a new view replaces one whose entries have all expired before the least recently used live one. An emptying is a new generation, and an answer whose lookup started under an older one is not kept. toyos-net-node: the cache sits beside the lease state in one type whose state only its setter writes, and the setter empties it; a link that goes down empties it too. `Node::resolve` takes the view, answers a kept name in the call, holding no lookup place, and keeps an ended lookup's answer. netstack: a lookup's view is the badge on its connection, read per lookup; an unbadged connection's lookup is refused ERR_PERMISSION_DENIED. The two log lines that named a failed lookup's name no longer do. supervisor: every program whose row receives netstack is minted its own connector with its row and session; the shared connector is dropped at once. Boot hands every service its acceptors before it starts any, so a mint on another service's port does not depend on [boot] start's order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- ...rograms-cached-answers-to-keep-anothers.md | 13 +- system/netstack/dns/Cargo.toml | 5 +- system/netstack/dns/src/lib.rs | 336 +++++++++++++++--- system/netstack/dns/src/tests.rs | 329 +++++++++++++++-- system/netstack/node/src/lease.rs | 71 +++- system/netstack/node/src/lib.rs | 2 +- system/netstack/node/src/resolve.rs | 60 +++- system/netstack/node/tests/resolve.rs | 290 +++++++++++++-- system/netstack/src/main.rs | 2 +- system/netstack/src/serve.rs | 93 +++-- system/supervisor/src/main.rs | 86 +++-- 11 files changed, 1096 insertions(+), 191 deletions(-) diff --git a/issues/netstack-replaces-one-programs-cached-answers-to-keep-anothers.md b/issues/netstack-replaces-one-programs-cached-answers-to-keep-anothers.md index 9c17bdcaab..66a6beae59 100644 --- a/issues/netstack-replaces-one-programs-cached-answers-to-keep-anothers.md +++ b/issues/netstack-replaces-one-programs-cached-answers-to-keep-anothers.md @@ -8,11 +8,12 @@ opened: 2026-10-12 netstack's DNS cache keeps each program's answers in a view of its own, named by the row and session its `netstack` connector was minted for, and holds at -most `toyos_dns::MAX_VIEWS` views. When a program with no view looks a name up -and every view holds a live answer, the least recently used view is replaced -whole. That view's program finds its next lookup of a name it held sent to the -resolver, and so learns that at least `MAX_VIEWS` other programs kept answers -since its own last lookup: a count of programs, never a name. Anyone can +most `toyos_dns::MAX_VIEWS` views. When a lookup of a program with no view +ends with an answer to keep and every view holds a live answer, the least +recently used view is replaced whole. That view's program finds its next +lookup of a name it held sent to the resolver, and so learns that at least +`MAX_VIEWS` other programs kept answers since its own last lookup: a count of +programs, never a name. Anyone can multiply the tenants, since every ssh login opens a session, so a program can also replace another's view on purpose, or keep views of its own and watch the oldest go to count other programs' first lookups. A view whose answers have all @@ -25,7 +26,7 @@ share its root: what netstack holds for a program is charged to netstack, not to the program. Evidence: `toyos-dns`'s host test -`a_new_view_replaces_an_expired_view_first_and_else_the_least_recently_used`. +`a_new_view_replaces_the_least_recently_used_live_view_when_none_has_expired`. Exit condition: a view is charged to the program it serves, so netstack never replaces one program's view to keep another's, and a test of the node in which diff --git a/system/netstack/dns/Cargo.toml b/system/netstack/dns/Cargo.toml index a021034546..401db14398 100644 --- a/system/netstack/dns/Cargo.toml +++ b/system/netstack/dns/Cargo.toml @@ -1,11 +1,12 @@ # A member of the host workspace (root `Cargo.toml`): netstack resolves with it on # the guest, and its tests run on the host. What lives here is every decision a # stub resolver makes — the question, which datagram answers it, what the -# answer says, when to ask again — pure, so each is checked without a network. +# answer says, when to ask again, how long an answer is kept and for whom — +# pure, so each is checked without a network. [package] name = "toyos-dns" -description = "A stub DNS resolver's decisions (RFC 1035): the question, which datagram answers it, what it says and when to ask again, pure." +description = "A stub DNS resolver's decisions (RFC 1035): the question, which datagram answers it, what it says, when to ask again, and how long an answer is kept, and for whom, pure." version = "0.1.0" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/system/netstack/dns/src/lib.rs b/system/netstack/dns/src/lib.rs index 1712abefd1..6ffecde6de 100644 --- a/system/netstack/dns/src/lib.rs +++ b/system/netstack/dns/src/lib.rs @@ -1,8 +1,9 @@ //! A stub resolver's decisions (RFC 1035): the question it asks for a name's //! IPv4 addresses, which datagram answers that question, what the answer says, -//! and when the question is asked again. Pure: `core` and `alloc`, no -//! `unsafe`, no I/O, and no clock or randomness of its own. The caller hands in -//! the time, the draw of every query's ID and every datagram with its source. +//! when the question is asked again, and how long an answer is kept and for +//! whom. Pure: `core` and `alloc`, no `unsafe`, no I/O, and no clock or +//! randomness of its own. The caller hands in the time, the draw of every +//! query's ID and every datagram with its source. //! //! **A reply has crossed a trust boundary.** Anyone on the path can send a //! datagram to the port a query left from, so a reply is read only if it @@ -26,7 +27,23 @@ //! 512 bytes a UDP reply may carry (RFC 1035 §4.2.1), which an `A` question //! for an ordinary name does not reach. //! -//! **No cache.** Every lookup asks. +//! **An answer is kept for the view that asked it, under the name it asked** +//! ([`Cache`]): a view is one program's, and no view is answered from another's, +//! so no program learns from the time a lookup takes which names another asked. +//! No alias target, additional or authority record is kept in its own right, +//! so a reply for one name plants nothing for another. +//! +//! **One clock a lookup.** Every TTL a lookup reads counts from its first +//! query's send, which is no later than any resolver sent any record it read, +//! so no record outlives what its own reply said. An answer is kept until then +//! plus the least TTL of the aliases followed and the address RRset, its TTLs +//! read at their lowest where they differ (RFC 2181 §5.2); a name's absence, +//! NXDOMAIN or NODATA (RFC 2308 §2.1, §2.2), until then plus the least of the +//! aliases that led to it and its zone's SOA's TTL and MINIMUM (§5), where the +//! authority section holds an SOA owned by the denied name or an ancestor of +//! it, and never where it holds none. A TTL is read unsigned (RFC 8767 §4) and +//! capped at [`KEEP_CAP_S`]; one of zero keeps nothing (RFC 1035 §4.1.3), and +//! neither does a failure. #![no_std] #![forbid(unsafe_code)] @@ -59,6 +76,21 @@ pub const MAX_ALIASES: usize = 8; /// number of them. pub const MAX_LOOKUPS: usize = 16; +/// The longest any answer is kept, in seconds, whatever its TTL says. Policy: +/// it bounds how long a wrong answer that passed every check, forged or for a +/// host that moved, outlives its correction, at a query an hour for each name +/// a program keeps asking. +pub const KEEP_CAP_S: u32 = 3_600; + +/// The names one view keeps at once. Policy, against what one desktop session +/// resolves. +pub const VIEW_ENTRIES: usize = 128; + +/// The views a [`Cache`] keeps at once. Policy, as [`VIEW_ENTRIES`]. An entry +/// is a name of at most 255 bytes and the addresses of one reply, so the two +/// counts bound the bytes. +pub const MAX_VIEWS: usize = 32; + /// RFC 1035 §4.1.1. const HEADER: usize = 12; const QR: u16 = 0x8000; @@ -71,6 +103,7 @@ const NXDOMAIN: u8 = 3; /// RFC 1035 §3.2.2 and §3.2.4. const TYPE_A: u16 = 1; const TYPE_CNAME: u16 = 5; +const TYPE_SOA: u16 = 6; const CLASS_IN: u16 = 1; /// RFC 1035 §2.3.4: a label is at most 63 octets, and a name at most 255 in @@ -145,6 +178,21 @@ impl Name { pub fn same(&self, other: &Name) -> bool { self.wire.eq_ignore_ascii_case(&other.wire) } + + /// This name is `zone` or a name below it: `zone` is this one, or this one + /// with labels taken off its front. + fn within(&self, zone: &Name) -> bool { + let mut at = 0; + loop { + if self.wire[at..].eq_ignore_ascii_case(&zone.wire) { + return true; + } + match usize::from(self.wire[at]) { + 0 => return false, + len => at += 1 + len, + } + } + } } /// A standard query (RFC 1035 §4.1.1, §4.1.2) with ID `id`, asking with @@ -188,8 +236,9 @@ pub enum Verdict { /// not define), which asks the next server. ServerFailed(u8), /// RCODE 3: the name, or the name an alias leads to, does not exist - /// (RFC 2308 §2.1). - NoSuchName, + /// (RFC 2308 §2.1); and for how many seconds that may be kept, by the + /// aliases followed to it and its zone's SOA, zero with no SOA. + NoSuchName(u32), /// The answer, read along the alias chain from the asked name. Answer(Answer), /// The alias chain is longer than the bound it was read under. @@ -204,6 +253,10 @@ pub struct Answer { pub aliases: Vec, /// Addresses of the chain's last name, each once, in the reply's order. pub addrs: Vec<[u8; 4]>, + /// For how many seconds what this reply says may be kept: the least TTL + /// among the aliases followed and the chain's last name's addresses; with + /// neither, the denied name's SOA's, zero with no SOA. + pub keep: u32, } impl PartialEq for Name { @@ -271,6 +324,11 @@ impl<'a> Message<'a> { Ok(u16::from_be_bytes([b[0], b[1]])) } + fn u32(&self, at: usize) -> Result { + let b = self.slice(at, 4)?; + Ok(u32::from_be_bytes([b[0], b[1], b[2], b[3]])) + } + /// The name at `at`, and where the bytes after it begin. /// /// **A pointer must point before the run of labels it ends**, which is @@ -323,6 +381,8 @@ struct Record { owner: Name, rtype: u16, class: u16, + /// Capped, as [`keep`] reads one. + ttl: u32, data: usize, end: usize, } @@ -331,10 +391,59 @@ fn record(msg: &Message, at: usize) -> Result { let (owner, fixed) = msg.name(at)?; let rtype = msg.u16(fixed)?; let class = msg.u16(fixed + 2)?; + let ttl = keep(msg.u32(fixed + 4)?); let len = usize::from(msg.u16(fixed + 8)?); let data = fixed + 10; msg.slice(data, len)?; - Ok(Record { owner, rtype, class, data, end: data + len }) + Ok(Record { owner, rtype, class, ttl, data, end: data + len }) +} + +/// A TTL as seconds to keep: unsigned (RFC 8767 §4), and at most +/// [`KEEP_CAP_S`]. +fn keep(ttl: u32) -> u32 { + ttl.min(KEEP_CAP_S) +} + +/// The aliases from `asked` along `cnames`, each with its TTL, or `None` for a +/// chain longer than `budget`. +fn chain<'n>(asked: &Name, cnames: &'n [(Name, Name, u32)], budget: usize) -> Option> { + let mut chain: Vec<(&Name, u32)> = Vec::new(); + loop { + let now = chain.last().map_or(asked, |(name, _)| *name); + let Some((_, target, ttl)) = cnames.iter().find(|(owner, ..)| owner.same(now)) else { return Some(chain) }; + if chain.len() == budget { + return None; + } + chain.push((target, *ttl)); + } +} + +/// For how many seconds `denied`'s absence may be kept, by the `count` records +/// of the authority section at `at`: the least, over each SOA there owned by +/// `denied` or an ancestor of it, of its TTL and its MINIMUM (RFC 2308 §5); +/// zero where there is none. +fn absence(msg: &Message, mut at: usize, count: u16, denied: &Name) -> Result { + let mut least: Option = None; + for _ in 0..count { + let r = record(msg, at)?; + at = r.end; + if r.class != CLASS_IN || r.rtype != TYPE_SOA { + continue; + } + // MNAME and RNAME, then SERIAL, REFRESH, RETRY, EXPIRE and MINIMUM + // (RFC 1035 §3.3.13). + let (_, rname) = msg.name(r.data)?; + let (_, fields) = msg.name(rname)?; + if fields + 20 != r.end { + return Err(Stray::Malformed("an SOA record whose data is not two names and five numbers")); + } + if !denied.within(&r.owner) { + continue; + } + let soa = r.ttl.min(keep(msg.u32(fields + 16)?)); + least = Some(least.map_or(soa, |least| least.min(soa))); + } + Ok(least.unwrap_or(0)) } /// What `msg`, a datagram that carries `id` from a server a query went to, @@ -367,14 +476,13 @@ fn read_checked(msg: &Message, id: u16, asked: &Name, budget: usize) -> Result {} - NXDOMAIN => return Ok(Verdict::NoSuchName), - rcode => return Ok(Verdict::ServerFailed(rcode)), + let rcode = (flags & RCODE) as u8; + if rcode != 0 && rcode != NXDOMAIN { + return Ok(Verdict::ServerFailed(rcode)); } - let mut cnames: Vec<(Name, Name)> = Vec::new(); - let mut addrs: Vec<(Name, [u8; 4])> = Vec::new(); + let mut cnames: Vec<(Name, Name, u32)> = Vec::new(); + let mut addrs: Vec<(Name, [u8; 4], u32)> = Vec::new(); let mut at = after + 4; for _ in 0..msg.u16(6)? { let r = record(msg, at)?; @@ -387,7 +495,7 @@ fn read_checked(msg: &Message, id: u16, asked: &Name, budget: usize) -> Result { let (target, end) = msg.name(r.data)?; @@ -396,32 +504,46 @@ fn read_checked(msg: &Message, id: u16, asked: &Name, budget: usize) -> Result {} } } - - let mut chain: Vec = Vec::new(); - loop { - let now = chain.last().unwrap_or(asked); - let Some((_, target)) = cnames.iter().find(|(owner, _)| owner.same(now)) else { break }; - if chain.len() == budget { - return Ok(Verdict::TooManyAliases); - } - chain.push(target.clone()); + let authority = msg.u16(8)?; + + let chain = chain(asked, &cnames, budget); + if rcode == NXDOMAIN { + // The name denied is the chain's last (RFC 2308 §2.1). A chain too + // long to read keeps nothing, and the name is no less denied. + let keep = match chain { + Some(chain) => { + let denied = chain.last().map_or(asked, |(name, _)| *name); + chain.iter().map(|(_, ttl)| *ttl).fold(absence(msg, at, authority, denied)?, u32::min) + } + None => 0, + }; + return Ok(Verdict::NoSuchName(keep)); } - let owner = chain.last().unwrap_or(asked); + let Some(chain) = chain else { return Ok(Verdict::TooManyAliases) }; + let owner = chain.last().map_or(asked, |(name, _)| *name); let mut found: Vec<[u8; 4]> = Vec::new(); - for (_, addr) in addrs.iter().filter(|(name, _)| name.same(owner)) { + let mut least = chain.iter().map(|(_, ttl)| *ttl).min(); + for (_, addr, ttl) in addrs.iter().filter(|(name, ..)| name.same(owner)) { + least = Some(least.map_or(*ttl, |least| least.min(*ttl))); if !found.contains(addr) { found.push(*addr); } } - Ok(Verdict::Answer(Answer { aliases: chain, addrs: found })) + let keep = match least { + Some(least) => least, + // Neither an alias nor an address: the name has no `A` record (RFC + // 2308 §2.2), and its absence is the SOA's to time. + None => absence(msg, at, authority, asked)?, + }; + Ok(Verdict::Answer(Answer { aliases: chain.into_iter().map(|(name, _)| name.clone()).collect(), addrs: found, keep })) } /// How a lookup ended without an address. @@ -529,6 +651,10 @@ pub struct Lookup { /// The RCODE of the last server failure, which is what a lookup that runs /// out of queries reports if any server answered at all. failed: Option, + /// When the first query was sent, which every TTL read counts from. + began: u64, + /// The least TTL of every record read so far, in seconds. + keep: u32, } impl Lookup { @@ -547,6 +673,8 @@ impl Lookup { asked: 0, due: now, failed: None, + began: now, + keep: KEEP_CAP_S, }; let step = lookup.ask(now, id); Some((lookup, step)) @@ -557,6 +685,13 @@ impl Lookup { self.due } + /// Until when what the lookup ended with may be answered again without + /// asking: its first query's send plus the least TTL of every record it + /// read, and `None` where that is zero. + pub fn keep_until(&self) -> Option { + (self.keep > 0).then(|| self.began.saturating_add(u64::from(self.keep) * 1_000)) + } + /// The queries whose answer is still read, oldest first. The port of any /// other query of this lookup can be let go. pub fn waiting(&self) -> impl Iterator + '_ { @@ -658,7 +793,10 @@ impl Lookup { match read(msg, carried, &self.name, MAX_ALIASES - self.aliases) { Verdict::Stray(_) => Step::Wait, Verdict::Truncated => Step::Done(Err(Failure::Truncated)), - Verdict::NoSuchName => Step::Done(Err(Failure::NoSuchName)), + Verdict::NoSuchName(keep) => { + self.keep = self.keep.min(keep); + Step::Done(Err(Failure::NoSuchName)) + } Verdict::TooManyAliases => Step::Done(Err(Failure::TooManyAliases)), Verdict::ServerFailed(rcode) => { self.failed = Some(rcode); @@ -674,25 +812,133 @@ impl Lookup { Step::Wait } } - Verdict::Answer(answer) if !answer.addrs.is_empty() => Step::Done(Ok(answer.addrs)), - Verdict::Answer(answer) => match answer.aliases.last() { - None => Step::Done(Err(Failure::NoAddress)), - // The chain ends at a name this reply holds nothing for: that - // name is asked in its own right (RFC 1034 §5.3.3), with every - // query and server afresh. - Some(last) => { - self.aliases += answer.aliases.len(); - self.name = last.clone(); - self.sent.clear(); - self.asked = 0; - self.failed = None; - for (_, free) in &mut self.servers { - *free = now; + Verdict::Answer(answer) => { + self.keep = self.keep.min(answer.keep); + match answer.aliases.last() { + _ if !answer.addrs.is_empty() => Step::Done(Ok(answer.addrs)), + None => Step::Done(Err(Failure::NoAddress)), + // The chain ends at a name this reply holds nothing for: + // that name is asked in its own right (RFC 1034 §5.3.3), + // with every query and server afresh. + Some(last) => { + self.aliases += answer.aliases.len(); + self.name = last.clone(); + self.sent.clear(); + self.asked = 0; + self.failed = None; + for (_, free) in &mut self.servers { + *free = now; + } + self.ask(now, id) } - self.ask(now, id) } - }, + } + } + } +} + +/// Which emptying of a [`Cache`] a lookup started after: its answer is kept +/// only where no emptying came since. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Generation(u64); + +/// One name a view keeps. +#[derive(Debug)] +struct Entry { + name: Name, + answer: Result, Failure>, + /// Answered before this time, and never at it or after. + until: u64, +} + +/// One program's names, least recently used first. +#[derive(Debug)] +struct View { + key: Vec, + entries: Vec, +} + +/// Every view's answers, and what empties them all at once. +/// +/// **A view is named by bytes the caller vouches for**, and is answered only +/// from its own entries. A full view drops its expired entries, then its least +/// recently used one, so no view's lookup takes another's entry. With +/// [`MAX_VIEWS`] held a new view replaces another whole: one whose entries +/// have all expired, which tells its program nothing, and with none the least +/// recently used, live entries and all, whose program then finds its next +/// lookup asked (`issues/netstack-replaces-one-programs-cached-answers-to-keep-anothers.md`). +/// +/// **An emptying is a new generation**: an answer whose lookup started under +/// an older one is not kept, so a lookup in flight across an emptying keeps +/// nothing. +#[derive(Debug, Default)] +pub struct Cache { + /// Least recently used first. + views: Vec, + generation: u64, +} + +impl Cache { + /// The generation a lookup starting now carries to [`Self::keep`]. + pub fn generation(&self) -> Generation { + Generation(self.generation) + } + + /// Forgets every answer of every view. + pub fn empty(&mut self) { + self.views.clear(); + self.generation += 1; + } + + /// What `view` keeps for `name` at `now`, if it keeps anything still. + pub fn answer(&mut self, view: &[u8], name: &Name, now: u64) -> Option, Failure>> { + let at = self.views.iter().position(|kept| kept.key == view)?; + let mut kept = self.views.remove(at); + let answer = kept.entries.iter().position(|entry| entry.name.same(name)).and_then(|at| { + let entry = kept.entries.remove(at); + (now < entry.until).then(|| { + let answer = entry.answer.clone(); + kept.entries.push(entry); + answer + }) + }); + self.views.push(kept); + answer + } + + /// Keeps for `view` what a lookup of `name` that started under `started` + /// ended with, until `until`: addresses or the name's absence, and never a + /// failure, an answer [`Lookup::keep_until`] gives no time, or one from + /// before an emptying. A later answer for the name replaces an earlier one. + pub fn keep(&mut self, view: &[u8], name: &Name, started: Generation, answer: Result<&[[u8; 4]], Failure>, until: Option, now: u64) { + let answer = match answer { + Ok(addrs) => Ok(addrs.to_vec()), + Err(absent @ (Failure::NoSuchName | Failure::NoAddress)) => Err(absent), + Err(_) => return, + }; + let Some(until) = until.filter(|until| now < *until) else { return }; + if started != self.generation() { + return; + } + let mut kept = match self.views.iter().position(|kept| kept.key == view) { + Some(at) => self.views.remove(at), + None => { + if self.views.len() == MAX_VIEWS { + let spent = self.views.iter().position(|kept| kept.entries.iter().all(|entry| entry.until <= now)); + self.views.remove(spent.unwrap_or(0)); + } + View { key: view.to_vec(), entries: Vec::new() } + } + }; + kept.entries.retain(|entry| !entry.name.same(name)); + if kept.entries.len() == VIEW_ENTRIES { + kept.entries.retain(|entry| now < entry.until); + } + if kept.entries.len() == VIEW_ENTRIES { + kept.entries.remove(0); } + kept.entries.push(Entry { name: name.clone(), answer, until }); + self.views.push(kept); } } diff --git a/system/netstack/dns/src/tests.rs b/system/netstack/dns/src/tests.rs index 630ac141f3..4890bed37a 100644 --- a/system/netstack/dns/src/tests.rs +++ b/system/netstack/dns/src/tests.rs @@ -76,10 +76,15 @@ fn wire(text: &str) -> Vec { } fn rr(owner: &[u8], rtype: u16, class: u16, data: &[u8]) -> Vec { + timed(owner, rtype, class, 300, data) +} + +/// A record whose TTL is `ttl` (RFC 1035 §4.1.3). +fn timed(owner: &[u8], rtype: u16, class: u16, ttl: u32, data: &[u8]) -> Vec { let mut out = owner.to_vec(); out.extend_from_slice(&rtype.to_be_bytes()); out.extend_from_slice(&class.to_be_bytes()); - out.extend_from_slice(&300u32.to_be_bytes()); + out.extend_from_slice(&ttl.to_be_bytes()); out.extend_from_slice(&(data.len() as u16).to_be_bytes()); out.extend_from_slice(data); out @@ -96,21 +101,37 @@ fn cname(owner: &str, target: &str) -> Vec { /// A response with ID `id`, `flags`, one question for `asked`'s `A`, and /// `answers` as the answer section. fn reply(id: u16, flags: u16, asked: &str, answers: &[Vec]) -> Vec { + reply_with(id, flags, asked, answers, &[]) +} + +/// [`reply`], with `authority` as the authority section. +fn reply_with(id: u16, flags: u16, asked: &str, answers: &[Vec], authority: &[Vec]) -> Vec { let mut out = Vec::new(); - for word in [id, flags, 1, answers.len() as u16, 0, 0] { + for word in [id, flags, 1, answers.len() as u16, authority.len() as u16, 0] { out.extend_from_slice(&word.to_be_bytes()); } out.extend_from_slice(&wire(asked)); out.extend_from_slice(&TYPE_A.to_be_bytes()); out.extend_from_slice(&CLASS_IN.to_be_bytes()); - for answer in answers { - out.extend_from_slice(answer); + for record in answers.iter().chain(authority) { + out.extend_from_slice(record); } out } -fn answer(aliases: &[&str], addrs: &[[u8; 4]]) -> Verdict { - Verdict::Answer(Answer { aliases: aliases.iter().map(|a| name(a)).collect(), addrs: addrs.to_vec() }) +/// An SOA record of `zone` (RFC 1035 §3.3.13) whose TTL is `ttl` and whose +/// MINIMUM, its last field, is `minimum`. +fn soa(zone: &str, ttl: u32, minimum: u32) -> Vec { + let mut data = wire("ns.example"); + data.extend_from_slice(&wire("hostmaster.example")); + for field in [1, 7_200, 900, 1_209_600, minimum] { + data.extend_from_slice(&u32::to_be_bytes(field)); + } + timed(&wire(zone), TYPE_SOA, CLASS_IN, ttl, &data) +} + +fn answer(aliases: &[&str], addrs: &[[u8; 4]], keep: u32) -> Verdict { + Verdict::Answer(Answer { aliases: aliases.iter().map(|a| name(a)).collect(), addrs: addrs.to_vec(), keep }) } fn stray(why: &'static str) -> Verdict { @@ -183,7 +204,7 @@ fn rfc1035_5_1_a_name_off_the_wire_is_written_with_every_odd_byte_escaped() { #[test] fn a_real_reply_with_two_addresses_gives_both_in_order() { - assert_eq!(read(&REAL_GOOGLE, 0x1001, &name("dns.google"), MAX_ALIASES), answer(&[], &[[8, 8, 4, 4], [8, 8, 8, 8]])); + assert_eq!(read(&REAL_GOOGLE, 0x1001, &name("dns.google"), MAX_ALIASES), answer(&[], &[[8, 8, 4, 4], [8, 8, 8, 8]], 719)); } #[test] @@ -192,12 +213,13 @@ fn rfc1034_3_6_2_a_real_chain_of_aliases_is_followed_to_its_address() { read(&REAL_APPLE, 0x1000, &name("www.apple.com"), MAX_ALIASES), answer( &["www-apple-com.v.aaplimg.com", "www.apple.com.edgekey.net", "e6858.dsce9.akamaiedge.net"], - &[[23, 32, 112, 246]] + &[[23, 32, 112, 246]], + 9 ) ); assert_eq!( read(&REAL_MICROSOFT, 0x1003, &name("www.microsoft.com"), MAX_ALIASES), - answer(&["www.microsoft.com-c-3.edgekey.net", "e13678.dscb.akamaiedge.net"], &[[23, 212, 193, 218]]) + answer(&["www.microsoft.com-c-3.edgekey.net", "e13678.dscb.akamaiedge.net"], &[[23, 212, 193, 218]], 7) ); assert_eq!( read(&REAL_APPLE, 0x1000, &name("www.apple.com"), 2), @@ -208,7 +230,7 @@ fn rfc1034_3_6_2_a_real_chain_of_aliases_is_followed_to_its_address() { #[test] fn rfc6761_6_4_a_real_invalid_name_does_not_exist() { - assert_eq!(read(&REAL_INVALID, 0x1002, &name("doesnotexist.invalid"), MAX_ALIASES), Verdict::NoSuchName); + assert_eq!(read(&REAL_INVALID, 0x1002, &name("doesnotexist.invalid"), MAX_ALIASES), Verdict::NoSuchName(KEEP_CAP_S)); } // --- Whose reply it is (RFC 5452 §9.1) --- @@ -220,7 +242,7 @@ fn rfc5452_9_1_a_reply_is_read_only_for_its_id_and_its_question() { assert_eq!(read(&REAL_GOOGLE, 0x1001, &name("dns.googlf"), MAX_ALIASES), Verdict::Stray(Stray::OtherQuestion)); assert_eq!( read(&REAL_GOOGLE, 0x1001, &name("DNS.Google"), MAX_ALIASES), - answer(&[], &[[8, 8, 4, 4], [8, 8, 8, 8]]), + answer(&[], &[[8, 8, 4, 4], [8, 8, 8, 8]], 719), "a server may echo the question in another case" ); let mut other_type = REAL_GOOGLE; @@ -262,8 +284,9 @@ fn rfc1035_4_1_1_every_rcode_is_named() { let mut msg = REAL_GOOGLE; msg[3] = (msg[3] & 0xf0) | rcode; let expected = match rcode { - 0 => answer(&[], &[[8, 8, 4, 4], [8, 8, 8, 8]]), - 3 => Verdict::NoSuchName, + 0 => answer(&[], &[[8, 8, 4, 4], [8, 8, 8, 8]], 719), + // With no SOA to say how long, nothing. + 3 => Verdict::NoSuchName(0), _ => Verdict::ServerFailed(rcode), }; assert_eq!(read(&msg, 0x1001, &name("dns.google"), MAX_ALIASES), expected, "rcode {rcode}"); @@ -273,7 +296,7 @@ fn rfc1035_4_1_1_every_rcode_is_named() { #[test] fn rfc2308_2_2_a_name_with_no_address_is_an_empty_answer() { let msg = reply(7, OK, "mail.example", &[]); - assert_eq!(read(&msg, 7, &name("mail.example"), MAX_ALIASES), answer(&[], &[])); + assert_eq!(read(&msg, 7, &name("mail.example"), MAX_ALIASES), answer(&[], &[], 0)); } // --- Which records are read --- @@ -286,12 +309,12 @@ fn rfc2181_5_4_1_an_address_for_a_name_not_asked_is_not_read() { "www.example", &[a("bank.example", [6, 6, 6, 6]), a("www.example", [1, 2, 3, 4]), a("example", [6, 6, 6, 6])], ); - assert_eq!(read(&msg, 7, &name("www.example"), MAX_ALIASES), answer(&[], &[[1, 2, 3, 4]])); + assert_eq!(read(&msg, 7, &name("www.example"), MAX_ALIASES), answer(&[], &[[1, 2, 3, 4]], 300)); // An address beside an alias belongs to the name that is not the chain's // end, and so is not read either (RFC 1034 §3.6.2: no other data at an // alias). let beside = reply(7, OK, "www.example", &[a("www.example", [6, 6, 6, 6]), cname("www.example", "cdn.example")]); - assert_eq!(read(&beside, 7, &name("www.example"), MAX_ALIASES), answer(&["cdn.example"], &[])); + assert_eq!(read(&beside, 7, &name("www.example"), MAX_ALIASES), answer(&["cdn.example"], &[], 300)); } #[test] @@ -310,7 +333,7 @@ fn rfc1034_3_6_2_a_chain_is_followed_in_any_order() { ); assert_eq!( read(&msg, 7, &name("www.example"), MAX_ALIASES), - answer(&["b.example", "c.example"], &[[9, 9, 9, 9], [8, 8, 8, 8]]), + answer(&["b.example", "c.example"], &[[9, 9, 9, 9], [8, 8, 8, 8]], 300), "each address once, in the reply's order" ); } @@ -329,7 +352,7 @@ fn a_chain_exactly_at_the_bound_is_followed() { answers.push(a(&hops[MAX_ALIASES], [5, 5, 5, 5])); let msg = reply(7, OK, &hops[0], &answers); let aliases: Vec<&str> = hops[1..].iter().map(|s| s.as_str()).collect(); - assert_eq!(read(&msg, 7, &name(&hops[0]), MAX_ALIASES), answer(&aliases, &[[5, 5, 5, 5]])); + assert_eq!(read(&msg, 7, &name(&hops[0]), MAX_ALIASES), answer(&aliases, &[[5, 5, 5, 5]], 300)); assert_eq!(read(&msg, 7, &name(&hops[0]), MAX_ALIASES - 1), Verdict::TooManyAliases); } @@ -347,7 +370,7 @@ fn records_of_another_class_or_type_are_passed_over() { "www.example", &[rr(&wire("www.example"), TYPE_A, 3, &[6, 6, 6, 6]), rr(&wire("www.example"), 28, CLASS_IN, &[6; 16]), a("www.example", [1, 1, 1, 1])], ); - assert_eq!(read(&msg, 7, &name("www.example"), MAX_ALIASES), answer(&[], &[[1, 1, 1, 1]])); + assert_eq!(read(&msg, 7, &name("www.example"), MAX_ALIASES), answer(&[], &[[1, 1, 1, 1]], 300)); } #[test] @@ -393,11 +416,11 @@ const FIRST_ANSWER: u16 = 12 + 13 + 4; #[test] fn rfc1035_4_1_4_a_pointer_to_a_prior_name_is_read() { - assert_eq!(read(&with_owner(&[0xc0, 12]), 7, &name("www.example"), MAX_ALIASES), answer(&[], &[[1, 2, 3, 4]])); + assert_eq!(read(&with_owner(&[0xc0, 12]), 7, &name("www.example"), MAX_ALIASES), answer(&[], &[[1, 2, 3, 4]], 300)); // A label, then a pointer into the question's second label. let mut owner = vec![3, b'w', b'w', b'w']; owner.extend_from_slice(&[0xc0, 16]); - assert_eq!(read(&with_owner(&owner), 7, &name("www.example"), MAX_ALIASES), answer(&[], &[[1, 2, 3, 4]])); + assert_eq!(read(&with_owner(&owner), 7, &name("www.example"), MAX_ALIASES), answer(&[], &[[1, 2, 3, 4]], 300)); } #[test] @@ -466,7 +489,7 @@ fn rfc1035_2_3_4_a_name_built_past_255_bytes_by_pointers_is_refused() { // Three of them are 193 bytes, and read. assert_eq!( read(&pointing_at(starts[2]), 7, &name("www.example"), MAX_ALIASES), - answer(&[], &[]), + answer(&[], &[], 0), "a 193-byte owner not asked about" ); } @@ -491,7 +514,7 @@ fn rfc1035_2_3_4_an_owner_of_255_bytes_is_read_and_one_of_256_is_refused() { let msg = with_owner(&edge); let (read_back, after) = name_at(&msg, at).expect("a 255-byte owner"); assert_eq!((read_back.wire(), after), (&edge[..], at + 255)); - assert_eq!(read(&msg, 7, &name("www.example"), MAX_ALIASES), answer(&[], &[]), "255 bytes, not asked about"); + assert_eq!(read(&msg, 7, &name("www.example"), MAX_ALIASES), answer(&[], &[], 0), "255 bytes, not asked about"); let msg = with_owner(&past); assert_eq!(name_at(&msg, at).map(|_| ()), Err(Stray::Malformed("a name longer than 255 bytes"))); assert_eq!(read(&msg, 7, &name("www.example"), MAX_ALIASES), stray("a name longer than 255 bytes")); @@ -1220,3 +1243,263 @@ fn a_server_that_answered_with_a_failure_is_what_a_lookup_that_then_reached_nobo assert_eq!(to(&third), (S1, 3)); assert_eq!(lookup.on_unreached(asked(&third).0, 3, undrawn), Step::Done(Err(Failure::ServerFailed(2)))); } + +// --- How long an answer is kept (RFC 1035 §4.1.3, RFC 2181 §5.2, RFC 2308 §5) --- + +const NX: u16 = OK | 3; + +/// Until when a lookup of `text` that starts at 1 s keeps what it ends with, +/// having read `msgs` in turn, each the reply to the query before it, at the +/// time it is paired with. +fn kept_until(text: &str, msgs: &[(u64, Vec)]) -> Option { + let (mut lookup, first) = Lookup::start(name(text), &[S1], 1_000, || 1).unwrap(); + let mut q = asked(&first).0; + for (at, msg) in msgs { + match lookup.on_datagram(q, S1, PORT, msg, *at, || 2) { + Step::Done(_) => break, + step => q = asked(&step).0, + } + } + lookup.keep_until() +} + +#[test] +fn rfc2181_5_2_an_answer_is_kept_for_the_least_ttl_of_its_aliases_and_its_differing_addresses() { + let msg = reply( + 1, + OK, + "www.example", + &[ + timed(&wire("www.example"), TYPE_CNAME, CLASS_IN, 200, &wire("cdn.example")), + timed(&wire("cdn.example"), TYPE_A, CLASS_IN, 100, &[1, 1, 1, 1]), + timed(&wire("cdn.example"), TYPE_A, CLASS_IN, 50, &[2, 2, 2, 2]), + timed(&wire("other.example"), TYPE_A, CLASS_IN, 5, &[6, 6, 6, 6]), + ], + ); + assert_eq!(read(&msg, 1, &name("www.example"), MAX_ALIASES), answer(&["cdn.example"], &[[1, 1, 1, 1], [2, 2, 2, 2]], 50)); + assert_eq!(kept_until("www.example", &[(1_500, msg)]), Some(1_000 + 50_000), "from the query's send at 1 s, not the reply's read"); +} + +// RFC 1034 §5.3.3 asks an alias's end again: the second query leaves after +// the first reply is read, and the first reply's alias, which holds the least +// TTL, still counts from the first query's send. +#[test] +fn every_ttl_counts_from_the_first_querys_send_across_an_alias_asked_again() { + let first = reply(1, OK, "www.example", &[timed(&wire("www.example"), TYPE_CNAME, CLASS_IN, 10, &wire("cdn.example"))]); + let second = reply(2, OK, "cdn.example", &[timed(&wire("cdn.example"), TYPE_A, CLASS_IN, 300, &[1, 2, 3, 4])]); + assert_eq!(kept_until("www.example", &[(4_000, first), (4_500, second)]), Some(1_000 + 10_000)); +} + +// RFC 1035 §4.2.1 asks again when a wait is over: a reply to the first query, +// read after the second left, counts from the first's send. +#[test] +fn every_ttl_counts_from_the_first_querys_send_across_a_query_sent_again() { + let (mut lookup, first) = Lookup::start(name("www.example"), &[S1, S2], 0, || 1).unwrap(); + assert_eq!(to(&lookup.on_time(WAIT_MS, || 2)), (S2, 2), "the premise: a second query left at the first one's wait"); + let late = reply(1, OK, "www.example", &[timed(&wire("www.example"), TYPE_A, CLASS_IN, 10, &[1, 2, 3, 4])]); + assert_eq!(lookup.on_datagram(asked(&first).0, S1, PORT, &late, WAIT_MS + 1, undrawn), Step::Done(Ok(vec![[1, 2, 3, 4]]))); + assert_eq!(lookup.keep_until(), Some(10_000)); +} + +#[test] +fn rfc1035_4_1_3_a_ttl_of_zero_anywhere_in_what_was_read_keeps_nothing() { + let alias = reply( + 1, + OK, + "www.example", + &[timed(&wire("www.example"), TYPE_CNAME, CLASS_IN, 0, &wire("cdn.example")), timed(&wire("cdn.example"), TYPE_A, CLASS_IN, 300, &[1, 2, 3, 4])], + ); + let address = reply(1, OK, "www.example", &[timed(&wire("www.example"), TYPE_A, CLASS_IN, 0, &[1, 2, 3, 4])]); + let earlier = reply(1, OK, "www.example", &[timed(&wire("www.example"), TYPE_CNAME, CLASS_IN, 0, &wire("cdn.example"))]); + let later = reply(2, OK, "cdn.example", &[timed(&wire("cdn.example"), TYPE_A, CLASS_IN, 300, &[1, 2, 3, 4])]); + assert_eq!(kept_until("www.example", &[(1_000, alias)]), None, "an alias's"); + assert_eq!(kept_until("www.example", &[(1_000, address)]), None, "an address's"); + assert_eq!(kept_until("www.example", &[(1_000, earlier), (1_000, later)]), None, "an earlier reply's"); +} + +// RFC 8767 §4: "a 32-bit unsigned integer number of seconds", "SHOULD be +// capped". The high-order bit set is a long time, not none. +#[test] +fn rfc8767_4_a_ttl_is_read_unsigned_and_kept_an_hour_at_most() { + for ttl in [KEEP_CAP_S + 1, 86_400, 0x8000_0000, u32::MAX] { + let msg = reply(1, OK, "www.example", &[timed(&wire("www.example"), TYPE_A, CLASS_IN, ttl, &[1, 2, 3, 4])]); + assert_eq!(kept_until("www.example", &[(1_000, msg)]), Some(1_000 + 3_600_000), "{ttl:#x}"); + } + assert_eq!( + read(&REAL_INVALID, 0x1002, &name("doesnotexist.invalid"), MAX_ALIASES), + Verdict::NoSuchName(KEEP_CAP_S), + "the root's SOA, TTL and MINIMUM 86,400 s" + ); +} + +#[test] +fn rfc2308_5_an_absence_is_kept_for_the_least_of_its_soas_ttl_and_minimum_and_of_its_aliases() { + let nxdomain = |alias: u32, ttl: u32, minimum: u32| { + reply_with( + 1, + NX, + "www.example", + &[timed(&wire("www.example"), TYPE_CNAME, CLASS_IN, alias, &wire("gone.example"))], + &[soa("example", ttl, minimum)], + ) + }; + assert_eq!(read(&nxdomain(1_000, 300, 900), 1, &name("www.example"), MAX_ALIASES), Verdict::NoSuchName(300), "the SOA's TTL"); + assert_eq!(read(&nxdomain(1_000, 900, 300), 1, &name("www.example"), MAX_ALIASES), Verdict::NoSuchName(300), "its MINIMUM"); + assert_eq!(read(&nxdomain(100, 900, 300), 1, &name("www.example"), MAX_ALIASES), Verdict::NoSuchName(100), "the alias that led there"); + let nodata = reply_with(1, OK, "www.example", &[], &[soa("www.example", 120, 900)]); + assert_eq!(read(&nodata, 1, &name("www.example"), MAX_ALIASES), answer(&[], &[], 120), "NODATA, under its own name's SOA"); + assert_eq!(kept_until("www.example", &[(1_000, nodata)]), Some(1_000 + 120_000)); +} + +// RFC 2308 §5: "Negative responses without SOA records SHOULD NOT be cached"; +// and an SOA counts only where the denied name, the chain's last, is in its +// zone. +#[test] +fn rfc2308_5_an_absence_with_no_soa_or_with_one_outside_the_denied_names_zone_keeps_nothing() { + let denied = |authority: &[Vec]| { + reply_with(1, NX, "www.example", &[cname("www.example", "gone.other")], authority) + }; + for (what, authority) in [ + ("none", vec![]), + ("another zone's", vec![soa("example", 300, 300)]), + ("a name below the denied one", vec![soa("x.gone.other", 300, 300)]), + ] { + assert_eq!(read(&denied(&authority), 1, &name("www.example"), MAX_ALIASES), Verdict::NoSuchName(0), "{what}"); + } + for zone in ["gone.other", "other", "OTHER"] { + assert_eq!(read(&denied(&[soa(zone, 300, 300)]), 1, &name("www.example"), MAX_ALIASES), Verdict::NoSuchName(300), "{zone}"); + } + let nodata = reply_with(1, OK, "www.example", &[], &[soa("other", 300, 300)]); + assert_eq!(read(&nodata, 1, &name("www.example"), MAX_ALIASES), answer(&[], &[], 0), "NODATA"); +} + +#[test] +fn rfc1035_3_3_13_an_soa_record_is_two_names_and_five_numbers() { + let mut long = soa("example", 300, 300); + let len_at = long.len() - 20 - wire("ns.example").len() - wire("hostmaster.example").len() - 2; + long[len_at + 1] += 1; + long.push(0); + let mut short = soa("example", 300, 300); + short[len_at + 1] -= 1; + short.pop(); + for record in [long, short] { + let msg = reply_with(1, NX, "www.example", &[], &[record]); + assert_eq!(read(&msg, 1, &name("www.example"), MAX_ALIASES), stray("an SOA record whose data is not two names and five numbers")); + } +} + +// --- What a cache answers, to whom, and for how long --- + +const ONE: &[u8] = b"one"; +const TWO: &[u8] = b"two"; + +/// `cache` keeps `addr` for `view` under `text`, from a lookup that started +/// under its generation, until `until`. +fn keep(cache: &mut Cache, view: &[u8], text: &str, addr: [u8; 4], until: u64) { + let generation = cache.generation(); + cache.keep(view, &name(text), generation, Ok(&[addr]), Some(until), 0); +} + +#[test] +fn an_answer_is_given_before_its_deadline_and_not_at_it() { + let mut cache = Cache::default(); + keep(&mut cache, ONE, "www.example", [1, 2, 3, 4], 10_000); + assert_eq!(cache.answer(ONE, &name("WWW.example"), 9_999), Some(Ok(vec![[1, 2, 3, 4]]))); + assert_eq!(cache.answer(ONE, &name("www.example"), 10_000), None); +} + +#[test] +fn a_view_is_never_answered_from_another_views_entry() { + let mut cache = Cache::default(); + keep(&mut cache, ONE, "www.example", [1, 2, 3, 4], 10_000); + assert_eq!(cache.answer(TWO, &name("www.example"), 0), None); + assert_eq!(cache.answer(ONE, &name("www.example"), 0), Some(Ok(vec![[1, 2, 3, 4]])), "the premise: the entry is live"); +} + +#[test] +fn a_failure_is_never_kept_and_an_absence_is() { + let mut cache = Cache::default(); + let generation = cache.generation(); + for failure in [Failure::TimedOut, Failure::Unreachable, Failure::Truncated, Failure::ServerFailed(2), Failure::TooManyAliases] { + cache.keep(ONE, &name("www.example"), generation, Err(failure), Some(10_000), 0); + assert_eq!(cache.answer(ONE, &name("www.example"), 0), None, "{failure:?}"); + } + for absence in [Failure::NoSuchName, Failure::NoAddress] { + cache.keep(ONE, &name("www.example"), generation, Err(absence), Some(10_000), 0); + assert_eq!(cache.answer(ONE, &name("www.example"), 0), Some(Err(absence))); + } +} + +#[test] +fn the_answer_of_a_lookup_started_before_an_emptying_is_not_kept() { + let mut cache = Cache::default(); + let started = cache.generation(); + cache.empty(); + cache.keep(ONE, &name("www.example"), started, Ok(&[[1, 2, 3, 4]]), Some(10_000), 0); + assert_eq!(cache.answer(ONE, &name("www.example"), 0), None); + keep(&mut cache, ONE, "www.example", [1, 2, 3, 4], 10_000); + assert_eq!(cache.answer(ONE, &name("www.example"), 0), Some(Ok(vec![[1, 2, 3, 4]])), "the premise: the same answer, started after it"); +} + +/// A distinct name for each `i`. +fn nth(i: usize) -> std::string::String { + std::format!("n{i}.example") +} + +#[test] +fn a_full_view_drops_its_own_least_recently_used_entry_and_no_other_views() { + let mut cache = Cache::default(); + keep(&mut cache, TWO, "www.example", [2, 2, 2, 2], 10_000); + for i in 0..VIEW_ENTRIES { + keep(&mut cache, ONE, &nth(i), [1, 1, 1, 1], 10_000); + } + assert!(cache.answer(ONE, &name(&nth(0)), 0).is_some(), "the premise: the first is kept, and now the most recently used"); + keep(&mut cache, ONE, "www.example", [1, 2, 3, 4], 10_000); + assert_eq!(cache.answer(ONE, &name(&nth(1)), 0), None, "the least recently used"); + for i in (0..VIEW_ENTRIES).filter(|i| *i != 1) { + assert!(cache.answer(ONE, &name(&nth(i)), 0).is_some(), "{}", nth(i)); + } + assert_eq!(cache.answer(TWO, &name("www.example"), 0), Some(Ok(vec![[2, 2, 2, 2]])), "the other view's"); +} + +#[test] +fn a_full_view_drops_its_expired_entries_before_a_live_one() { + let mut cache = Cache::default(); + for i in 0..VIEW_ENTRIES { + keep(&mut cache, ONE, &nth(i), [1, 1, 1, 1], if i == VIEW_ENTRIES - 1 { 5_000 } else { 10_000 }); + } + let generation = cache.generation(); + cache.keep(ONE, &name("www.example"), generation, Ok(&[[1, 2, 3, 4]]), Some(10_000), 5_000); + assert!(cache.answer(ONE, &name(&nth(0)), 5_000).is_some(), "the least recently used, live"); + assert!(cache.answer(ONE, &name("www.example"), 5_000).is_some()); +} + +/// `cache` holding [`MAX_VIEWS`] views, view `i` named `[i]` and keeping one +/// name until `until(i)`, view 0 the least recently used. +fn every_view(cache: &mut Cache, until: impl Fn(u8) -> u64) { + for i in 0..MAX_VIEWS as u8 { + keep(cache, &[i], "www.example", [i, i, i, i], until(i)); + } +} + +#[test] +fn a_new_view_replaces_one_whose_entries_have_all_expired_before_a_live_one() { + let mut cache = Cache::default(); + every_view(&mut cache, |i| if i == 5 { 1_000 } else { 10_000 }); + let generation = cache.generation(); + cache.keep(b"new", &name("www.example"), generation, Ok(&[[9, 9, 9, 9]]), Some(10_000), 1_000); + assert_eq!(cache.answer(&[0], &name("www.example"), 1_000), Some(Ok(vec![[0, 0, 0, 0]])), "the least recently used view, live"); + assert_eq!(cache.answer(b"new", &name("www.example"), 1_000), Some(Ok(vec![[9, 9, 9, 9]]))); +} + +#[test] +fn a_new_view_replaces_the_least_recently_used_live_view_when_none_has_expired() { + let mut cache = Cache::default(); + every_view(&mut cache, |_| 10_000); + assert!(cache.answer(&[0], &name("www.example"), 0).is_some(), "the premise: view 0 is now the most recently used"); + keep(&mut cache, b"new", "www.example", [9, 9, 9, 9], 10_000); + assert_eq!(cache.answer(&[1], &name("www.example"), 0), None, "view 1's live entry"); + for i in (0..MAX_VIEWS as u8).filter(|i| *i != 1) { + assert_eq!(cache.answer(&[i], &name("www.example"), 0), Some(Ok(vec![[i, i, i, i]])), "view {i}"); + } +} diff --git a/system/netstack/node/src/lease.rs b/system/netstack/node/src/lease.rs index 9398ca4d32..130160f564 100644 --- a/system/netstack/node/src/lease.rs +++ b/system/netstack/node/src/lease.rs @@ -8,17 +8,52 @@ //! route outlives its address; //! - a lease is held only against a [`Verified`], which only [`Stack::report`] makes, from [ip]'s //! report for the address under probe, so no address is in use before conflict detection ends; -//! - the resolvers are the held lease's, so they come and go with it. +//! - the resolvers are the held lease's, so they come and go with it, and the answers they gave +//! go with every write of the state and every link that goes down ([`network::Network`]): after +//! either the next lookup may be asking another network, so none is answered from this one's. //! //! [ip] reports on no address but the one this module added, and one report at a time: each is //! read against the state as it stands. mod tcp; +mod network { + use toyos_dns::Cache; + + use super::State; + + /// The state and the answers its resolvers gave, written together: the state only by + /// [`Network::set`], which empties the answers as it writes it. + pub(super) struct Network { + state: State, + answers: Cache, + } + + impl Network { + pub(super) fn new() -> Self { + Self { state: State::Unaddressed, answers: Cache::default() } + } + + pub(super) fn state(&self) -> &State { + &self.state + } + + pub(super) fn set(&mut self, state: State) { + self.state = state; + self.answers.empty(); + } + + pub(super) fn answers(&mut self) -> &mut Cache { + &mut self.answers + } + } +} + use alloc::collections::VecDeque; use core::net::Ipv4Addr; use toyos_dhcp::{Lease, Transmission}; +use toyos_dns::Cache; use toyos_net_shard::{Config, Event, Refusal, Shard}; use toyos_net_udp::{Received, SocketId}; use toyos_net_wire::ethernet::MacAddr; @@ -54,7 +89,7 @@ pub(crate) struct Stack { shard: Shard, /// The DHCP client's socket. socket: SocketId, - state: State, + network: network::Network, /// What [ip] reported of an address and [`Stack::report`] has not read: never a log line. inbox: VecDeque, } @@ -79,7 +114,7 @@ impl Stack { pub(crate) fn new(now: Instant, config: Config) -> Result { let mut shard = Shard::new(now, config)?; let Ok(socket) = shard.acquisition() else { unreachable!("a new shard has no socket on port 68") }; - Ok(Self { shard, socket, state: State::Unaddressed, inbox: VecDeque::new() }) + Ok(Self { shard, socket, network: network::Network::new(), inbox: VecDeque::new() }) } pub(crate) fn shard(&self) -> &Shard { @@ -87,14 +122,14 @@ impl Stack { } pub(crate) fn lease(&self) -> Option<&Lease> { - match &self.state { + match self.network.state() { State::Held(lease) => Some(lease), State::Unaddressed | State::Probing(_) => None, } } fn address(&self) -> Option { - match &self.state { + match self.network.state() { State::Unaddressed => None, State::Probing(address) => Some(*address), State::Held(lease) => Some(lease.address), @@ -119,8 +154,18 @@ impl Stack { self.shard.fire(now); } + /// Down, the answers go and a held lease stays: the link may come back on another network + /// that the lease's verification never hears from. pub(crate) fn link(&mut self, now: Instant, up: bool) { agreed(if up { self.shard.link_up(now) } else { self.shard.link_down(now) }); + if !up { + self.network.answers().empty(); + } + } + + /// What the held lease's resolvers answered, kept by the view that asked. + pub(crate) fn answers(&mut self) -> &mut Cache { + self.network.answers() } // ---- the DHCP client's socket ---- @@ -202,8 +247,8 @@ impl Stack { /// The next report [`Self::refusals`] took, read against the state. pub(crate) fn report(&mut self) -> Option { Some(match self.inbox.pop_front()? { - Fate::Verified(addr) => match self.state { - State::Probing(address) if address == addr => Report::Verified(Verified(addr)), + Fate::Verified(addr) => match self.network.state() { + State::Probing(address) if *address == addr => Report::Verified(Verified(addr)), _ => unreachable!("[ip] verified {addr}, which is not under probe"), }, Fate::Taken(addr, by) => { @@ -218,7 +263,7 @@ impl Stack { if self.address() != Some(addr) { unreachable!("[ip] removed {addr}, which the lease does not name"); } - self.state = State::Unaddressed; + self.network.set(State::Unaddressed); } /// Nothing is held: the address goes, and [ip] withdraws the gateway with it. @@ -226,7 +271,7 @@ impl Stack { if let Some(address) = self.address() { agreed(self.shard.remove_address(now, address)); } - self.state = State::Unaddressed; + self.network.set(State::Unaddressed); } /// A server acknowledged `address`: [ip] probes it, and nothing uses it yet. Refused, the @@ -234,7 +279,7 @@ impl Stack { pub(crate) fn probe(&mut self, now: Instant, address: Ipv4Addr, prefix_len: u8) -> Result<(), toyos_net_ip::Counter> { self.release(now); self.shard.add_address(now, address, prefix_len)?; - self.state = State::Probing(address); + self.network.set(State::Probing(address)); Ok(()) } @@ -246,7 +291,7 @@ impl Stack { unreachable!("the client configured {}, having probed {}", lease.address, verified.0); } let refused = self.route(now, &mut lease); - self.state = State::Held(lease); + self.network.set(State::Held(lease)); refused } @@ -254,12 +299,12 @@ impl Stack { /// Returns whether [ip] refused its router. A prefix [ip] refuses changes nothing: the lease /// stays as it was held. pub(crate) fn renew(&mut self, now: Instant, mut lease: Lease) -> Result { - if !matches!(&self.state, State::Held(held) if held.address == lease.address) { + if !matches!(self.network.state(), State::Held(held) if held.address == lease.address) { unreachable!("the client renewed {}, which is not held", lease.address); } self.shard.add_address(now, lease.address, lease.prefix_len)?; let refused = self.route(now, &mut lease); - self.state = State::Held(lease); + self.network.set(State::Held(lease)); Ok(refused) } diff --git a/system/netstack/node/src/lib.rs b/system/netstack/node/src/lib.rs index 3a25ececfe..8849b74fdd 100644 --- a/system/netstack/node/src/lib.rs +++ b/system/netstack/node/src/lib.rs @@ -58,7 +58,7 @@ use toyos_net_wire::Instant; pub use datagram::{Datagram, DatagramId, Refused}; use lease::{Report, Stack, Verified}; -pub use resolve::{Ended, LookupId, NotStarted, Resolved}; +pub use resolve::{Ended, LookupId, NotStarted, Resolved, Resolving}; toyos_net_wire::counters! { DhcpUnsent = "node.dhcp-unsent"; diff --git a/system/netstack/node/src/resolve.rs b/system/netstack/node/src/resolve.rs index 054fa6ac96..917fa53147 100644 --- a/system/netstack/node/src/resolve.rs +++ b/system/netstack/node/src/resolve.rs @@ -1,7 +1,12 @@ //! The machine's one resolver: a name's IPv4 addresses, asked of the resolvers the held lease -//! names. Every decision about a question and every byte of a reply is `toyos_dns::Lookup`'s; -//! this is its sockets on [udp], its clock and its draws. +//! names. Every decision about a question and every byte of a reply is `toyos_dns::Lookup`'s, +//! and every decision about what is kept `toyos_dns::Cache`'s; this is its sockets on [udp], its +//! clock and its draws. //! +//! - **A lookup is made for a view**, bytes the caller names its client by: a name the view keeps +//! an answer for is answered in the call, holding no place and sending nothing, and an answer +//! is kept for the view that asked, under the name it asked, when no emptying of the lease's +//! answers came since its lookup started. //! - **Each query leaves from a socket of its own, connected to its resolver's port 53**, on an //! ephemeral port [udp] picks from one draw, and carries an id that is another draw (RFC 5452 //! §9.2): an off-path sender has to guess both to be read at all. [udp] delivers a connected @@ -37,7 +42,7 @@ use alloc::vec; use alloc::vec::Vec; use core::net::Ipv4Addr; -use toyos_dns::{Asked, Failure, Lookup, Name, Step, MAX_LOOKUPS, PORT}; +use toyos_dns::{Asked, Failure, Generation, Lookup, Name, Step, MAX_LOOKUPS, PORT}; use toyos_net_udp::{Error, SocketError, SocketId}; use toyos_net_wire::Instant; @@ -77,6 +82,15 @@ pub struct Resolved { pub result: Result, Ended>, } +/// What [`Node::resolve`] did. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Resolving { + /// The view keeps an answer for the name: the addresses, or the name's absence. + Kept(Result, Ended>), + /// A lookup asks: its end is in a later [`Node::take_resolved`]. + Asking(LookupId), +} + /// One query whose answer is still read. #[derive(Clone, Copy)] struct Query { @@ -92,6 +106,11 @@ struct Asking { /// The resolvers of the lease the lookup started under. resolvers: Vec, queries: Vec, + /// Where its answer is kept: the view that asked, the name it asked, and the answers' + /// generation it started under. + view: Vec, + name: Name, + generation: Generation, } pub(crate) struct Resolver { @@ -184,7 +203,10 @@ impl Resolver { self.asking.iter().map(|asking| Instant::from_millis(asking.lookup.due())).min() } - fn start(&mut self, now: Instant, name: Name, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Result { + fn start(&mut self, now: Instant, view: &[u8], name: Name, stack: &mut Stack, counters: &mut Counters, draw: &mut impl FnMut() -> u32) -> Result { + if let Some(kept) = stack.answers().answer(view, &name, millis(now)) { + return Ok(Resolving::Kept(kept.map_err(Ended::Failed))); + } let resolvers = stack.lease().map(|lease| lease.dns.clone()).unwrap_or_default(); if resolvers.is_empty() { return Err(NotStarted::NotConnected); @@ -193,8 +215,9 @@ impl Resolver { return Err(NotStarted::ResourceExhausted); } let servers: Vec<[u8; 4]> = resolvers.iter().map(Ipv4Addr::octets).collect(); - let Some((lookup, step)) = Lookup::start(name, &servers, millis(now), || id(&mut *draw)) else { unreachable!("the lease names a resolver") }; - let mut asking = Asking { id: LookupId(self.next), lookup, resolvers, queries: Vec::new() }; + let generation = stack.answers().generation(); + let Some((lookup, step)) = Lookup::start(name.clone(), &servers, millis(now), || id(&mut *draw)) else { unreachable!("the lease names a resolver") }; + let mut asking = Asking { id: LookupId(self.next), lookup, resolvers, queries: Vec::new(), view: view.to_vec(), name, generation }; let done = act(&mut asking, step, now, stack, counters, &mut *draw, &mut false); if done == Some(Err(Ended::NoPort)) { return Err(NotStarted::ResourceExhausted); @@ -207,7 +230,7 @@ impl Resolver { // holds no socket. Some(result) => self.ended.push(Resolved { id: started, result }), } - Ok(started) + Ok(Resolving::Asking(started)) } /// Ends every lookup whose lease went or names other resolvers, reads every reply that @@ -246,6 +269,14 @@ impl Resolver { for query in &asking.queries { close(stack, now, query.socket); } + let answer = match &result { + Ok(addrs) => Some(Ok(addrs.as_slice())), + Err(Ended::Failed(failure)) => Some(Err(*failure)), + Err(Ended::NoPort | Ended::LeaseChanged) => None, + }; + if let Some(answer) = answer { + stack.answers().keep(&asking.view, &asking.name, asking.generation, answer, asking.lookup.keep_until(), ms); + } ended.push(Resolved { id: asking.id, result }); false }); @@ -267,13 +298,14 @@ impl Resolver { } impl Node { - /// Starts looking `name` up at the resolvers the held lease names, which spends two draws a - /// query, its id and then its port: for the first query, and for each asked in its place - /// because [udp] refused the one before. A call refused for want of a port has spent them; - /// any other refused call spends none. A lookup no resolver could be sent a query of has - /// started and ended: its end is in the next [`Self::take_resolved`]. - pub fn resolve(&mut self, now: Instant, name: Name, mut draw: impl FnMut() -> u32) -> Result { - self.resolver.start(now, name, &mut self.stack, &mut self.counters, &mut draw) + /// `name`'s answer kept for `view`, or a lookup of it at the resolvers the held lease names, + /// which spends two draws a query, its id and then its port: for the first query, and for + /// each asked in its place because [udp] refused the one before. A call refused for want of + /// a port has spent them; any other refused call, and a kept answer, spends none. A lookup no + /// resolver could be sent a query of has started and ended: its end is in the next + /// [`Self::take_resolved`]. + pub fn resolve(&mut self, now: Instant, view: &[u8], name: Name, mut draw: impl FnMut() -> u32) -> Result { + self.resolver.start(now, view, name, &mut self.stack, &mut self.counters, &mut draw) } /// The lookups that ended since the last call, each once. diff --git a/system/netstack/node/tests/resolve.rs b/system/netstack/node/tests/resolve.rs index 7491ed213f..32030c0d82 100644 --- a/system/netstack/node/tests/resolve.rs +++ b/system/netstack/node/tests/resolve.rs @@ -13,10 +13,10 @@ mod lan; use std::net::Ipv4Addr; use std::time::Duration; -use common::{arp, from_server, message, message_of, sum, terms, xid, A, ACK, DNS, MAC, MAC_B, MAC_R, NAK, R}; +use common::{arp, from_server, message, message_of, option, sum, terms, xid, A, ACK, BROADCAST, DISCOVER, DNS, MAC, MAC_B, MAC_R, NAK, OFFER, R, REQUEST}; use lan::{udp, Lan, Seen, Udp, B}; use toyos_dns::{Failure, Name, MAX_LOOKUPS, ROUNDS, WAIT_MS}; -use toyos_net_node::{Counter, Ended, LookupId, NotStarted, Refused, Resolved}; +use toyos_net_node::{Counter, Ended, LookupId, NotStarted, Refused, Resolved, Resolving}; use toyos_net_udp::limits::{EPHEMERAL_COUNT, EPHEMERAL_FIRST, TX_DATAGRAMS}; use toyos_net_udp::Counter as Rule; use toyos_net_wire::{Instant, Port}; @@ -49,13 +49,27 @@ const RESPONSE: u16 = 0x8180; /// RFC 1035 §3.2.2. const TYPE_A: u16 = 1; const TYPE_CNAME: u16 = 5; +const TYPE_SOA: u16 = 6; +/// RFC 1035 §4.1.1: a response, recursion desired and available, RCODE 3: the name does not exist. +const NXDOMAIN: u16 = RESPONSE | 3; /// No lookup has ended. const NONE: [Resolved; 0] = []; +/// The view every lookup is made for where a test names none. +const ONE: &[u8] = b"one"; +const TWO: &[u8] = b"two"; fn name(text: &str) -> Name { Name::parse(text).expect("a host name") } +/// The lookup a resolve started, where nothing was kept to answer it. +fn asking(resolving: Resolving) -> LookupId { + match resolving { + Resolving::Asking(id) => id, + Resolving::Kept(kept) => panic!("answered from what the view keeps: {kept:?}"), + } +} + /// The draw of a call that draws nothing. fn undrawn() -> u32 { panic!("this call draws nothing") @@ -110,23 +124,42 @@ fn asked_name(query: &[u8]) -> String { /// and one answer a record; §4.1.2's question as the query spelled it; then §4.1.3's records, each /// owned by a §4.1.4 pointer to the question's name at octet 12, in class IN with a TTL of 60. fn reply(query: &[u8], flags: u16, records: &[(u16, Vec)]) -> Vec { + let owned: Vec<(Vec, u16, u32, Vec)> = records.iter().map(|(rtype, data)| (vec![0xc0, 12], *rtype, 60, data.clone())).collect(); + message_for(query, flags, &owned, &[]) +} + +/// A reply to `query` as [`reply`] spells one, each record with its own owner, spelled whole or +/// as a pointer, and its own TTL, and `authority` as §4.1.1's authority section. +fn message_for(query: &[u8], flags: u16, answers: &[(Vec, u16, u32, Vec)], authority: &[(Vec, u16, u32, Vec)]) -> Vec { let mut out = query[..2].to_vec(); out.extend_from_slice(&flags.to_be_bytes()); out.extend_from_slice(&[0, 1]); - out.extend_from_slice(&u16::try_from(records.len()).unwrap().to_be_bytes()); - out.extend_from_slice(&[0, 0, 0, 0]); + out.extend_from_slice(&u16::try_from(answers.len()).unwrap().to_be_bytes()); + out.extend_from_slice(&u16::try_from(authority.len()).unwrap().to_be_bytes()); + out.extend_from_slice(&[0, 0]); out.extend_from_slice(&query[12..]); - for (rtype, data) in records { - out.extend_from_slice(&[0xc0, 12]); + for (owner, rtype, ttl, data) in answers.iter().chain(authority) { + out.extend_from_slice(owner); out.extend_from_slice(&rtype.to_be_bytes()); out.extend_from_slice(&[0, 1]); - out.extend_from_slice(&60u32.to_be_bytes()); + out.extend_from_slice(&ttl.to_be_bytes()); out.extend_from_slice(&u16::try_from(data.len()).unwrap().to_be_bytes()); out.extend_from_slice(data); } out } +/// RFC 1035 §3.3.13: the SOA record of `zone`, with a TTL of `ttl`: MNAME and RNAME, then +/// SERIAL, REFRESH, RETRY and EXPIRE, and MINIMUM, `minimum`. +fn soa(zone: &str, ttl: u32, minimum: u32) -> (Vec, u16, u32, Vec) { + let mut data = labels("ns.example"); + data.extend_from_slice(&labels("hostmaster.example")); + for field in [1u32, 7_200, 900, 1_209_600, minimum] { + data.extend_from_slice(&field.to_be_bytes()); + } + (labels(zone), TYPE_SOA, ttl, data) +} + /// A reply to `query` that gives `address`. fn gives(query: &[u8], address: [u8; 4]) -> Vec { reply(query, RESPONSE, &[(TYPE_A, address.to_vec())]) @@ -168,6 +201,8 @@ enum Says { Address([u8; 4]), /// A CNAME to this name and nothing else, which the resolver asks again at its end. Alias(&'static str), + /// NXDOMAIN, with the SOA of `example` whose TTL and MINIMUM are this, or with none. + Absent(Option), } /// A query that reached the wire. @@ -200,17 +235,21 @@ struct Net { /// The last draw [`Self::resolve`] handed out: its ids are 0x8000 and up, and those the node /// draws for itself from `lan`'s count are below it. draws: u32, + /// The TTL of every record [`ANSWERS`] answers with. + ttl: u32, } impl Net { /// The node holding a day's lease of `A`/24 that names `servers`, through `router`, at a whole /// millisecond of its clock. fn leased(servers: &[Ipv4Addr], router: Option) -> Self { - let mut options = terms(86_400, router); - options.retain(|(code, _)| *code != 6); - options.push((6, servers.iter().flat_map(Ipv4Addr::octets).collect())); + Self::leased_for(86_400, servers, router) + } + + /// [`Self::leased`], for a lease of `seconds`. + fn leased_for(seconds: u32, servers: &[Ipv4Addr], router: Option) -> Self { let mut lan = Lan::new(); - lan.lease_on(&options); + lan.lease_on(&lease_of(seconds, servers, router)); assert_eq!(lan.node.lease().expect("held").dns, servers, "the premise: the lease names these resolvers"); // `toyos-dns` keeps whole milliseconds and conflict detection's waits are drawn finer: the // origin is the next whole one, so a wait counted from it ends on the millisecond named. @@ -227,6 +266,7 @@ impl Net { failed: Vec::new(), ended: Vec::new(), draws: 0x7c00_8000, + ttl: 60, } } @@ -244,7 +284,11 @@ impl Net { } fn resolve(&mut self, text: &str) -> Result { - self.lan.node.resolve(self.lan.now, name(text), counter(&mut self.draws)) + self.resolve_in(ONE, text).map(asking) + } + + fn resolve_in(&mut self, view: &[u8], text: &str) -> Result { + self.lan.node.resolve(self.lan.now, view, name(text), counter(&mut self.draws)) } /// `message` from the resolver's port 53 to the node's `to`. @@ -284,11 +328,12 @@ impl Net { assert_eq!((udp.to, udp.destination), (MAC_S, ANSWERS), "a query left for a server the wire cannot reach"); let query = Query { at, udp: udp.clone(), id: u16::from_be_bytes([udp.payload[0], udp.payload[1]]), name: asked_name(&udp.payload) }; if let Some((_, not_before, says)) = self.zone.iter().find(|(name, ..)| *name == query.name) { - let record = match says { - Says::Address(address) => (TYPE_A, address.to_vec()), - Says::Alias(target) => (TYPE_CNAME, labels(target)), + let owner = vec![0xc0, 12]; + let message = match says { + Says::Address(address) => message_for(&udp.payload, RESPONSE, &[(owner, TYPE_A, self.ttl, address.to_vec())], &[]), + Says::Alias(target) => message_for(&udp.payload, RESPONSE, &[(owner, TYPE_CNAME, self.ttl, labels(target))], &[]), + Says::Absent(soa_ttl) => message_for(&udp.payload, NXDOMAIN, &[], &soa_ttl.map(|ttl| soa("example", ttl, ttl)).into_iter().collect::>()), }; - let message = reply(&udp.payload, RESPONSE, &[record]); let due = self.at(*not_before).max(self.lan.now); self.held.push((due, lan::udp(MAC, MAC_S, (ANSWERS, 53), (A, udp.source_port), &message))); } @@ -367,7 +412,7 @@ impl Net { #[test] fn a_query_is_rfc_1035s_octets_from_a_port_and_with_an_id_of_its_own_draws() { let mut net = Net::leased(&[ANSWERS], Some(R)); - net.lan.node.resolve(net.lan.now, name("www.example"), sequence([0xabcd_1234, 7])).expect("two draws: the id, then the port"); + net.lan.node.resolve(net.lan.now, ONE, name("www.example"), sequence([0xabcd_1234, 7])).expect("two draws: the id, then the port"); net.pass(); #[rustfmt::skip] let payload = vec![ @@ -498,7 +543,7 @@ fn a_query_udp_refuses_is_counted_holds_no_port_and_the_next_resolver_is_asked_a let mut net = Net::leased(&[UNROUTED, ANSWERS], None); net.zone.push(("www.example", 0, Says::Address(ADDRESS))); let refused = net.lan.counted(Rule::NoRoute); - let id = net.lan.node.resolve(net.lan.now, name("www.example"), sequence([0x8001, 9, 0x8002, 10])).expect("a lookup whose first query cannot leave"); + let id = asking(net.lan.node.resolve(net.lan.now, ONE, name("www.example"), sequence([0x8001, 9, 0x8002, 10])).expect("a lookup whose first query cannot leave")); assert_eq!(net.lan.counted(Rule::NoRoute), refused + 1, "the premise: [udp] refused the query for want of a route"); assert_eq!(net.lan.node.counters().get(Counter::QueryUnsent), 1); assert!(!net.port_held(49_152 + 9), "the refused query's socket"); @@ -519,14 +564,14 @@ fn a_query_udp_refuses_is_counted_holds_no_port_and_the_next_resolver_is_asked_a fn a_lookup_udp_refuses_every_resolver_of_ends_in_the_call_that_started_it() { let mut net = Net::leased(&[UNROUTED, UNROUTED_TOO], None); let refused = net.lan.counted(Rule::NoRoute); - let id = net.lan.node.resolve(net.lan.now, name("www.example"), sequence([0x8001, 9, 0x8002, 10])).expect("started, and ended"); + let id = asking(net.lan.node.resolve(net.lan.now, ONE, name("www.example"), sequence([0x8001, 9, 0x8002, 10])).expect("started, and ended")); assert_eq!(net.lan.counted(Rule::NoRoute), refused + 2, "the premise: [udp] refused both queries for want of a route"); assert_eq!(net.lan.node.counters().get(Counter::QueryUnsent), 2); assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Err(Ended::Failed(Failure::Unreachable)) }]); assert!(!net.port_held(49_152 + 9) && !net.port_held(49_152 + 10), "the refused queries' sockets"); net.pass(); assert!(net.queried.is_empty() && net.refused.is_empty(), "a query left"); - let next = net.lan.node.resolve(net.lan.now, name("other.example"), sequence([0x8003, 11, 0x8004, 12])).expect("the next lookup"); + let next = asking(net.lan.node.resolve(net.lan.now, ONE, name("other.example"), sequence([0x8003, 11, 0x8004, 12])).expect("the next lookup")); assert_ne!(next, id, "an ended lookup's id is not given again"); } @@ -700,9 +745,9 @@ fn the_lookup_past_the_cap_is_refused_until_an_answer_is_taken() { for held in 1..MAX_LOOKUPS { net.resolve("www.example").unwrap_or_else(|why| panic!("lookup {held} was refused: {why:?}")); } - assert_eq!(net.lan.node.resolve(net.lan.now, name("www.example"), undrawn), Err(NotStarted::ResourceExhausted)); + assert_eq!(net.lan.node.resolve(net.lan.now, ONE, name("www.example"), undrawn), Err(NotStarted::ResourceExhausted)); net.until(1_000); - assert_eq!(net.lan.node.resolve(net.lan.now, name("www.example"), undrawn), Err(NotStarted::ResourceExhausted), "an answer nobody took holds its place"); + assert_eq!(net.lan.node.resolve(net.lan.now, ONE, name("www.example"), undrawn), Err(NotStarted::ResourceExhausted), "an answer nobody took holds its place"); assert_eq!(net.lan.node.take_resolved(), [Resolved { id: first, result: Ok(vec![ADDRESS]) }]); net.resolve("www.example").expect("a taken answer's place is another's"); } @@ -712,17 +757,17 @@ fn the_lookup_past_the_cap_is_refused_until_an_answer_is_taken() { #[test] fn a_lookup_with_no_resolver_to_ask_is_refused_as_not_connected() { let mut lan = Lan::new(); - assert_eq!(lan.node.resolve(lan.now, name("www.example"), undrawn), Err(NotStarted::NotConnected), "before a lease"); + assert_eq!(lan.node.resolve(lan.now, ONE, name("www.example"), undrawn), Err(NotStarted::NotConnected), "before a lease"); let mut options = terms(3_600, Some(R)); options.retain(|(code, _)| *code != 6); lan.lease_on(&options); - assert_eq!(lan.node.resolve(lan.now, name("www.example"), undrawn), Err(NotStarted::NotConnected), "a lease that names no resolver"); + assert_eq!(lan.node.resolve(lan.now, ONE, name("www.example"), undrawn), Err(NotStarted::NotConnected), "a lease that names no resolver"); let mut lan = Lan::new(); lan.lease(600); - lan.node.resolve(lan.now, name("www.example"), sequence([1, 2])).expect("the premise: a held lease's resolver is asked"); + lan.node.resolve(lan.now, ONE, name("www.example"), sequence([1, 2])).expect("the premise: a held lease's resolver is asked"); assert!(lan.run_until(Duration::from_secs(700), |lan| lan.node.lease().is_none()), "the lease runs out"); - assert_eq!(lan.node.resolve(lan.now, name("www.example"), undrawn), Err(NotStarted::NotConnected), "after the lease"); + assert_eq!(lan.node.resolve(lan.now, ONE, name("www.example"), undrawn), Err(NotStarted::NotConnected), "after the lease"); } // A lookup nobody waits for is let go at once: the ports its queries left from are free, and its @@ -799,8 +844,8 @@ fn a_query_with_no_port_to_leave_from_ends_its_lookup_by_name() { net.lan.node.udp_bind(ANY, None, || offset).unwrap(); } assert_eq!(net.lan.node.udp_bind(ANY, None, || last), Err(toyos_net_node::Refused::ResourceExhausted), "every place is a client's socket"); - let id = net.lan.node.resolve(net.lan.now, name("www.example"), sequence([0x8001, last])).expect("the one port left, and a query needs no place"); - assert_eq!(net.lan.node.resolve(net.lan.now, name("other.example"), sequence([0x8002, 0])), Err(NotStarted::ResourceExhausted)); + let id = asking(net.lan.node.resolve(net.lan.now, ONE, name("www.example"), sequence([0x8001, last])).expect("the one port left, and a query needs no place")); + assert_eq!(net.lan.node.resolve(net.lan.now, ONE, name("other.example"), sequence([0x8002, 0])), Err(NotStarted::ResourceExhausted)); assert_eq!(net.run(id, 10 * WAIT_MS), Some(Err(Ended::NoPort))); assert_eq!(net.ms(), WAIT_MS); let left: Vec = net.queried.iter().map(|query| query.udp.source_port).collect(); @@ -926,7 +971,7 @@ fn a_recorded_reply_of_a_public_resolver_answers_the_nodes_query() { 0x00, 0x04, 0x08, 0x08, 0x08, 0x08, ]; let mut net = Net::leased(&[ANSWERS], Some(R)); - let id = net.lan.node.resolve(net.lan.now, name("dns.google"), sequence([0x1001, 3])).unwrap(); + let id = asking(net.lan.node.resolve(net.lan.now, ONE, name("dns.google"), sequence([0x1001, 3])).unwrap()); net.pass(); let asked = net.queried[0].clone(); assert_eq!((asked.id, &asked.udp.payload[12..]), (0x1001, &RECORDED[12..28]), "the premise: the node's query carries the reply's id and question"); @@ -1016,9 +1061,14 @@ fn renewing_under_a_lookup() -> (Net, LookupId, u32) { /// The options of a day's lease through the router that names `resolver`. fn naming(resolver: Ipv4Addr) -> Vec<(u8, Vec)> { - let mut options = terms(86_400, Some(R)); + lease_of(86_400, &[resolver], Some(R)) +} + +/// The options of a lease of `seconds` that names `servers`, through `router`. +fn lease_of(seconds: u32, servers: &[Ipv4Addr], router: Option) -> Vec<(u8, Vec)> { + let mut options = terms(seconds, router); options.retain(|(code, _)| *code != 6); - options.push((6, resolver.octets().to_vec())); + options.push((6, servers.iter().flat_map(Ipv4Addr::octets).collect())); options } @@ -1057,3 +1107,181 @@ fn a_lookup_ends_when_its_lease_is_lost() { assert_eq!(net.lan.node.lease(), None, "the premise: the lease is gone"); assert_eq!(net.lan.node.take_resolved(), [Resolved { id, result: Err(Ended::LeaseChanged) }]); } + +// --- What a view keeps (`toyos_dns::Cache`) --- + +/// A lease of `seconds` naming [`ANSWERS`], which answers `www.example` with an hour's TTL, and +/// the answer kept for [`ONE`]: the far end has heard one query. +fn kept(seconds: u32) -> Net { + let mut net = Net::leased_for(seconds, &[ANSWERS], Some(R)); + net.ttl = 3_600; + net.zone.push(("www.example", 0, Says::Address(ADDRESS))); + let id = net.resolve("www.example").unwrap(); + assert_eq!(net.run(id, WAIT_MS), Some(Ok(vec![ADDRESS]))); + assert_eq!(net.resolve_in(ONE, "www.example"), Ok(Resolving::Kept(Ok(vec![ADDRESS]))), "the premise: the view keeps the answer"); + net +} + +/// The next resolve of `www.example` for [`ONE`] asks [`ANSWERS`], and is answered. +fn asks_again(net: &mut Net) { + let asked = net.queried.len(); + let id = net.resolve("www.example").expect("a lookup"); + let until = net.ms() + WAIT_MS; + assert_eq!(net.run(id, until), Some(Ok(vec![ADDRESS]))); + assert_eq!(net.queried.len(), asked + 1); +} + +/// The DHCP client's last message. +fn last_dhcp(net: &Net) -> Vec { + net.lan.udp().into_iter().rfind(|udp| udp.port == 67).expect("a DHCP message").payload.clone() +} + +/// The lease of `kept(600)`'s first renewal, sent at T1, with the answer still kept. +fn renewing() -> (Net, u32) { + let mut net = kept(600); + let sent = net.lan.udp().iter().filter(|udp| udp.port == 67).count(); + assert!(net.lan.run_until(Duration::from_secs(400), |lan| lan.udp().iter().filter(|udp| udp.port == 67).count() > sent), "the renewal leaves"); + assert_eq!(option(&last_dhcp(&net), 53), Some(&[REQUEST][..])); + assert_eq!(net.resolve_in(ONE, "www.example"), Ok(Resolving::Kept(Ok(vec![ADDRESS]))), "the premise: kept until the renewal is answered"); + let renewal = xid(&last_dhcp(&net)); + (net, renewal) +} + +// RFC 1035 §4.1.3: a record may be kept for its TTL. Kept, it is answered in the call that asks, +// an hour less a second after it was asked, and nothing leaves. +#[test] +fn a_name_its_view_keeps_is_answered_in_the_call_and_sends_nothing() { + let mut net = kept(86_400); + net.until(3_599_000); + let sent = net.lan.sent.len(); + assert_eq!(net.resolve_in(ONE, "www.example"), Ok(Resolving::Kept(Ok(vec![ADDRESS])))); + net.pass(); + assert_eq!(net.lan.sent.len(), sent, "{:?}", &net.lan.sent[sent..]); +} + +// A kept answer holds no place: it is given while every lookup place is held. +#[test] +fn a_kept_answer_is_given_while_every_lookup_place_is_held() { + let mut net = kept(86_400); + for held in 0..MAX_LOOKUPS { + net.resolve(&format!("held{held}.example")).unwrap_or_else(|why| panic!("lookup {held} was refused: {why:?}")); + } + assert_eq!(net.resolve_in(ONE, "other.example"), Err(NotStarted::ResourceExhausted), "the premise: every place is held"); + assert_eq!(net.resolve_in(ONE, "www.example"), Ok(Resolving::Kept(Ok(vec![ADDRESS])))); +} + +// What one view keeps answers no other: the same name for another view is its own query. +#[test] +fn the_same_name_for_another_view_sends_its_own_query() { + let mut net = kept(86_400); + let id = asking(net.resolve_in(TWO, "www.example").expect("a lookup")); + assert_eq!(net.run(id, WAIT_MS), Some(Ok(vec![ADDRESS]))); + assert_eq!(net.queried.len(), 2); +} + +// RFC 2131 §4.3.2 has a server with no record of the client stay silent, so a held lease's +// INIT-REBOOT goes unanswered on another network as on its own, and the client keeps the lease in +// use (`toyos-dhcp`): only the link that went down says the network may be another one. +#[test] +fn a_link_that_goes_down_empties_what_every_view_keeps_though_the_lease_stays() { + let mut net = kept(86_400); + net.lan.link(false); + let up = net.lan.sent.len(); + net.lan.link(true); + net.until(100_000); + let requests = net.lan.sent[up..].iter().filter(|(_, seen)| matches!(seen, Seen::Udp(udp) if udp.port == 67)).count(); + assert_eq!(requests, usize::try_from(toyos_dhcp::limits::REQUEST_TRANSMISSIONS).unwrap(), "the premise: every INIT-REBOOT went unanswered"); + assert_eq!(net.lan.node.lease().map(|lease| lease.dns.clone()), Some(vec![ANSWERS]), "the premise: the lease is in use, its resolver the same"); + asks_again(&mut net); +} + +// A lease lost and a new one naming the same resolver are two writes of the lease, each of which +// empties what is kept: no list of resolvers is compared. +#[test] +fn a_lease_lost_and_another_naming_the_same_resolver_empty_what_every_view_keeps() { + let (mut net, renewal) = renewing(); + net.lan.deliver(&from_server(BROADCAST, Ipv4Addr::BROADCAST, &message(NAK, renewal, Ipv4Addr::UNSPECIFIED, &[(54, R.octets().to_vec())]))); + assert_eq!(net.lan.node.lease(), None, "the premise: the lease is gone"); + let discover = last_dhcp(&net); + assert_eq!(option(&discover, 53), Some(&[DISCOVER][..])); + let terms = lease_of(600, &[ANSWERS], Some(R)); + net.lan.deliver(&from_server(MAC, A, &message_of(OFFER, xid(&discover), &terms))); + net.lan.deliver(&from_server(BROADCAST, Ipv4Addr::BROADCAST, &message_of(ACK, xid(&discover), &terms))); + assert!(net.lan.run_until(Duration::from_secs(10), |lan| lan.node.lease().is_some()), "the new lease is held"); + assert_eq!(net.lan.node.lease().expect("held").dns, [ANSWERS], "the premise: it names the same resolver"); + asks_again(&mut net); +} + +// An INIT-REBOOT another network's server acknowledges with the same prefix, router and resolvers +// is a renewal like this one (`toyos_dhcp::Config::Extended`): it empties what is kept too. +#[test] +fn a_renewal_with_the_same_terms_empties_what_every_view_keeps() { + let (mut net, renewal) = renewing(); + let expiry = |net: &Net| net.lan.node.lease().and_then(|lease| lease.timers).map(|timers| timers.expiry); + let before = expiry(&net); + net.lan.deliver(&from_server(MAC, A, &message_of(ACK, renewal, &lease_of(600, &[ANSWERS], Some(R))))); + assert_ne!(expiry(&net), before, "the premise: the renewal was taken"); + asks_again(&mut net); +} + +// A lookup that spans a link going down may have read its answer on another network: the answer +// is its client's, and is not kept. +#[test] +fn a_lookup_in_flight_as_the_link_goes_down_is_answered_and_keeps_nothing() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + net.ttl = 3_600; + net.zone.push(("www.example", 500, Says::Address(ADDRESS))); + let id = net.resolve("www.example").unwrap(); + net.pass(); + assert_eq!(net.queried.len(), 1, "the premise: the query left"); + net.lan.link(false); + net.lan.link(true); + assert_eq!(net.run(id, WAIT_MS), Some(Ok(vec![ADDRESS])), "its answer, read after the link came back"); + asks_again(&mut net); +} + +// RFC 2308 §5: an absence is kept for its SOA's TTL, and "Negative responses without SOA records +// SHOULD NOT be cached". +#[test] +fn an_absence_is_kept_where_an_soa_says_for_how_long_and_asked_again_where_none_does() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + net.zone.push(("gone.example", 0, Says::Absent(Some(3_600)))); + net.zone.push(("lost.example", 0, Says::Absent(None))); + let absent = Err(Ended::Failed(Failure::NoSuchName)); + for text in ["gone.example", "lost.example"] { + let id = net.resolve(text).unwrap(); + assert_eq!(net.run(id, WAIT_MS), Some(absent.clone()), "{text}"); + } + assert_eq!(net.resolve_in(ONE, "gone.example"), Ok(Resolving::Kept(absent))); + let asked = net.queried.len(); + net.resolve("lost.example").expect("a lookup"); + net.pass(); + assert_eq!(net.queried.len(), asked + 1); +} + +// RFC 2181 §5.4.1: a reply's records about names not asked are not read. Neither they nor the +// alias the asked name leads to are kept under their own names. +#[test] +fn nothing_is_kept_under_a_name_the_view_did_not_ask() { + let mut net = Net::leased(&[ANSWERS], Some(R)); + net.ttl = 3_600; + net.zone.push(("cdn.example", 0, Says::Address(ADDRESS))); + let id = net.resolve("www.example").unwrap(); + net.pass(); + let asked = net.queried[0].clone(); + let alias = message_for( + &asked.udp.payload, + RESPONSE, + &[(vec![0xc0, 12], TYPE_CNAME, 3_600, labels("cdn.example")), (labels("bank.example"), TYPE_A, 3_600, FORGED.to_vec())], + &[], + ); + net.resolver_says(asked.udp.source_port, &alias); + assert_eq!(net.run(id, WAIT_MS), Some(Ok(vec![ADDRESS])), "the alias's end, asked in its own right"); + assert_eq!(net.resolve_in(ONE, "www.example"), Ok(Resolving::Kept(Ok(vec![ADDRESS]))), "the premise: the name asked is kept"); + for other in ["cdn.example", "bank.example"] { + let before = net.queried.len(); + net.resolve(other).expect("a lookup"); + net.pass(); + assert_eq!(net.queried.len(), before + 1, "{other}"); + } +} diff --git a/system/netstack/src/main.rs b/system/netstack/src/main.rs index 73960cddf2..21b690877b 100644 --- a/system/netstack/src/main.rs +++ b/system/netstack/src/main.rs @@ -540,7 +540,7 @@ fn main() { answer_inspect(&request, &card, &leases, &sockets, &node); continue; } - sockets.request(&mut node, clock(), request, draw); + sockets.request(&mut node, clock(), request, &acceptor, draw); } } } diff --git a/system/netstack/src/serve.rs b/system/netstack/src/serve.rs index 7a95dbf023..7b055d13c5 100644 --- a/system/netstack/src/serve.rs +++ b/system/netstack/src/serve.rs @@ -18,6 +18,13 @@ //! bounded: one receive a socket, a second refused `ERR_RESOURCE_EXHAUSTED`. //! Nothing a client sends panics netstack. //! +//! **A lookup's view is the badge on its connection**, which the supervisor +//! minted with the row and session of the program it endowed the connector, +//! and which is bytes here: what the node keeps of one view's answers answers +//! no other. A lookup on a connection with no badge is refused +//! `ERR_PERMISSION_DENIED`. Neither the name a lookup asks nor anything kept is +//! said in the log or to `inspect`, which every holder of `netstack` reads. +//! //! **An id is a number any client can name** //! (`issues/netstack-socket-ids-are-ambient.md`): the table starts at a //! random one so that an id a client kept across a replaced netstack names @@ -29,12 +36,13 @@ use std::time::Duration; use toyos::net::*; use toyos::poller::{OTHER_END_GONE, READABLE, WRITABLE, Poller}; +use toyos::port::Acceptor; use toyos::{ipc, say, AsHandle, Pipe}; -use toyos_abi::syscall::SyscallError; +use toyos_abi::syscall::{SyscallError, MAX_BADGE}; use toyos_inspect::Snapshot; use toyos_net_node::{ AcceptRefused, ConnectRefused, DatagramId, Ended, Event, ListenRefused, ListenerId, LookupId, Node, NotStarted, - PipeEnd, Pipes, Refused, StreamEvent, StreamId, WriteRefusal, + PipeEnd, Pipes, Refused, Resolving, StreamEvent, StreamId, WriteRefusal, }; use toyos_net_shard::ConnectError; use toyos_net_tcp::{Endpoint, Failure}; @@ -106,7 +114,7 @@ pub struct Sockets { /// The client each unanswered connect is answered to, and the id it is /// told. connecting: BTreeMap, - lookups: Vec<(LookupId, Client, toyos_dns::Name)>, + lookups: Vec<(LookupId, Client)>, /// At most one receive waits on a socket, so a client's requests alone /// hold no more of netstack's handles than its sockets do. receiving: BTreeMap, @@ -206,6 +214,30 @@ fn answer_lookup(client: &Client, addrs: &[[u8; 4]]) { client.result_bytes(&answer); } +/// How a lookup ended, kept or asked, in the pipe ABI's words. +fn answer_resolved(client: &Client, result: Result, Ended>) { + use toyos_dns::Failure as Dns; + match result { + Ok(addrs) => answer_lookup(client, &addrs), + // The protocol's one answer for a name with no address, whether the + // name or only its address is missing. + Err(Ended::Failed(Dns::NoSuchName | Dns::NoAddress)) => answer_lookup(client, &[]), + Err(Ended::Failed(Dns::TimedOut)) => client.error(ERR_TIMED_OUT), + // No query found a way out, or the lease the lookup asked under went: + // this machine is on no network that answers the name, which a lease + // clears. Neither is that word's (see `connect_failed`). + Err(Ended::Failed(Dns::Unreachable) | Ended::LeaseChanged) => client.error(ERR_NOT_CONNECTED), + Err(Ended::Failed(why @ (Dns::Truncated | Dns::ServerFailed(_) | Dns::TooManyAliases))) => { + say!("netstack: a lookup ended without an answer: {why:?}"); + client.error(ERR_OTHER); + } + Err(Ended::NoPort) => { + say!("netstack: a lookup ended with every dynamic port bound, none left for its next query"); + client.error(ERR_RESOURCE_EXHAUSTED); + } + } +} + impl Sockets { pub fn new(first_id: u32, places: usize) -> Self { Self { @@ -268,7 +300,7 @@ impl Sockets { /// One whole request. A call that answers at once drops its client where /// it answers; a connect, a lookup and a receive with nothing to hand /// over keep theirs until the node has the answer ([`Self::settle`]). - pub fn request(&mut self, node: &mut Node, now: Instant, req: Request, draw: fn() -> u32) { + pub fn request(&mut self, node: &mut Node, now: Instant, req: Request, acceptor: &Acceptor, draw: fn() -> u32) { match MsgType::from_u32(req.msg_type) { Some(MsgType::TcpClose) => self.close(node, now, &req), Some(MsgType::TcpShutdown) => self.shutdown(node, now, &req), @@ -276,7 +308,7 @@ impl Sockets { Some(MsgType::UdpSendTo) => self.udp_send_to(node, now, &req), Some(MsgType::UdpRecvFrom) => self.udp_recv_from(node, now, req), Some(MsgType::UdpClose) => self.udp_close(node, now, &req), - Some(MsgType::DnsLookup) => self.lookup(node, now, req, draw), + Some(MsgType::DnsLookup) => self.lookup(node, now, req, acceptor, draw), Some(MsgType::TcpSetOption) => self.set_option(node, now, &req), Some(MsgType::TcpListenerSetOption) => self.listener_set_option(node, &req), Some(MsgType::UdpSetOption) => self.udp_set_option(node, &req), @@ -603,9 +635,10 @@ impl Sockets { msg.client.done(); } - /// Starts resolving the name `msg` carries, or answers at once where - /// there is nothing to ask: an address written as one. - fn lookup(&mut self, node: &mut Node, now: Instant, msg: Request, draw: fn() -> u32) { + /// Starts resolving the name `msg` carries for the view its connection's + /// badge names, or answers at once where there is nothing to ask: an + /// address written as one, or a name the view keeps an answer for. + fn lookup(&mut self, node: &mut Node, now: Instant, msg: Request, acceptor: &Acceptor, draw: fn() -> u32) { let Ok(hostname) = std::str::from_utf8(msg.payload()) else { msg.client.error(ERR_INVALID_INPUT); return; @@ -618,8 +651,18 @@ impl Sockets { msg.client.error(ERR_INVALID_INPUT); return; }; - match node.resolve(now, name.clone(), draw) { - Ok(id) => self.lookups.push((id, msg.client, name)), + let mut badge = [0u8; MAX_BADGE]; + let view = match acceptor.badge(&msg.client.conn, &mut badge) { + Ok(view) => view, + Err(SyscallError::NotFound) => { + msg.client.error(ERR_PERMISSION_DENIED); + return; + } + Err(why) => panic!("netstack: the kernel refused the badge of a connection its acceptor accepted: {why:?}"), + }; + match node.resolve(now, view, name, draw) { + Ok(Resolving::Kept(result)) => answer_resolved(&msg.client, result), + Ok(Resolving::Asking(id)) => self.lookups.push((id, msg.client)), Err(NotStarted::NotConnected) => msg.client.error(ERR_NOT_CONNECTED), Err(NotStarted::ResourceExhausted) => msg.client.error(ERR_RESOURCE_EXHAUSTED), } @@ -658,31 +701,11 @@ impl Sockets { } for resolved in node.take_resolved() { - let Some(at) = self.lookups.iter().position(|(id, ..)| *id == resolved.id) else { + let Some(at) = self.lookups.iter().position(|(id, _)| *id == resolved.id) else { unreachable!("the node ended a lookup nobody waits for") }; - let (_, client, name) = self.lookups.swap_remove(at); - use toyos_dns::Failure as Dns; - match resolved.result { - Ok(addrs) => answer_lookup(&client, &addrs), - // The protocol's one answer for a name with no address, - // whether the name or only its address is missing. - Err(Ended::Failed(Dns::NoSuchName | Dns::NoAddress)) => answer_lookup(&client, &[]), - Err(Ended::Failed(Dns::TimedOut)) => client.error(ERR_TIMED_OUT), - // No query found a way out, or the lease the lookup asked - // under went: this machine is on no network that answers the - // name, which a lease clears. Neither is that word's (see - // `connect_failed`). - Err(Ended::Failed(Dns::Unreachable) | Ended::LeaseChanged) => client.error(ERR_NOT_CONNECTED), - Err(Ended::Failed(why @ (Dns::Truncated | Dns::ServerFailed(_) | Dns::TooManyAliases))) => { - say!("netstack: a lookup of {name} ended without an answer: {why:?}"); - client.error(ERR_OTHER); - } - Err(Ended::NoPort) => { - say!("netstack: a lookup of {name} ended with every dynamic port bound, none left for its next query"); - client.error(ERR_RESOURCE_EXHAUSTED); - } - } + let (_, client) = self.lookups.swap_remove(at); + answer_resolved(&client, resolved.result); } let ended: Vec<(ListenerId, WriteRefusal)> = node.drain_ended_listeners().collect(); @@ -762,7 +785,7 @@ impl Sockets { Socket::Stream(_) => {} } } - for (_, client, _) in &self.lookups { + for (_, client) in &self.lookups { poller.watch(&client.conn, READABLE, TOKEN_LOOKUP | u64::from(client.conn.as_handle().0)); } } @@ -821,7 +844,7 @@ impl Sockets { // A lookup whose client has left ends now: its sockets and // its place are another client's. let spoke = |client: &Client| u64::from(client.conn.as_handle().0) == u64::from(socket_id); - self.lookups.retain(|(id, client, _)| { + self.lookups.retain(|(id, client)| { let left = spoke(client) && client.gone(); if left { node.let_go(now, *id); diff --git a/system/supervisor/src/main.rs b/system/supervisor/src/main.rs index 3bcec5fa71..42e31a0714 100644 --- a/system/supervisor/src/main.rs +++ b/system/supervisor/src/main.rs @@ -54,6 +54,12 @@ //! A launch of a program no row names is answered with the session's, which //! the caller's direct spawn carries in place of its own. //! +//! **Every program whose row receives `netstack` holds one minted for it**, +//! with its row and session as the badge ([`Grants::netstack`]), which netstack +//! keeps each program's DNS answers apart by: nothing holds the port's own +//! connector, and a program started once its server has ended for good is +//! minted none. +//! //! **An installed package's launch also holds the folder of the home it was //! granted** ([`toyos_manifest::grants`]): the answer stored for the exact //! bytes it starts, which the supervisor alone writes, at the request of the @@ -111,6 +117,10 @@ use toyos_abi::syscall::{ SVC_LABEL, SYSCAP_LABEL, }; +/// netstack's port, whose every connector the supervisor mints for one row and +/// session (`Grants::netstack`). +const NETSTACK: &str = "netstack"; + /// What the supervisor makes in the session user's home before anything runs. English on /// disk in every locale: a translation is a label, never a rename. const HOME_FOLDERS: [&str; 8] = @@ -401,6 +411,9 @@ fn main() { acceptors.insert(name, acceptor); connectors.insert(name, connector); } + // Its connector goes at once: every `netstack` a program holds is one + // minted with that program's row and session (`Grants::netstack`). + drop(connectors.remove(NETSTACK)); // One port per file-server role, made here for the same reason: a // program's first open works whether or not its server runs yet. Its @@ -459,7 +472,7 @@ fn main() { syscap: &syscap, acceptors, connectors, - grants: Grants { roles: Vec::new(), block: None }, + grants: Grants { roles: Vec::new(), block: None, netstack: None }, sessions: Sessions::default(), files, services: Vec::new(), @@ -817,6 +830,11 @@ impl<'a> Supervisor<'a> { /// before the file servers its grants are minted on — every other start /// may make a directory, and a directory is a file server's — and the /// session's home is made before the first row that is not one. + /// + /// **Every service holds its acceptors before any is started**, so what a + /// start mints on another service's port — a role's directories, a + /// partition, `netstack` — does not depend on the order `[boot] start` + /// lists them in. fn boot(&mut self, role_acceptors: &mut BTreeMap<&str, Acceptor>, wake: toyos::RawHandle) { let system = self.system; let rank = |name: &&String| match system.program(name) { @@ -826,15 +844,10 @@ impl<'a> Supervisor<'a> { }; let mut storage_first: Vec<&String> = system.start.iter().collect(); storage_first.sort_by_key(rank); - let mut homes_made = false; for name in storage_first { let program = system .program(name) .unwrap_or_else(|| panic!("supervisor: [boot] start names `{name}`, which is not declared")); - if !is_storage(program) && !homes_made { - self.make_session_home(); - homes_made = true; - } let instances: Vec> = match program.roles.is_empty() { true => vec![None], false => program.roles.iter().map(|r| Some(r.as_str())).collect(), @@ -858,8 +871,7 @@ impl<'a> Supervisor<'a> { vec![(format!("{CAPABILITY_PREFIX}{role}"), acceptor)] } }; - self.make_home(program); - let mut service = Service::new(program, role, kept, self.sessions.machine(), wake); + let service = Service::new(program, role, kept, self.sessions.machine(), wake); if let Some(role) = role { self.grants.roles.push((role, Arc::clone(&service.kept))); } @@ -867,19 +879,32 @@ impl<'a> Supervisor<'a> { assert!(self.grants.block.is_none(), "supervisor: two rows start a block service"); self.grants.block = Some(Arc::clone(&service.kept)); } - let started = - service.spawn(&program.path, &[], system, self.syscap, &self.connectors, &self.grants, &self.minted, &mut self.log); - match started { - Ok(_) => {} - Err(StartError::Partition(why)) => { - service.kept.lock().expect("supervisor: a service's state is poisoned").acceptors.clear(); - say!("supervisor: {} did not start ({why}); its ports are closed this boot", service.label()); - } - Err(e) => panic!("supervisor: cannot start {}: {e}", program.name), + if program.serves.iter().any(|name| name == NETSTACK) { + self.grants.netstack = Some(Arc::clone(&service.kept)); } self.services.push(service); } } + let mut homes_made = false; + for at in 0..self.services.len() { + let program = self.services[at].program; + if !is_storage(program) && !homes_made { + self.make_session_home(); + homes_made = true; + } + self.make_home(program); + let service = &mut self.services[at]; + let started = + service.spawn(&program.path, &[], system, self.syscap, &self.connectors, &self.grants, &self.minted, &mut self.log); + match started { + Ok(_) => {} + Err(StartError::Partition(why)) => { + service.kept.lock().expect("supervisor: a service's state is poisoned").acceptors.clear(); + say!("supervisor: {} did not start ({why}); its ports are closed this boot", service.label()); + } + Err(e) => panic!("supervisor: cannot start {}: {e}", program.name), + } + } if !homes_made { self.make_session_home(); } @@ -2455,6 +2480,7 @@ fn start<'a>( } let mut view = grants.view(program, launcher.1); view.extend(ports); + view.extend(grants.netstack(program, launcher.1)); if let Some(ns) = build_namespace(program, system, connectors, view, extras)? { let raw = ns.into_raw(); command.endow(SVC_LABEL, raw.0); @@ -2742,11 +2768,12 @@ fn build_namespace( // Never the swap port nor the grants port: [`swap_namespace`] and // [`grants_namespace`] say why. Never the block port's own connector, // which reaches nothing: a holder of `block` holds one minted for what it - // reaches, in `view`. + // reaches, in `view`; nor netstack's, which nothing holds: a holder of + // `netstack` holds one minted for its row and session, in `view` too. let receives: Vec<&String> = program .receives .iter() - .filter(|name| *name != toyos_swap::PORT && *name != grants::PORT && *name != toyos_blockring::PORT) + .filter(|name| *name != toyos_swap::PORT && *name != grants::PORT && *name != toyos_blockring::PORT && *name != NETSTACK) .collect(); if receives.is_empty() && extras.is_empty() && view.is_empty() { return Ok(None); @@ -2791,13 +2818,16 @@ fn build_namespace( /// /// **A partition is reached through a connector minted for it** /// ([`toyos_blockring::wire::Grant`]), never through the block port's own, -/// which no program is handed. +/// which no program is handed. **So is netstack**, through one minted with the +/// row and session of the program that holds it. struct Grants<'a> { /// Each role's service, whose kept acceptor is the role's port. roles: Vec<(&'a str, Arc>)>, /// The block service the supervisor started, whose kept acceptor is /// [`toyos_blockring::PORT`]. block: Option>>, + /// The service the supervisor started serving [`NETSTACK`]. + netstack: Option>>, } impl Grants<'_> { @@ -2816,6 +2846,22 @@ impl Grants<'_> { .map_err(|e| format!("no connector to the block service could be minted: {e:?}")) } + /// [`NETSTACK`] minted with `program`'s row and `session`, for a row whose + /// `receives` names it: the badge is the view netstack keeps its DNS + /// answers in. None once the port has closed for good, as for a role. + fn netstack(&self, program: &Program, session: Session) -> Option<(String, Connector)> { + if !program.receives.iter().any(|name| name == NETSTACK) { + return None; + } + let kept = self.netstack.as_ref()?.lock().expect("supervisor: a service's state is poisoned"); + let (_, acceptor) = kept.acceptors.iter().find(|(name, _)| name == NETSTACK)?; + let badge = Authority { row: program.name.clone(), session }.encode(); + let connector = acceptor + .mint(&badge) + .unwrap_or_else(|e| panic!("supervisor: no netstack connector for {}: {e:?}", program.name)); + Some((NETSTACK.to_string(), connector)) + } + /// The directory capabilities `program` is endowed for one start in /// `session`, by namespace name: its row's view (`Program::view`), but /// that a storage row sees none, since a file server resolving a path of From 3177f84eb5c5162b8c28b0e2f51bd929128c2f7d Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 12 Oct 2026 20:05:38 +0200 Subject: [PATCH 3/7] toyos-dns: the cache names each failure it does not keep `Cache::keep` matched every failure but the two absences with a wildcard, so a variant added to `Failure` later would be dropped from the cache without anyone deciding it. The five are named, and a sixth fails to compile until somebody says whether it is kept (review of #883, NOTE). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- system/netstack/dns/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/system/netstack/dns/src/lib.rs b/system/netstack/dns/src/lib.rs index 6ffecde6de..ec0f8c09b0 100644 --- a/system/netstack/dns/src/lib.rs +++ b/system/netstack/dns/src/lib.rs @@ -914,7 +914,7 @@ impl Cache { let answer = match answer { Ok(addrs) => Ok(addrs.to_vec()), Err(absent @ (Failure::NoSuchName | Failure::NoAddress)) => Err(absent), - Err(_) => return, + Err(Failure::TimedOut | Failure::Unreachable | Failure::Truncated | Failure::ServerFailed(_) | Failure::TooManyAliases) => return, }; let Some(until) = until.filter(|until| now < *until) else { return }; if started != self.generation() { From f0acec6eb02e279ce877fd4c2bf7e091e753a64d Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 12 Oct 2026 20:05:38 +0200 Subject: [PATCH 4/7] The build refuses a received name no started row serves, or one its row serves itself The supervisor mints every program's `netstack` from the running server's kept acceptor (`Grants::netstack`), which made two shapes of manifest go wrong without a word (review of #883, two NOTEs): - a row receiving `netstack` in an image where no row `[boot] start` names serves it was endowed nothing, since `build_namespace` filters the name out and no assert sees it; - netstack's own row receiving `netstack` would have locked its own `Kept`, which `Service::spawn` holds, and the boot would hang silent. `receives_reach_a_running_server` refuses both when the manifest is rendered, so a metal arm's derived config is checked as a committed one is. It is the test-only `receives_have_providers` and `apps_receive_a_served_name` made one render gate and tightened: a served name counts only when a row `[boot] start` names serves it, since a launch starts no row that serves (`starts_name_what_a_launch_can_start`), and a row never receives a port it serves. Every committed config passes it (`every_config_renders_its_manifest`). The supervisor's `?` on a missing netstack service becomes a panic that names the row: no manifest the build renders reaches it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- build/build.rs | 122 +++++++++++++++++----------------- system/supervisor/src/main.rs | 10 ++- 2 files changed, 70 insertions(+), 62 deletions(-) diff --git a/build/build.rs b/build/build.rs index 0f70c1ab06..d9a0fe7b33 100644 --- a/build/build.rs +++ b/build/build.rs @@ -673,13 +673,44 @@ fn starts_name_what_a_launch_can_start(config: &SystemConfig) -> Result<(), Stri Ok(()) } +/// What a row receives reaches a server that runs: a port the supervisor +/// serves, one a row `[boot] start` names serves, since a launch starts no row +/// that serves ([`starts_name_what_a_launch_can_start`]), or one a row +/// provides; and never a port the row serves itself. `[apps] receives` is +/// narrower: nobody makes a `provides` port for a package. +/// +/// **Checked on every manifest rendered**: the supervisor mints `netstack` +/// from its running server's acceptor, under the lock that server's own start +/// holds, so a row receiving it where none runs is endowed nothing, and the +/// server's own row receiving it waits at boot for ever. +fn receives_reach_a_running_server(config: &SystemConfig) -> Result<(), String> { + let running = |port: &String| { + SUPERVISOR_SERVED.contains(&port.as_str()) + || config.boot.start.iter().any(|key| config.programs.get(key).is_some_and(|row| row.serves.contains(port))) + }; + for (name, program) in &config.programs { + for port in &program.receives { + if program.serves.contains(port) { + return Err(format!("`{name}` receives `{port}`, which it serves itself")); + } + if !running(port) && !config.programs.values().any(|row| row.provides.contains(port)) { + return Err(format!("`{name}` receives `{port}`, which no row in `[boot] start` serves and no row provides")); + } + } + } + match config.apps.receives.iter().find(|port| !running(port)) { + Some(port) => Err(format!("`[apps] receives` names `{port}`, which no row in `[boot] start` serves")), + None => Ok(()), + } +} + /// The resolved config as the records `/system/bin/supervisor` reads. /// /// The format, the renderer and the parser are `lib/manifest/`, whose /// round-trip test is what makes "what the build writes is what the supervisor reads" a /// fact rather than two hand-matched implementations. fn render_manifest(config: &SystemConfig) -> Vec { - for gate in [held_by_their_holders_alone, starts_name_what_a_launch_can_start] { + for gate in [held_by_their_holders_alone, starts_name_what_a_launch_can_start, receives_reach_a_running_server] { if let Err(why) = gate(config) { panic!("the image config cannot be rendered as a manifest: {why}"); } @@ -3004,36 +3035,37 @@ mod tests { parse_config(&Path::new(env!("CARGO_MANIFEST_DIR")).join(cfg)) } - /// Every name a program `receives` must be served or provided by some - /// program in the *same* config, or served by the supervisor. The build-time form of - /// "a client cannot name a service the system does not have", and the gate - /// with the sharpest teeth: no guest, no mutated tree. - fn receives_have_providers(cfg: &SystemConfig) -> Result<(), String> { - let mut providers: Vec<&str> = SUPERVISOR_SERVED.to_vec(); - for prog in cfg.programs.values() { - providers.extend(prog.serves.iter().map(String::as_str)); - providers.extend(prog.provides.iter().map(String::as_str)); - } - for (name, prog) in &cfg.programs { - for r in &prog.receives { - if !providers.contains(&r.as_str()) { - return Err(format!( - "program `{name}` receives `{r}`, which no program serves or provides" - )); - } - } - } - Ok(()) - } - + /// A received name reaches the supervisor, a started row's port or a row's + /// own, and each refusal has a config that takes it and nothing else. + /// `every_config_renders_its_manifest` runs it over every committed config. #[test] - fn every_receives_names_a_provider() { - for cfg in ALL_CONFIGS { - receives_have_providers(&load(cfg)).unwrap_or_else(|e| panic!("{cfg}: {e}")); + fn a_received_name_reaches_a_server_that_runs_and_never_its_own() { + let gate = |text: &str| receives_reach_a_running_server(&toml::from_str(text).unwrap()); + assert_eq!( + gate("[boot]\nstart = [\"netstack\"]\n[programs.netstack]\nserves = [\"netstack\"]\n\ + [programs.host]\nreceives = [\"netstack\", \"power\", \"surface\"]\n\ + [programs.terminal]\nprovides = [\"surface\"]\n[apps]\nreceives = [\"netstack\", \"power\"]\n"), + Ok(()) + ); + for (text, said) in [ + ("[programs.host]\nreceives = [\"ghost\"]\n", "`host` receives `ghost`, which no row in `[boot] start` serves"), + ( + "[programs.netstack]\nserves = [\"netstack\"]\n[programs.host]\nreceives = [\"netstack\"]\n", + "`host` receives `netstack`, which no row in `[boot] start` serves", + ), + ( + "[boot]\nstart = [\"netstack\"]\n[programs.netstack]\nserves = [\"netstack\"]\nreceives = [\"netstack\"]\n", + "`netstack` receives `netstack`, which it serves itself", + ), + ("[apps]\nreceives = [\"ghost\"]\n", "`[apps] receives` names `ghost`"), + ("[programs.netstack]\nserves = [\"netstack\"]\n[apps]\nreceives = [\"netstack\"]\n", "`[apps] receives` names `netstack`"), + ("[programs.terminal]\nprovides = [\"surface\"]\n[apps]\nreceives = [\"surface\"]\n", "`[apps] receives` names `surface`"), + ] { + match gate(text) { + Err(why) if why.contains(said) => {} + other => panic!("{text:?}: {other:?}, not {said:?}"), + } } - let bad: SystemConfig = - toml::from_str("[programs.client]\nreceives = [\"ghost\"]\n").unwrap(); - assert!(receives_have_providers(&bad).is_err()); } /// The swap port reaches `swap` and nothing else, and the idle slot @@ -3123,38 +3155,6 @@ mod tests { assert!(toml::from_str::("[programs.shell]\nstart = [\"toybox\"]\n").is_err()); } - /// `[apps] receives` is narrower than a program's: a `provides` name is one - /// port per instance and nobody makes one for a package, so naming one here - /// is a namespace the supervisor cannot build. - fn apps_receive_a_served_name(cfg: &SystemConfig) -> Result<(), String> { - let mut served: Vec<&str> = SUPERVISOR_SERVED.to_vec(); - for prog in cfg.programs.values() { - served.extend(prog.serves.iter().map(String::as_str)); - } - for name in &cfg.apps.receives { - if !served.contains(&name.as_str()) { - return Err(format!("`[apps] receives` names `{name}`, which no program serves")); - } - } - Ok(()) - } - - #[test] - fn an_installed_app_receives_only_names_the_supervisor_holds() { - for cfg in ALL_CONFIGS { - apps_receive_a_served_name(&load(cfg)).unwrap_or_else(|e| panic!("{cfg}: {e}")); - } - let provided: SystemConfig = toml::from_str( - "[apps]\nreceives = [\"surface\"]\n\ - [programs.terminal]\nprovides = [\"surface\"]\n", - ) - .unwrap(); - assert!(apps_receive_a_served_name(&provided).is_err()); - let ghost: SystemConfig = - toml::from_str("[apps]\nreceives = [\"ghost\"]\n").unwrap(); - assert!(apps_receive_a_served_name(&ghost).is_err()); - } - /// A `serves` name is one port machine-wide; a `provides` name is one port /// per instance. A name declared both ways is a config where the supervisor makes a /// port nobody accepts from while the real one is made elsewhere. diff --git a/system/supervisor/src/main.rs b/system/supervisor/src/main.rs index 42e31a0714..ea178cc0bb 100644 --- a/system/supervisor/src/main.rs +++ b/system/supervisor/src/main.rs @@ -2849,11 +2849,19 @@ impl Grants<'_> { /// [`NETSTACK`] minted with `program`'s row and `session`, for a row whose /// `receives` names it: the badge is the view netstack keeps its DNS /// answers in. None once the port has closed for good, as for a role. + /// + /// The build renders no manifest in which such a row is netstack's own or + /// no row `[boot] start` names serves it (`build/build.rs`). fn netstack(&self, program: &Program, session: Session) -> Option<(String, Connector)> { if !program.receives.iter().any(|name| name == NETSTACK) { return None; } - let kept = self.netstack.as_ref()?.lock().expect("supervisor: a service's state is poisoned"); + let kept = self + .netstack + .as_ref() + .unwrap_or_else(|| panic!("supervisor: {} receives `{NETSTACK}`, which no started row serves", program.name)) + .lock() + .expect("supervisor: a service's state is poisoned"); let (_, acceptor) = kept.acceptors.iter().find(|(name, _)| name == NETSTACK)?; let badge = Authority { row: program.name.clone(), session }.encode(); let connector = acceptor From 75cf86188e0033ed038285026980350e41ca9a2f Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 12 Oct 2026 20:05:38 +0200 Subject: [PATCH 5/7] issues: the cache's isolation exit reads the session too, and the bimodal accepted-to-read step is filed - nothing-shows-netstack-keys-a-lookups-cached-answers-by-its-program: the badge is a row and a session, and the T14's K4 read only the row half (`host` ran in test-runner's session). The exit now asks a second session of the same row as well (review of #883, NOTE). - the-step-from-netstacks-accept-to-a-whole-request-is-bimodal: #883's T14 run found accepted->read 0.0285-0.2220 ms on 474 of 481 lookup stamps and 0.3586-0.4056 ms on seven, in both arms, which no file named. Filed as the review asked, with its exit and an owner. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- ...a-lookups-cached-answers-by-its-program.md | 20 +++++---- ...ks-accept-to-a-whole-request-is-bimodal.md | 41 +++++++++++++++++++ 2 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 issues/the-step-from-netstacks-accept-to-a-whole-request-is-bimodal.md diff --git a/issues/nothing-shows-netstack-keys-a-lookups-cached-answers-by-its-program.md b/issues/nothing-shows-netstack-keys-a-lookups-cached-answers-by-its-program.md index a884e93df5..6549fb9ff4 100644 --- a/issues/nothing-shows-netstack-keys-a-lookups-cached-answers-by-its-program.md +++ b/issues/nothing-shows-netstack-keys-a-lookups-cached-answers-by-its-program.md @@ -8,16 +8,20 @@ opened: 2026-10-12 netstack's DNS cache answers a program only from its own view, which `system/netstack/src/serve.rs` names by the badge on the lookup's connection, -and the supervisor mints each program its own `netstack` connector. Host tests -hold the cache's rules, and none reaches either wiring. A change that passed -one view for every lookup, or minted one badge for every program, would turn -no test red: no guest test resolves a name (`https_get` dials an address), and -under QEMU's slirp the lease's resolver forwards to the host's, which reaches -the internet. The T14 read the wiring once, in #883's metal measurement. +and the supervisor mints each program its own `netstack` connector, badged +with its row and its session. Host tests hold the cache's rules, and none +reaches either wiring. A change that passed one view for every lookup, minted +one badge for every program, or minted a row's badge without its session, so +that two logins of one row shared answers, would turn no test red: no guest +test resolves a name (`https_get` dials an address), and under QEMU's slirp +the lease's resolver forwards to the host's, which reaches the internet. The +T14 read the row half once, in #883's metal measurement; nothing has read the +session half, since that run's second row ran in the first's session. Exit condition: a netcase guest test against a resolver the harness answers -(`issues/there-is-no-network-gate.md`'s `-netdev socket`) shows that a second -row's lookup of a name the first row holds sends a query. +(`issues/there-is-no-network-gate.md`'s `-netdev socket`) shows that a lookup +of a name one row holds sends a query when a second row asks it, and when the +same row asks it in a second session. Owner: `issues/there-is-no-network-gate.md`, whose `-netdev socket` the test needs. diff --git a/issues/the-step-from-netstacks-accept-to-a-whole-request-is-bimodal.md b/issues/the-step-from-netstacks-accept-to-a-whole-request-is-bimodal.md new file mode 100644 index 0000000000..9054721680 --- /dev/null +++ b/issues/the-step-from-netstacks-accept-to-a-whole-request-is-bimodal.md @@ -0,0 +1,41 @@ +--- +status: open +kind: finding +opened: 2026-10-12 +--- + +# The step from netstack's accept to a whole request is bimodal + +Found by pull request #883's T14 run, which stamped every lookup netstack +served in two boots of one session: base, without the DNS cache, and cache, +with it ([evidence `a370c0a9`](https://github.com/ToyOSOrg/evidence/tree/a370c0a950b49ca54cea7abbe234a4f597dcdfed/dnscache-metal-804ccd7fc/run)). +The stamps bound the step from `n1`, the return of the wait in which +netstack's pass accepted the client's connection, to `n2`, its read of the +request whole. Read per stamp ([`accread-base.txt`](https://github.com/ToyOSOrg/evidence/blob/aad605669378cdf7ee67c82eccc9eb31070943b2/dnscache-r2/accread-base.txt), +[`accread-cache.txt`](https://github.com/ToyOSOrg/evidence/blob/aad605669378cdf7ee67c82eccc9eb31070943b2/dnscache-r2/accread-cache.txt)), +it has two modes in both arms and nothing between them: + +- 0.0285–0.2007 ms on 225 of base's 228 stamps, and 0.0394–0.2220 ms on 249 + of cache's 253; +- 0.3586–0.4056 ms on the other seven: in base, the download's own lookup in + runs 5 and 6 and `host`'s, and in cache, the download's own lookup in runs + 4, 5 and 6 and `host`'s. `host`'s read 0.362 ms in both. + +Hits and misses show it alike, in both arms, so it is no work of the cache, +whose own part of a hit, `n2` to the answer, has a median of 5.3 µs. It is not yet +known to be netstack's: the span also holds the client's write of its request +after its connect returns, and netstack's next pass of its loop up to that +read. The step after the answer, to the client's return, read 0.131–0.209 ms +over the twelve downloads' lookups, the same order as the slow mode's excess. + +Both arms predate pull request #861, after which netstack's wait returns only +on a change (`system/netstack/src/watches.rs`), which moves what a pass waits +on inside this span. + +**Exit**: a T14 run on a tree with #861, whose stamps on both sides of the +IPC, the client's write and its return beside netstack's wake and read, name +what the slow mode waits on; then it is removed, or recorded as a defect in +the file that owns that step, and this file goes. + +**Owner**: whoever holds `issues/toyos-has-its-own-network-stack.md`, whose +server's loop and stamps the span crosses. From 0fb70e840844c7f2434321539a559ba1d40ffdaa Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 12 Oct 2026 20:53:40 +0200 Subject: [PATCH 6/7] The build refuses a row that provides `netstack` `receives_reach_a_running_server` counted any row's `provides` as a server for a received name, `netstack` included. But the supervisor mints every `netstack` connector from its started server's kept acceptor (`Grants::netstack`) and `build_namespace` never takes `netstack` from a provider, so a manifest in which a row provides `netstack` and no started row serves it rendered, and the first receiver's start panicked the supervisor. The gate now refuses `netstack` in any `provides` by name, and its test has a config that takes that refusal alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- build/build.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/build/build.rs b/build/build.rs index d9a0fe7b33..d377d81206 100644 --- a/build/build.rs +++ b/build/build.rs @@ -676,19 +676,23 @@ fn starts_name_what_a_launch_can_start(config: &SystemConfig) -> Result<(), Stri /// What a row receives reaches a server that runs: a port the supervisor /// serves, one a row `[boot] start` names serves, since a launch starts no row /// that serves ([`starts_name_what_a_launch_can_start`]), or one a row -/// provides; and never a port the row serves itself. `[apps] receives` is -/// narrower: nobody makes a `provides` port for a package. +/// provides, never `netstack`; and never a port the row serves itself. +/// `[apps] receives` is narrower: nobody makes a `provides` port for a package. /// /// **Checked on every manifest rendered**: the supervisor mints `netstack` /// from its running server's acceptor, under the lock that server's own start -/// holds, so a row receiving it where none runs is endowed nothing, and the -/// server's own row receiving it waits at boot for ever. +/// holds, and never from a provider, so a row receiving it where none runs is +/// endowed nothing, and the server's own row receiving it waits at boot for +/// ever. fn receives_reach_a_running_server(config: &SystemConfig) -> Result<(), String> { let running = |port: &String| { SUPERVISOR_SERVED.contains(&port.as_str()) || config.boot.start.iter().any(|key| config.programs.get(key).is_some_and(|row| row.serves.contains(port))) }; for (name, program) in &config.programs { + if program.provides.iter().any(|port| port == "netstack") { + return Err(format!("`{name}` provides `netstack`, which the supervisor mints from its started server alone")); + } for port in &program.receives { if program.serves.contains(port) { return Err(format!("`{name}` receives `{port}`, which it serves itself")); @@ -3057,6 +3061,10 @@ mod tests { "[boot]\nstart = [\"netstack\"]\n[programs.netstack]\nserves = [\"netstack\"]\nreceives = [\"netstack\"]\n", "`netstack` receives `netstack`, which it serves itself", ), + ( + "[programs.relay]\nprovides = [\"netstack\"]\n[programs.host]\nreceives = [\"netstack\"]\n", + "`relay` provides `netstack`, which the supervisor mints from its started server alone", + ), ("[apps]\nreceives = [\"ghost\"]\n", "`[apps] receives` names `ghost`"), ("[programs.netstack]\nserves = [\"netstack\"]\n[apps]\nreceives = [\"netstack\"]\n", "`[apps] receives` names `netstack`"), ("[programs.terminal]\nprovides = [\"surface\"]\n[apps]\nreceives = [\"surface\"]\n", "`[apps] receives` names `surface`"), From d6e5a33f08c14ac7ef48c0125048112c8d4e4cea Mon Sep 17 00:00:00 2001 From: japabu Date: Mon, 12 Oct 2026 20:54:50 +0200 Subject: [PATCH 7/7] issues: the bimodal step's arms ran #861's netstack, and its exit asks no tree The finding said both T14 arms predate #861 and asked for a run on a tree with it. Both arms, `89a2dd00f` and `703f474c6`, descend from #861's head `443fb5d10` and carry `system/netstack/src/watches.rs`, so that exit was already met. What they predate is #871's kernel; the exit now asks only for the stamps that name what the slow mode waits on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015xhaydXCVRNscoccHYFUWy --- ...tacks-accept-to-a-whole-request-is-bimodal.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/issues/the-step-from-netstacks-accept-to-a-whole-request-is-bimodal.md b/issues/the-step-from-netstacks-accept-to-a-whole-request-is-bimodal.md index 9054721680..dd6a3d9c3f 100644 --- a/issues/the-step-from-netstacks-accept-to-a-whole-request-is-bimodal.md +++ b/issues/the-step-from-netstacks-accept-to-a-whole-request-is-bimodal.md @@ -28,14 +28,16 @@ after its connect returns, and netstack's next pass of its loop up to that read. The step after the answer, to the client's return, read 0.131–0.209 ms over the twelve downloads' lookups, the same order as the slow mode's excess. -Both arms predate pull request #861, after which netstack's wait returns only -on a change (`system/netstack/src/watches.rs`), which moves what a pass waits -on inside this span. +Both arms ran pull request #861's netstack, whose wait returns only on a +change (`system/netstack/src/watches.rs`): each descends from its head +`443fb5d10`. Both predate #871's kernel, which saves user FP/vector state only +at a switch instead of at every system call, and the span holds system calls +on both sides. -**Exit**: a T14 run on a tree with #861, whose stamps on both sides of the -IPC, the client's write and its return beside netstack's wake and read, name -what the slow mode waits on; then it is removed, or recorded as a defect in -the file that owns that step, and this file goes. +**Exit**: a T14 run whose stamps on both sides of the IPC, the client's write +and its return beside netstack's wake and read, name what the slow mode waits +on; then it is removed, or recorded as a defect in the file that owns that +step, and this file goes. **Owner**: whoever holds `issues/toyos-has-its-own-network-stack.md`, whose server's loop and stamps the span crosses.