tailscale-localapi 0.6.0

Client for the Tailscale local API
Documentation
#![cfg(any(unix, windows))]

use std::{env, net::SocketAddr, time::Duration};

#[cfg(unix)]
use tailscale_localapi::UnixStreamClient;
#[cfg(windows)]
use tailscale_localapi::WindowsNamedPipeClient;
use tailscale_localapi::{BackendState, LocalApi, Status, Whois};

#[cfg(unix)]
type TestClient = UnixStreamClient;
#[cfg(windows)]
type TestClient = WindowsNamedPipeClient;

const READINESS_TIMEOUT: Duration = Duration::from_secs(30);
const WHOIS_TIMEOUT: Duration = Duration::from_secs(5);

fn local_api() -> LocalApi<TestClient> {
    #[cfg(unix)]
    {
        let socket = env::var("TAILSCALE_SOCKET")
            .expect("TAILSCALE_SOCKET must identify the Compose LocalAPI socket");
        LocalApi::new_with_socket_path(socket)
    }

    #[cfg(windows)]
    {
        let pipe = env::var("TAILSCALE_PIPE")
            .expect("TAILSCALE_PIPE must identify the Windows LocalAPI named pipe");
        LocalApi::new_with_named_pipe_path(pipe)
    }
}

async fn wait_for_node_b(api: &LocalApi<TestClient>, deadline: Duration) -> Status {
    let mut last_error = String::from("node-b never appeared in the peer map");
    let readiness = async {
        loop {
            match api.status().await {
                Ok(status) if status.peer.values().any(|peer| peer.hostname == "node-b") => {
                    return status;
                }
                Ok(_) => {
                    last_error = String::from("status succeeded but node-b was absent");
                }
                Err(error) => {
                    last_error = error.to_string();
                }
            }
            tokio::time::sleep(Duration::from_secs(1)).await;
        }
    };

    tokio::time::timeout(deadline, readiness)
        .await
        .unwrap_or_else(|_| panic!("timed out waiting for the Headscale network map: {last_error}"))
}

fn node_b_address(status: &Status) -> SocketAddr {
    let node_b = status
        .peer
        .values()
        .find(|peer| peer.hostname == "node-b")
        .expect("node-b disappeared from the peer map");
    let node_b_ip = *node_b
        .tailscale_ips
        .first()
        .expect("node-b has no Tailscale IP");

    SocketAddr::new(node_b_ip, 443)
}

async fn whois_with_timeout(
    api: &LocalApi<TestClient>,
    address: SocketAddr,
    deadline: Duration,
) -> Whois {
    tokio::time::timeout(deadline, api.whois(address))
        .await
        .expect("timed out requesting Whois")
        .expect("Whois request failed")
}

#[tokio::test]
#[ignore = "requires the tests/headscale Docker Compose environment"]
async fn status_reports_running_tailnet_and_peer() {
    let api = local_api();
    let status = wait_for_node_b(&api, READINESS_TIMEOUT).await;

    assert!(matches!(status.backend_state, BackendState::Running));
    assert!(!status.tailscale_ips.is_empty());
    assert!(status.peer.values().any(|peer| peer.hostname == "node-b"));
}

#[tokio::test]
#[ignore = "requires the tests/headscale Docker Compose environment"]
async fn whois_identifies_peer_and_user() {
    let api = local_api();
    let status = wait_for_node_b(&api, READINESS_TIMEOUT).await;
    let whois = whois_with_timeout(&api, node_b_address(&status), WHOIS_TIMEOUT).await;

    assert_eq!(whois.node.hostinfo.hostname.as_deref(), Some("node-b"));
    assert_eq!(whois.user_profile.login_name, "test");
}