#![doc = include_str!("../README.md")]
use std::{collections::HashMap, sync::Arc, time::Instant};
use ts_bart::RoutingTable;
use ts_overlay_router as or;
use ts_packet::PacketMut;
use ts_packetfilter::{FilterExt, IpProto};
use ts_time::{Handle, Scheduler};
use ts_transport::{OverlayTransportId, PeerId, UnderlayTransportId};
use ts_tunnel::{Endpoint, NodeKeyPair};
use ts_underlay_router as ur;
pub mod async_tokio;
const ALLOWED_LINK_LOCAL_V4: std::net::Ipv4Addr = std::net::Ipv4Addr::new(169, 254, 169, 254);
fn drop_before_rules(dst: std::net::IpAddr) -> bool {
if dst.is_multicast() {
return true;
}
match dst {
std::net::IpAddr::V4(v4) => v4.is_link_local() && v4 != ALLOWED_LINK_LOCAL_V4,
std::net::IpAddr::V6(v6) => (v6.segments()[0] & 0xffc0) == 0xfe80,
}
}
fn inbound_filter_verdict(
filter: &(dyn ts_packetfilter::Filter + Send + Sync),
proto: IpProto,
src: std::net::IpAddr,
dst: std::net::IpAddr,
dst_port: u16,
) -> bool {
if drop_before_rules(dst) {
tracing::trace!(?dst, "dropping multicast/link-local dst (pre-rule)");
return false;
}
if proto == IpProto::TSMP {
tracing::trace!(?dst, "accepting TSMP inbound (bypasses ACL, Go parity)");
return true;
}
let info = ts_packetfilter::PacketInfo {
ip_proto: proto,
port: dst_port,
src,
dst,
};
let caps = [];
let verdict = filter.can_access(&info, caps);
tracing::trace!(?info, ?caps, verdict);
verdict
}
pub enum Subsystem {
Wireguard,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CapturePath {
FromLocal = 0,
FromPeer = 1,
SynthesizedToLocal = 2,
SynthesizedToPeer = 3,
}
impl CapturePath {
pub fn code(self) -> u16 {
self as u16
}
}
pub type CaptureHook = std::sync::Arc<dyn Fn(CapturePath, &[u8]) + Send + Sync>;
pub struct DataPlane {
pub wireguard: Endpoint,
pub or_out: or::outbound::Router,
pub ur_out: ur::outbound::Router,
pub src_filter_in: Arc<ts_bart::Table<PeerId>>,
pub or_in: or::inbound::Router,
pub packet_filter: Arc<dyn ts_packetfilter::Filter + Send + Sync>,
pub events: Scheduler<Subsystem>,
pub wg_next: Option<Handle<Subsystem>>,
pub capture: Option<CaptureHook>,
}
impl DataPlane {
pub fn new(my_key: NodeKeyPair) -> Self {
DataPlane {
wireguard: Endpoint::new(my_key),
or_out: Default::default(),
ur_out: Default::default(),
src_filter_in: Default::default(),
or_in: Default::default(),
events: Default::default(),
packet_filter: Arc::new(ts_packetfilter::DropAllFilter),
wg_next: None,
capture: None,
}
}
#[tracing::instrument(skip_all, fields(n_packets = packets.len()))]
pub fn process_outbound(&mut self, packets: Vec<PacketMut>) -> OutboundResult {
if let Some(hook) = &self.capture {
for p in &packets {
hook(CapturePath::FromLocal, p.as_ref());
}
}
let or::outbound::Result {
to_wireguard,
loopback,
} = self.or_out.route(packets);
let to_wireguard = to_wireguard
.into_iter()
.map(|(k, v)| (ts_tunnel::PeerId(k.0), v))
.collect::<Vec<_>>();
let ts_tunnel::SendResult {
to_peers: encrypted,
} = self.wireguard.send(to_wireguard);
let to_peers = self
.ur_out
.route(encrypted.into_iter().map(|(k, v)| (PeerId(k.0), v)));
if let Some(next) = self.wireguard.next_event()
&& let Some(prev) = self
.wg_next
.replace(self.events.add(next, Subsystem::Wireguard))
{
prev.cancel();
}
OutboundResult { to_peers, loopback }
}
pub fn process_inbound(
&mut self,
packets: impl IntoIterator<Item = PacketMut>,
) -> InboundResult {
let ts_tunnel::RecvResult { to_local, to_peers } = self.wireguard.recv(packets);
if let Some(hook) = &self.capture {
for packets in to_local.values() {
for p in packets {
hook(CapturePath::FromPeer, p.as_ref());
}
}
}
let to_local = to_local
.into_iter()
.map(|(peer_id, mut packets)| -> Vec<PacketMut> {
let _span = tracing::trace_span!(
"src_filter_inbound",
peer_id = ?peer_id,
n_packet = packets.len(),
)
.entered();
packets.retain(|packet| {
let Some(src) = packet.get_src_addr() else {
tracing::trace!("does not look like ip packet");
return false;
};
let verdict = if let Some(allowed_peer) = self.src_filter_in.lookup(src) {
*allowed_peer == PeerId(peer_id.0)
} else {
tracing::trace!(remote_ip = %src, "unknown peer address");
false
};
tracing::trace!(?src, verdict);
verdict
});
packets
})
.map(|mut v| {
let _span =
tracing::trace_span!("packet_filter_inbound", n_packet = v.len()).entered();
v.retain(|pkt| {
let Ok(pkt) = etherparse::SlicedPacket::from_ip(pkt.as_ref()) else {
tracing::trace!("does not look like ip packet");
return false;
};
let (proto, src, dst) = match pkt.net {
Some(etherparse::NetSlice::Ipv4(ipv4)) => (
IpProto::new(ipv4.payload().ip_number.0 as _),
ipv4.header().source_addr().into(),
ipv4.header().destination_addr().into(),
),
Some(etherparse::NetSlice::Ipv6(ipv6)) => (
IpProto::new(ipv6.payload().ip_number.0 as _),
ipv6.header().source_addr().into(),
ipv6.header().destination_addr().into(),
),
_ => {
tracing::trace!("parsed packet is neither IPv4 nor IPv6; dropping");
return false;
}
};
let (_src_port, dst_port) = match pkt.transport {
Some(etherparse::TransportSlice::Udp(udp)) => {
(udp.source_port(), udp.destination_port())
}
Some(etherparse::TransportSlice::Tcp(tcp)) => {
(tcp.source_port(), tcp.destination_port())
}
_ => (0, 0),
};
inbound_filter_verdict(self.packet_filter.as_ref(), proto, src, dst, dst_port)
});
v
});
let to_peers = to_peers
.into_iter()
.map(|(k, v)| (ts_transport::PeerId(k.0), v));
let to_local = self.or_in.route(to_local.flatten());
let to_peers = self.ur_out.route(to_peers);
if let Some(next) = self.wireguard.next_event()
&& let Some(prev) = self
.wg_next
.replace(self.events.add(next, Subsystem::Wireguard))
{
prev.cancel();
}
InboundResult { to_local, to_peers }
}
pub fn next_event(&self) -> Option<Instant> {
self.events.next_dispatch()
}
pub fn process_events(&mut self) -> EventResult {
let mut to_peers = HashMap::new();
let now = Instant::now();
for event in self.events.dispatch(now) {
match event {
Subsystem::Wireguard => {
let res = self.wireguard.dispatch_events(now);
to_peers.extend(
res.to_peers
.into_iter()
.map(|(id, pkts)| (ts_transport::PeerId(id.0), pkts)),
);
}
}
}
let to_peers = self.ur_out.route(to_peers);
if let Some(next) = self.wireguard.next_event()
&& let Some(prev) = self
.wg_next
.replace(self.events.add(next, Subsystem::Wireguard))
{
prev.cancel();
}
EventResult { to_peers }
}
}
pub struct OutboundResult {
pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
pub loopback: HashMap<OverlayTransportId, Vec<PacketMut>>,
}
pub struct InboundResult {
pub to_local: HashMap<OverlayTransportId, Vec<PacketMut>>,
pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
}
#[derive(Default)]
pub struct EventResult {
pub to_peers: HashMap<(UnderlayTransportId, PeerId), Vec<PacketMut>>,
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
type CaptureLog = Arc<Mutex<Vec<(CapturePath, Vec<u8>)>>>;
#[test]
fn capture_path_codes() {
assert_eq!(CapturePath::FromLocal.code(), 0);
assert_eq!(CapturePath::FromPeer.code(), 1);
assert_eq!(CapturePath::SynthesizedToLocal.code(), 2);
assert_eq!(CapturePath::SynthesizedToPeer.code(), 3);
}
#[test]
fn pre_rule_drop_matches_go() {
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
assert!(drop_before_rules(ip("224.0.0.1")), "IPv4 multicast dropped");
assert!(
drop_before_rules(ip("239.255.255.250")),
"IPv4 multicast (SSDP) dropped"
);
assert!(
drop_before_rules(ip("169.254.1.1")),
"IPv4 link-local dropped"
);
assert!(drop_before_rules(ip("ff02::1")), "IPv6 multicast dropped");
assert!(drop_before_rules(ip("fe80::1")), "IPv6 link-local dropped");
assert!(
drop_before_rules(ip("febf:ffff::1")),
"top of fe80::/10 dropped (locks the 0xffc0/0xfe80 mask)"
);
assert!(
!drop_before_rules(ip("fec0::1")),
"just past fe80::/10 passes (locks the 0xffc0/0xfe80 mask)"
);
assert!(
!drop_before_rules(ip("::ffff:224.0.0.1")),
"4in6-mapped multicast falls through to the ACL, matching Go"
);
assert!(
!drop_before_rules(ip("::ffff:169.254.1.1")),
"4in6-mapped link-local falls through to the ACL, matching Go"
);
assert!(
!drop_before_rules(ip("100.64.0.5")),
"ordinary tailnet unicast passes"
);
assert!(
!drop_before_rules(ip("8.8.8.8")),
"ordinary public unicast passes"
);
assert!(
!drop_before_rules(ip("169.254.169.254")),
"the cloud-metadata link-local address is the Go-allowlisted exception"
);
assert!(
!drop_before_rules(ip("fd7a:115c:a1e0::1")),
"IPv6 ULA (tailnet) passes"
);
}
struct DenyAll;
impl ts_packetfilter::Filter for DenyAll {
fn match_for(
&self,
_info: &ts_packetfilter::PacketInfo,
_caps: ts_packetfilter::filter::CapIter,
) -> Option<&str> {
None
}
}
#[test]
fn tsmp_bypasses_acl_matches_go() {
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
let src = ip("100.64.0.9");
let dst = ip("100.64.0.1");
let tsmp = IpProto::new(99);
assert!(
inbound_filter_verdict(&DenyAll, tsmp, src, dst, 0),
"TSMP admitted by bypassing the (deny-all) ACL"
);
assert!(
!inbound_filter_verdict(&DenyAll, IpProto::TCP, src, dst, 443),
"TCP still consults the ACL (deny-all → dropped)"
);
assert!(
!inbound_filter_verdict(&DenyAll, tsmp, src, ip("224.0.0.1"), 0),
"TSMP to a multicast dst is still dropped (pre() before the switch)"
);
assert!(
!inbound_filter_verdict(&DenyAll, tsmp, src, ip("169.254.1.1"), 0),
"TSMP to a link-local dst is still dropped (pre() before the switch)"
);
assert_eq!(IpProto::TSMP, tsmp, "IpProto::TSMP == 99");
}
#[test]
fn capture_hook_fires_on_outbound() {
let mut dp = DataPlane::new(NodeKeyPair::new());
let recorded: CaptureLog = Arc::new(Mutex::new(Vec::new()));
let sink = recorded.clone();
dp.capture = Some(Arc::new(move |path: CapturePath, bytes: &[u8]| {
sink.lock().unwrap().push((path, bytes.to_vec()));
}));
let payload: Vec<u8> = vec![0xde, 0xad, 0xbe, 0xef];
let packet = PacketMut::from(payload.clone());
drop(dp.process_outbound(vec![packet]));
let captured = recorded.lock().unwrap();
assert_eq!(captured.len(), 1, "hook must fire exactly once per packet");
assert_eq!(captured[0].0, CapturePath::FromLocal);
assert_eq!(captured[0].1, payload);
}
}