systemd-resolved-rs 0.1.1

A compatibility-oriented reimplementation of systemd-resolved
use super::dnssd_config::{DnsSdConfigError, ServiceCatalog};
use super::parity::{MdnsAddressFamily, MdnsInterface};
use super::parity_dnssd::DnsSdRecord;
use std::collections::BTreeMap;
use std::net::IpAddr;
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};

const RELOAD_INTERVAL: Duration = Duration::from_secs(2);
const RETIRED_RETENTION: Duration = Duration::from_secs(5);

#[derive(Clone, Debug)]
struct RetiredCatalog {
    expires: Instant,
    catalog: ServiceCatalog,
}

#[derive(Debug)]
struct CatalogState {
    catalog: ServiceCatalog,
    next_reload: Instant,
    retired: Vec<RetiredCatalog>,
}

impl Default for CatalogState {
    fn default() -> Self {
        Self {
            catalog: ServiceCatalog::default(),
            next_reload: Instant::now(),
            retired: Vec::new(),
        }
    }
}

static CATALOG: OnceLock<Mutex<CatalogState>> = OnceLock::new();

fn state() -> MutexGuard<'static, CatalogState> {
    CATALOG
        .get_or_init(|| Mutex::new(CatalogState::default()))
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner)
}

fn retire(state: &mut CatalogState, catalog: ServiceCatalog, now: Instant) {
    if catalog.is_empty() {
        return;
    }
    state.retired.push(RetiredCatalog {
        expires: now + RETIRED_RETENTION,
        catalog,
    });
    state.retired.retain(|entry| entry.expires > now);
}

fn reconcile_catalog(state: &mut CatalogState, loaded: ServiceCatalog, now: Instant) -> bool {
    let previous = state.catalog.clone();
    if !state.catalog.reconcile(loaded) {
        state.retired.retain(|entry| entry.expires > now);
        return false;
    }
    retire(state, previous, now);
    true
}

fn refresh(state: &mut CatalogState) -> Result<(), DnsSdConfigError> {
    let now = Instant::now();
    if now < state.next_reload {
        state.retired.retain(|entry| entry.expires > now);
        return Ok(());
    }
    state.next_reload = now + RELOAD_INTERVAL;
    let loaded = ServiceCatalog::load()?;
    reconcile_catalog(state, loaded, now);
    Ok(())
}

fn interface_for(ifindex: u32, addresses: &[IpAddr]) -> MdnsInterface {
    let family = if addresses.iter().any(IpAddr::is_ipv4) {
        MdnsAddressFamily::Ipv4
    } else {
        MdnsAddressFamily::Ipv6
    };
    MdnsInterface::new(ifindex, family)
}

pub fn records_for(
    ifindex: u32,
    addresses: &[IpAddr],
    host_label: &str,
    goodbye: bool,
) -> Result<Vec<DnsSdRecord>, DnsSdConfigError> {
    let mut state = state();
    refresh(&mut state)?;
    state.catalog.records(
        interface_for(ifindex, addresses),
        addresses,
        host_label,
        goodbye,
    )
}

pub fn goodbye_records_for(
    ifindex: u32,
    addresses: &[IpAddr],
    host_label: &str,
) -> Result<Vec<DnsSdRecord>, DnsSdConfigError> {
    let mut state = state();
    refresh(&mut state)?;
    let now = Instant::now();
    state.retired.retain(|entry| entry.expires > now);
    let interface = interface_for(ifindex, addresses);
    let mut output = Vec::new();
    for entry in &state.retired {
        output.extend(entry.catalog.records(
            interface,
            addresses,
            host_label,
            true,
        )?);
    }
    output.sort();
    output.dedup();
    Ok(output)
}

pub fn instance_owners(
    ifindex: u32,
    addresses: &[IpAddr],
    host_label: &str,
) -> Result<BTreeMap<Vec<u8>, String>, DnsSdConfigError> {
    let mut state = state();
    refresh(&mut state)?;
    state
        .catalog
        .instance_owners(interface_for(ifindex, addresses), addresses, host_label)
}

pub fn rename_conflicting_owner(
    owner: &[u8],
    rr_type: u16,
    ifindex: u32,
    addresses: &[IpAddr],
    host_label: &str,
) -> Result<Option<String>, DnsSdConfigError> {
    let mut state = state();
    refresh(&mut state)?;
    let interface = interface_for(ifindex, addresses);
    let owners = state
        .catalog
        .instance_owners(interface, addresses, host_label)?;
    let Some(service) = owners.get(owner).cloned() else {
        return Ok(None);
    };
    if !matches!(rr_type, 16 | 33) {
        return Ok(None);
    }
    let previous = state.catalog.clone();
    if state.catalog.rename_after_conflict(&service) {
        retire(&mut state, previous, Instant::now());
        Ok(Some(service))
    } else {
        Ok(None)
    }
}

pub fn generation() -> u64 {
    let mut state = state();
    if let Err(error) = refresh(&mut state) {
        eprintln!("systemd-resolved: failed to refresh DNS-SD services: {error}");
    }
    state.catalog.generation()
}

pub fn force_reload() -> Result<bool, DnsSdConfigError> {
    let loaded = ServiceCatalog::load()?;
    let mut state = state();
    let changed = reconcile_catalog(&mut state, loaded, Instant::now());
    state.next_reload = Instant::now() + RELOAD_INTERVAL;
    Ok(changed)
}

pub fn flush() {
    let mut state = state();
    let previous = std::mem::take(&mut state.catalog);
    retire(&mut state, previous, Instant::now());
    state.catalog = ServiceCatalog::default();
    state.next_reload = Instant::now();
}

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

    #[test]
    fn retired_catalogs_expire() {
        let mut state = CatalogState::default();
        let now = Instant::now();
        state.retired.push(RetiredCatalog {
            expires: now,
            catalog: ServiceCatalog::default(),
        });
        state.retired.retain(|entry| entry.expires > now);
        assert!(state.retired.is_empty());
    }
}