diff --git a/.github/workflows/patchbay.yml b/.github/workflows/patchbay.yml new file mode 100644 index 000000000..cde80ccec --- /dev/null +++ b/.github/workflows/patchbay.yml @@ -0,0 +1,107 @@ +name: Patchbay Tests + +on: + pull_request: + push: + branches: + - main + +concurrency: + group: patchbay-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + RUST_BACKTRACE: 1 + RUSTFLAGS: "-Dwarnings --cfg patchbay_tests" + SCCACHE_CACHE_SIZE: "10G" + +jobs: + patchbay_tests: + name: Patchbay Tests + timeout-minutes: 45 + runs-on: [self-hosted, linux, X64] + env: + RUSTC_WRAPPER: "sccache" + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - uses: mozilla-actions/sccache-action@v0.0.9 + + - name: Run patchbay tests + id: tests + run: cargo test --release -p noq --test netsim --features rustls-ring -- --test-threads=1 + env: + RUST_LOG: debug,noq=trace + + - name: Push results + if: always() + env: + PATCHBAY_URL: https://frando.gateway.lol + PATCHBAY_API_KEY: ${{ secrets.PATCHBAY_API_KEY }} + TEST_STATUS: ${{ steps.tests.outcome }} + run: | + set -euo pipefail + PROJECT="${{ github.event.repository.name }}" + TESTDIR="$(cargo metadata --format-version=1 --no-deps | jq -r .target_directory)/testdir-current" + [ ! -d "$TESTDIR" ] && echo "No testdir output, skipping" && exit 0 + + cat > "$TESTDIR/run.json" <> "$GITHUB_ENV" + echo "PATCHBAY_TEST_STATUS=$TEST_STATUS" >> "$GITHUB_ENV" + echo "Results: $VIEW_URL" + + - name: Comment on PR + if: always() && env.PATCHBAY_VIEW_URL + uses: actions/github-script@v7 + with: + script: | + // Find PR number: from event or by looking up open PRs for the branch + let prNumber = context.issue?.number; + if (!prNumber) { + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, repo: context.repo.repo, + head: `${context.repo.owner}:${{ github.ref_name }}`, + state: 'open', + }); + if (!prs.length) return; + prNumber = prs[0].number; + } + + const status = process.env.PATCHBAY_TEST_STATUS; + const icon = status === 'success' ? '\u2705' : '\u274c'; + const marker = ''; + const body = `${marker}\n${icon} **patchbay:** ${status} | ${process.env.PATCHBAY_VIEW_URL}`; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, + }); + const existing = comments.find(c => c.body.includes(marker)); + const params = { owner: context.repo.owner, repo: context.repo.repo }; + if (existing) { + await github.rest.issues.updateComment({ ...params, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ ...params, issue_number: prNumber, body }); + } diff --git a/Cargo.lock b/Cargo.lock index 23b1c48fa..a5be7f619 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -500,6 +509,37 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24b1f0365a6c6bb4020cd05806fd0d33c44d38046b8bd7f0e40814b9763cabfc" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", +] + [[package]] name = "cast" version = "0.3.0" @@ -545,6 +585,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -829,6 +881,22 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctor" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" + [[package]] name = "ctr" version = "0.9.2" @@ -907,18 +975,18 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "convert_case", "proc-macro2", @@ -960,6 +1028,21 @@ dependencies = [ "syn", ] +[[package]] +name = "dtor" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" + [[package]] name = "dunce" version = "1.0.5" @@ -1075,6 +1158,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1082,6 +1180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1090,6 +1189,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -1109,6 +1219,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -1127,10 +1248,16 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -1381,6 +1508,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -1530,6 +1681,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -1639,6 +1799,7 @@ checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags", "libc", + "redox_syscall", ] [[package]] @@ -1748,7 +1909,67 @@ dependencies = [ "serde_json", "serde_with", "smallvec", - "strum", + "strum 0.27.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.17", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", ] [[package]] @@ -1777,6 +1998,7 @@ dependencies = [ "clap", "crc", "criterion", + "ctor", "directories-next", "futures-io", "gcc", @@ -1784,6 +2006,7 @@ dependencies = [ "lazy_static", "noq-proto", "noq-udp", + "patchbay", "pin-project-lite", "rand", "rcgen", @@ -1792,6 +2015,7 @@ dependencies = [ "sharded-slab", "smol", "socket2", + "testdir", "testresult", "thiserror 2.0.17", "tokio", @@ -1856,6 +2080,15 @@ dependencies = [ "windows-sys 0.61.1", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -1958,6 +2191,36 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "patchbay" +version = "0.1.0" +source = "git+https://github.com/n0-computer/patchbay.git?branch=feat%2Fserver-push#0e388f96aa7547d1e2e7108ba8e8d76cb7db5cc0" +dependencies = [ + "anyhow", + "chrono", + "derive_more", + "futures", + "ipnet", + "libc", + "nix", + "rtnetlink", + "serde", + "serde_json", + "strum 0.28.0", + "tokio", + "tokio-util", + "toml", + "tracing", + "tracing-core", + "tracing-subscriber", +] + [[package]] name = "pem" version = "3.0.5" @@ -2292,6 +2555,24 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rtnetlink" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b960d5d873a75b5be9761b1e73b146f52dddcd27bac75263f40fba686d4d7b5" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "nix", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -2467,6 +2748,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -2512,6 +2797,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_with" version = "3.14.1" @@ -2660,7 +2954,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -2675,6 +2978,18 @@ dependencies = [ "syn", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2709,6 +3024,20 @@ dependencies = [ "syn", ] +[[package]] +name = "sysinfo" +version = "0.26.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c18a6156d1f27a9592ee18c1a846ca8dd5c258b7179fc193ae87c74ebb666f5" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "winapi", +] + [[package]] name = "test-strategy" version = "0.4.3" @@ -2722,6 +3051,21 @@ dependencies = [ "syn", ] +[[package]] +name = "testdir" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ffa013be124f7e8e648876190de818e3a87088ed97ccd414a398b403aec8c8" +dependencies = [ + "anyhow", + "backtrace", + "cargo-platform", + "cargo_metadata", + "once_cell", + "sysinfo", + "whoami", +] + [[package]] name = "testresult" version = "0.4.1" @@ -2901,6 +3245,45 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.0.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.10+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.7+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" + [[package]] name = "tonic" version = "0.14.2" @@ -3164,6 +3547,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.106" @@ -3283,6 +3672,17 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3314,12 +3714,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -3551,6 +4004,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" + [[package]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index 41746ad23..3a4a0a04f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,7 +81,7 @@ debug = true [workspace.lints.rust] elided_lifetimes_in_paths = "warn" # https://rust-fuzz.github.io/book/cargo-fuzz/guide.html#cfgfuzzing -unexpected_cfgs = { level = "warn", check-cfg = ['cfg(fuzzing)'] } +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(fuzzing)', 'cfg(patchbay_tests)'] } unnameable_types = "warn" unreachable_pub = "warn" diff --git a/noq/Cargo.toml b/noq/Cargo.toml index 26ef78d3a..0f592dae5 100644 --- a/noq/Cargo.toml +++ b/noq/Cargo.toml @@ -105,6 +105,12 @@ tracing-futures = { workspace = true } url = { workspace = true } tokio-stream = "0.1.15" testresult = "0.4.1" +proto = { package = "noq-proto", path = "../noq-proto", default-features = false, features = ["rustls-ring", "ring"] } + +[target.'cfg(target_os = "linux")'.dev-dependencies] +patchbay = { git = "https://github.com/n0-computer/patchbay.git", branch = "feat/server-push" } +ctor = "0.4" +testdir = "0.9" [build-dependencies] cfg_aliases = { workspace = true } @@ -132,6 +138,10 @@ required-features = ["rustls", "ring"] name = "connection" required-features = ["rustls", "ring"] +[[test]] +name = "netsim" +required-features = ["rustls-ring"] + [[test]] name = "post_quantum" required-features = ["__rustls-post-quantum-test"] diff --git a/noq/tests/netsim.rs b/noq/tests/netsim.rs new file mode 100644 index 000000000..8ec94a3e4 --- /dev/null +++ b/noq/tests/netsim.rs @@ -0,0 +1,570 @@ +// patchbay only runs on linux +#![cfg(target_os = "linux")] +// Only compile these tests when the patchbay_tests cfg is enabled. +// Run with: RUSTFLAGS="--cfg patchbay_tests" cargo test -p noq --features rustls-ring --test netsim -- --test-threads=1 +#![cfg(patchbay_tests)] +//! Network simulation tests using patchbay. +//! +//! These tests exercise holepunching and path migration through realistic +//! network topologies backed by Linux network namespaces. + +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use patchbay::{Lab, LabOpts, OutDir, RouterPreset}; +use proto::{PathEvent, n0_nat_traversal}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use testdir::testdir; +use tokio::sync::oneshot; + +#[ctor::ctor] +unsafe fn init() { + unsafe { patchbay::init_userns_for_ctor() }; +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +fn subscribe() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "noq=debug,info".parse().unwrap()), + ) + .with_test_writer() + .try_init() + .ok(); +} + +/// Create a self-signed cert + matching client/server configs with NAT traversal enabled. +struct CryptoConfig { + server_config: noq::ServerConfig, + client_config: noq::ClientConfig, +} + +impl CryptoConfig { + fn new() -> Self { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let key = PrivateKeyDer::Pkcs8(cert.signing_key.serialize_der().into()); + let cert_der: CertificateDer<'static> = cert.cert.into(); + + let mut transport = noq::TransportConfig::default(); + // Enable NAT traversal (which also enables multipath) + transport.set_max_remote_nat_traversal_addresses(8); + + let transport = Arc::new(transport); + + let mut server_config = + noq::ServerConfig::with_single_cert(vec![cert_der.clone()], key).unwrap(); + server_config.transport_config(transport.clone()); + + let mut roots = rustls::RootCertStore::empty(); + roots.add(cert_der).unwrap(); + let mut client_config = noq::ClientConfig::with_root_certificates(Arc::new(roots)).unwrap(); + client_config.transport_config(transport); + + Self { + server_config, + client_config, + } + } +} + +/// Send a ping over a bidirectional stream and read back the echo. +async fn ping(conn: &noq::Connection) -> Result<()> { + let (mut send, mut recv) = conn.open_bi().await?; + send.write_all(b"ping").await?; + send.finish()?; + let data = recv.read_to_end(64).await?; + anyhow::ensure!(data == b"ping", "unexpected echo payload"); + Ok(()) +} + +/// Accept bidi streams and echo them back. Run as a background task. +async fn echo_server(conn: noq::Connection) -> Result<()> { + loop { + let (mut send, mut recv) = match conn.accept_bi().await { + Ok(pair) => pair, + Err( + noq::ConnectionError::ApplicationClosed(_) | noq::ConnectionError::LocallyClosed, + ) => { + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + let data = recv.read_to_end(1024).await?; + send.write_all(&data).await?; + send.finish()?; + } +} + +/// Wait for a PathEvent::Opened on the connection's path_events channel. +async fn wait_path_opened(conn: &noq::Connection, timeout: Duration) -> Result { + let mut path_events = conn.path_events(); + tokio::time::timeout(timeout, async { + loop { + match path_events.recv().await { + Ok(PathEvent::Opened { id }) => return Ok(id), + Ok(evt) => { + tracing::debug!(?evt, "path event (waiting for Opened)"); + continue; + } + Err(e) => anyhow::bail!("path events channel error: {e}"), + } + } + }) + .await + .context("timeout waiting for path to open")? +} + +/// Wait for a PathEvent::Opened whose remote address satisfies `predicate`. +async fn wait_path_opened_matching( + conn: &noq::Connection, + timeout: Duration, + predicate: fn(SocketAddr) -> bool, +) -> Result { + let mut path_events = conn.path_events(); + tokio::time::timeout(timeout, async { + loop { + match path_events.recv().await { + Ok(PathEvent::Opened { id }) => { + if let Some(path) = conn.path(id) { + if let Ok(addr) = path.remote_address() { + if predicate(addr) { + tracing::info!(?addr, ?id, "matching path opened"); + return Ok(id); + } + } + } + tracing::debug!(?id, "path opened but does not match predicate"); + } + Ok(_) => continue, + Err(e) => anyhow::bail!("path events channel error: {e}"), + } + } + }) + .await + .context("timeout waiting for matching path")? +} + +// ── Tests ────────────────────────────────────────────────────────────── + +/// Two peers on separate public networks. The server has two interfaces +/// on different subnets. The initial QUIC connection is established via one +/// subnet, then the NAT traversal protocol opens a second path via the other. +/// +/// This tests the core NAT traversal protocol (ADD_ADDRESS, REACH_OUT, +/// multipath PATH_CHALLENGE/RESPONSE) through a realistic patchbay topology. +#[tokio::test] +async fn holepunch() -> Result<()> { + subscribe(); + + let mut opts = LabOpts::default().outdir(OutDir::Exact(testdir!())); + if let Some(name) = std::thread::current().name() { + opts = opts.label(name); + } + let lab = Lab::with_opts(opts).await?; + let guard = lab.test_guard(); + + let public1 = lab + .add_router("public1") + .preset(RouterPreset::Public) + .build() + .await?; + let public2 = lab + .add_router("public2") + .preset(RouterPreset::Public) + .build() + .await?; + + // Server has two interfaces on different public networks + let server_dev = lab + .add_device("server") + .iface("eth0", public1.id(), None) + .iface("eth1", public2.id(), None) + .build() + .await?; + + // Client on public1 — shares a subnet with server's eth0 + let client_dev = lab + .add_device("client") + .iface("eth0", public1.id(), None) + .build() + .await?; + + let server_ip_eth0 = server_dev + .iface("eth0") + .context("eth0")? + .ip() + .context("no ip on eth0")?; + let server_ip_eth1 = server_dev + .iface("eth1") + .context("eth1")? + .ip() + .context("no ip on eth1")?; + + let crypto = CryptoConfig::new(); + let (ready_tx, ready_rx) = oneshot::channel(); + + // ── Server ── + let server_handle = { + let sc = crypto.server_config.clone(); + let cc = crypto.client_config.clone(); + server_dev.spawn(async move |_dev| -> Result<()> { + let socket = + std::net::UdpSocket::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 4433)))?; + let ep = noq::Endpoint::new( + noq::EndpointConfig::default(), + Some(sc), + socket, + Arc::new(noq::TokioRuntime), + )?; + ep.set_default_client_config(cc); + ready_tx.send(()).ok(); + + let conn = ep.accept().await.context("accept")?.await?; + + // Advertise the SECOND interface's IP for NAT traversal. + // This creates a genuinely new path (different remote IP for the client). + conn.add_nat_traversal_address(SocketAddr::from((server_ip_eth1, 4433)))?; + + echo_server(conn).await + })? + }; + + ready_rx.await?; + + // ── Client ── + let client_handle = { + let sc = crypto.server_config.clone(); + let cc = crypto.client_config.clone(); + client_dev.spawn(async move |dev| -> Result<()> { + let socket = std::net::UdpSocket::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)))?; + let ep = noq::Endpoint::new( + noq::EndpointConfig::default(), + Some(sc), + socket, + Arc::new(noq::TokioRuntime), + )?; + ep.set_default_client_config(cc); + + // Initial connection via server's eth0 (same subnet) + let conn = ep + .connect(SocketAddr::from((server_ip_eth0, 4433)), "localhost")? + .await?; + + // Subscribe early so we don't miss ADD_ADDRESS frames + let mut nat_updates = conn.nat_traversal_updates(); + + ping(&conn).await.context("initial ping")?; + tracing::info!("initial ping OK"); + + // Wait for server to advertise its second address + let addr = tokio::time::timeout(Duration::from_secs(10), async { + loop { + match nat_updates.recv().await { + Ok(n0_nat_traversal::Event::AddressAdded(addr)) => return Ok(addr), + Ok(_) => continue, + Err(e) => anyhow::bail!("channel error: {e}"), + } + } + }) + .await + .context("timeout waiting for server address")??; + tracing::info!(?addr, "received server NAT traversal address"); + + // Advertise our address + let local_ip = dev.ip().context("client ip")?; + let local_port = ep.local_addr()?.port(); + conn.add_nat_traversal_address(SocketAddr::from((local_ip, local_port)))?; + + // Initiate NAT traversal — should open a path to the server's second IP + let probed = conn.initiate_nat_traversal_round()?; + tracing::info!(?probed, "initiated NAT traversal round"); + + // Wait for the new path to open + let path_id = wait_path_opened(&conn, Duration::from_secs(15)).await?; + tracing::info!(?path_id, "second path opened via NAT traversal"); + + ping(&conn).await.context("ping on second path")?; + tracing::info!("ping on second path OK"); + + conn.close(0u32.into(), b"done"); + Ok(()) + })? + }; + + client_handle.await?.context("client task")?; + let _ = server_handle.await; + guard.ok(); + Ok(()) +} + +/// After establishing a direct connection via NAT traversal, one peer switches +/// network (replug to a different router). The peer then re-establishes +/// connectivity by initiating a new NAT traversal round with updated addresses. +/// +/// Variations test different IP version transitions. +async fn switch_uplink_inner( + path: std::path::PathBuf, + initial_preset: RouterPreset, + target_preset: RouterPreset, + _check_initial: fn(SocketAddr) -> bool, + check_target: fn(SocketAddr) -> bool, + label: &str, +) -> Result<()> { + subscribe(); + + let mut opts = LabOpts::default().outdir(OutDir::Exact(path)); + if let Some(name) = std::thread::current().name() { + opts = opts.label(name); + } + let lab = Lab::with_opts(opts).await?; + let guard = lab.test_guard(); + + // Public router for the server (dualstack) + let public = lab + .add_router("public") + .preset(RouterPreset::Public) + .build() + .await?; + + // Initial router for the mobile peer + let router_initial = lab + .add_router("router-initial") + .preset(initial_preset) + .build() + .await?; + + // Target router the mobile peer will switch to + let router_target = lab + .add_router("router-target") + .preset(target_preset) + .build() + .await?; + + // Server device on public network + let server_dev = lab + .add_device("server") + .iface("eth0", public.id(), None) + .build() + .await?; + + // Mobile device initially on router_initial + let mobile_dev = lab + .add_device("mobile") + .iface("eth0", router_initial.id(), None) + .build() + .await?; + + // Pre-extract router uplink IPs for NAT traversal addresses. + let initial_uplink_ip = router_initial.uplink_ip(); + let initial_uplink_ip_v6 = router_initial.uplink_ip_v6(); + let target_uplink_ip = router_target.uplink_ip(); + let target_uplink_ip_v6 = router_target.uplink_ip_v6(); + + // Send both IPv4 and IPv6 server addresses so the client can pick the right one. + let (server_addrs_tx, server_addrs_rx) = + oneshot::channel::<(Option, Option)>(); + let (connected_tx, connected_rx) = oneshot::channel::<()>(); + + let crypto = CryptoConfig::new(); + + // Server side + let server_handle = { + let server_config = crypto.server_config.clone(); + let client_config = crypto.client_config.clone(); + server_dev.spawn(async move |dev| -> Result<()> { + let socket = + std::net::UdpSocket::bind(SocketAddr::from((Ipv6Addr::UNSPECIFIED, 4433)))?; + let ep = noq::Endpoint::new( + noq::EndpointConfig::default(), + Some(server_config), + socket, + Arc::new(noq::TokioRuntime), + )?; + ep.set_default_client_config(client_config); + + let listen_port = ep.local_addr()?.port(); + let addr_v4 = dev.ip().map(|ip| SocketAddr::from((ip, listen_port))); + let addr_v6 = dev.ip6().map(|ip| SocketAddr::from((ip, listen_port))); + server_addrs_tx.send((addr_v4, addr_v6)).ok(); + + let conn = ep.accept().await.context("accept")?.await?; + + // Advertise our addresses for NAT traversal (server is public, no NAT) + if let Some(ip4) = dev.ip() { + conn.add_nat_traversal_address(SocketAddr::from((ip4, listen_port)))?; + } + if let Some(ip6) = dev.ip6() { + conn.add_nat_traversal_address(SocketAddr::from((ip6, listen_port)))?; + } + + connected_tx.send(()).ok(); + + echo_server(conn).await + })? + }; + + let (server_addr_v4, server_addr_v6) = server_addrs_rx.await?; + tracing::info!( + ?server_addr_v4, + ?server_addr_v6, + "[{label}] server listening" + ); + + // Pick server address reachable from the initial network. + let server_addr = match initial_preset { + RouterPreset::IspV6 => server_addr_v6.context("server has no ipv6 for IspV6 client")?, + _ => server_addr_v4 + .or(server_addr_v6) + .context("server has no address")?, + }; + + // Client (mobile) side + let mobile_handle = { + let router_target_id = router_target.id(); + let server_config = crypto.server_config.clone(); + let client_config = crypto.client_config.clone(); + let label = label.to_string(); + mobile_dev.spawn(async move |dev| -> Result<()> { + let socket = std::net::UdpSocket::bind(SocketAddr::from((Ipv6Addr::UNSPECIFIED, 0)))?; + let ep = noq::Endpoint::new( + noq::EndpointConfig::default(), + Some(server_config), + socket, + Arc::new(noq::TokioRuntime), + )?; + ep.set_default_client_config(client_config); + + let conn = ep.connect(server_addr, "localhost")?.await?; + tracing::info!("[{label}] connected to server"); + + ping(&conn).await.context("initial ping")?; + tracing::info!("[{label}] initial ping OK"); + + // Wait for server to advertise addresses + connected_rx.await?; + tokio::time::sleep(Duration::from_secs(1)).await; + + // Register our NATted public addresses + let port = ep.local_addr()?.port(); + if let Some(ip4) = initial_uplink_ip { + conn.add_nat_traversal_address(SocketAddr::from((ip4, port)))?; + } + if let Some(ip6) = initial_uplink_ip_v6 { + conn.add_nat_traversal_address(SocketAddr::from((ip6, port)))?; + } + + let probed = conn.initiate_nat_traversal_round()?; + tracing::info!(?probed, "[{label}] first NAT traversal round"); + + let path_id = wait_path_opened(&conn, Duration::from_secs(15)).await?; + tracing::info!(?path_id, "[{label}] direct path opened"); + + ping(&conn).await.context("ping on direct path")?; + tracing::info!("[{label}] direct ping OK"); + + // ── Device switch ── + tracing::info!("[{label}] switching uplink..."); + dev.replug_iface("eth0", router_target_id).await?; + tokio::time::sleep(Duration::from_secs(1)).await; + + let new_socket = + std::net::UdpSocket::bind(SocketAddr::from((Ipv6Addr::UNSPECIFIED, 0)))?; + ep.rebind(new_socket)?; + ep.handle_network_change(None); + + let new_port = ep.local_addr()?.port(); + if let Some(ip4) = target_uplink_ip { + conn.add_nat_traversal_address(SocketAddr::from((ip4, new_port)))?; + } + if let Some(ip6) = target_uplink_ip_v6 { + conn.add_nat_traversal_address(SocketAddr::from((ip6, new_port)))?; + } + + let probed = conn.initiate_nat_traversal_round()?; + tracing::info!(?probed, "[{label}] NAT traversal round after switch"); + + let path_id = + wait_path_opened_matching(&conn, Duration::from_secs(20), check_target).await?; + tracing::info!(?path_id, "[{label}] target path established after switch"); + + ping(&conn).await.context("ping after device switch")?; + tracing::info!("[{label}] ping after switch OK"); + + conn.close(0u32.into(), b"done"); + Ok(()) + })? + }; + + mobile_handle + .await? + .context(format!("[{label}] mobile task"))?; + + let _ = server_handle.await; + + guard.ok(); + Ok(()) +} + +fn is_ipv4(addr: SocketAddr) -> bool { + match addr { + SocketAddr::V4(_) => true, + SocketAddr::V6(v6) => v6.ip().to_ipv4_mapped().is_some(), + } +} + +fn is_ipv6(addr: SocketAddr) -> bool { + match addr { + SocketAddr::V4(_) => false, + SocketAddr::V6(v6) => v6.ip().to_ipv4_mapped().is_none(), + } +} + +fn is_any(_addr: SocketAddr) -> bool { + true +} + +/// Re-holepunch after switching from a dualstack Home router to an IPv6-only ISP. +#[tokio::test] +async fn switch_dualstack_to_ipv6() -> Result<()> { + switch_uplink_inner( + testdir!(), + RouterPreset::Home, + RouterPreset::IspV6, + is_any, + is_ipv6, + "dualstack->ipv6", + ) + .await +} + +/// Re-holepunch after switching from an IPv6-only ISP to a dualstack Home router. +#[tokio::test] +async fn switch_ipv6_to_dualstack() -> Result<()> { + switch_uplink_inner( + testdir!(), + RouterPreset::IspV6, + RouterPreset::Home, + is_ipv6, + is_any, + "ipv6->dualstack", + ) + .await +} + +/// Re-holepunch after switching from an IPv6-only ISP to an IPv4-capable Home router. +#[tokio::test] +async fn switch_ipv6_to_ipv4() -> Result<()> { + switch_uplink_inner( + testdir!(), + RouterPreset::IspV6, + RouterPreset::Home, + is_ipv6, + is_ipv4, + "ipv6->ipv4", + ) + .await +}