kunobi-daemon 0.10.1

Process ownership, request draining, and verified upgrades for local daemons
Documentation
//! Credentials from a local connection name the process at the other end, in
//! both directions, and policies grant or refuse it. The listener and its peer
//! are separate operating-system processes.

#![cfg(feature = "local")]

use kunobi_daemon::local::peer::PeerCredentials;
use kunobi_daemon::peer::{
    Credentials, ExpectedProcess, First, Rejected, SameUser, UserEvidence, authenticate,
};
use kunobi_daemon::{Candidate, ProcessId};
use std::{
    io::{Read, Write},
    process::{Command, Stdio},
    sync::mpsc,
    time::{Duration, Instant},
};

const BUDGET: Duration = Duration::from_secs(20);
const ENDPOINT_ENV: &str = "KUNOBI_DAEMON_PEER_TEST_ENDPOINT";
const SERVER_PID_ENV: &str = "KUNOBI_DAEMON_PEER_TEST_SERVER";

fn own_pid() -> ProcessId {
    ProcessId::new(std::process::id()).unwrap()
}

/// A fixture candidate is killed and reaped if the test fails before it exits.
struct Reaped(Candidate);

impl Reaped {
    fn spawn(endpoint: &str) -> Self {
        let child = Command::new(std::env::current_exe().unwrap())
            .args(["--exact", "peer_fixture_process", "--ignored"])
            .env(ENDPOINT_ENV, endpoint)
            .env(SERVER_PID_ENV, std::process::id().to_string())
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .unwrap();
        Self(Candidate::new(child))
    }

    fn pid(&self) -> ProcessId {
        self.0.id()
    }

    /// Wait for the child to finish its own checks, within the budget.
    fn assert_succeeds(&mut self) {
        let status = self
            .0
            .wait_until(Instant::now() + BUDGET)
            .unwrap()
            .expect("the child did not exit");
        assert!(status.success(), "the child's own checks failed: {status}");
    }
}

/// The child: connect, check that the server is the parent, announce our PID,
/// then wait for the parent to close the connection.
#[test]
#[ignore = "child-process entry point, invoked by the evidence tests"]
fn peer_fixture_process() {
    let endpoint = std::env::var(ENDPOINT_ENV).unwrap();
    let server: u32 = std::env::var(SERVER_PID_ENV).unwrap().parse().unwrap();
    let mut stream = connect(&endpoint);
    let server_evidence = client_evidence(&stream);
    assert_eq!(
        (
            server_evidence.pid,
            server_evidence.same_user,
            server_evidence.user_evidence
        ),
        (ProcessId::new(server), true, CLIENT_USER_EVIDENCE)
    );
    let accepted = authenticate(
        &stream,
        server_evidence,
        &ExpectedProcess::new(move || ProcessId::new(server)),
    );
    assert!(accepted.is_ok(), "the child refused its server");
    stream.write_all(&std::process::id().to_le_bytes()).unwrap();
    let _ = stream.read(&mut [0u8; 1]);
}

/// Where a client's check of its server's user comes from. A named pipe gives
/// a client no call that reads the server's context.
const CLIENT_USER_EVIDENCE: UserEvidence = if cfg!(windows) {
    UserEvidence::PidLookup
} else {
    UserEvidence::Connection
};

/// The parent's checks on the connection it accepted from `child`, which
/// wrote before they ran.
fn check_accepted(peer: Credentials, child: ProcessId, announced: [u8; 4]) {
    assert_eq!(
        (peer.pid, peer.same_user, peer.user_evidence),
        (Some(child), true, UserEvidence::Connection)
    );
    assert_eq!(peer.pid, ProcessId::new(u32::from_le_bytes(announced)));
    assert_ne!(peer.pid, Some(own_pid()));

    // The child is the expected process: full grant. Expecting this test
    // process instead leaves only the same-user tier.
    let tiers = |expected: Option<ProcessId>| {
        First::new()
            .then(ExpectedProcess::new(move || expected), "full")
            .then(SameUser, "redacted")
    };
    assert_eq!(
        authenticate((), peer.clone(), &tiers(Some(child))).map(|a| *a.grant()),
        Ok("full")
    );
    assert_eq!(
        authenticate((), peer.clone(), &tiers(Some(own_pid()))).map(|a| *a.grant()),
        Ok("redacted")
    );
    assert_eq!(
        authenticate((), peer, &ExpectedProcess::new(|| Some(own_pid()))).unwrap_err(),
        Rejected::OtherProcess {
            expected: own_pid(),
            observed: child
        }
    );
}

/// Accept on a thread so a child that never connects fails the test instead of
/// hanging it.
fn accept_within_budget<S: Send + 'static>(
    accept: impl FnOnce() -> std::io::Result<S> + Send + 'static,
) -> S {
    let (sent, accepted) = mpsc::channel();
    std::thread::spawn(move || {
        let _ = sent.send(accept());
    });
    accepted
        .recv_timeout(BUDGET)
        .expect("the child did not connect")
        .unwrap()
}

#[cfg(unix)]
mod platform {
    use super::*;
    use std::os::unix::net::{UnixListener, UnixStream};

    pub(super) fn connect(endpoint: &str) -> UnixStream {
        let stream = UnixStream::connect(endpoint).unwrap();
        stream.set_read_timeout(Some(BUDGET)).unwrap();
        stream
    }

    pub(super) fn client_evidence(stream: &UnixStream) -> Credentials {
        stream.credentials().unwrap()
    }

    #[test]
    fn both_ends_of_a_unix_socket_see_the_other_process() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("peer.sock");
        let listener = UnixListener::bind(&path).unwrap();
        let mut child = Reaped::spawn(path.to_str().unwrap());

        let mut stream = accept_within_budget(move || listener.accept().map(|(s, _)| s));
        stream.set_read_timeout(Some(BUDGET)).unwrap();
        let mut announced = [0u8; 4];
        stream.read_exact(&mut announced).unwrap();
        check_accepted(stream.credentials().unwrap(), child.pid(), announced);

        drop(stream);
        child.assert_succeeds();
    }
}

#[cfg(windows)]
mod platform {
    use super::*;
    use interprocess::local_socket::traits::{Listener as _, Stream as _};
    use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName};
    use kunobi_daemon::local::{Bound, socket::acquire};
    use std::path::Path;

    pub(super) fn connect(endpoint: &str) -> Stream {
        Stream::connect(endpoint.to_ns_name::<GenericNamespaced>().unwrap()).unwrap()
    }

    pub(super) fn client_evidence(stream: &Stream) -> Credentials {
        stream.credentials().unwrap()
    }

    #[test]
    fn both_ends_of_a_named_pipe_see_the_other_process() {
        let unique = tempfile::tempdir().unwrap();
        let name = format!(
            "daemon-peer-{}-{}",
            std::process::id(),
            unique.path().file_name().unwrap().to_string_lossy()
        );
        let Bound::Won(listener) = acquire(Path::new(&name)).unwrap() else {
            panic!("first owner")
        };
        let mut child = Reaped::spawn(&name);

        let mut stream = accept_within_budget(move || listener.accept());
        let mut announced = [0u8; 4];
        stream.read_exact(&mut announced).unwrap();
        check_accepted(stream.credentials().unwrap(), child.pid(), announced);

        drop(stream);
        child.assert_succeeds();
    }
}

use platform::{client_evidence, connect};