Repository: fujiapple852/trippy (language: rust)

Issue:
Add `tun` based simulation test (IPv4 only)



Discussion / hints:

Fix patch:
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 57f59087a..d596261f2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -175,12 +175,38 @@ version = "3.14.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec"
 
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
 [[package]]
 name = "bytes"
 version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
 
+[[package]]
+name = "c2rust-bitfields"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b43c3f07ab0ef604fa6f595aa46ec2f8a22172c975e186f6f5bf9829a3b72c41"
+dependencies = [
+ "c2rust-bitfields-derive",
+]
+
+[[package]]
+name = "c2rust-bitfields-derive"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3cbc102e2597c9744c8bd8c15915d554300601c91a079430d309816b0912545"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+]
+
 [[package]]
 name = "caps"
 version = "0.5.5"
@@ -575,6 +601,12 @@ version = "0.3.30"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
 
+[[package]]
+name = "futures-sink"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
+
 [[package]]
 name = "futures-task"
 version = "0.3.30"
@@ -733,7 +765,7 @@ dependencies = [
  "iana-time-zone-haiku",
  "js-sys",
  "wasm-bindgen",
- "windows-core",
+ "windows-core 0.52.0",
 ]
 
 [[package]]
@@ -799,6 +831,12 @@ version = "2.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1e186cfbae8084e513daff4240b4797e342f988cecda4fb6c939150f96315fd8"
 
+[[package]]
+name = "ioctl-sys"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8bd11f3a29434026f5ff98c730b668ba74b1033637b8817940b54d040696133c"
+
 [[package]]
 name = "ipconfig"
 version = "0.3.2"
@@ -826,6 +864,15 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "ipnetwork"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "itertools"
 version = "0.12.0"
@@ -858,9 +905,19 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
 
 [[package]]
 name = "libc"
-version = "0.2.151"
+version = "0.2.152"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4"
+checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7"
+
+[[package]]
+name = "libloading"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c571b676ddfc9a8c12f1f3d3085a7b163966a8fd8098a90640953ce5f6170161"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.48.0",
+]
 
 [[package]]
 name = "linked-hash-map"
@@ -929,7 +986,7 @@ version = "0.23.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fe2ba61113f9f7a9f0e87c519682d39c43a6f3f79c2cc42c3ba3dda83b1fa334"
 dependencies = [
- "ipnetwork",
+ "ipnetwork 0.18.0",
  "log",
  "memchr",
  "serde",
@@ -1114,9 +1171,9 @@ dependencies = [
 
 [[package]]
 name = "proc-macro2"
-version = "1.0.75"
+version = "1.0.76"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "907a61bd0f64c2f29cd1cf1dc34d05176426a3f504a78010f08416ddb7b13708"
+checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c"
 dependencies = [
  "unicode-ident",
 ]
@@ -1287,18 +1344,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
 
 [[package]]
 name = "serde"
-version = "1.0.194"
+version = "1.0.195"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b114498256798c94a0689e1a15fec6005dee8ac1f41de56404b67afc2a4b773"
+checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02"
 dependencies = [
  "serde_derive",
 ]
 
 [[package]]
 name = "serde_derive"
-version = "1.0.194"
+version = "1.0.195"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a3385e45322e8f9931410f01b3031ec534c3947d0e94c18049af4d9f9907d4e0"
+checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1354,6 +1411,19 @@ dependencies = [
  "syn 2.0.48",
 ]
 
+[[package]]
+name = "serde_yaml"
+version = "0.9.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1bf28c79a99f70ee1f1d83d10c875d2e70618417fda01ad1785e027579d9d38"
+dependencies = [
+ "indexmap 2.1.0",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
 [[package]]
 name = "sharded-slab"
 version = "0.1.7"
@@ -1606,11 +1676,39 @@ dependencies = [
  "libc",
  "mio",
  "num_cpus",
+ "parking_lot",
  "pin-project-lite",
+ "signal-hook-registry",
  "socket2",
+ "tokio-macros",
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "tokio-macros"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.48",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+ "tracing",
+]
+
 [[package]]
 name = "toml"
 version = "0.8.8"
@@ -1751,6 +1849,7 @@ dependencies = [
  "hickory-resolver",
  "humantime",
  "indexmap 2.1.0",
+ "ipnetwork 0.20.0",
  "itertools",
  "maxminddb",
  "nix",
@@ -1763,18 +1862,39 @@ dependencies = [
  "serde",
  "serde_json",
  "serde_with",
+ "serde_yaml",
  "socket2",
  "strum",
  "test-case",
  "thiserror",
+ "tokio",
+ "tokio-util",
  "toml",
  "tracing",
  "tracing-chrome",
  "tracing-subscriber",
+ "tun",
  "widestring",
  "windows-sys 0.52.0",
 ]
 
+[[package]]
+name = "tun"
+version = "0.6.1"
+source = "git+https://github.com/fujiapple852/rust-tun?rev=e944758112d23a2199b0c8d8adf8ffc6a8c1e9f5#e944758112d23a2199b0c8d8adf8ffc6a8c1e9f5"
+dependencies = [
+ "byteorder",
+ "bytes",
+ "futures-core",
+ "ioctl-sys",
+ "libc",
+ "log",
+ "thiserror",
+ "tokio",
+ "tokio-util",
+ "wintun",
+]
+
 [[package]]
 name = "unicode-bidi"
 version = "0.3.14"
@@ -1808,6 +1928,12 @@ version = "0.1.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85"
 
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab4c90930b95a82d00dc9e9ac071b4991924390d46cbd0dfe566148667605e4b"
+
 [[package]]
 name = "url"
 version = "2.5.0"
@@ -1925,6 +2051,25 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
 
+[[package]]
+name = "windows"
+version = "0.51.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca229916c5ee38c2f2bc1e9d8f04df975b4bd93f9955dc69fabb5d91270045c9"
+dependencies = [
+ "windows-core 0.51.1",
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.51.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
 [[package]]
 name = "windows-core"
 version = "0.52.0"
@@ -2068,9 +2213,9 @@ checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04"
 
 [[package]]
 name = "winnow"
-version = "0.5.32"
+version = "0.5.33"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8434aeec7b290e8da5c3f0d628cb0eac6cabcb31d14bb74f779a08109a5914d6"
+checksum = "b7520bbdec7211caa7c4e682eb1fbe07abe20cee6756b6e00f537c82c11816aa"
 dependencies = [
  "memchr",
 ]
@@ -2085,6 +2230,19 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "wintun"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29b83b0eca06dd125dbcd48a45327c708a6da8aada3d95a3f06db0ce4b17e0d4"
+dependencies = [
+ "c2rust-bitfields",
+ "libloading",
+ "log",
+ "thiserror",
+ "windows",
+]
+
 [[package]]
 name = "yansi"
 version = "0.5.1"
diff --git a/Cargo.toml b/Cargo.toml
index b5c9b6cfa..3b49d5e27 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -79,9 +79,19 @@ windows-sys = { version = "0.52.0", features = [
 
 [dev-dependencies]
 hex-literal = "0.4.1"
+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/src/tracing/packet/checksum.rs b/src/tracing/packet/checksum.rs
index 669636334..cef83fc04 100644
--- a/src/tracing/packet/checksum.rs
+++ b/src/tracing/packet/checksum.rs
@@ -31,6 +31,12 @@ pub fn udp_ipv4_checksum(data: &[u8], src_addr: Ipv4Addr, dest_addr: Ipv4Addr) -
     ipv4_checksum(data, 3, src_addr, dest_addr, IpProtocol::Udp)
 }
 
+/// Calculate the checksum for an `IPv4` `TCP` packet.
+#[must_use]
+pub fn tcp_ipv4_checksum(data: &[u8], src_addr: Ipv4Addr, dest_addr: Ipv4Addr) -> u16 {
+    ipv4_checksum(data, 8, src_addr, dest_addr, IpProtocol::Tcp)
+}
+
 /// Calculate the checksum for an `IPv6` `UDP` packet.
 #[must_use]
 pub fn udp_ipv6_checksum(data: &[u8], src_addr: Ipv6Addr, dest_addr: Ipv6Addr) -> u16 {
@@ -179,4 +185,17 @@ mod tests {
         let bytes = hex!("45 00 0f fc 38 c0 00 00 40 01 2e 3b 0a 00 00 02 0a 00 00 01");
         assert_eq!(0x1e3f, ipv4_header_checksum(&bytes));
     }
+
+    #[test]
+    fn test_tcp_ipv4_checksum() {
+        let bytes = hex!("00 50 80 ea 00 00 00 00 95 9d 2e c7 50 12 ff ff 55 cc 00 00");
+        assert_eq!(
+            0x55cc,
+            tcp_ipv4_checksum(
+                &bytes,
+                Ipv4Addr::new(10, 0, 0, 103),
+                Ipv4Addr::new(10, 0, 0, 1)
+            )
+        );
+    }
 }


Test patch:
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_gaps.yaml b/tests/resources/simulation/ipv4_icmp_gaps.yaml
new file mode 100644
index 000000000..28a34070b
--- /dev/null
+++ b/tests/resources/simulation/ipv4_icmp_gaps.yaml
@@ -0,0 +1,37 @@
+name: IPv4/ICMP with 9 hops, 2 of which do not respond
+target: 10.0.0.109
+protocol: Icmp
+icmp_identifier: 3
+hops:
+  - ttl: 1
+    resp: !SingleHost
+      addr: 10.0.0.101
+      rtt_ms: 10
+  - ttl: 2
+    resp: NoResponse
+  - ttl: 3
+    resp: !SingleHost
+      addr: 10.0.0.103
+      rtt_ms: 20
+  - ttl: 4
+    resp: !SingleHost
+      addr: 10.0.0.104
+      rtt_ms: 20
+  - ttl: 5
+    resp: !SingleHost
+      addr: 10.0.0.105
+      rtt_ms: 20
+  - ttl: 6
+    resp: !SingleHost
+      addr: 10.0.0.106
+      rtt_ms: 20
+  - ttl: 7
+    resp: !SingleHost
+      addr: 10.0.0.107
+      rtt_ms: 20
+  - ttl: 8
+    resp: NoResponse
+  - ttl: 9
+    resp: !SingleHost
+      addr: 10.0.0.109
+      rtt_ms: 20
\ No newline at end of file
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<Mutex<TunDevice>>,
+    sim: Arc<Simulation>,
+    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<TimeExceededPacket<'a>> {
+    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<EchoReplyPacket> {
+    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<DestinationUnreachablePacket<'a>> {
+    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<TcpPacket<'a>> {
+    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<Ipv4Packet<'a>> {
+    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<Hop>,
+}
+
+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<Protocol> 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<PortDirection> 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<MultipathStrategy> 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<Arc<Mutex<Runtime>>> = OnceLock::new();
+
+pub fn runtime() -> &'static Arc<Mutex<Runtime>> {
+    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<Simulation>,
+    token: CancellationToken,
+}
+
+impl Tracer {
+    pub fn new(sim: Arc<Simulation>, 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<Arc<Mutex<TunDevice>>> = OnceLock::new();
+
+/// Get a reference to the singleton `tun` device, initializing as necessary.
+pub fn tun() -> &'static Arc<Mutex<TunDevice>> {
+    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<Self> {
+        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<usize> {
+        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<usize> {
+        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
+    }
+}