setdns 0.1.1

Temporarily apply and restore system DNS configuration, including global and split DNS.
Documentation
use std::{ffi::CString, net::IpAddr};

use zbus::blocking::{Connection, Proxy};

use crate::{Error, Result, config::NormalizedConfig};

const DESTINATION: &str = "org.freedesktop.resolve1";
const PATH: &str = "/org/freedesktop/resolve1";
const MANAGER_INTERFACE: &str = "org.freedesktop.resolve1.Manager";

pub(crate) struct Manager {
    connection: Connection,
}

pub(crate) struct SetDns {
    connection: Connection,
    ifindex: i32,
}

#[derive(Debug, thiserror::Error)]
pub(super) enum ResolvedError {
    #[error("failed to connect to the system D-Bus")]
    SystemBus(#[source] zbus::Error),
    #[error("systemd-resolved is not available on the system D-Bus")]
    ServiceUnavailable(#[source] zbus::Error),
    #[error("systemd-resolved has no owner on the system D-Bus")]
    NoOwner,
    #[error("interface name contains an interior NUL byte")]
    InvalidInterfaceName(#[source] std::ffi::NulError),
    #[error("failed to resolve interface index for {interface}")]
    InterfaceIndex {
        interface: String,
        #[source]
        source: std::io::Error,
    },
    #[error("systemd-resolved D-Bus call {method} failed")]
    Method {
        method: &'static str,
        #[source]
        source: zbus::Error,
    },
}

impl From<ResolvedError> for Error {
    fn from(error: ResolvedError) -> Self {
        Self::Backend(Box::new(error))
    }
}

impl Manager {
    pub(crate) fn connect() -> std::result::Result<Self, ResolvedError> {
        let connection = Connection::system().map_err(ResolvedError::SystemBus)?;
        let dbus = Proxy::new(
            &connection,
            "org.freedesktop.DBus",
            "/org/freedesktop/DBus",
            "org.freedesktop.DBus",
        )
        .map_err(ResolvedError::ServiceUnavailable)?;
        let has_owner: bool = dbus
            .call("NameHasOwner", &(DESTINATION))
            .map_err(ResolvedError::ServiceUnavailable)?;
        if !has_owner {
            return Err(ResolvedError::NoOwner);
        }

        log::debug!("connected to systemd-resolved on the system bus");
        Ok(Self { connection })
    }

    pub(crate) fn apply(&self, config: NormalizedConfig) -> Result<SetDns> {
        let ifindex = interface_index(
            config
                .device
                .as_deref()
                .expect("device is checked by caller"),
        )?;
        log::debug!(
            "applying systemd-resolved DNS: ifindex={}, mode={}, servers={}, domains={}",
            ifindex,
            if config.domains.is_empty() {
                "global"
            } else {
                "split"
            },
            config.servers.len(),
            config.domains.len()
        );
        let proxy = manager_proxy(&self.connection)?;
        let dns = dns_servers(&config.servers);
        call(&proxy, "SetLinkDNS", &(ifindex, dns))?;

        if config.domains.is_empty() {
            let domains = [(".", true)];
            call(&proxy, "SetLinkDomains", &(ifindex, domains.as_slice()))?;
            call(&proxy, "SetLinkDefaultRoute", &(ifindex, true))?;
        } else {
            let domains: Vec<(String, bool)> = config
                .domains
                .iter()
                .map(|suffix| (resolved_domain(&suffix.domain), true))
                .collect();
            call(&proxy, "SetLinkDomains", &(ifindex, domains.as_slice()))?;
            call(&proxy, "SetLinkDefaultRoute", &(ifindex, false))?;
        }

        call(&proxy, "FlushCaches", &())?;
        log::debug!("flushed systemd-resolved caches after apply");

        Ok(SetDns {
            connection: self.connection.clone(),
            ifindex,
        })
    }
}

impl SetDns {
    pub(crate) fn close(self) -> Result<()> {
        let proxy = manager_proxy(&self.connection)?;
        call(&proxy, "RevertLink", &(self.ifindex))?;
        log::debug!(
            "reverted systemd-resolved link configuration: ifindex={}",
            self.ifindex
        );
        call(&proxy, "FlushCaches", &())
    }
}

fn manager_proxy(connection: &Connection) -> Result<Proxy<'_>> {
    Ok(Proxy::new(connection, DESTINATION, PATH, MANAGER_INTERFACE)
        .map_err(ResolvedError::ServiceUnavailable)?)
}

fn call<B>(proxy: &Proxy<'_>, method: &'static str, body: &B) -> Result<()>
where
    B: serde::ser::Serialize + zbus::zvariant::DynamicType,
{
    proxy
        .call::<_, _, ()>(method, body)
        .map_err(|source| ResolvedError::Method { method, source })?;
    Ok(())
}

fn interface_index(interface: &str) -> Result<i32> {
    let name = CString::new(interface).map_err(ResolvedError::InvalidInterfaceName)?;
    let index = unsafe { libc::if_nametoindex(name.as_ptr()) };
    if index == 0 {
        return Err(ResolvedError::InterfaceIndex {
            interface: interface.to_owned(),
            source: std::io::Error::last_os_error(),
        }
        .into());
    }
    Ok(index as i32)
}

fn dns_servers(servers: &[IpAddr]) -> Vec<(i32, Vec<u8>)> {
    servers
        .iter()
        .map(|server| match server {
            IpAddr::V4(addr) => (libc::AF_INET, addr.octets().to_vec()),
            IpAddr::V6(addr) => (libc::AF_INET6, addr.octets().to_vec()),
        })
        .collect()
}

fn resolved_domain(domain: &str) -> String {
    let mut resolved = String::with_capacity(domain.len() + 1);
    resolved.push_str(domain);
    resolved.push('.');
    resolved
}