diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3121761b1..b45af2831 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,35 @@ jobs: os: windows-latest target: x86_64-pc-windows-msvc rust: stable + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ matrix.rust }} + target: ${{ matrix.target }} + - run: cargo test --target ${{ matrix.target }} + + sim-test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + build: + - linux-stable + - macos-stable + - windows-stable + include: + - build: linux-stable + os: ubuntu-20.04 + target: x86_64-unknown-linux-gnu + rust: stable + - build: macos-stable + os: macos-latest + target: x86_64-apple-darwin + rust: stable + - build: windows-stable + os: windows-latest + target: x86_64-pc-windows-msvc + rust: stable steps: - uses: actions/checkout@v4 - uses: Swatinem/rust-cache@v2 @@ -50,7 +79,24 @@ jobs: with: toolchain: ${{ matrix.rust }} target: ${{ matrix.target }} - - run: cargo test --workspace --all-features --target ${{ matrix.target }} + - name: Copy wintun.dll to current dir + if: startsWith(matrix.build, 'windows') + shell: bash + run: | + cp "tests/resources/wintun.dll" "." + - name: Allow ICMPv4 and ICMPv6 in Windows defender firewall + if: startsWith(matrix.build, 'windows') + shell: pwsh + run: | + New-NetFirewallRule -DisplayName "ICMPv4 Trippy Allow" -Name ICMPv4_TRIPPY_ALLOW -Protocol ICMPv4 -Action Allow + New-NetFirewallRule -DisplayName "ICMPv6 Trippy Allow" -Name ICMPv6_TRIPPY_ALLOW -Protocol ICMPv6 -Action Allow + - name: Run simulation test on ${{ matrix.build }} + if: ${{ ! startsWith(matrix.build, 'windows') }} + run: sudo -E env "PATH=$PATH" cargo test --target ${{ matrix.target }} --features sim-tests --test sim -- --exact --nocapture + - name: Run simulation test on ${{ matrix.build }} + if: startsWith(matrix.build, 'windows') + run: cargo test --target --target ${{ matrix.target }} --features sim-tests --test sim -- --exact --nocapture + fmt: runs-on: ubuntu-22.04 steps: diff --git a/Cargo.lock b/Cargo.lock index fd09893d6..d596261f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1676,7 +1676,9 @@ dependencies = [ "libc", "mio", "num_cpus", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.48.0", @@ -1865,6 +1867,8 @@ dependencies = [ "strum", "test-case", "thiserror", + "tokio", + "tokio-util", "toml", "tracing", "tracing-chrome", diff --git a/Cargo.toml b/Cargo.toml index 06e9389f2..3b49d5e27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,8 +83,15 @@ ipnetwork = "0.20.0" pretty_assertions = "1.4.0" rand = "0.8.5" test-case = "3.3.1" +# see https://github.com/meh/rust-tun/pull/74 tun = { git = "https://github.com/fujiapple852/rust-tun", rev = "e944758112d23a2199b0c8d8adf8ffc6a8c1e9f5", features = [ "async" ] } serde_yaml = "0.9.30" +tokio = { version = "1.35.1", features = [ "full" ] } +tokio-util = { version = "0.7.10" } + +[features] +# Enable simulation integration tests +sim-tests = [] # cargo-generate-rpm dependencies [package.metadata.generate-rpm] diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/resources/simulation/ipv4_icmp.yaml b/tests/resources/simulation/ipv4_icmp.yaml new file mode 100644 index 000000000..b37fa7c0e --- /dev/null +++ b/tests/resources/simulation/ipv4_icmp.yaml @@ -0,0 +1,33 @@ +name: IPv4/ICMP +target: 10.0.0.107 +protocol: Icmp +icmp_identifier: 1 +hops: + - ttl: 1 + resp: !SingleHost + addr: 10.0.0.101 + rtt_ms: 10 + - ttl: 2 + resp: !SingleHost + addr: 10.0.0.102 + rtt_ms: 20 + - ttl: 3 + resp: !SingleHost + addr: 10.0.0.103 + rtt_ms: 30 + - ttl: 4 + resp: !SingleHost + addr: 10.0.0.104 + rtt_ms: 40 + - ttl: 5 + resp: !SingleHost + addr: 10.0.0.105 + rtt_ms: 50 + - ttl: 6 + resp: !SingleHost + addr: 10.0.0.106 + rtt_ms: 60 + - ttl: 7 + resp: !SingleHost + addr: 10.0.0.107 + rtt_ms: 70 \ No newline at end of file diff --git a/tests/resources/simulation/ipv4_icmp_simple.yaml b/tests/resources/simulation/ipv4_icmp_gaps.yaml similarity index 84% rename from tests/resources/simulation/ipv4_icmp_simple.yaml rename to tests/resources/simulation/ipv4_icmp_gaps.yaml index ffd0628ea..28a34070b 100644 --- a/tests/resources/simulation/ipv4_icmp_simple.yaml +++ b/tests/resources/simulation/ipv4_icmp_gaps.yaml @@ -1,6 +1,7 @@ -name: Simple example +name: IPv4/ICMP with 9 hops, 2 of which do not respond target: 10.0.0.109 -icmp_identifier: 314 +protocol: Icmp +icmp_identifier: 3 hops: - ttl: 1 resp: !SingleHost @@ -29,7 +30,7 @@ hops: addr: 10.0.0.107 rtt_ms: 20 - ttl: 8 - resp: !NoResponse + resp: NoResponse - ttl: 9 resp: !SingleHost addr: 10.0.0.109 diff --git a/tests/resources/simulation/ipv4_icmp_ooo.yaml b/tests/resources/simulation/ipv4_icmp_ooo.yaml new file mode 100644 index 000000000..5d53e2473 --- /dev/null +++ b/tests/resources/simulation/ipv4_icmp_ooo.yaml @@ -0,0 +1,25 @@ +name: IPv4/ICMP with out of order responses +target: 10.0.0.105 +protocol: Icmp +icmp_identifier: 4 +hops: + - ttl: 1 + resp: !SingleHost + addr: 10.0.0.101 + rtt_ms: 20 + - ttl: 2 + resp: !SingleHost + addr: 10.0.0.102 + rtt_ms: 15 + - ttl: 3 + resp: !SingleHost + addr: 10.0.0.103 + rtt_ms: 10 + - ttl: 4 + resp: !SingleHost + addr: 10.0.0.104 + rtt_ms: 5 + - ttl: 5 + resp: !SingleHost + addr: 10.0.0.105 + rtt_ms: 0 \ No newline at end of file diff --git a/tests/resources/simulation/ipv4_tcp_fixed_dest.yaml b/tests/resources/simulation/ipv4_tcp_fixed_dest.yaml new file mode 100644 index 000000000..ab10efa67 --- /dev/null +++ b/tests/resources/simulation/ipv4_tcp_fixed_dest.yaml @@ -0,0 +1,17 @@ +name: IPv4/TCP with a fixed dest port +target: 10.0.0.103 +protocol: Tcp +port_direction: !FixedDest 80 +hops: + - ttl: 1 + resp: !SingleHost + addr: 10.0.0.101 + rtt_ms: 10 + - ttl: 2 + resp: !SingleHost + addr: 10.0.0.102 + rtt_ms: 20 + - ttl: 3 + resp: !SingleHost + addr: 10.0.0.103 + rtt_ms: 20 \ No newline at end of file diff --git a/tests/resources/simulation/ipv4_udp_classic_fixed_dest.yaml b/tests/resources/simulation/ipv4_udp_classic_fixed_dest.yaml new file mode 100644 index 000000000..bc308806b --- /dev/null +++ b/tests/resources/simulation/ipv4_udp_classic_fixed_dest.yaml @@ -0,0 +1,18 @@ +name: IPv4/UDP classic with a fixed dest port +target: 10.0.0.103 +protocol: Udp +port_direction: !FixedDest 33000 +multipath_strategy: Classic +hops: + - ttl: 1 + resp: !SingleHost + addr: 10.0.0.101 + rtt_ms: 10 + - ttl: 2 + resp: !SingleHost + addr: 10.0.0.102 + rtt_ms: 20 + - ttl: 3 + resp: !SingleHost + addr: 10.0.0.103 + rtt_ms: 20 \ No newline at end of file diff --git a/tests/resources/simulation/ipv4_udp_classic_fixed_src.yaml b/tests/resources/simulation/ipv4_udp_classic_fixed_src.yaml new file mode 100644 index 000000000..fa4da1b23 --- /dev/null +++ b/tests/resources/simulation/ipv4_udp_classic_fixed_src.yaml @@ -0,0 +1,18 @@ +name: IPv4/UDP classic with a fixed src port +target: 10.0.0.103 +protocol: Udp +port_direction: !FixedSrc 5000 +multipath_strategy: Classic +hops: + - ttl: 1 + resp: !SingleHost + addr: 10.0.0.101 + rtt_ms: 10 + - ttl: 2 + resp: !SingleHost + addr: 10.0.0.102 + rtt_ms: 20 + - ttl: 3 + resp: !SingleHost + addr: 10.0.0.103 + rtt_ms: 20 \ No newline at end of file diff --git a/tests/resources/simulation/ipv4_udp_paris_fixed_both.yaml b/tests/resources/simulation/ipv4_udp_paris_fixed_both.yaml new file mode 100644 index 000000000..3ce15c2fb --- /dev/null +++ b/tests/resources/simulation/ipv4_udp_paris_fixed_both.yaml @@ -0,0 +1,20 @@ +name: IPv4/UDP Paris with a fixed src and dest port +target: 10.0.0.103 +protocol: Udp +port_direction: !FixedBoth + src: 5000 + dest: 33000 +multipath_strategy: Paris +hops: + - ttl: 1 + resp: !SingleHost + addr: 10.0.0.101 + rtt_ms: 10 + - ttl: 2 + resp: !SingleHost + addr: 10.0.0.102 + rtt_ms: 20 + - ttl: 3 + resp: !SingleHost + addr: 10.0.0.103 + rtt_ms: 20 \ No newline at end of file diff --git a/tests/resources/wintun.dll b/tests/resources/wintun.dll new file mode 100644 index 000000000..aee04e77b Binary files /dev/null and b/tests/resources/wintun.dll differ diff --git a/tests/sim/main.rs b/tests/sim/main.rs new file mode 100644 index 000000000..4094d0f24 --- /dev/null +++ b/tests/sim/main.rs @@ -0,0 +1,6 @@ +#![cfg(feature = "sim-tests")] +mod network; +mod simulation; +mod tests; +mod tracer; +mod tun_device; diff --git a/tests/sim/network.rs b/tests/sim/network.rs new file mode 100644 index 000000000..ce87acf20 --- /dev/null +++ b/tests/sim/network.rs @@ -0,0 +1,281 @@ +use crate::simulation::{Protocol, Response, Simulation, SingleHost}; +use crate::tun_device::TunDevice; +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info}; +use trippy::tracing::packet::checksum::{ + icmp_ipv4_checksum, ipv4_header_checksum, tcp_ipv4_checksum, +}; +use trippy::tracing::packet::icmpv4::destination_unreachable::DestinationUnreachablePacket; +use trippy::tracing::packet::icmpv4::echo_reply::EchoReplyPacket; +use trippy::tracing::packet::icmpv4::echo_request::EchoRequestPacket; +use trippy::tracing::packet::icmpv4::time_exceeded::TimeExceededPacket; +use trippy::tracing::packet::icmpv4::{IcmpCode, IcmpType}; +use trippy::tracing::packet::ipv4::Ipv4Packet; +use trippy::tracing::packet::tcp::TcpPacket; +use trippy::tracing::packet::udp::UdpPacket; +use trippy::tracing::packet::IpProtocol; + +pub async fn run( + tun: Arc>, + sim: Arc, + token: CancellationToken, +) -> anyhow::Result<()> { + loop { + let mut buf = [0_u8; 4096]; + let bytes_read = { + let tun = tun.clone(); + let mut tun = tun.lock().await; + tokio::select!( + _ = token.cancelled() => { + return Ok(()) + }, + bytes_read = tun.read(&mut buf) => { + bytes_read? + }, + ) + }; + + let ipv4 = Ipv4Packet::new_view(&buf[..bytes_read])?; + if ipv4.get_version() != 4 { + debug!("skipping ipv6 packet"); + continue; + } + debug!("read: {:?}", ipv4); + + let orig_datagram_length = usize::from(ipv4.get_header_length() * 4) + 8; + + match (ipv4.get_protocol(), sim.protocol) { + (IpProtocol::Icmp, Protocol::Icmp) => { + let echo_request = EchoRequestPacket::new_view(ipv4.payload())?; + if echo_request.get_identifier() != sim.icmp_identifier { + debug!( + "skipping EchoRequest with unexpected id (exp={} act={}))", + echo_request.get_identifier(), + sim.icmp_identifier + ); + continue; + } + debug!("payload in: {:?}", echo_request); + info!( + "received EchoRequest with ttl={} id={} seq={}", + ipv4.get_ttl(), + echo_request.get_identifier(), + echo_request.get_sequence() + ); + } + (IpProtocol::Udp, Protocol::Udp) => { + let udp = UdpPacket::new_view(ipv4.payload())?; + debug!("payload in: {:?}", udp); + info!( + "received UdpPacket with ttl={} src={} dest={}", + ipv4.get_ttl(), + udp.get_source(), + udp.get_destination() + ); + } + (IpProtocol::Tcp, Protocol::Tcp) => { + let tcp = TcpPacket::new_view(ipv4.payload())?; + debug!("payload in: {:?}", tcp); + info!( + "received TcpPacket with ttl={} src={} dest={}", + ipv4.get_ttl(), + tcp.get_source(), + tcp.get_destination() + ); + } + _ => { + continue; + } + } + + // if the ttl is greater than the largest ttl in our sim we will reply as the last node in the sim + let index = std::cmp::min(usize::from(ipv4.get_ttl()) - 1, sim.hops.len() - 1); + let (reply_addr, reply_delay_ms) = match sim.hops[index].resp { + Response::NoResponse => { + continue; + } + Response::SingleHost(SingleHost { + addr: IpAddr::V4(addr), + rtt_ms, + }) => (addr, rtt_ms), + _ => unimplemented!(), + }; + + // decide what response to send + let (protocol, payload) = if IpAddr::V4(reply_addr) == sim.target { + match sim.protocol { + Protocol::Icmp => { + info!( + "sending ICMP EchoReply from {} to {} for ttl {} after {}ms delay", + reply_addr, + ipv4.get_source(), + ipv4.get_ttl(), + reply_delay_ms, + ); + let echo_request = EchoRequestPacket::new_view(ipv4.payload())?; + let mut packet_buf = vec![0_u8; EchoReplyPacket::minimum_packet_size()]; + let packet = make_echo_reply_v4( + &mut packet_buf, + sim.icmp_identifier, + echo_request.get_sequence(), + )?; + debug!("payload out: {:?}", packet); + (IpProtocol::Icmp, packet_buf) + } + Protocol::Udp => { + info!( + "sending ICMP DestinationUnreachable from {} to {} for ttl {} after {}ms delay", + reply_addr, + ipv4.get_source(), + ipv4.get_ttl(), + reply_delay_ms, + ); + let length = + DestinationUnreachablePacket::minimum_packet_size() + orig_datagram_length; + let mut packet_buf = vec![0_u8; length]; + let packet = make_destination_unreachable_v4( + &mut packet_buf, + &ipv4.packet()[..orig_datagram_length], + )?; + debug!("payload out: {:?}", packet); + (IpProtocol::Icmp, packet_buf) + } + Protocol::Tcp => { + info!( + "sending TCP syn+ack from {} to {} for ttl {} after {}ms delay", + reply_addr, + ipv4.get_source(), + ipv4.get_ttl(), + reply_delay_ms, + ); + let tcp_in = TcpPacket::new_view(ipv4.payload())?; + let mut packet_buf = vec![0_u8; TcpPacket::minimum_packet_size()]; + let packet = make_tcp_syn_ack(&mut packet_buf, &ipv4, &tcp_in)?; + debug!("payload out: {:?}", packet); + (IpProtocol::Tcp, packet_buf) + } + } + } else { + info!( + "sending ICMP TimeExceeded from {} to {} for ttl {} after {}ms delay", + reply_addr, + ipv4.get_source(), + ipv4.get_ttl(), + reply_delay_ms, + ); + let length = TimeExceededPacket::minimum_packet_size() + orig_datagram_length; + let mut packet_buf = vec![0_u8; length]; + let packet = + make_time_exceeded_v4(&mut packet_buf, &ipv4.packet()[..orig_datagram_length])?; + debug!("payload out: {:?}", packet); + (IpProtocol::Icmp, packet_buf) + }; + + let ipv4_length = Ipv4Packet::minimum_packet_size() + payload.len(); + let mut ipv4_buf = vec![0_u8; ipv4_length]; + make_ip_v4( + &mut ipv4_buf, + reply_addr, + ipv4.get_source(), + protocol, + &payload, + )?; + + { + let tun = tun.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(u64::from(reply_delay_ms))).await; + let mut tun = tun.lock().await; + let ipv4 = Ipv4Packet::new_view(&ipv4_buf).unwrap(); + debug!("write: {:?}", ipv4); + tun.write(ipv4.packet()).await.expect("send"); + }); + } + } +} + +fn make_time_exceeded_v4<'a>( + buf: &'a mut [u8], + payload: &[u8], +) -> anyhow::Result> { + let mut packet = TimeExceededPacket::new(buf)?; + packet.set_icmp_type(IcmpType::TimeExceeded); + packet.set_icmp_code(IcmpCode(0)); + packet.set_payload(payload); + packet.set_checksum(icmp_ipv4_checksum(packet.packet())); + Ok(packet) +} + +fn make_echo_reply_v4( + buf: &mut [u8], + icmp_identifier: u16, + sequence: u16, +) -> anyhow::Result { + let mut packet = EchoReplyPacket::new(buf)?; + packet.set_icmp_type(IcmpType::EchoReply); + packet.set_icmp_code(IcmpCode(0)); + packet.set_identifier(icmp_identifier); + packet.set_sequence(sequence); + packet.set_checksum(icmp_ipv4_checksum(packet.packet())); + Ok(packet) +} + +fn make_destination_unreachable_v4<'a>( + buf: &'a mut [u8], + payload: &[u8], +) -> anyhow::Result> { + let mut packet = DestinationUnreachablePacket::new(buf)?; + packet.set_icmp_type(IcmpType::DestinationUnreachable); + packet.set_icmp_code(IcmpCode(0)); + packet.set_payload(payload); + packet.set_checksum(icmp_ipv4_checksum(packet.packet())); + Ok(packet) +} + +fn make_tcp_syn_ack<'a>( + buf: &'a mut [u8], + ipv4: &Ipv4Packet<'_>, + tcp_in: &TcpPacket<'_>, +) -> anyhow::Result> { + let mut packet = TcpPacket::new(buf)?; + packet.set_data_offset(5); + packet.set_source(tcp_in.get_destination()); + packet.set_destination(tcp_in.get_source()); + packet.set_sequence(0); + packet.set_acknowledgement(tcp_in.get_sequence() + 1); + packet.set_flags(0b00010010); + packet.set_window_size(0xFFFF); + packet.set_checksum(tcp_ipv4_checksum( + packet.packet(), + ipv4.get_destination(), + ipv4.get_source(), + )); + Ok(packet) +} + +fn make_ip_v4<'a>( + buf: &'a mut [u8], + source: Ipv4Addr, + destination: Ipv4Addr, + protocol: IpProtocol, + payload: &[u8], +) -> anyhow::Result> { + let ipv4_total_length = buf.len(); + let mut packet = Ipv4Packet::new(buf)?; + packet.set_version(4); + packet.set_header_length(5); + packet.set_protocol(protocol); + packet.set_ttl(64); + packet.set_source(source); + packet.set_destination(destination); + packet.set_total_length(u16::try_from(ipv4_total_length)?); + packet.set_checksum(ipv4_header_checksum( + &packet.packet()[..Ipv4Packet::minimum_packet_size()], + )); + packet.set_payload(payload); + Ok(packet) +} diff --git a/tests/sim/simulation.rs b/tests/sim/simulation.rs new file mode 100644 index 000000000..7160e9b0a --- /dev/null +++ b/tests/sim/simulation.rs @@ -0,0 +1,118 @@ +use serde::Deserialize; +use std::net::IpAddr; +use trippy::tracing::Port; + +/// A simulated trace. +#[derive(Debug, Deserialize)] +pub struct Simulation { + pub name: String, + pub target: IpAddr, + pub protocol: Protocol, + #[serde(default)] + pub port_direction: PortDirection, + #[serde(default)] + pub multipath_strategy: MultipathStrategy, + #[serde(default)] + pub icmp_identifier: u16, + pub hops: Vec, +} + +impl Simulation { + pub fn latest_ttl(&self) -> u8 { + if self.hops.is_empty() { + 0 + } else { + self.hops[self.hops.len() - 1].ttl + } + } +} + +/// A simulated hop. +#[derive(Debug, Deserialize)] +pub struct Hop { + /// The simulated time-to-live (TTL). + pub ttl: u8, + /// The simulated probe response. + pub resp: Response, +} + +/// A simulated probe response. +#[derive(Debug, Deserialize)] +pub enum Response { + /// Simulate a hop which does not response to probes. + NoResponse, + /// Simulate a hop which responds to probes from a single host. + SingleHost(SingleHost), +} + +/// A simulated probe response with a single addr and fixed ttl. +#[derive(Debug, Deserialize)] +pub struct SingleHost { + /// The simulated host responding to the probe. + pub addr: IpAddr, + /// The simulated round trim time (RTT) in ms. + pub rtt_ms: u16, +} + +#[derive(Copy, Clone, Debug, Deserialize)] +pub enum Protocol { + Icmp, + Udp, + Tcp, +} + +impl From for trippy::tracing::Protocol { + fn from(value: Protocol) -> Self { + match value { + Protocol::Icmp => Self::Icmp, + Protocol::Udp => Self::Udp, + Protocol::Tcp => Self::Tcp, + } + } +} + +#[derive(Copy, Clone, Debug, Default, Deserialize)] +pub enum PortDirection { + #[default] + None, + FixedSrc(u16), + FixedDest(u16), + FixedBoth(FixedBoth), +} + +#[derive(Copy, Clone, Debug, Default, Deserialize)] +pub struct FixedBoth { + pub src: u16, + pub dest: u16, +} + +impl From for trippy::tracing::PortDirection { + fn from(value: PortDirection) -> Self { + match value { + PortDirection::None => Self::None, + PortDirection::FixedSrc(src) => Self::FixedSrc(Port(src)), + PortDirection::FixedDest(dest) => Self::FixedDest(Port(dest)), + PortDirection::FixedBoth(FixedBoth { src, dest }) => { + Self::FixedBoth(Port(src), Port(dest)) + } + } + } +} + +#[derive(Copy, Clone, Debug, Default, Deserialize)] +pub enum MultipathStrategy { + #[default] + Classic, + Paris, + Dublin, +} + +impl From for trippy::tracing::MultipathStrategy { + fn from(value: MultipathStrategy) -> Self { + match value { + MultipathStrategy::Classic => Self::Classic, + MultipathStrategy::Paris => Self::Paris, + MultipathStrategy::Dublin => Self::Dublin, + } + } +} diff --git a/tests/sim/tests.rs b/tests/sim/tests.rs new file mode 100644 index 000000000..0aa81a0f8 --- /dev/null +++ b/tests/sim/tests.rs @@ -0,0 +1,58 @@ +use crate::simulation::Simulation; +use crate::tun_device::tun; +use crate::{network, tracer}; +use std::sync::{Arc, Mutex, OnceLock}; +use test_case::test_case; +use tokio::runtime::Runtime; +use tokio_util::sync::CancellationToken; +use tracing::info; +use tracing_subscriber::fmt::format::FmtSpan; + +static RUNTIME: OnceLock>> = OnceLock::new(); + +pub fn runtime() -> &'static Arc> { + RUNTIME.get_or_init(|| { + tracing_subscriber::fmt() + .with_span_events(FmtSpan::NONE) + .with_env_filter("trippy=debug,sim=debug") + .init(); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + Arc::new(Mutex::new(runtime)) + }) +} + +macro_rules! sim { + ($path:expr) => {{ + let yaml = include_str!(concat!("../resources/simulation/", $path)); + serde_yaml::from_str(yaml)? + }}; +} + +#[test_case(sim!("ipv4_icmp.yaml"))] +#[test_case(sim!("ipv4_icmp_gaps.yaml"))] +#[test_case(sim!("ipv4_icmp_ooo.yaml"))] +#[test_case(sim!("ipv4_udp_classic_fixed_src.yaml"))] +#[test_case(sim!("ipv4_udp_classic_fixed_dest.yaml"))] +#[test_case(sim!("ipv4_udp_paris_fixed_both.yaml"))] +#[test_case(sim!("ipv4_tcp_fixed_dest.yaml"))] +fn test_simulation(simulation: Simulation) -> anyhow::Result<()> { + run_simulation(simulation) +} + +fn run_simulation(simulation: Simulation) -> anyhow::Result<()> { + let runtime = runtime().lock().unwrap(); + info!("simulating {}", simulation.name); + runtime.block_on(async { + let tun = tun(); + let sim = Arc::new(simulation); + let token = CancellationToken::new(); + let handle = tokio::spawn(network::run(tun.clone(), sim.clone(), token.clone())); + tokio::task::spawn_blocking(move || tracer::Tracer::new(sim, token).trace()).await??; + handle.await??; + Ok(()) + }) +} diff --git a/tests/sim/tracer.rs b/tests/sim/tracer.rs new file mode 100644 index 000000000..1de5ab5e1 --- /dev/null +++ b/tests/sim/tracer.rs @@ -0,0 +1,69 @@ +use crate::simulation::{Response, Simulation, SingleHost}; +use std::num::NonZeroUsize; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; +use tracing::info; +use trippy::tracing::{ + Builder, CompletionReason, MaxRounds, MultipathStrategy, PortDirection, ProbeStatus, Protocol, + TimeToLive, TraceId, TracerRound, +}; + +pub struct Tracer { + sim: Arc, + token: CancellationToken, +} + +impl Tracer { + pub fn new(sim: Arc, token: CancellationToken) -> Self { + Self { sim, token } + } + + pub fn trace(&self) -> anyhow::Result<()> { + Builder::new(self.sim.target, |round| self.validate_round(round)) + .trace_identifier(TraceId(self.sim.icmp_identifier)) + .protocol(Protocol::from(self.sim.protocol)) + .port_direction(PortDirection::from(self.sim.port_direction)) + .multipath_strategy(MultipathStrategy::from(self.sim.multipath_strategy)) + .max_rounds(MaxRounds(NonZeroUsize::MIN)) + .start()?; + self.token.cancel(); + Ok(()) + } + + fn validate_round(&self, round: &TracerRound<'_>) { + assert_eq!(CompletionReason::TargetFound, round.reason); + assert_eq!(TimeToLive(self.sim.latest_ttl()), round.largest_ttl); + for hop in round + .probes + .iter() + .filter(|p| matches!(p.status, ProbeStatus::Awaited | ProbeStatus::Complete)) + .take(round.largest_ttl.0 as usize) + { + match hop.status { + ProbeStatus::Complete => { + info!( + "{} {} {}", + hop.round.0, + hop.ttl.0, + hop.host.as_ref().map(ToString::to_string).unwrap(), + ); + } + ProbeStatus::Awaited => { + info!("{} {} * * *", hop.round.0, hop.ttl.0); + } + _ => {} + } + let hop_index = usize::from(hop.ttl.0 - 1); + let (expected_status, expected_host) = match self.sim.hops[hop_index].resp { + Response::NoResponse => (ProbeStatus::Awaited, None), + Response::SingleHost(SingleHost { addr, .. }) => { + (ProbeStatus::Complete, Some(addr)) + } + }; + let expected_ttl = TimeToLive(self.sim.hops[hop_index].ttl); + assert_eq!(expected_status, hop.status); + assert_eq!(expected_host, hop.host); + assert_eq!(expected_ttl, hop.ttl); + } + } +} diff --git a/tests/sim/tun_device.rs b/tests/sim/tun_device.rs new file mode 100644 index 000000000..6b926a81e --- /dev/null +++ b/tests/sim/tun_device.rs @@ -0,0 +1,115 @@ +use ipnetwork::Ipv4Network; +use std::sync::{Arc, OnceLock}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::Mutex; + +static TUN: OnceLock>> = OnceLock::new(); + +/// Get a reference to the singleton `tun` device, initializing as necessary. +pub fn tun() -> &'static Arc> { + TUN.get_or_init(|| { + let tun = TunDevice::start().expect("tun"); + Arc::new(Mutex::new(tun)) + }) +} + +/// The CIDR network range to route to the `tun` device. +/// +/// The `tun` device will be assigned the 2nd ip address from the CIDR network +/// range. +/// +/// For example, if this is set to `10.0.0.0/24` then the `tun` device will be +/// assigned the IP `10.0.0.1` and all packets sent to the network range +/// `10.0.0.0/24` will be routed via the `tun` device and sent from IP +/// `10.0.0.1`. +const TUN_NETWORK_CIDR: &str = "10.0.0.0/24"; + +/// The flags (u16) and proto (u16) packet information. +/// +/// These 4 octets are prepended to incoming and outgoing packets on some +/// platforms. +const PACKET_INFO: [u8; 4] = [0x0, 0x0, 0x0, 0x2]; + +/// A `tun` device. +pub struct TunDevice { + dev: tun::AsyncDevice, +} + +impl TunDevice { + pub fn start() -> anyhow::Result { + let net: Ipv4Network = TUN_NETWORK_CIDR.parse()?; + let addr = net.nth(1).expect("addr"); + let mut config = tun::Configuration::default(); + config.address(addr).netmask(net.mask()).up(); + let dev = tun::create_as_async(&config)?; + Self::create_route()?; + Ok(Self { dev }) + } + + pub async fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let bytes_read = self.dev.read(buf).await?; + if self.has_packet_information() { + buf.rotate_left(4); + Ok(bytes_read - 4) + } else { + Ok(bytes_read) + } + } + + pub async fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.has_packet_information() { + let mut dev_buf = [0_u8; 4096 + 4]; + dev_buf[..4].copy_from_slice(&PACKET_INFO); + dev_buf[4..buf.len() + 4].copy_from_slice(buf); + self.dev.write_all(&dev_buf[..buf.len() + 4]).await?; + } else { + self.dev.write_all(buf).await?; + } + Ok(buf.len()) + } + + #[cfg(target_os = "macos")] + fn create_route() -> anyhow::Result<()> { + // macOS requires that we explicitly add the route. + let net: Ipv4Network = TUN_NETWORK_CIDR.parse()?; + let addr = net.nth(1).expect("addr"); + std::process::Command::new("sudo") + .args([ + "route", + "-n", + "add", + "-net", + &net.to_string(), + &addr.to_string(), + ]) + .status()?; + Ok(()) + } + + #[cfg(target_os = "linux")] + fn create_route() -> anyhow::Result<()> { + Ok(()) + } + + #[cfg(target_os = "windows")] + fn create_route() -> anyhow::Result<()> { + // allow time for the routing table to reflect the tun device. + std::thread::sleep(std::time::Duration::from_millis(10000)); + Ok(()) + } + + #[cfg(target_os = "macos")] + fn has_packet_information(&self) -> bool { + true + } + + #[cfg(target_os = "linux")] + fn has_packet_information(&self) -> bool { + false + } + + #[cfg(target_os = "windows")] + fn has_packet_information(&self) -> bool { + false + } +}