diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3121761b1..1e0111890 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 }} + run: cargo test --target --target ${{ matrix.target }} --features sim-tests --test sim -- --exact --nocapture + if: startsWith(matrix.build, 'windows') + fmt: runs-on: ubuntu-22.04 steps: diff --git a/Cargo.toml b/Cargo.toml index 9f6c133bb..5b3d790dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,10 @@ test-case = "3.3.1" tun = "0.6.1" serde_yaml = "0.9.30" +[features] +# Enable simulation integration tests +sim-tests = [] + # cargo-generate-rpm dependencies [package.metadata.generate-rpm] assets = [ 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_simple.yaml b/tests/resources/simulation/ipv4_icmp_simple.yaml index ffd0628ea..c0b00acd3 100644 --- a/tests/resources/simulation/ipv4_icmp_simple.yaml +++ b/tests/resources/simulation/ipv4_icmp_simple.yaml @@ -1,4 +1,4 @@ -name: Simple example +name: Simple IPV4/ICMP example with 9 hops, 2 of which do not respond target: 10.0.0.109 icmp_identifier: 314 hops: @@ -29,7 +29,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/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..4e5d01660 --- /dev/null +++ b/tests/sim/network.rs @@ -0,0 +1,99 @@ +use crate::simulation::{Response, Simulation, SingleHost}; +use crate::tun_device::TunDevice; +use parking_lot::Mutex; +use std::io::{ErrorKind, Read, Write}; +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; +use trippy::tracing::packet::checksum::{icmp_ipv4_checksum, ipv4_header_checksum}; +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::IpProtocol; + +pub fn run(tun: &Arc>, sim: Arc) -> anyhow::Result<()> { + let mut tun = tun.lock(); + loop { + let mut buf = [0_u8; 4096]; + let bytes_read = match tun.read(&mut buf) { + Ok(bytes) => Ok(bytes), + Err(err) if err.kind() == ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + continue; + } + Err(err) => Err(err), + }?; + let ipv4 = Ipv4Packet::new_view(&buf[..bytes_read])?; + if ipv4.get_version() != 4 { + continue; + } + let echo_request = EchoRequestPacket::new_view(ipv4.payload())?; + if echo_request.get_identifier() != sim.icmp_identifier { + 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 = match sim.hops[index].resp { + Response::NoResponse => { + continue; + } + Response::SingleHost(SingleHost { + addr: IpAddr::V4(addr), + .. + }) => addr, + _ => unimplemented!(), + }; + println!("sending ttl {} reply from {}", ipv4.get_ttl(), reply_addr); + let orig_datagram_length = usize::from(ipv4.get_header_length() * 4) + 8; + let te_length = TimeExceededPacket::minimum_packet_size() + orig_datagram_length; + let mut te_buf = vec![0_u8; te_length]; + let te_packet = make_time_exceeded_v4(&mut te_buf, &ipv4.packet()[..orig_datagram_length])?; + let ipv4_length = Ipv4Packet::minimum_packet_size() + te_packet.packet().len(); + let mut ipv4_buf = vec![0_u8; ipv4_length]; + let ipv4_packet = make_ip_v4( + &mut ipv4_buf, + reply_addr, + ipv4.get_source(), + te_packet.packet(), + )?; + tun.write_all(ipv4_packet.packet())?; + } +} + +// assumes buf is exactly the right size for the full TimeExceededPacket +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) +} + +// assumes buf is exactly the right size for the full Ipv4Packet +fn make_ip_v4<'a>( + buf: &'a mut [u8], + source: Ipv4Addr, + destination: Ipv4Addr, + 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(IpProtocol::Icmp); + 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..451f4d559 --- /dev/null +++ b/tests/sim/simulation.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; +use std::net::IpAddr; + +/// A simulated trace. +#[derive(Debug, Serialize, Deserialize)] +pub struct Simulation { + pub name: String, + pub target: IpAddr, + pub icmp_identifier: u16, + pub hops: Vec, +} + +impl Simulation { + pub fn latest_ttl(&self) -> u8 { + // TODO will fail if no hops + self.hops[self.hops.len() - 1].ttl + } +} + +/// A simulated hop. +#[derive(Debug, Serialize, 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, Serialize, 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, Serialize, 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, +} diff --git a/tests/sim/tests.rs b/tests/sim/tests.rs new file mode 100644 index 000000000..0f821e022 --- /dev/null +++ b/tests/sim/tests.rs @@ -0,0 +1,25 @@ +use crate::simulation::Simulation; +use crate::tun_device::{tun, tun_lock}; +use crate::{network, tracer}; +use std::sync::Arc; +use std::thread; + +#[test] +fn test_simulation() -> anyhow::Result<()> { + let sim = serde_yaml::from_str(include_str!( + "../resources/simulation/ipv4_icmp_simple.yaml" + ))?; + run_test(sim) +} + +fn run_test(simulation: Simulation) -> anyhow::Result<()> { + let _lock = tun_lock().lock(); + let tun = tun(); + let sim = Arc::new(simulation); + let _handle = { + let sim = sim.clone(); + thread::spawn(move || network::run(tun, sim).unwrap()) + }; + tracer::Tracer::new(sim).trace()?; + Ok(()) +} diff --git a/tests/sim/tracer.rs b/tests/sim/tracer.rs new file mode 100644 index 000000000..3d9c3118b --- /dev/null +++ b/tests/sim/tracer.rs @@ -0,0 +1,62 @@ +use crate::simulation::{Response, Simulation, SingleHost}; +use std::num::NonZeroUsize; +use std::sync::Arc; +use trippy::tracing::{ + Builder, CompletionReason, MaxRounds, ProbeStatus, TimeToLive, TraceId, TracerRound, +}; + +pub struct Tracer { + sim: Arc, +} + +impl Tracer { + pub fn new(sim: Arc) -> Self { + Self { sim } + } + + pub fn trace(&self) -> anyhow::Result<()> { + Builder::new(self.sim.target, |round| self.validate_round(round)) + .trace_identifier(TraceId(self.sim.icmp_identifier)) + .max_rounds(MaxRounds(NonZeroUsize::MIN)) + .start()?; + Ok(()) + } + + fn validate_round(&self, round: &TracerRound<'_>) { + self.show(round); + assert_eq!(CompletionReason::TargetFound, round.reason); + assert_eq!(TimeToLive(self.sim.latest_ttl()), round.largest_ttl); + let largest_ttl = usize::from(round.largest_ttl.0); + for (i, hop) in round.probes[..largest_ttl].iter().enumerate() { + let (expected_status, expected_host) = match self.sim.hops[i].resp { + Response::NoResponse => (ProbeStatus::Awaited, None), + Response::SingleHost(SingleHost { addr, .. }) => { + (ProbeStatus::Complete, Some(addr)) + } + }; + let expected_ttl = TimeToLive(self.sim.hops[i].ttl); + assert_eq!(expected_status, hop.status); + assert_eq!(expected_host, hop.host); + assert_eq!(expected_ttl, hop.ttl); + } + } + + fn show(&self, round: &TracerRound<'_>) { + for hop in &round.probes[..round.largest_ttl.0 as usize] { + match hop.status { + ProbeStatus::Complete => { + println!( + "{} {} {}", + hop.round.0, + hop.ttl.0, + hop.host.as_ref().map(ToString::to_string).unwrap(), + ); + } + ProbeStatus::Awaited => { + println!("{} {} * * *", hop.round.0, hop.ttl.0); + } + _ => {} + } + } + } +} diff --git a/tests/sim/tun_device.rs b/tests/sim/tun_device.rs new file mode 100644 index 000000000..084dc45ca --- /dev/null +++ b/tests/sim/tun_device.rs @@ -0,0 +1,136 @@ +use ipnetwork::Ipv4Network; +use parking_lot::Mutex; +use std::io::{Read, Write}; +use std::sync::{Arc, OnceLock}; + +static TUN: OnceLock>> = OnceLock::new(); +static TUN_LOCK: 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)) + }) +} + +/// Get a reference to a lock. +pub fn tun_lock() -> &'static Mutex<()> { + TUN_LOCK.get_or_init(|| Mutex::new(())) +} + +/// 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"; + +#[cfg(not(target_os = "windows"))] +/// 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::platform::Device, +} + +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(&config)?; + Self::create_route()?; + Ok(Self { dev }) + } + + #[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(not(target_os = "windows"))] +impl Read for TunDevice { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let bytes_read = self.dev.read(buf)?; + if self.dev.has_packet_information() { + buf.rotate_left(4); + Ok(bytes_read - 4) + } else { + Ok(bytes_read) + } + } +} + +#[cfg(not(target_os = "windows"))] +impl Write for TunDevice { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.dev.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])?; + } else { + self.dev.write_all(buf)?; + } + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.dev.flush() + } +} + +#[cfg(target_os = "windows")] +impl Read for TunDevice { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + self.dev.read(buf) + } +} + +#[cfg(target_os = "windows")] +impl Write for TunDevice { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.dev.write_all(buf)?; + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.dev.flush() + } +}