use std::net::{IpAddr, SocketAddr};
use ts_control::{Node, StableNodeId, UserId};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Status {
pub self_node: Option<StatusNode>,
pub peers: Vec<StatusNode>,
pub active_exit_node: Option<StableNodeId>,
pub magic_dns_suffix: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusNode {
pub stable_id: StableNodeId,
pub display_name: String,
pub ipv4: IpAddr,
pub ipv6: IpAddr,
pub online: Option<bool>,
pub last_seen: Option<chrono::DateTime<chrono::Utc>>,
pub allowed_routes: Vec<ipnet::IpNet>,
pub is_exit_node: bool,
}
impl StatusNode {
pub fn from_node(node: &Node) -> Self {
let is_exit_node = node
.accepted_routes
.iter()
.any(|route| route.prefix_len() == 0);
Self {
stable_id: node.stable_id.clone(),
display_name: node
.fqdn_opt(false)
.unwrap_or_else(|| node.hostname.clone()),
ipv4: node.tailnet_address.ipv4.addr().into(),
ipv6: node.tailnet_address.ipv6.addr().into(),
online: node.online,
last_seen: node.last_seen,
allowed_routes: node.accepted_routes.clone(),
is_exit_node,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WhoIs {
pub node: Node,
pub user: Option<String>,
pub capabilities: Vec<(String, Vec<String>)>,
}
impl WhoIs {
pub(crate) fn from_node_with_user(node: Node, user: Option<String>) -> Self {
let capabilities = node
.cap_map
.iter()
.map(|(cap, args)| (cap.clone(), args.clone()))
.collect();
Self {
node,
user,
capabilities,
}
}
}
pub(crate) fn whois_addr(addr: SocketAddr) -> IpAddr {
addr.ip()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileTarget {
pub node: Node,
pub peerapi_url: String,
}
pub(crate) fn build_file_targets(peers: Vec<Node>, self_user_id: UserId) -> Vec<FileTarget> {
let mut targets: Vec<FileTarget> = peers
.into_iter()
.filter_map(|peer| {
let addr = peer.peerapi_addr()?;
let eligible = peer.user_id == self_user_id || peer.is_file_sharing_target();
if !eligible {
return None;
}
Some(FileTarget {
peerapi_url: format!("http://{addr}"),
node: peer,
})
})
.collect();
targets.sort_by(|a, b| {
let name = |t: &FileTarget| {
t.node
.fqdn_opt(false)
.unwrap_or_else(|| t.node.hostname.clone())
};
name(a).cmp(&name(b))
});
targets
}
#[cfg(test)]
mod tests {
use ts_control::{Node, StableNodeId, TailnetAddress};
use super::*;
fn node(stable: &str, hostname: &str, tailnet: Option<&str>, ipv4: &str) -> Node {
Node {
id: 1,
stable_id: StableNodeId(stable.to_string()),
hostname: hostname.to_string(),
user_id: 0,
tailnet: tailnet.map(str::to_string),
tags: vec![],
tailnet_address: TailnetAddress {
ipv4: format!("{ipv4}/32").parse().unwrap(),
ipv6: "fd7a::1/128".parse().unwrap(),
},
node_key: [0u8; 32].into(),
node_key_expiry: None,
online: None,
last_seen: None,
key_signature: vec![],
machine_key: None,
disco_key: None,
accepted_routes: vec![],
underlay_addresses: vec![],
derp_region: None,
cap: Default::default(),
cap_map: Default::default(),
peerapi_port: None,
peerapi_dns_proxy: false,
is_wireguard_only: false,
exit_node_dns_resolvers: vec![],
peer_relay: false,
service_vips: Default::default(),
}
}
#[test]
fn status_node_display_name_prefers_fqdn() {
let with_tailnet = node("n1", "host", Some("ts.net"), "100.64.0.1");
assert_eq!(
StatusNode::from_node(&with_tailnet).display_name,
"host.ts.net"
);
let bare = node("n2", "solo", None, "100.64.0.2");
assert_eq!(StatusNode::from_node(&bare).display_name, "solo");
}
#[test]
fn status_node_addresses_and_online_surfaced() {
let n = node("n1", "host", Some("ts.net"), "100.64.0.7");
let s = StatusNode::from_node(&n);
assert_eq!(s.ipv4, "100.64.0.7".parse::<IpAddr>().unwrap());
assert_eq!(s.ipv6, "fd7a::1".parse::<IpAddr>().unwrap());
assert_eq!(s.online, None);
assert_eq!(s.last_seen, None);
let mut online = node("n2", "up", Some("ts.net"), "100.64.0.8");
online.online = Some(true);
assert_eq!(StatusNode::from_node(&online).online, Some(true));
let mut offline = node("n3", "down", Some("ts.net"), "100.64.0.9");
offline.online = Some(false);
assert_eq!(StatusNode::from_node(&offline).online, Some(false));
}
#[test]
fn status_node_detects_exit_node() {
let mut not_exit = node("n1", "a", Some("ts.net"), "100.64.0.1");
not_exit.accepted_routes = vec!["100.64.0.1/32".parse().unwrap()];
assert!(!StatusNode::from_node(¬_exit).is_exit_node);
let mut exit = node("n2", "b", Some("ts.net"), "100.64.0.2");
exit.accepted_routes = vec![
"100.64.0.2/32".parse().unwrap(),
"0.0.0.0/0".parse().unwrap(),
];
assert!(StatusNode::from_node(&exit).is_exit_node);
let mut exit6 = node("n3", "c", Some("ts.net"), "100.64.0.3");
exit6.accepted_routes = vec!["::/0".parse().unwrap()];
assert!(StatusNode::from_node(&exit6).is_exit_node);
}
#[test]
fn whois_caps_empty_when_node_has_none() {
let n = node("n1", "host", Some("ts.net"), "100.64.0.9");
let whois = WhoIs::from_node_with_user(n.clone(), None);
assert_eq!(whois.node, n);
assert_eq!(whois.user, None);
assert!(whois.capabilities.is_empty());
}
#[test]
fn whois_populates_capabilities_from_cap_map() {
let mut n = node("n1", "host", Some("ts.net"), "100.64.0.9");
n.cap_map
.insert("https://tailscale.com/cap/is-admin".to_string(), vec![]);
n.cap_map.insert(
"cap/ssh".to_string(),
vec!["root".to_string(), "ubuntu".to_string()],
);
let whois = WhoIs::from_node_with_user(n, None);
assert_eq!(
whois.capabilities,
vec![
(
"cap/ssh".to_string(),
vec!["root".to_string(), "ubuntu".to_string()]
),
("https://tailscale.com/cap/is-admin".to_string(), vec![]),
]
);
}
#[test]
fn whois_from_node_with_user_sets_user_and_caps() {
let mut n = node("n1", "host", Some("ts.net"), "100.64.0.9");
n.cap_map.insert("cap/x".to_string(), vec!["y".to_string()]);
let whois = WhoIs::from_node_with_user(n, Some("alice@example.com".to_string()));
assert_eq!(whois.user, Some("alice@example.com".to_string()));
assert_eq!(
whois.capabilities,
vec![("cap/x".to_string(), vec!["y".to_string()])]
);
}
fn peer_with_peerapi(stable: &str, hostname: &str, ipv4: &str, user: UserId) -> Node {
let mut n = node(stable, hostname, Some("ts.net"), ipv4);
n.user_id = user;
n.peerapi_port = Some(8089);
n
}
#[test]
fn file_targets_includes_same_owner_peer_with_peerapi() {
let peer = peer_with_peerapi("p1", "host", "100.64.0.5", 42);
let targets = build_file_targets(vec![peer], 42);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].peerapi_url, "http://100.64.0.5:8089");
assert_eq!(targets[0].node.hostname, "host");
}
#[test]
fn file_targets_includes_cross_owner_peer_with_target_cap() {
let mut peer = peer_with_peerapi("p1", "host", "100.64.0.5", 99);
peer.cap_map
.insert("tailscale.com/cap/file-sharing-target".to_string(), vec![]);
let targets = build_file_targets(vec![peer], 42);
assert_eq!(
targets.len(),
1,
"cross-owner peer with the target cap qualifies"
);
}
#[test]
fn file_targets_excludes_cross_owner_peer_without_cap() {
let peer = peer_with_peerapi("p1", "host", "100.64.0.5", 99);
let targets = build_file_targets(vec![peer], 42);
assert!(
targets.is_empty(),
"a different owner without the cap is not a target"
);
}
#[test]
fn file_targets_excludes_peer_without_peerapi() {
let mut peer = peer_with_peerapi("p1", "host", "100.64.0.5", 42);
peer.peerapi_port = None;
let targets = build_file_targets(vec![peer], 42);
assert!(
targets.is_empty(),
"a peer with no peerAPI cannot be a Taildrop target"
);
}
#[test]
fn file_targets_sorted_by_magic_dns_name() {
let zeta = peer_with_peerapi("p2", "zeta", "100.64.0.6", 42);
let alpha = peer_with_peerapi("p1", "alpha", "100.64.0.5", 42);
let targets = build_file_targets(vec![zeta, alpha], 42);
let names: Vec<_> = targets.iter().map(|t| t.node.hostname.clone()).collect();
assert_eq!(names, vec!["alpha", "zeta"]);
}
}