setdns 0.1.1

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

use core_foundation::{
    array::CFArray,
    base::{CFType, TCFType, ToVoid},
    dictionary::{CFDictionary, CFMutableDictionary},
    string::CFString,
};
use core_foundation_sys::{
    base::{Boolean, CFRelease},
    dictionary::{CFDictionaryRef, CFDictionarySetValue},
};
use system_configuration::{
    dynamic_store::SCDynamicStoreBuilder,
    network_configuration::{SCNetworkService, SCNetworkSet},
    preferences::SCPreferences,
};
use system_configuration_sys::{
    network_configuration::{
        SCNetworkInterfaceForceConfigurationRefresh, SCNetworkProtocolGetConfiguration,
        SCNetworkProtocolRef, SCNetworkProtocolSetConfiguration, SCNetworkServiceCopy,
        SCNetworkServiceCopyProtocol, SCNetworkServiceGetEnabled,
    },
    preferences::{
        SCPreferencesApplyChanges, SCPreferencesCommitChanges, SCPreferencesLock,
        SCPreferencesUnlock,
    },
    schema_definitions::{
        kSCDynamicStorePropNetPrimaryService, kSCEntNetDNS, kSCPropNetDNSServerAddresses,
    },
};

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

const STORE_NAME: &str = "setdns";
const PRIMARY_IPV4_KEY: &str = "State:/Network/Global/IPv4";

pub(crate) struct SetDns {
    service_id: String,
    original_dns: Option<CFDictionary>,
}

#[derive(Debug, thiserror::Error)]
enum GlobalError {
    #[error("no current network set")]
    NoCurrentSet,
    #[error("no primary network service")]
    NoPrimaryService,
    #[error("network service {0} was not found")]
    ServiceNotFound(String),
    #[error("network service {0} has no DNS protocol")]
    NoDnsProtocol(String),
    #[error("failed to lock SystemConfiguration preferences")]
    LockFailed,
    #[error("failed to unlock SystemConfiguration preferences")]
    UnlockFailed,
    #[error("failed to set DNS protocol configuration for service {0}")]
    SetConfigurationFailed(String),
    #[error("failed to commit SystemConfiguration changes")]
    CommitFailed,
    #[error("failed to apply SystemConfiguration changes")]
    ApplyFailed,
}

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

impl SetDns {
    pub(crate) fn apply(config: NormalizedConfig) -> Result<Self> {
        let prefs = preferences();
        let service = target_service(&prefs, config.device.as_deref())?;
        let service_id = service_id(&service)?;
        log::debug!(
            "applying macOS global DNS: service_id={}, servers={}, device={}",
            service_id,
            config.servers.len(),
            config.device.as_deref().unwrap_or("primary")
        );
        let protocol = dns_protocol(&service, &service_id)?;
        let original_dns = protocol_configuration(&protocol);
        let next_dns = dns_with_servers(original_dns.as_ref(), &config.servers);

        with_preferences_lock(&prefs, || {
            set_protocol_configuration(&protocol, Some(&next_dns), &service_id)?;
            commit_and_apply_changes(&prefs)
        })?;

        refresh_interface(&service);
        crate::platform::macos::state::flush_dns_cache();
        log::debug!("applied macOS global DNS to service {service_id}");

        Ok(Self {
            service_id,
            original_dns,
        })
    }

    pub(crate) fn close(self) -> Result<()> {
        let prefs = preferences();
        let service = service_by_id(&prefs, &self.service_id)?;
        let protocol = dns_protocol(&service, &self.service_id)?;
        log::debug!("restoring macOS global DNS for service {}", self.service_id);

        with_preferences_lock(&prefs, || {
            set_protocol_configuration(&protocol, self.original_dns.as_ref(), &self.service_id)?;
            commit_and_apply_changes(&prefs)
        })?;

        refresh_interface(&service);
        crate::platform::macos::state::flush_dns_cache();
        Ok(())
    }
}

fn preferences() -> SCPreferences {
    SCPreferences::default(&CFString::new("setdns"))
}

fn target_service(prefs: &SCPreferences, device: Option<&str>) -> Result<SCNetworkService> {
    if let Some(ifname) = device {
        match service_by_interface(prefs, ifname) {
            Ok(service) => return Ok(service),
            Err(error) if is_dynamic_tunnel_interface(ifname) && is_service_not_found(&error) => {
                log::debug!(
                    "macOS network service for dynamic interface {ifname} was not found; falling \
                     back to the primary network service"
                );
            },
            Err(error) => return Err(error),
        }
    }

    let service_id = primary_service_id()?;
    service_by_id(prefs, &service_id)
}

fn is_dynamic_tunnel_interface(ifname: &str) -> bool {
    ifname.starts_with("utun")
}

fn is_service_not_found(error: &Error) -> bool {
    match error {
        Error::Backend(source) => source
            .downcast_ref::<GlobalError>()
            .is_some_and(|error| matches!(error, GlobalError::ServiceNotFound(_))),
        Error::InvalidConfig(_) => false,
    }
}

fn service_by_interface(prefs: &SCPreferences, ifname: &str) -> Result<SCNetworkService> {
    let set = current_set(prefs)?;
    let services = SCNetworkService::get_services(prefs);

    for ordered_id in set.service_order().iter() {
        let ordered_id = ordered_id.to_string();
        for service in services.iter() {
            if service_id_ref(&service).as_deref() != Some(ordered_id.as_str()) {
                continue;
            }
            if !service_enabled(&service) {
                continue;
            }
            let Some(interface) = service.network_interface() else {
                continue;
            };
            let Some(bsd_name) = interface.bsd_name() else {
                continue;
            };
            if bsd_name == ifname {
                return Ok(service.clone());
            }
        }
    }

    Err(GlobalError::ServiceNotFound(ifname.to_owned()).into())
}

fn service_by_id(prefs: &SCPreferences, service_id: &str) -> Result<SCNetworkService> {
    let cf_id = CFString::new(service_id);
    let service = unsafe { SCNetworkServiceCopy(prefs.to_void(), cf_id.as_concrete_TypeRef()) };
    if service.is_null() {
        Err(GlobalError::ServiceNotFound(service_id.to_owned()).into())
    } else {
        Ok(unsafe { SCNetworkService::wrap_under_create_rule(service) })
    }
}

fn current_set(prefs: &SCPreferences) -> Result<SCNetworkSet> {
    let set = unsafe {
        system_configuration_sys::network_configuration::SCNetworkSetCopyCurrent(prefs.to_void())
    };
    if set.is_null() {
        Err(GlobalError::NoCurrentSet.into())
    } else {
        Ok(unsafe { SCNetworkSet::wrap_under_create_rule(set) })
    }
}

fn primary_service_id() -> Result<String> {
    let Some(store) = SCDynamicStoreBuilder::new(CFString::new(STORE_NAME)).build() else {
        return Err(GlobalError::NoPrimaryService.into());
    };
    let Some(value) = store.get(PRIMARY_IPV4_KEY) else {
        return Err(GlobalError::NoPrimaryService.into());
    };
    if !value.instance_of::<CFDictionary>() {
        return Err(GlobalError::NoPrimaryService.into());
    }
    let dictionary = unsafe {
        CFDictionary::<CFString, CFType>::wrap_under_get_rule(
            value.as_concrete_TypeRef() as CFDictionaryRef
        )
    };
    let key = unsafe { CFString::wrap_under_get_rule(kSCDynamicStorePropNetPrimaryService) };
    dictionary
        .find(&key)
        .and_then(|service| service.downcast::<CFString>())
        .map(|service| service.to_string())
        .ok_or_else(|| GlobalError::NoPrimaryService.into())
}

struct DnsProtocol {
    raw: SCNetworkProtocolRef,
}

impl DnsProtocol {
    fn as_ptr(&self) -> SCNetworkProtocolRef {
        self.raw
    }
}

impl Drop for DnsProtocol {
    fn drop(&mut self) {
        unsafe { CFRelease(self.raw as _) };
    }
}

fn dns_protocol(service: &SCNetworkService, service_id: &str) -> Result<DnsProtocol> {
    let protocol =
        unsafe { SCNetworkServiceCopyProtocol(service.as_concrete_TypeRef(), kSCEntNetDNS) };
    if protocol.is_null() {
        Err(GlobalError::NoDnsProtocol(service_id.to_owned()).into())
    } else {
        Ok(DnsProtocol { raw: protocol })
    }
}

fn protocol_configuration(protocol: &DnsProtocol) -> Option<CFDictionary> {
    let configuration = unsafe { SCNetworkProtocolGetConfiguration(protocol.as_ptr()) };
    if configuration.is_null() {
        None
    } else {
        Some(unsafe { CFDictionary::wrap_under_get_rule(configuration) })
    }
}

fn dns_with_servers(original: Option<&CFDictionary>, servers: &[IpAddr]) -> CFDictionary {
    let dictionary: CFMutableDictionary = match original {
        Some(original) => unsafe { original.to_mutable() }.copy_with_capacity(0),
        None => CFMutableDictionary::new(),
    };
    let key = unsafe { CFString::wrap_under_get_rule(kSCPropNetDNSServerAddresses) };
    let values: Vec<CFString> = servers
        .iter()
        .map(|server| CFString::new(&server.to_string()))
        .collect();
    let addresses = CFArray::from_CFTypes(&values);
    unsafe {
        CFDictionarySetValue(
            dictionary.as_concrete_TypeRef(),
            key.to_void(),
            addresses.as_concrete_TypeRef() as _,
        );
    }
    dictionary.to_immutable()
}

fn set_protocol_configuration(
    protocol: &DnsProtocol,
    configuration: Option<&CFDictionary>,
    service_id: &str,
) -> Result<()> {
    let config = configuration.map_or(ptr::null(), |dns| dns.as_concrete_TypeRef());
    if unsafe { SCNetworkProtocolSetConfiguration(protocol.as_ptr(), config) } == 0 {
        Err(GlobalError::SetConfigurationFailed(service_id.to_owned()).into())
    } else {
        Ok(())
    }
}

fn with_preferences_lock<F>(prefs: &SCPreferences, f: F) -> Result<()>
where
    F: FnOnce() -> Result<()>,
{
    if unsafe { SCPreferencesLock(prefs.to_void(), true as Boolean) } == 0 {
        return Err(GlobalError::LockFailed.into());
    }

    let result = f();
    let unlock_result = if unsafe { SCPreferencesUnlock(prefs.to_void()) } == 0 {
        Err(GlobalError::UnlockFailed.into())
    } else {
        Ok(())
    };

    result.and(unlock_result)
}

fn commit_and_apply_changes(prefs: &SCPreferences) -> Result<()> {
    if unsafe { SCPreferencesCommitChanges(prefs.to_void()) } == 0 {
        return Err(GlobalError::CommitFailed.into());
    }
    if unsafe { SCPreferencesApplyChanges(prefs.to_void()) } == 0 {
        Err(GlobalError::ApplyFailed.into())
    } else {
        Ok(())
    }
}

fn refresh_interface(service: &SCNetworkService) {
    if let Some(interface) = service.network_interface() {
        if unsafe { SCNetworkInterfaceForceConfigurationRefresh(interface.as_concrete_TypeRef()) }
            == 0
        {
            log::warn!("failed to refresh macOS network interface after DNS change");
        }
    }
}

fn service_enabled(service: &SCNetworkService) -> bool {
    unsafe { SCNetworkServiceGetEnabled(service.as_concrete_TypeRef()) != 0 }
}

fn service_id(service: &SCNetworkService) -> Result<String> {
    service_id_ref(service).ok_or_else(|| GlobalError::NoPrimaryService.into())
}

fn service_id_ref(service: &SCNetworkService) -> Option<String> {
    service.id().map(|id| id.to_string())
}