use core::num::NonZeroU16;
use std::{
net::{Ipv4Addr, SocketAddrV4},
sync::Arc,
};
use kameo::{
actor::ActorRef,
message::{Context, Message},
};
use netstack::netcore::Channel;
use tokio::{sync::watch, task::JoinSet};
use ts_transport::OverlayTransport;
use ts_transport_tun::{AsyncTunTransport, Config as TunDeviceConfig};
use crate::{
Error,
dataplane::{OverlayFromDataplane, OverlayToDataplane},
env::Env,
magic_dns::{Decision, DnsView, RecursivePlan, decide, forward_query, recursive_plan},
peer_tracker::PeerState,
};
const MAGIC_DNS_IP: Ipv4Addr = Ipv4Addr::new(100, 100, 100, 100);
const MAGIC_DNS_PORT: u16 = 53;
const DNS_REPLY_TTL: u8 = 64;
pub struct TunActor {
_joinset: JoinSet<()>,
env: Env,
tun_config: ts_control::TunConfig,
overlay_to_dataplane: Option<OverlayToDataplane>,
overlay_from_dataplane: Option<OverlayFromDataplane>,
gating: HostRouteGating,
host_guard: Option<HostGuard>,
dns_view: watch::Sender<Arc<DnsView>>,
channel: Channel,
}
#[derive(Clone, Copy, Debug)]
pub struct HostRouteGating {
pub accept_routes: bool,
pub exit_node_configured: bool,
}
struct HostGuard(Box<dyn ts_host_net::HostNet>);
impl Drop for HostGuard {
fn drop(&mut self) {
self.0.teardown();
}
}
pub(crate) fn tun_config_from_control(
cfg: &ts_control::TunConfig,
prefix: ipnet::Ipv4Net,
) -> TunDeviceConfig {
TunDeviceConfig {
name: cfg.name.clone().unwrap_or_else(|| "tailscale0".to_owned()),
mtu: cfg
.mtu
.and_then(NonZeroU16::new)
.unwrap_or(NonZeroU16::new(1280).unwrap()),
prefix: ipnet::IpNet::V4(prefix),
}
}
pub(crate) fn host_routes_from_node(
node: &ts_control::Node,
if_name: String,
gating: HostRouteGating,
magic_dns: bool,
) -> ts_host_net::HostRoutes {
let self_v4 = node.tailnet_address.ipv4;
let mut routed: Vec<ipnet::Ipv4Net> = node
.accepted_routes
.iter()
.filter_map(|route| match route {
ipnet::IpNet::V4(v4) => Some(*v4),
ipnet::IpNet::V6(_) => None,
})
.filter(|v4| {
if *v4 == self_v4 {
return false;
}
if v4.prefix_len() == 0 {
return gating.exit_node_configured;
}
gating.accept_routes || !node.is_subnet_route(&ipnet::IpNet::V4(*v4))
})
.collect();
if magic_dns {
let magic_dns_net = ipnet::Ipv4Net::new(MAGIC_DNS_IP, 32).expect("/32 is a valid prefix");
if magic_dns_net != self_v4 && !routed.contains(&magic_dns_net) {
routed.push(magic_dns_net);
}
}
ts_host_net::HostRoutes {
if_name,
self_v4,
routed,
}
}
pub(crate) fn host_dns_from_dns_config(
dns: Option<&ts_control::DnsConfig>,
if_name: String,
) -> ts_host_net::HostDns {
let magic_dns = matches!(dns, Some(d) if d.magic_dns);
let match_domains = if magic_dns {
dns.map(|d| d.search_domains.clone()).unwrap_or_default()
} else {
vec![]
};
ts_host_net::HostDns {
if_name,
nameservers: if magic_dns {
vec![MAGIC_DNS_IP]
} else {
vec![]
},
match_domains,
}
}
struct MagicDnsQuery<'a> {
src: SocketAddrV4,
dns_payload: &'a [u8],
}
fn classify_magic_dns(pkt: &[u8]) -> Option<MagicDnsQuery<'_>> {
let sliced = etherparse::SlicedPacket::from_ip(pkt).ok()?;
let (src_ip, dst_ip) = match sliced.net {
Some(etherparse::NetSlice::Ipv4(ipv4)) => (
ipv4.header().source_addr(),
ipv4.header().destination_addr(),
),
_ => return None,
};
if dst_ip != MAGIC_DNS_IP {
return None;
}
let udp = match sliced.transport {
Some(etherparse::TransportSlice::Udp(udp)) => udp,
_ => return None,
};
if udp.destination_port() != MAGIC_DNS_PORT {
return None;
}
Some(MagicDnsQuery {
src: SocketAddrV4::new(src_ip, udp.source_port()),
dns_payload: udp.payload(),
})
}
fn build_dns_response(dst: SocketAddrV4, dns_response: &[u8]) -> Vec<u8> {
let builder =
etherparse::PacketBuilder::ipv4(MAGIC_DNS_IP.octets(), dst.ip().octets(), DNS_REPLY_TTL)
.udp(MAGIC_DNS_PORT, dst.port());
let mut out = Vec::with_capacity(builder.size(dns_response.len()));
builder
.write(&mut out, dns_response)
.expect("writing an IPv4+UDP packet into a Vec is infallible");
out
}
fn build_dns_view(
env: &Env,
update: &ts_control::StateUpdate,
peers: Option<Arc<crate::peer_tracker::PeerDb>>,
enable_ipv6: bool,
) -> DnsView {
let exit_doh = env.exit_node().as_ref().and_then(|sel| {
let peers = peers.as_ref()?;
let id = sel.resolve(peers.peers().values())?;
peers
.peers()
.values()
.find(|peer| peer.stable_id == id)
.and_then(|n| n.peerapi_doh_addr())
});
DnsView {
cfg: update.dns_config.clone().unwrap_or_default(),
peers,
self_node: update.node.clone(),
exit_doh,
enable_ipv6,
}
}
enum Intercept {
NotIntercepted,
Dropped,
Reply {
response: Vec<u8>,
src: SocketAddrV4,
},
Forward {
plan: RecursivePlan,
query: Vec<u8>,
nxdomain: Vec<u8>,
src: SocketAddrV4,
},
}
fn plan_intercept(view: &DnsView, pkt: &[u8]) -> Intercept {
let Some(query) = classify_magic_dns(pkt) else {
return Intercept::NotIntercepted;
};
let src = query.src;
match decide(view, query.dns_payload) {
None => Intercept::Dropped,
Some(Decision::Reply(response)) => Intercept::Reply { response, src },
Some(Decision::Forward {
upstreams,
query,
nxdomain,
recursive,
}) => {
let plan = if recursive {
recursive_plan(view, upstreams)
} else {
RecursivePlan::Udp(upstreams)
};
Intercept::Forward {
plan,
query,
nxdomain,
src,
}
}
}
}
async fn send_dns_reply(device: &Arc<AsyncTunTransport>, src: SocketAddrV4, response: &[u8]) {
let reply_pkt = build_dns_response(src, response);
if let Err(e) = device
.send(core::iter::once(ts_packet::PacketMut::from(reply_pkt)))
.await
{
tracing::warn!(error = %e, "magic dns tun reply send failed");
}
}
async fn up_pump(
dev_up: Arc<AsyncTunTransport>,
up: OverlayToDataplane,
dns_view_rx: watch::Receiver<Arc<DnsView>>,
dns_channel: Channel,
) {
const MAX_INFLIGHT_FORWARDS: usize = 256;
let mut forwards: JoinSet<()> = JoinSet::new();
loop {
let batch: Vec<_> = dev_up.recv().await.into_iter().collect();
for pkt in batch {
match pkt {
Ok(p) => {
let plan = plan_intercept(&dns_view_rx.borrow(), p.as_ref());
match plan {
Intercept::NotIntercepted => {
if up.send(vec![p]).is_err() {
return;
}
}
Intercept::Dropped => {}
Intercept::Reply { response, src } => {
send_dns_reply(&dev_up, src, &response).await;
}
Intercept::Forward {
plan,
query,
nxdomain,
src,
} => {
if forwards.len() >= MAX_INFLIGHT_FORWARDS {
drop(forwards.join_next().await);
}
forwards.spawn(run_forward(
dev_up.clone(),
dns_channel.clone(),
plan,
query,
nxdomain,
src,
));
}
}
}
Err(e) => tracing::warn!(error = %e, "tun recv error"),
}
}
while forwards.try_join_next().is_some() {}
}
}
async fn run_forward(
device: Arc<AsyncTunTransport>,
channel: Channel,
plan: RecursivePlan,
query: Vec<u8>,
nxdomain: Vec<u8>,
src: SocketAddrV4,
) {
let response = match plan {
RecursivePlan::Udp(ups) => forward_query(&channel, &ups, &query, nxdomain).await,
RecursivePlan::Doh(addr) => {
crate::peerapi_doh::forward_doh(&channel, addr, &query, nxdomain).await
}
};
let reply_pkt = build_dns_response(src, &response);
if let Err(e) = device
.send(core::iter::once(ts_packet::PacketMut::from(reply_pkt)))
.await
{
tracing::warn!(error = %e, "magic dns tun forwarded reply send failed");
}
}
impl kameo::Actor for TunActor {
type Args = (
Env,
ts_control::TunConfig,
OverlayToDataplane,
OverlayFromDataplane,
HostRouteGating,
Channel,
);
type Error = Error;
async fn on_start(
(env, tun_config, overlay_to_dataplane, overlay_from_dataplane, gating, channel): Self::Args,
slf: ActorRef<Self>,
) -> Result<Self, Self::Error> {
env.subscribe::<Arc<ts_control::StateUpdate>>(&slf).await?;
env.subscribe::<Arc<PeerState>>(&slf).await?;
let (dns_view, _) = watch::channel(Arc::new(DnsView {
enable_ipv6: env.enable_ipv6,
..DnsView::default()
}));
Ok(Self {
_joinset: JoinSet::new(),
env,
tun_config,
overlay_to_dataplane: Some(overlay_to_dataplane),
overlay_from_dataplane: Some(overlay_from_dataplane),
gating,
host_guard: None,
dns_view,
channel,
})
}
}
impl Message<Arc<ts_control::StateUpdate>> for TunActor {
type Reply = ();
async fn handle(
&mut self,
msg: Arc<ts_control::StateUpdate>,
_ctx: &mut Context<Self, Self::Reply>,
) {
let env = &self.env;
self.dns_view.send_modify(|view| {
*view = Arc::new(build_dns_view(
env,
&msg,
view.peers.clone(),
view.enable_ipv6,
));
});
let Some(self_node) = &msg.node else {
return;
};
let (Some(up), Some(down)) = (
self.overlay_to_dataplane.take(),
self.overlay_from_dataplane.take(),
) else {
return;
};
let device_config =
tun_config_from_control(&self.tun_config, self_node.tailnet_address.ipv4);
let device = match AsyncTunTransport::new(&device_config) {
Ok(d) => Arc::new(d),
Err(e) => {
tracing::error!(error = %e, "TUN device creation failed; no overlay data path (fail-closed)");
return;
}
};
let if_name = device.name();
let mut host = match ts_host_net::host_net() {
Ok(h) => h,
Err(e) => {
tracing::error!(error = %e, "host net unsupported; TUN idle (fail-closed)");
return;
}
};
let magic_dns = msg.dns_config.as_ref().is_some_and(|d| d.magic_dns);
let routes = host_routes_from_node(self_node, if_name.clone(), self.gating, magic_dns);
if let Err(e) = host.apply_routes(&routes) {
tracing::error!(error = %e, "host route programming failed; TUN idle (fail-closed)");
host.teardown();
return; }
if let Err(e) = host.apply_dns(&host_dns_from_dns_config(msg.dns_config.as_ref(), if_name))
{
tracing::warn!(error = %e, "host dns programming failed (continuing; routes are up)");
}
self.host_guard = Some(HostGuard(host));
let dev_up = device.clone();
let dns_view_rx = self.dns_view.subscribe();
let dns_channel = self.channel.clone();
self._joinset
.spawn(up_pump(dev_up, up, dns_view_rx, dns_channel));
let dev_down = device.clone();
let mut down = down;
self._joinset.spawn(async move {
while let Some(bufs) = down.recv().await {
if let Err(e) = dev_down.send(bufs).await {
tracing::warn!(error = %e, "tun send error");
}
}
tracing::warn!("tun downlink shut down!");
});
tracing::debug!(prefix = ?self_node.tailnet_address.ipv4, "TUN device created");
}
}
impl Message<Arc<PeerState>> for TunActor {
type Reply = ();
async fn handle(&mut self, state: Arc<PeerState>, _ctx: &mut Context<Self, Self::Reply>) {
let exit_doh = self.env.exit_node().as_ref().and_then(|sel| {
let id = sel.resolve(state.peers.peers().values())?;
state
.peers
.peers()
.values()
.find(|peer| peer.stable_id == id)
.and_then(|n| n.peerapi_doh_addr())
});
self.dns_view.send_modify(|view| {
let mut next = (**view).clone();
next.peers = Some(state.peers.clone());
next.exit_doh = exit_doh;
*view = Arc::new(next);
});
}
}
#[cfg(test)]
mod tests {
use core::net::{Ipv4Addr, SocketAddrV4};
use std::sync::Arc;
use ipnet::Ipv4Net;
use tokio::sync::watch;
use ts_control::TunConfig;
use super::{
HostRouteGating, Intercept, build_dns_view, host_dns_from_dns_config,
host_routes_from_node, plan_intercept, tun_config_from_control,
};
use crate::{
env::{Env, ForwarderConfig},
magic_dns::{Decision, RecursivePlan, decide, recursive_plan},
peer_tracker::PeerDb,
};
fn test_env(exit_node: Option<ts_control::ExitNodeSelector>) -> Env {
let (_shutdown_tx, shutdown_rx) = watch::channel(false);
Env::new(
ts_keys::NodeState::generate(),
shutdown_rx,
ForwarderConfig {
accept_routes: false,
exit_node,
forward_routes: Vec::new(),
forward_tcp_ports: Vec::new(),
forward_udp_ports: Vec::new(),
forward_all_ports: false,
forward_exit_egress: false,
exit_proxy: None,
peerapi_port: None,
taildrop_dir: None,
enable_ipv6: false,
persistent_keepalive_interval: None,
ingress_active: Arc::new(std::sync::atomic::AtomicBool::new(false)),
},
)
}
fn exit_peer(stable_id: &str, ipv4: &str, peerapi_port: u16) -> ts_control::Node {
use ts_control::{Node, StableNodeId, TailnetAddress};
Node {
id: 2,
user_id: 0,
stable_id: StableNodeId(stable_id.to_string()),
hostname: stable_id.to_string(),
tailnet: Some("ts.net".to_string()),
tags: vec![],
tailnet_address: TailnetAddress {
ipv4: format!("{ipv4}/32").parse().unwrap(),
ipv6: "fd7a::2/128".parse().unwrap(),
},
node_key: [1u8; 32].into(),
node_key_expiry: None,
key_signature: vec![],
machine_key: None,
disco_key: None,
accepted_routes: vec!["0.0.0.0/0".parse().unwrap()],
underlay_addresses: vec![],
derp_region: None,
cap: Default::default(),
cap_map: Default::default(),
peerapi_port: Some(peerapi_port),
peerapi_dns_proxy: true,
is_wireguard_only: false,
exit_node_dns_resolvers: vec![],
peer_relay: false,
service_vips: Default::default(),
}
}
fn peer_db_with(peer: &ts_control::Node) -> Arc<PeerDb> {
let mut db = PeerDb::default();
db.upsert(peer);
Arc::new(db)
}
fn udp_resolver(addr: &str) -> ts_control::DnsResolver {
ts_control::DnsResolver {
transport: ts_control::ResolverTransport::Udp(addr.parse().unwrap()),
use_with_exit_node: false,
}
}
fn build_query(id: u16, labels: &[&str]) -> Vec<u8> {
let mut buf: Vec<u8> = Vec::new();
buf.extend_from_slice(&id.to_be_bytes());
buf.extend_from_slice(&0u16.to_be_bytes()); buf.extend_from_slice(&1u16.to_be_bytes()); buf.extend_from_slice(&0u16.to_be_bytes()); buf.extend_from_slice(&0u16.to_be_bytes()); buf.extend_from_slice(&0u16.to_be_bytes()); for label in labels {
buf.push(label.len() as u8);
buf.extend_from_slice(label.as_bytes());
}
buf.push(0); buf.extend_from_slice(&1u16.to_be_bytes()); buf.extend_from_slice(&1u16.to_be_bytes()); buf
}
fn gating_all() -> HostRouteGating {
HostRouteGating {
accept_routes: true,
exit_node_configured: true,
}
}
fn prefix() -> Ipv4Net {
Ipv4Net::new(Ipv4Addr::new(100, 64, 0, 1), 32).unwrap()
}
fn fixture_node() -> ts_control::Node {
use ts_control::{Node, StableNodeId, TailnetAddress};
Node {
id: 1,
user_id: 0,
stable_id: StableNodeId("n1".to_string()),
hostname: "self".to_string(),
tailnet: Some("ts.net".to_string()),
tags: vec![],
tailnet_address: TailnetAddress {
ipv4: "100.64.0.1/32".parse().unwrap(),
ipv6: "fd7a::1/128".parse().unwrap(),
},
node_key: [0u8; 32].into(),
node_key_expiry: None,
key_signature: vec![],
machine_key: None,
disco_key: None,
accepted_routes: vec![
"100.64.0.1/32".parse().unwrap(),
"fd7a::1/128".parse().unwrap(),
"192.168.1.0/24".parse().unwrap(),
"0.0.0.0/0".parse().unwrap(),
],
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 host_routes_includes_subnet_and_default_excludes_self() {
let node = fixture_node();
let routes = host_routes_from_node(&node, "utun9".to_owned(), gating_all(), false);
assert_eq!(routes.if_name, "utun9");
assert_eq!(routes.self_v4, "100.64.0.1/32".parse::<Ipv4Net>().unwrap());
assert!(
routes.routed.contains(&"192.168.1.0/24".parse().unwrap()),
"subnet /24 must be routed when accept_routes is set"
);
assert!(
routes.routed.contains(&"0.0.0.0/0".parse().unwrap()),
"default /0 must be routed when an exit node is configured"
);
assert!(
!routes.routed.contains(&"100.64.0.1/32".parse().unwrap()),
"self /32 must never be re-routed"
);
}
#[test]
fn host_routes_excludes_subnet_without_accept_routes() {
let node = fixture_node();
let routes = host_routes_from_node(
&node,
"utun9".to_owned(),
HostRouteGating {
accept_routes: false,
exit_node_configured: true,
},
false,
);
assert!(
!routes.routed.contains(&"192.168.1.0/24".parse().unwrap()),
"subnet /24 must be excluded when accept_routes is false"
);
}
#[test]
fn host_routes_excludes_default_without_exit_node() {
let node = fixture_node();
let routes = host_routes_from_node(
&node,
"utun9".to_owned(),
HostRouteGating {
accept_routes: true,
exit_node_configured: false,
},
false,
);
assert!(
!routes.routed.contains(&"0.0.0.0/0".parse().unwrap()),
"default /0 must be excluded when no exit node is configured"
);
}
#[test]
fn host_routes_drops_ipv6() {
let baseline =
host_routes_from_node(&fixture_node(), "utun9".to_owned(), gating_all(), false);
let mut node_v6 = fixture_node();
node_v6
.accepted_routes
.push("2001:db8::/32".parse().unwrap());
let routes_v6 = host_routes_from_node(&node_v6, "utun9".to_owned(), gating_all(), false);
assert_eq!(
routes_v6.routed, baseline.routed,
"adding a v6 subnet must not change the v4-only routed set"
);
}
#[test]
fn host_dns_nameservers_point_at_magic_dns_when_enabled() {
let none = host_dns_from_dns_config(None, "utun9".to_owned());
assert!(none.nameservers.is_empty());
assert!(none.match_domains.is_empty());
let on = ts_control::DnsConfig {
magic_dns: true,
search_domains: vec!["user.ts.net.".to_owned()],
..Default::default()
};
let dns_on = host_dns_from_dns_config(Some(&on), "utun9".to_owned());
assert_eq!(
dns_on.nameservers,
vec![Ipv4Addr::new(100, 100, 100, 100)],
"nameservers must point at the MagicDNS IP when MagicDNS is enabled"
);
assert_eq!(dns_on.match_domains, vec!["user.ts.net.".to_owned()]);
let off = ts_control::DnsConfig {
magic_dns: false,
search_domains: vec!["user.ts.net.".to_owned()],
..Default::default()
};
let dns_off = host_dns_from_dns_config(Some(&off), "utun9".to_owned());
assert!(
dns_off.nameservers.is_empty(),
"nameservers must stay empty when MagicDNS is disabled"
);
assert!(dns_off.match_domains.is_empty());
}
#[test]
fn host_routes_includes_magic_dns_when_enabled() {
let node = fixture_node();
let magic_dns_net: Ipv4Net = "100.100.100.100/32".parse().unwrap();
let with = host_routes_from_node(&node, "utun9".to_owned(), gating_all(), true);
assert!(
with.routed.contains(&magic_dns_net),
"100.100.100.100/32 must be routed when MagicDNS is enabled"
);
let without = host_routes_from_node(&node, "utun9".to_owned(), gating_all(), false);
assert!(
!without.routed.contains(&magic_dns_net),
"100.100.100.100/32 must not be routed when MagicDNS is disabled"
);
}
#[test]
fn classify_and_build_round_trip() {
use super::{build_dns_response, classify_magic_dns};
let client: SocketAddrV4 = "100.64.0.7:34567".parse().unwrap();
let payload = b"hello-dns-query";
let query_pkt = {
let b = etherparse::PacketBuilder::ipv4(client.ip().octets(), [100, 100, 100, 100], 64)
.udp(client.port(), 53);
let mut out = Vec::with_capacity(b.size(payload.len()));
b.write(&mut out, payload).unwrap();
out
};
let q = classify_magic_dns(&query_pkt).expect("quad-100/udp/53 is classified as DNS");
assert_eq!(q.src, client, "source endpoint extracted");
assert_eq!(q.dns_payload, payload, "DNS payload extracted");
let resp_payload = b"a-dns-answer";
let reply_pkt = build_dns_response(client, resp_payload);
let sliced = etherparse::SlicedPacket::from_ip(&reply_pkt).expect("reply parses");
match sliced.net {
Some(etherparse::NetSlice::Ipv4(ip)) => {
assert_eq!(
ip.header().source_addr(),
Ipv4Addr::new(100, 100, 100, 100),
"reply is FROM the MagicDNS service IP"
);
assert_eq!(
ip.header().destination_addr(),
*client.ip(),
"reply is TO the original querier"
);
}
_ => panic!("reply must be IPv4"),
}
match sliced.transport {
Some(etherparse::TransportSlice::Udp(udp)) => {
assert_eq!(udp.source_port(), 53, "reply source port is 53");
assert_eq!(
udp.destination_port(),
client.port(),
"reply dest port is the querier's source port"
);
assert_eq!(udp.payload(), resp_payload, "reply carries the DNS answer");
}
_ => panic!("reply must be UDP"),
}
}
#[test]
fn classify_passthrough_for_non_dns() {
use super::classify_magic_dns;
let client: SocketAddrV4 = "100.64.0.7:1234".parse().unwrap();
let to_other_ip = {
let b = etherparse::PacketBuilder::ipv4(client.ip().octets(), [8, 8, 8, 8], 64)
.udp(client.port(), 53);
let mut out = Vec::new();
b.write(&mut out, b"x").unwrap();
out
};
assert!(
classify_magic_dns(&to_other_ip).is_none(),
"UDP/53 to a non-quad-100 IP must pass through"
);
let wrong_port = {
let b = etherparse::PacketBuilder::ipv4(client.ip().octets(), [100, 100, 100, 100], 64)
.udp(client.port(), 443);
let mut out = Vec::new();
b.write(&mut out, b"x").unwrap();
out
};
assert!(
classify_magic_dns(&wrong_port).is_none(),
"non-53 dport to quad-100 must pass through"
);
let tcp = {
let b = etherparse::PacketBuilder::ipv4(client.ip().octets(), [100, 100, 100, 100], 64)
.tcp(client.port(), 53, 0, 1024);
let mut out = Vec::new();
b.write(&mut out, b"x").unwrap();
out
};
assert!(
classify_magic_dns(&tcp).is_none(),
"TCP to quad-100:53 must pass through (UDP-only intercept)"
);
assert!(
classify_magic_dns(&[0u8; 4]).is_none(),
"unparseable bytes must pass through"
);
}
fn dns_update(resolvers: Vec<ts_control::DnsResolver>) -> ts_control::StateUpdate {
ts_control::StateUpdate {
session_handle: None,
seq: 0,
derp: None,
node: Some(fixture_node()),
peer_update: None,
user_profiles: Vec::new(),
ping: None,
packetfilter: None,
pop_browser_url: None,
dial_plan: None,
dns_config: Some(ts_control::DnsConfig {
magic_dns: true,
search_domains: vec!["user.ts.net".to_owned()],
resolvers,
..Default::default()
}),
ssh_policy: None,
tka: None,
}
}
#[tokio::test]
async fn build_dns_view_maps_update() {
let update = dns_update(vec![]);
let no_exit_env = test_env(None);
let peer = exit_peer("exit", "100.64.0.9", 1080);
let db = peer_db_with(&peer);
let view = build_dns_view(&no_exit_env, &update, Some(db.clone()), true);
assert!(view.cfg.magic_dns, "dns config carried");
assert!(view.self_node.is_some(), "self node carried");
assert!(view.peers.is_some(), "peer db passed through");
assert!(
view.exit_doh.is_none(),
"no exit node configured ⇒ exit_doh None"
);
assert!(view.enable_ipv6, "ipv6 gate threaded from Env");
let exit_env = test_env(Some(ts_control::ExitNodeSelector::StableId(
ts_control::StableNodeId("exit".to_owned()),
)));
let view = build_dns_view(&exit_env, &update, Some(db), true);
assert_eq!(
view.exit_doh,
peer.peerapi_doh_addr(),
"exit_doh resolves to the active exit peer's peerAPI DoH address"
);
assert_eq!(
view.exit_doh,
Some("100.64.0.9:1080".parse().unwrap()),
"exit_doh is the peer's tailnet IPv4 + peerAPI port"
);
let ghost_env = test_env(Some(ts_control::ExitNodeSelector::StableId(
ts_control::StableNodeId("ghost".to_owned()),
)));
let view = build_dns_view(&ghost_env, &update, Some(peer_db_with(&peer)), true);
assert!(
view.exit_doh.is_none(),
"unresolved selector ⇒ exit_doh None (fail-closed)"
);
}
#[tokio::test]
async fn forward_decision_produces_udp_then_doh_plan() {
let update = dns_update(vec![udp_resolver("8.8.8.8:53")]);
let env = test_env(None);
let peer = exit_peer("exit", "100.64.0.9", 1080);
let view = build_dns_view(&env, &update, Some(peer_db_with(&peer)), true);
let query = build_query(0x4242, &["example", "com"]);
let (upstreams, recursive) = match decide(&view, &query).expect("decides") {
Decision::Forward {
upstreams,
recursive,
..
} => (upstreams, recursive),
Decision::Reply(_) => panic!("a public name with a global resolver must Forward"),
};
assert!(recursive, "an unrouted public name is a recursive forward");
assert_eq!(
upstreams,
vec!["8.8.8.8:53".parse().unwrap()],
"the IPv4 global resolver is the upstream (v4-only filter inherited)"
);
match recursive_plan(&view, upstreams.clone()) {
RecursivePlan::Udp(ups) => assert_eq!(ups, upstreams, "no exit node ⇒ UDP plan"),
RecursivePlan::Doh(_) => panic!("no exit node configured ⇒ must not delegate to DoH"),
}
let exit_env = test_env(Some(ts_control::ExitNodeSelector::StableId(
ts_control::StableNodeId("exit".to_owned()),
)));
let exit_view = build_dns_view(&exit_env, &update, Some(peer_db_with(&peer)), true);
match recursive_plan(&exit_view, upstreams) {
RecursivePlan::Doh(addr) => assert_eq!(
Some(addr),
peer.peerapi_doh_addr(),
"active exit node ⇒ delegate recursion to its peerAPI DoH endpoint"
),
RecursivePlan::Udp(_) => panic!("active exit node with no kept-local resolvers ⇒ DoH"),
}
}
fn quad100_query_packet(client: SocketAddrV4, payload: &[u8]) -> Vec<u8> {
let b = etherparse::PacketBuilder::ipv4(client.ip().octets(), [100, 100, 100, 100], 64)
.udp(client.port(), 53);
let mut out = Vec::with_capacity(b.size(payload.len()));
b.write(&mut out, payload).unwrap();
out
}
#[tokio::test]
async fn intercept_plan_spawns_forward_and_classifies_fast_paths() {
let client: SocketAddrV4 = "100.64.0.7:34567".parse().unwrap();
let update = dns_update(vec![udp_resolver("8.8.8.8:53")]);
let env = test_env(None);
let peer = exit_peer("exit", "100.64.0.9", 1080);
let view = build_dns_view(&env, &update, Some(peer_db_with(&peer)), true);
let fwd_pkt = quad100_query_packet(client, &build_query(0x4242, &["example", "com"]));
match plan_intercept(&view, &fwd_pkt) {
Intercept::Forward {
plan, src, query, ..
} => {
assert_eq!(
src, client,
"forward reply is addressed back to the querier"
);
assert!(
!query.is_empty(),
"the original query bytes are carried verbatim"
);
match plan {
RecursivePlan::Udp(ups) => assert_eq!(
ups,
vec!["8.8.8.8:53".parse().unwrap()],
"no exit node ⇒ UDP plan of the v4-only-filtered upstream (never built here)"
),
RecursivePlan::Doh(_) => panic!("no exit node configured ⇒ must not be DoH"),
}
}
_ => panic!("a public name with a global resolver must yield a SPAWNED Forward plan"),
}
let reply_pkt =
quad100_query_packet(client, &build_query(0x1, &["self", "user", "ts", "net"]));
match plan_intercept(&view, &reply_pkt) {
Intercept::Reply { src, response } => {
assert_eq!(
src, client,
"the inline reply is addressed back to the querier"
);
assert!(
!response.is_empty(),
"an authoritative reply carries response bytes"
);
}
other => assert!(
matches!(other, Intercept::Forward { .. }),
"a tailnet self-name is answered authoritatively (Reply) or, lacking overlay data, \
Forwarded — never NotIntercepted/Dropped"
),
}
let passthrough = {
let b = etherparse::PacketBuilder::ipv4(client.ip().octets(), [8, 8, 8, 8], 64)
.udp(client.port(), 53);
let mut out = Vec::new();
b.write(&mut out, b"x").unwrap();
out
};
assert!(
matches!(
plan_intercept(&view, &passthrough),
Intercept::NotIntercepted
),
"a packet not destined to quad-100:53 must pass through to the overlay"
);
let malformed = quad100_query_packet(client, &[0xff, 0x00]);
assert!(
matches!(plan_intercept(&view, &malformed), Intercept::Dropped),
"a malformed quad-100/UDP/53 query is dropped, never forwarded to the overlay"
);
}
#[test]
fn defaults_and_prefix() {
let cfg = TunConfig {
name: None,
mtu: None,
};
let dev = tun_config_from_control(&cfg, prefix());
assert_eq!(dev.name, "tailscale0");
assert_eq!(dev.mtu.get(), 1280);
assert_eq!(dev.prefix, ipnet::IpNet::V4(prefix()));
}
#[test]
fn mtu_zero_falls_back_and_overrides_honored() {
let zero = TunConfig {
name: Some("tun9".to_owned()),
mtu: Some(0),
};
let dev_zero = tun_config_from_control(&zero, prefix());
assert_eq!(dev_zero.name, "tun9");
assert_eq!(
dev_zero.mtu.get(),
1280,
"mtu=Some(0) must fall back to 1280"
);
let big = TunConfig {
name: None,
mtu: Some(9000),
};
let dev_big = tun_config_from_control(&big, prefix());
assert_eq!(dev_big.mtu.get(), 9000, "a valid mtu must be honored");
}
}