use std::net::{Ipv4Addr, SocketAddr};
use std::process::Command;
use super::BondTransport;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TailnetIdentity {
pub ipv4: Ipv4Addr,
pub magic_dns: Option<String>,
}
#[must_use]
pub fn is_cgnat_ipv4(ip: Ipv4Addr) -> bool {
let [a, b, _, _] = ip.octets();
a == 100 && (64..=127).contains(&b)
}
fn normalize_magic_dns(name: &str) -> String {
name.trim().trim_end_matches('.').to_string()
}
#[must_use]
pub fn parse_tailscale_status_ipv4(json: &str) -> Option<(Ipv4Addr, Option<String>)> {
let value: serde_json::Value = serde_json::from_str(json).ok()?;
let self_node = value.get("Self")?;
let ipv4 = self_node
.get("TailscaleIPs")?
.as_array()?
.iter()
.filter_map(serde_json::Value::as_str)
.filter_map(|entry| entry.parse::<Ipv4Addr>().ok())
.find(|ip| is_cgnat_ipv4(*ip))?;
let magic_dns = self_node
.get("DNSName")
.and_then(serde_json::Value::as_str)
.map(normalize_magic_dns)
.filter(|name| !name.is_empty());
Some((ipv4, magic_dns))
}
#[must_use]
pub fn parse_tailscale_ip_line(stdout: &str) -> Option<Ipv4Addr> {
stdout
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.filter_map(|line| line.parse::<Ipv4Addr>().ok())
.find(|ip| is_cgnat_ipv4(*ip))
}
#[must_use]
pub fn detect_local_tailnet() -> Option<TailnetIdentity> {
detect_via_status_json().or_else(detect_via_ip_flag)
}
fn detect_via_status_json() -> Option<TailnetIdentity> {
let output = Command::new("tailscale")
.arg("status")
.arg("--json")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let (ipv4, magic_dns) = parse_tailscale_status_ipv4(&stdout)?;
Some(TailnetIdentity { ipv4, magic_dns })
}
fn detect_via_ip_flag() -> Option<TailnetIdentity> {
let output = Command::new("tailscale")
.arg("ip")
.arg("-4")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let ipv4 = parse_tailscale_ip_line(&stdout)?;
Some(TailnetIdentity {
ipv4,
magic_dns: None,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReceiverEndpoints {
pub direct: Option<SocketAddr>,
pub tailnet: Option<SocketAddr>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransportPreference {
Auto,
Tailscale,
Direct,
Ssh,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DonorPathChoice {
pub transport: BondTransport,
pub dial: SocketAddr,
}
#[must_use]
pub fn select_donor_path(
pref: TransportPreference,
endpoints: &ReceiverEndpoints,
donor_on_tailnet: bool,
) -> Option<DonorPathChoice> {
if matches!(pref, TransportPreference::Ssh) {
let dial = endpoints.direct.or(endpoints.tailnet)?;
return Some(DonorPathChoice {
transport: BondTransport::Ssh,
dial,
});
}
let want_tailscale = matches!(
pref,
TransportPreference::Tailscale | TransportPreference::Auto
);
if want_tailscale
&& donor_on_tailnet
&& let Some(dial) = endpoints.tailnet
{
return Some(DonorPathChoice {
transport: BondTransport::Tailscale,
dial,
});
}
if let Some(dial) = endpoints.direct {
return Some(DonorPathChoice {
transport: BondTransport::DirectIp,
dial,
});
}
if donor_on_tailnet && let Some(dial) = endpoints.tailnet {
return Some(DonorPathChoice {
transport: BondTransport::Tailscale,
dial,
});
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
fn direct_addr() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)), 4711)
}
fn tailnet_addr() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(100, 101, 102, 103)), 4711)
}
#[test]
fn is_cgnat_ipv4_accepts_the_full_100_64_0_0_slash_10_range() {
assert!(is_cgnat_ipv4(Ipv4Addr::new(100, 64, 0, 0)));
assert!(is_cgnat_ipv4(Ipv4Addr::new(100, 127, 255, 255)));
assert!(is_cgnat_ipv4(Ipv4Addr::new(100, 96, 12, 34)));
}
#[test]
fn is_cgnat_ipv4_rejects_addresses_just_outside_the_boundaries() {
assert!(!is_cgnat_ipv4(Ipv4Addr::new(100, 63, 255, 255)));
assert!(!is_cgnat_ipv4(Ipv4Addr::new(100, 128, 0, 0)));
assert!(!is_cgnat_ipv4(Ipv4Addr::new(203, 0, 113, 7)));
assert!(!is_cgnat_ipv4(Ipv4Addr::new(10, 0, 0, 1)));
assert!(!is_cgnat_ipv4(Ipv4Addr::new(192, 168, 1, 1)));
assert!(!is_cgnat_ipv4(Ipv4Addr::new(100, 200, 0, 1)));
}
#[test]
fn parse_tailscale_status_ipv4_extracts_self_cgnat_ip_and_magic_dns() {
let json = r#"{
"Version": "1.62.0",
"BackendState": "Running",
"Self": {
"ID": "n123",
"HostName": "workstation",
"DNSName": "workstation.tail9f00.ts.net.",
"TailscaleIPs": ["100.101.102.103", "fd7a:115c:a1e0::1234"],
"Online": true
},
"Peer": {}
}"#;
let (ipv4, magic_dns) = parse_tailscale_status_ipv4(json).expect("self cgnat ip present");
assert_eq!(ipv4, Ipv4Addr::new(100, 101, 102, 103));
assert_eq!(magic_dns.as_deref(), Some("workstation.tail9f00.ts.net"));
}
#[test]
fn parse_tailscale_status_ipv4_skips_non_cgnat_ipv4_and_ipv6() {
let json = r#"{
"Self": {
"DNSName": "host.example.ts.net.",
"TailscaleIPs": ["192.0.2.9", "fd7a:115c::1", "100.64.0.5"]
}
}"#;
let (ipv4, magic_dns) = parse_tailscale_status_ipv4(json).expect("later cgnat ip found");
assert_eq!(ipv4, Ipv4Addr::new(100, 64, 0, 5));
assert_eq!(magic_dns.as_deref(), Some("host.example.ts.net"));
}
#[test]
fn parse_tailscale_status_ipv4_returns_none_without_a_cgnat_address() {
let json = r#"{ "Self": { "TailscaleIPs": ["192.0.2.9", "fd7a::1"] } }"#;
assert!(parse_tailscale_status_ipv4(json).is_none());
}
#[test]
fn parse_tailscale_status_ipv4_returns_none_on_malformed_or_empty_json() {
assert!(parse_tailscale_status_ipv4("not json at all").is_none());
assert!(parse_tailscale_status_ipv4("").is_none());
assert!(parse_tailscale_status_ipv4("{}").is_none());
assert!(parse_tailscale_status_ipv4(r#"{ "Self": {} }"#).is_none());
}
#[test]
fn parse_tailscale_status_ipv4_omits_empty_dns_name() {
let json = r#"{
"Self": { "DNSName": "", "TailscaleIPs": ["100.100.100.100"] }
}"#;
let (ipv4, magic_dns) = parse_tailscale_status_ipv4(json).expect("cgnat ip");
assert_eq!(ipv4, Ipv4Addr::new(100, 100, 100, 100));
assert_eq!(magic_dns, None);
}
#[test]
fn parse_tailscale_ip_line_takes_the_first_cgnat_line() {
let stdout = "100.115.92.1\nfd7a:115c:a1e0::1\n";
assert_eq!(
parse_tailscale_ip_line(stdout),
Some(Ipv4Addr::new(100, 115, 92, 1))
);
}
#[test]
fn parse_tailscale_ip_line_skips_blanks_and_non_cgnat_lines() {
let stdout = "\n \n192.0.2.4\n100.72.0.9\n";
assert_eq!(
parse_tailscale_ip_line(stdout),
Some(Ipv4Addr::new(100, 72, 0, 9))
);
}
#[test]
fn parse_tailscale_ip_line_returns_none_when_no_cgnat_line() {
assert!(parse_tailscale_ip_line("").is_none());
assert!(parse_tailscale_ip_line("not an ip\n").is_none());
assert!(parse_tailscale_ip_line("192.0.2.4\n").is_none());
}
#[test]
fn select_auto_both_on_tailnet_picks_tailscale() {
let endpoints = ReceiverEndpoints {
direct: Some(direct_addr()),
tailnet: Some(tailnet_addr()),
};
let choice =
select_donor_path(TransportPreference::Auto, &endpoints, true).expect("usable path");
assert_eq!(choice.transport, BondTransport::Tailscale);
assert_eq!(choice.dial, tailnet_addr());
}
#[test]
fn select_auto_donor_not_on_tailnet_picks_direct() {
let endpoints = ReceiverEndpoints {
direct: Some(direct_addr()),
tailnet: Some(tailnet_addr()),
};
let choice =
select_donor_path(TransportPreference::Auto, &endpoints, false).expect("usable path");
assert_eq!(choice.transport, BondTransport::DirectIp);
assert_eq!(choice.dial, direct_addr());
}
#[test]
fn select_direct_pref_picks_direct_even_when_tailnet_available() {
let endpoints = ReceiverEndpoints {
direct: Some(direct_addr()),
tailnet: Some(tailnet_addr()),
};
let choice =
select_donor_path(TransportPreference::Direct, &endpoints, true).expect("usable path");
assert_eq!(choice.transport, BondTransport::DirectIp);
assert_eq!(choice.dial, direct_addr());
}
#[test]
fn select_tailscale_pref_falls_back_to_direct_when_donor_not_on_tailnet() {
let endpoints = ReceiverEndpoints {
direct: Some(direct_addr()),
tailnet: Some(tailnet_addr()),
};
let choice = select_donor_path(TransportPreference::Tailscale, &endpoints, false)
.expect("usable path");
assert_eq!(choice.transport, BondTransport::DirectIp);
assert_eq!(choice.dial, direct_addr());
}
#[test]
fn select_tailscale_pref_uses_tailscale_when_both_on_tailnet() {
let endpoints = ReceiverEndpoints {
direct: Some(direct_addr()),
tailnet: Some(tailnet_addr()),
};
let choice = select_donor_path(TransportPreference::Tailscale, &endpoints, true)
.expect("usable path");
assert_eq!(choice.transport, BondTransport::Tailscale);
assert_eq!(choice.dial, tailnet_addr());
}
#[test]
fn select_ssh_pref_picks_ssh_dialing_direct() {
let endpoints = ReceiverEndpoints {
direct: Some(direct_addr()),
tailnet: Some(tailnet_addr()),
};
let choice =
select_donor_path(TransportPreference::Ssh, &endpoints, true).expect("usable path");
assert_eq!(choice.transport, BondTransport::Ssh);
assert_eq!(choice.dial, direct_addr());
}
#[test]
fn select_ssh_pref_without_direct_tunnels_over_tailnet() {
let endpoints = ReceiverEndpoints {
direct: None,
tailnet: Some(tailnet_addr()),
};
let choice =
select_donor_path(TransportPreference::Ssh, &endpoints, false).expect("usable path");
assert_eq!(choice.transport, BondTransport::Ssh);
assert_eq!(choice.dial, tailnet_addr());
}
#[test]
fn select_returns_none_when_no_endpoint_exists() {
let endpoints = ReceiverEndpoints {
direct: None,
tailnet: None,
};
assert!(select_donor_path(TransportPreference::Auto, &endpoints, true).is_none());
assert!(select_donor_path(TransportPreference::Direct, &endpoints, true).is_none());
assert!(select_donor_path(TransportPreference::Tailscale, &endpoints, true).is_none());
assert!(select_donor_path(TransportPreference::Ssh, &endpoints, true).is_none());
}
#[test]
fn select_direct_only_endpoint_ignores_tailnet_preference() {
let endpoints = ReceiverEndpoints {
direct: Some(direct_addr()),
tailnet: None,
};
let choice = select_donor_path(TransportPreference::Tailscale, &endpoints, true)
.expect("usable path");
assert_eq!(choice.transport, BondTransport::DirectIp);
assert_eq!(choice.dial, direct_addr());
}
#[test]
fn select_tailnet_only_endpoint_is_last_resort_when_donor_shares_tailnet() {
let endpoints = ReceiverEndpoints {
direct: None,
tailnet: Some(tailnet_addr()),
};
let choice =
select_donor_path(TransportPreference::Direct, &endpoints, true).expect("usable path");
assert_eq!(choice.transport, BondTransport::Tailscale);
assert_eq!(choice.dial, tailnet_addr());
assert!(select_donor_path(TransportPreference::Direct, &endpoints, false).is_none());
}
}