car-a2a 0.51.0

Bridge between Common Agent Runtime and the Linux Foundation Agent2Agent (A2A) v1.0 protocol
//! LAN peer discovery over mDNS/DNS-SD.
//!
//! CAR advertises its A2A surface as `_car-a2a._tcp.local.` and browses for
//! other CAR daemons doing the same, so two machines on one network find each
//! other without either being configured with the other's address.
//!
//! ## What is advertised, and what is not
//!
//! The advertisement names a **daemon's A2A surface**, never one of its agents.
//! That is what keeps admission unbypassable across hosts: a peer message lands
//! on the remote daemon, which then applies its own guard and policy before
//! reverse-calling one of its agents. Advertising agents directly would put a
//! second door next to the remote broker.
//!
//! The peer-reachable URL travels in a TXT record rather than being reconstructed
//! from the resolved IP and port. Those are not the same thing: an operator can
//! pass `--a2a-public-url` to declare a URL that differs from the bound socket
//! (behind a reverse proxy, or a host with several interfaces), and the card URL
//! is the one that actually works. Rebuilding it from the wire would silently
//! produce an address peers cannot reach.
//!
//! ## Discovery is not trust
//!
//! Anyone on the network can advertise anything, including a name that collides
//! with a peer you already know. A discovered peer is therefore a **candidate**:
//! visible in a listing, marked `PeerSource::Lan`, and not addressable until an
//! operator promotes it through the same gate `a2a.peers.add` uses. The registry
//! module already documents that registering a peer is a deliberate trust
//! decision; this module produces candidates for that decision, it does not make
//! it.
//!
//! ## Threading
//!
//! `mdns-sd` hands back a blocking channel, so browsing runs on a plain OS
//! thread and maintains a shared cache. Listing peers then reads the cache
//! instantly instead of waiting out a discovery window — a browse that blocked
//! `agents.peers` for a second or two would make the common case (no LAN peers
//! at all) the slowest one.

use mdns_sd::{ServiceDaemon, ServiceEvent, ServiceInfo};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

/// The DNS-SD service type CAR daemons advertise and browse.
pub const SERVICE_TYPE: &str = "_car-a2a._tcp.local.";

/// TXT key carrying the peer-reachable agent-card URL.
const TXT_URL: &str = "url";
/// TXT key carrying the daemon's human-facing name.
const TXT_NAME: &str = "name";

/// This machine's name, for the advertisement's human-facing label.
///
/// Falls back to `"CAR"` when the OS will not answer. A generic label is
/// better than a fabricated one: an operator seeing two peers both called
/// `CAR` learns something true (their hosts are unnamed), whereas an invented
/// unique name would look authoritative and mean nothing.
pub fn host_label() -> String {
    hostname::get()
        .ok()
        .and_then(|h| h.into_string().ok())
        .map(|h| h.trim_end_matches(".local").to_string())
        .filter(|h| !h.trim().is_empty())
        .unwrap_or_else(|| "CAR".to_string())
}

/// Rank an IPv4 by how likely it is to be the address peers can reach.
///
/// Lower is better. This is a **heuristic**, and it is one because the accurate
/// answer — which interface carries the default route — needs the routing table
/// and a per-OS way to read it.
///
/// The ordering encodes one observation: hypervisors and container runtimes
/// overwhelmingly hand out `172.16/12` (VMware, Parallels, and Docker's default
/// bridge all live there), while the interface a person actually reaches the
/// network on is usually `192.168/16` at home or `10/8` on a corporate LAN. This
/// host has both `172.16.201.2` and `192.168.1.39`; a naive sort picks the
/// former and sends every peer to an address nothing answers on.
///
/// When the guess is wrong, `--a2a-public-url` overrides it, and the chosen
/// address is logged at startup so a wrong pick is visible rather than
/// mysterious.
fn rank_ipv4(a: &std::net::Ipv4Addr) -> u8 {
    let o = a.octets();
    match (o[0], o[1]) {
        (192, 168) => 0,
        (10, _) => 1,
        (172, b) if (16..=31).contains(&b) => 3,
        _ => 2,
    }
}

/// This host's most likely peer-reachable IPv4 address.
///
/// Used to choose a default A2A bind that peers can actually reach. Binding a
/// wildcard would be simpler but produces an agent card advertising `0.0.0.0`,
/// which is not an address anyone can dial — the listener already refuses that
/// case rather than publish an unreachable card.
///
/// Link-local (169.254/16) is skipped: an interface that self-assigned has no
/// working network behind it. Returns `None` on a host with no LAN interface,
/// where the honest outcome is no A2A surface rather than one nobody can use.
pub fn primary_ipv4() -> Option<std::net::Ipv4Addr> {
    let mut candidates: Vec<std::net::Ipv4Addr> = if_addrs::get_if_addrs()
        .ok()?
        .into_iter()
        .filter(|i| !i.is_loopback())
        .filter_map(|i| match i.addr.ip() {
            std::net::IpAddr::V4(v4) => Some(v4),
            std::net::IpAddr::V6(_) => None,
        })
        .filter(|v4| !v4.is_link_local() && !v4.is_unspecified())
        .collect();
    // Rank first, then sort by address, so a host with two equally-ranked
    // interfaces keeps advertising the same one across restarts instead of
    // flapping and churning every peer's stored endpoint.
    candidates.sort_by_key(|a| (rank_ipv4(a), *a));
    candidates.into_iter().next()
}

/// A CAR daemon found on the local network.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LanPeer {
    /// The advertised name. Unverified — anyone can claim any name.
    pub name: String,
    /// The peer's agent-card root, from the TXT record.
    pub url: String,
    /// The mDNS fullname, used to correlate a later removal.
    pub fullname: String,
}

/// A live advertisement. Dropping it withdraws the record.
pub struct Advertisement {
    daemon: ServiceDaemon,
    fullname: String,
}

impl Advertisement {
    pub fn fullname(&self) -> &str {
        &self.fullname
    }
}

impl Drop for Advertisement {
    fn drop(&mut self) {
        // Best-effort: a withdrawn record stops peers offering a daemon that has
        // gone away. Failing to unregister is not worth surfacing at shutdown —
        // the record ages out on its own.
        let _ = self.daemon.unregister(&self.fullname);
    }
}

/// Advertise this daemon's A2A surface on the local network.
///
/// `instance` is the DNS-SD instance name and must be unique on the network;
/// `card_url` is the peer-reachable URL from the A2A listener, which is the
/// value peers will actually dial.
pub fn advertise(
    instance: &str,
    card_url: &str,
    port: u16,
    display_name: &str,
) -> Result<Advertisement, String> {
    let daemon = ServiceDaemon::new().map_err(|e| format!("mdns daemon: {e}"))?;
    let host = format!("{}.local.", sanitize_instance(instance));
    let props = [(TXT_URL, card_url), (TXT_NAME, display_name)];
    // An empty address list asks mdns-sd to fill in this host's addresses. The
    // URL peers use comes from TXT regardless, so the A records are only a
    // reachability hint.
    let info = ServiceInfo::new(
        SERVICE_TYPE,
        &sanitize_instance(instance),
        &host,
        (),
        port,
        &props[..],
    )
    .map_err(|e| format!("mdns service info: {e}"))?
    .enable_addr_auto();
    let fullname = info.get_fullname().to_string();
    daemon
        .register(info)
        .map_err(|e| format!("mdns register: {e}"))?;
    Ok(Advertisement { daemon, fullname })
}

/// DNS-SD instance names may not contain a dot — it separates label components,
/// so a hostname-derived name would silently split into a different service.
fn sanitize_instance(raw: &str) -> String {
    let cleaned: String = raw
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' {
                c
            } else {
                '-'
            }
        })
        .collect();
    let trimmed = cleaned.trim_matches('-');
    if trimmed.is_empty() {
        "car".to_string()
    } else {
        trimmed.to_string()
    }
}

/// A continuously-updated view of CAR daemons on the local network.
#[derive(Clone, Default)]
pub struct LanDirectory {
    peers: Arc<Mutex<HashMap<String, LanPeer>>>,
}

impl LanDirectory {
    /// Start browsing. The returned directory updates in the background.
    ///
    /// Errors only if the mDNS daemon cannot start; a network with no peers is
    /// a successful empty directory, not a failure.
    pub fn start() -> Result<Self, String> {
        let daemon = ServiceDaemon::new().map_err(|e| format!("mdns daemon: {e}"))?;
        let rx = daemon
            .browse(SERVICE_TYPE)
            .map_err(|e| format!("mdns browse: {e}"))?;
        let dir = LanDirectory::default();
        let peers = dir.peers.clone();
        std::thread::Builder::new()
            .name("car-lan-discovery".into())
            .spawn(move || {
                // Hold the daemon for the thread's lifetime: dropping it stops
                // the browse, and the receiver would then close on the next recv.
                let _daemon = daemon;
                while let Ok(event) = rx.recv() {
                    match event {
                        ServiceEvent::ServiceResolved(info) => {
                            if let Some(peer) = peer_from(&info) {
                                peers
                                    .lock()
                                    .unwrap_or_else(|e| e.into_inner())
                                    .insert(peer.fullname.clone(), peer);
                            }
                        }
                        ServiceEvent::ServiceRemoved(_, fullname) => {
                            peers
                                .lock()
                                .unwrap_or_else(|e| e.into_inner())
                                .remove(&fullname);
                        }
                        _ => {}
                    }
                }
            })
            .map_err(|e| format!("spawn lan discovery thread: {e}"))?;
        Ok(dir)
    }

    /// Snapshot of the peers currently visible.
    pub fn peers(&self) -> Vec<LanPeer> {
        let mut v: Vec<LanPeer> = self
            .peers
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .values()
            .cloned()
            .collect();
        v.sort_by(|a, b| a.name.cmp(&b.name));
        v
    }

    /// Insert a peer directly. Test seam.
    #[doc(hidden)]
    pub fn insert_for_test(&self, peer: LanPeer) {
        self.peers
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .insert(peer.fullname.clone(), peer);
    }
}

/// Build a peer from a resolved advertisement, or `None` when it is not one of
/// ours.
///
/// An advertisement without a usable `url` TXT value is dropped rather than
/// guessed at: reconstructing `http://<ip>:<port>` would manufacture an address
/// the operator never published, and on a host behind a proxy that address is
/// wrong.
fn peer_from(info: &ServiceInfo) -> Option<LanPeer> {
    let url = info.get_property_val_str(TXT_URL)?.trim().to_string();
    if !(url.starts_with("http://") || url.starts_with("https://")) {
        return None;
    }
    let fullname = info.get_fullname().to_string();
    let name = info
        .get_property_val_str(TXT_NAME)
        .map(str::to_string)
        .filter(|n| !n.trim().is_empty())
        .unwrap_or_else(|| {
            // Fall back to the instance label of the fullname.
            fullname.split('.').next().unwrap_or("car-peer").to_string()
        });
    Some(LanPeer {
        name,
        url,
        fullname,
    })
}

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

    #[test]
    fn instance_names_lose_dots_and_other_label_breakers() {
        // A dot would split the instance into extra DNS-SD labels and land the
        // record under a different service.
        assert_eq!(sanitize_instance("my.mac.local"), "my-mac-local");
        assert_eq!(sanitize_instance("Matt's Mac"), "Matt-s-Mac");
        assert_eq!(sanitize_instance("..."), "car");
        assert_eq!(sanitize_instance(""), "car");
    }

    #[test]
    fn the_directory_starts_empty_and_accepts_peers() {
        let dir = LanDirectory::default();
        assert!(dir.peers().is_empty());
        dir.insert_for_test(LanPeer {
            name: "desktop".into(),
            url: "http://192.168.1.20:8731".into(),
            fullname: "desktop._car-a2a._tcp.local.".into(),
        });
        assert_eq!(dir.peers().len(), 1);
        assert_eq!(dir.peers()[0].name, "desktop");
    }

    #[test]
    fn peers_are_sorted_by_name_for_a_stable_listing() {
        let dir = LanDirectory::default();
        for n in ["zeta", "alpha", "mid"] {
            dir.insert_for_test(LanPeer {
                name: n.into(),
                url: format!("http://host/{n}"),
                fullname: format!("{n}._car-a2a._tcp.local."),
            });
        }
        let names: Vec<String> = dir.peers().into_iter().map(|p| p.name).collect();
        assert_eq!(names, vec!["alpha", "mid", "zeta"]);
    }

    #[test]
    fn a_hypervisor_interface_loses_to_the_real_lan() {
        use std::net::Ipv4Addr;
        // The concrete case on the dev machine this was written on: a VM
        // host-only interface alongside the real LAN. Picking the 172.16 one
        // sends every peer to an unreachable address.
        let vm: Ipv4Addr = "172.16.201.2".parse().unwrap();
        let lan: Ipv4Addr = "192.168.1.39".parse().unwrap();
        assert!(rank_ipv4(&lan) < rank_ipv4(&vm));

        let corporate: Ipv4Addr = "10.1.2.3".parse().unwrap();
        assert!(rank_ipv4(&corporate) < rank_ipv4(&vm));
        assert!(rank_ipv4(&lan) < rank_ipv4(&corporate));
    }

    #[test]
    fn ranking_is_stable_for_equal_ranks() {
        use std::net::Ipv4Addr;
        let a: Ipv4Addr = "192.168.1.10".parse().unwrap();
        let b: Ipv4Addr = "192.168.1.39".parse().unwrap();
        assert_eq!(rank_ipv4(&a), rank_ipv4(&b));
        // Equal rank falls back to address order, so the choice does not flap
        // between restarts.
        let mut v = [b, a];
        v.sort_by_key(|x| (rank_ipv4(x), *x));
        assert_eq!(v[0], a);
    }

    #[test]
    fn the_service_type_is_the_one_both_sides_agree_on() {
        // Advertise and browse must use the identical string or the two halves
        // silently never meet.
        assert_eq!(SERVICE_TYPE, "_car-a2a._tcp.local.");
    }
}