diff --git a/build/build.rs b/build/build.rs index 0f70c1ab060..d377d81206d 100644 --- a/build/build.rs +++ b/build/build.rs @@ -673,13 +673,48 @@ 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, 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, 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")); + } + 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 +3039,41 @@ 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", + ), + ( + "[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`"), + ] { + 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 +3163,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/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 00000000000..66a6beae597 --- /dev/null +++ b/issues/netstack-replaces-one-programs-cached-answers-to-keep-anothers.md @@ -0,0 +1,36 @@ +--- +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 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 +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_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 +`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 00000000000..6549fb9ff4c --- /dev/null +++ b/issues/nothing-shows-netstack-keys-a-lookups-cached-answers-by-its-program.md @@ -0,0 +1,27 @@ +--- +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, 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 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-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 00000000000..a253aa52a80 --- /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. 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 00000000000..dd6a3d9c3f6 --- /dev/null +++ b/issues/the-step-from-netstacks-accept-to-a-whole-request-is-bimodal.md @@ -0,0 +1,43 @@ +--- +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 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 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. diff --git a/system/netstack/dns/Cargo.toml b/system/netstack/dns/Cargo.toml index a0210345463..401db143982 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 1712abefd18..ec0f8c09b06 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(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() { + 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 630ac141f3b..4890bed37ab 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 9398ca4d322..130160f564f 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 026abb3054d..060f818de20 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 054fa6ac960..917fa53147c 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 7491ed213f4..32030c0d82e 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 b61b1922a27..05bbf0172a8 100644 --- a/system/netstack/src/main.rs +++ b/system/netstack/src/main.rs @@ -541,7 +541,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 b6fd96f5e4c..5bc98909144 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}; +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}; @@ -107,7 +115,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, @@ -207,6 +215,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 { @@ -269,7 +301,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), @@ -277,7 +309,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), @@ -604,9 +636,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; @@ -619,8 +652,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), } @@ -659,31 +702,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(); @@ -763,7 +786,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)); } } @@ -822,7 +845,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 3bcec5fa71b..ea178cc0bb1 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,30 @@ 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. + /// + /// 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() + .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 + .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