Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/appliance-pid1/src/resolver.rs

//! **`/etc/resolv.conf`**: the one file that lets this appliance spell a name.
//!
//! Measured on gunnar.rs 2026-09-08, right after the 1.4.0 re-image:
//!
//! ```text
//!   gunnar-cli control mirror sync alice/git-scm --wait
//!   job  mirror-1  FAILED: https://github.com/git/git.git/info/refs?service=git-upload-pack
//!                          could not be reached for the advertisement: Dns
//! ```
//!
//! The box had an address. It had a default route. It had no idea what
//! `github.com` meant, because `crate::dhcp` read options 1, 3, 51, 54, 58 and
//! 59 out of the ACK and walked past option 6, and because **nothing under this
//! binary writes `/etc/resolv.conf`**. There is no distro here: korp-installer's
//! initramfs (`edda/crates/korp-installer/src/initramfs.rs`) lays exactly `.`,
//! `dev`, `dev/console`, `proc`, `sys` and `init`, and korp-start mounts only
//! `/proc`. There is no `/etc` on this root at all, so there was no file for
//! libc to fail to find in an interesting way — it simply had no server to ask.
//!
//! # Why a file, and not a resolver in this process
//!
//! The other shape was an in-process resolver: parse option 6 into a `Vec`, hold
//! it in a `OnceLock`, and answer lookups from it. It is rejected, and the
//! reason is one implementation rather than taste.
//!
//! Count the dial sites this server ends up in. `mirror_http.rs` hands a URL to
//! `ureq`. `mirror_fetch.rs`'s SSH arm hands a host to the russh dialler inside
//! `gunnar-client`. The billing URL client and the webhook sender each build
//! their own request. Every one of them bottoms out in
//! `std::net::ToSocketAddrs`, which on this target is musl's `getaddrinfo`. An
//! in-process resolver takes effect at none of them until each is rewritten to
//! ask it instead — including the ones that live inside a dependency and cannot
//! be rewritten at all without forking it. That is not one implementation of
//! name resolution, it is two, with a permanent list of which callers got the
//! new one; one implementation says the second one does not get to exist.
//!
//! **libc is already the one implementation on this box.** The whole gap is that
//! nobody ever told it anything. Sixty bytes on the RAM root close it for every
//! caller above, present and future, with zero new code paths — and a person on
//! the serial console can `cat` the answer.
//!
//! The one property that makes this work at boot time rather than at link time:
//! musl re-reads `/etc/resolv.conf` on **every** lookup (`__get_resolv_conf` in
//! `src/network/resolvconf.c` opens the file per call; there is no `res_init`
//! cache to invalidate). So a file written after the runtime is up is picked up
//! by the next `getaddrinfo` with nothing to restart — which is exactly what a
//! renewal that changes the server set needs.
//!
//! # What it writes, and what it refuses to write
//!
//! ```text
//!   nameserver 10.0.2.3
//!   nameserver 1.1.1.1
//!   options timeout:2 attempts:2
//! ```
//!
//! `timeout:2 attempts:2` rather than the 5-second, 2-attempt default: a git
//! mirror job that is going to fail on a dead resolver should fail in four
//! seconds, not ten, because the job queue behind it retries and the operator
//! is watching a dashboard. Both libcs cap the nameserver list at three
//! (`MAXNS`); more than three are still written, because the file is also read
//! by a human at the console and silently dropping a line the server sent would
//! be the fabricated-value trap in reverse.
//!
//! An **empty** server set writes nothing and — deliberately — does not truncate
//! a file that is already there. A renewal from a server that has stopped
//! sending option 6 is not a reason to take away a resolver that works.
//!
//! # The two arms of `the net spec` that this does NOT reach
//!
//! * **Static** (`eth0:10.0.2.15/24:10.0.2.2`). `crate::net::NetPlan::parse` is
//!   positional and hard-capped: "more than three `:`-separated fields" is a
//!   refusal. There is no `key=value` in that spelling to hang a `dns=` on, and
//!   inventing one would be a new configuration surface baked into ISOs, kernel
//!   command lines and recipes — a bigger promise than this fix is allowed to
//!   make. A static box therefore still has no resolver, and that is now a
//!   *stated* gap rather than a silent one. When it is wanted, this module is
//!   already the writer it will call.
//! * **`wg-client`**. `wg.rs` refuses the wg-quick `DNS` key **by name**
//!   (no `DNS`, `MTU`, `Table` or `PreUp`), which is `wg.rs`'s own decision; a
//!   tunnel is not where this appliance learns its resolvers.

use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

/// Where libc looks. Absolute, because that is what it *is* and what a log line
/// has to print; [`publish_under`] strips the leading slash before joining so a
/// test can stand a temp dir in for `/` (a `Path::join` with an absolute
/// argument would discard the root and write to the real `/etc`).
pub const RESOLV_CONF: &str = "/etc/resolv.conf";

/// What one [`publish_under`] did — for the caller to log, because this module
/// does not decide how loud a fact is.
///
/// Returned rather than logged so "say it once" is a property the boot path can
/// actually hold: `pid1` narrates every outcome at boot and only
/// [`Outcome::Wrote`] on renewal, which is the difference between a changed
/// resolver (news) and a lease that renewed unchanged twice a day (noise).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
    /// The file now holds these servers, in this order.
    Wrote {
        /// The absolute path written, for the log line.
        path: PathBuf,
        /// What went in it.
        servers: Vec<Ipv4Addr>,
    },
    /// The file already said exactly this. Nothing was opened for writing and
    /// the mtime is untouched.
    Unchanged,
    /// The lease named no servers. Nothing was written, and an existing file
    /// was left alone.
    NoServers,
}

/// Render the file's exact bytes.
///
/// Pure, and separate from the write for the same reason `dhcp`'s packet
/// builders are separate from the socket: a resolver bug here is a *bytes* bug,
/// and the only honest assertion is against the bytes.
pub fn render(servers: &[Ipv4Addr]) -> String {
    let mut out = String::with_capacity(16 + servers.len() * 24);
    for server in servers {
        out.push_str("nameserver ");
        out.push_str(&server.to_string());
        out.push('\n');
    }
    out.push_str("options timeout:2 attempts:2\n");
    out
}

/// Publish `servers` to the real root. The appliance's call.
pub fn publish(servers: &[Ipv4Addr]) -> Result<Outcome> {
    publish_under(Path::new("/"), servers)
}

/// Publish `servers` under `root` — `/` in production, a temp dir under test.
///
/// Creates the parent directory when it is missing, which on the appliance it
/// always is on the first call: the RAM root ships no `/etc`. The root itself is
/// rootfs and writable (`rootfs / rootfs rw 0 0` in `/proc/mounts`, the shape
/// `dashboard`'s own fixture holds), so this is a `mkdir` and a `write`, not a
/// remount.
///
/// The write is a temp file plus a `rename`, because musl opens this file on
/// **every** lookup: a plain `write` on a file that is being read is a torn read
/// with a truncated address in it, and a rename within one directory is atomic.
/// The window is small and the cost of closing it is one syscall.
pub fn publish_under(root: &Path, servers: &[Ipv4Addr]) -> Result<Outcome> {
    if servers.is_empty() {
        return Ok(Outcome::NoServers);
    }
    let path = root.join(RESOLV_CONF.trim_start_matches('/'));
    let wanted = render(servers);

    // Idempotence is a property of the FILE, not of a cached "last written" in
    // this process: a renewal loop that restarted, or a second writer, must
    // reach the same conclusion. Read-compare-write, and an unreadable file
    // (absent, or a directory) simply means "not what we want".
    if std::fs::read_to_string(&path).is_ok_and(|have| have == wanted) {
        return Ok(Outcome::Unchanged);
    }

    let parent = path
        .parent()
        .expect("RESOLV_CONF always has a parent component");
    std::fs::create_dir_all(parent)
        .with_context(|| format!("creating {} for the resolver", parent.display()))?;

    let staged = path.with_extension("conf.new");
    std::fs::write(&staged, wanted.as_bytes())
        .with_context(|| format!("writing {}", staged.display()))?;
    std::fs::rename(&staged, &path)
        .with_context(|| format!("renaming {} into place", staged.display()))?;

    Ok(Outcome::Wrote {
        path,
        servers: servers.to_vec(),
    })
}


/// The servers `/etc/resolv.conf` names right now — the dashboard's `dns`
/// column, read from the file rather than remembered from the lease, because
/// the file is what libc reads and the file is the thing that can be missing.
/// `None` when there is no file; `Some(vec![])` when there is one naming
/// nothing usable.
pub fn published() -> Option<Vec<Ipv4Addr>> {
    published_under(Path::new("/"))
}

/// [`published`], with the root pointed at a stand-in for tests.
pub fn published_under(root: &Path) -> Option<Vec<Ipv4Addr>> {
    let text = std::fs::read_to_string(root.join(RESOLV_CONF.trim_start_matches('/'))).ok()?;
    Some(
        text.lines()
            .filter_map(|line| line.strip_prefix("nameserver "))
            .filter_map(|rest| rest.trim().parse::<Ipv4Addr>().ok())
            .collect(),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_published_set_is_read_back_from_the_file_and_absence_is_none() {
        let dir = tempfile::tempdir().expect("a temp dir");
        assert_eq!(published_under(dir.path()), None, "no file: None, not empty");
        let two = [Ipv4Addr::new(94, 237, 127, 9), Ipv4Addr::new(94, 237, 40, 9)];
        publish_under(dir.path(), &two).expect("writes");
        assert_eq!(published_under(dir.path()), Some(two.to_vec()));
    }

    fn ip(a: u8, b: u8, c: u8, d: u8) -> Ipv4Addr {
        Ipv4Addr::new(a, b, c, d)
    }

    /// Where the file lands under a test root — and an assertion that it landed
    /// *under* it. [`RESOLV_CONF`] is absolute, and `Path::join` with an
    /// absolute argument silently discards the root: a test that lost the
    /// `trim_start_matches` would quietly rewrite the developer's real
    /// `/etc/resolv.conf` and still pass. This is the tripwire for that.
    fn conf(root: &Path) -> PathBuf {
        let path = root.join(RESOLV_CONF.trim_start_matches('/'));
        assert!(
            path.starts_with(root),
            "{} escaped the test root {}",
            path.display(),
            root.display()
        );
        path
    }

    /// The bytes, exactly. A resolver that is one newline off is a resolver
    /// that does not resolve, and no other assertion in this file would notice.
    #[test]
    fn the_rendered_bytes_are_exact_for_one_and_for_two_servers() {
        assert_eq!(
            render(&[ip(10, 0, 2, 3)]),
            "nameserver 10.0.2.3\noptions timeout:2 attempts:2\n"
        );
        assert_eq!(
            render(&[ip(10, 0, 2, 3), ip(1, 1, 1, 1)]),
            "nameserver 10.0.2.3\nnameserver 1.1.1.1\noptions timeout:2 attempts:2\n"
        );
        assert_eq!(
            render(&[ip(1, 1, 1, 1), ip(10, 0, 2, 3)]),
            "nameserver 1.1.1.1\nnameserver 10.0.2.3\noptions timeout:2 attempts:2\n",
            "wire order is preference order (RFC 2132 §3.8) and resolv.conf is \
             read in file order: reordering here would quietly demote the \
             server the operator's DHCP put first"
        );
    }

    /// The appliance case: no `/etc` at all until this call makes one.
    #[test]
    fn a_root_with_no_etc_gets_one_and_the_file_lands_in_it() {
        let root = tempfile::tempdir().expect("a temp root");
        assert!(
            !root.path().join("etc").exists(),
            "the fixture must start the way the RAM root starts: no /etc"
        );

        let outcome = publish_under(root.path(), &[ip(10, 0, 2, 3)]).expect("the write must land");
        let path = conf(root.path());
        assert_eq!(
            outcome,
            Outcome::Wrote {
                path: path.clone(),
                servers: vec![ip(10, 0, 2, 3)],
            }
        );
        assert_eq!(
            std::fs::read_to_string(&path).expect("the file exists"),
            "nameserver 10.0.2.3\noptions timeout:2 attempts:2\n"
        );
        assert!(
            !root.path().join("etc/resolv.conf.new").exists(),
            "the staging file must be renamed away, not left beside the real one"
        );
    }

    /// Renewals run twice a day for the life of the box. An unchanged set must
    /// not touch the file — asserted on the mtime, because "did not rewrite" is
    /// the claim and equal *content* would be true either way.
    #[test]
    fn an_unchanged_set_does_not_rewrite_and_a_changed_one_does() {
        let root = tempfile::tempdir().expect("a temp root");
        let path = conf(root.path());
        let servers = [ip(10, 0, 2, 3), ip(1, 1, 1, 1)];

        publish_under(root.path(), &servers).expect("the first write");
        let first = std::fs::metadata(&path)
            .and_then(|m| m.modified())
            .expect("an mtime");

        // Coarse filesystem timestamps would make an immediate rewrite look
        // identical, so the *absence* of a write is asserted through the
        // Outcome as well as the mtime.
        assert_eq!(
            publish_under(root.path(), &servers).expect("the second call"),
            Outcome::Unchanged,
            "the same set must be recognised from the file's own contents"
        );
        assert_eq!(
            std::fs::metadata(&path)
                .and_then(|m| m.modified())
                .expect("an mtime"),
            first,
            "an unchanged renewal rewrote the file; a resolver churned once per \
             T1 for no reason is a write on a RAM root and a torn-read window \
             for every getaddrinfo in flight"
        );

        let moved = [ip(192, 168, 1, 1)];
        let outcome = publish_under(root.path(), &moved).expect("the changed write");
        assert_eq!(
            outcome,
            Outcome::Wrote {
                path: path.clone(),
                servers: vec![ip(192, 168, 1, 1)],
            }
        );
        assert_eq!(
            std::fs::read_to_string(&path).expect("the file exists"),
            "nameserver 192.168.1.1\noptions timeout:2 attempts:2\n",
            "a changed set must REPLACE the file, not append to it"
        );
    }

    /// A server that stops sending option 6 must not take the resolver away.
    #[test]
    fn an_empty_set_writes_nothing_and_leaves_an_existing_file_standing() {
        let root = tempfile::tempdir().expect("a temp root");
        let path = conf(root.path());

        assert_eq!(
            publish_under(root.path(), &[]).expect("an empty set is not an error"),
            Outcome::NoServers
        );
        assert!(
            !root.path().join("etc").exists(),
            "an empty set must not even create the directory"
        );

        publish_under(root.path(), &[ip(10, 0, 2, 3)]).expect("a real set");
        assert_eq!(
            publish_under(root.path(), &[]).expect("still not an error"),
            Outcome::NoServers
        );
        assert_eq!(
            std::fs::read_to_string(&path).expect("the file survives"),
            "nameserver 10.0.2.3\noptions timeout:2 attempts:2\n",
            "a renewal with no option 6 truncated the resolver: the box would \
             lose name resolution it already had, to honour an omission"
        );
    }
}