diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 681b93c..f306f15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,16 @@ jobs: run: cargo clippy -- -D warnings - name: test run: cargo test + - name: audit + run: cargo install cargo-audit && cargo audit + + check-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: build + run: cargo build + - name: clippy + run: cargo clippy -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1971c6b..057a8d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,12 +19,15 @@ jobs: - target: aarch64-apple-darwin os: macos-latest name: numa-macos-aarch64 - - target: x86_64-unknown-linux-gnu + - target: x86_64-unknown-linux-musl os: ubuntu-latest name: numa-linux-x86_64 - - target: aarch64-unknown-linux-gnu + - target: aarch64-unknown-linux-musl os: ubuntu-latest name: numa-linux-aarch64 + - target: x86_64-pc-windows-msvc + os: windows-latest + name: numa-windows-x86_64 runs-on: ${{ matrix.os }} steps: @@ -35,23 +38,36 @@ jobs: with: targets: ${{ matrix.target }} - - name: Install cross-compilation tools - if: matrix.target == 'aarch64-unknown-linux-gnu' - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu + - name: Install musl tools (x86_64) + if: matrix.target == 'x86_64-unknown-linux-musl' + run: sudo apt-get update && sudo apt-get install -y musl-tools - - name: Build + - name: Install cross (aarch64) + if: matrix.target == 'aarch64-unknown-linux-musl' + run: cargo install cross + + - name: Build (native) + if: matrix.target != 'aarch64-unknown-linux-musl' run: cargo build --release --target ${{ matrix.target }} - env: - CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc - - name: Package + - name: Build (cross) + if: matrix.target == 'aarch64-unknown-linux-musl' + run: cross build --release --target ${{ matrix.target }} + + - name: Package (Unix) + if: runner.os != 'Windows' run: | cd target/${{ matrix.target }}/release tar czf ../../../${{ matrix.name }}.tar.gz numa cd ../../.. - sha256sum ${{ matrix.name }}.tar.gz > ${{ matrix.name }}.tar.gz.sha256 + sha256sum ${{ matrix.name }}.tar.gz > ${{ matrix.name }}.tar.gz.sha256 || shasum -a 256 ${{ matrix.name }}.tar.gz > ${{ matrix.name }}.tar.gz.sha256 + + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Compress-Archive -Path "target/${{ matrix.target }}/release/numa.exe" -DestinationPath "${{ matrix.name }}.zip" + (Get-FileHash "${{ matrix.name }}.zip" -Algorithm SHA256).Hash.ToLower() + " ${{ matrix.name }}.zip" | Out-File "${{ matrix.name }}.zip.sha256" -Encoding ascii - name: Upload artifact uses: actions/upload-artifact@v4 @@ -60,9 +76,24 @@ jobs: path: | ${{ matrix.name }}.tar.gz ${{ matrix.name }}.tar.gz.sha256 + ${{ matrix.name }}.zip + ${{ matrix.name }}.zip.sha256 + + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Publish to crates.io + run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} release: - needs: build + needs: [build, publish] runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 @@ -75,4 +106,5 @@ jobs: generate_release_notes: true files: | *.tar.gz + *.zip *.sha256 diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml new file mode 100644 index 0000000..9db4306 --- /dev/null +++ b/.github/workflows/static.yml @@ -0,0 +1,47 @@ +# Simple workflow for deploying static content to GitHub Pages +name: Deploy static content to Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["main"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + # Single deploy job since we're just deploying + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install pandoc + run: sudo apt-get install -y pandoc + - name: Generate blog HTML + run: make blog + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + # Upload entire repository + path: './site' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 1b715be..9dcba3d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target CLAUDE.md docs/ +site/blog/posts/ diff --git a/Cargo.lock b/Cargo.lock index da61725..35e4885 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,10 +18,16 @@ dependencies = [ ] [[package]] -name = "anstream" -version = "0.6.21" +name = "anes" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -34,15 +40,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -67,6 +73,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "arc-swap" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +dependencies = [ + "rustversion", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -142,9 +157,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", @@ -229,10 +244,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] -name = "cc" -version = "1.2.57" +name = "cast" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "jobserver", @@ -253,19 +274,71 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] -name = "cmake" -version = "0.1.57" +name = "ciborium" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compression-codecs" @@ -293,6 +366,73 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "data-encoding" version = "2.10.0" @@ -340,10 +480,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "env_filter" -version = "1.0.0" +name = "either" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -351,9 +497,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -384,6 +530,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -514,12 +666,48 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "http" version = "1.4.0" @@ -575,6 +763,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -621,7 +810,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -747,14 +936,25 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" dependencies = [ "memchr", "serde", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -762,10 +962,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] -name = "itoa" -version = "1.0.17" +name = "itertools" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" @@ -803,10 +1012,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -877,9 +1088,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -908,9 +1119,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -932,25 +1143,30 @@ dependencies = [ [[package]] name = "numa" -version = "0.1.0" +version = "0.7.2" dependencies = [ + "arc-swap", "axum", + "criterion", "env_logger", "futures", + "http", "http-body-util", "hyper", "hyper-util", "log", "rcgen", "reqwest", + "ring", "rustls", - "rustls-pemfile", "serde", "serde_json", + "socket2 0.5.10", "time", "tokio", "tokio-rustls", "toml", + "tower", ] [[package]] @@ -974,6 +1190,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "pem" version = "3.0.6" @@ -1002,6 +1224,34 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1010,9 +1260,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] @@ -1063,7 +1313,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -1100,7 +1350,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -1149,6 +1399,26 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -1201,6 +1471,7 @@ dependencies = [ "base64", "bytes", "futures-core", + "h2", "http", "http-body", "http-body-util", @@ -1275,15 +1546,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -1296,9 +1558,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", @@ -1318,6 +1580,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "serde" version = "1.0.228" @@ -1401,9 +1672,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "slab" @@ -1417,6 +1688,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -1551,6 +1832,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -1576,7 +1867,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -1769,6 +2060,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1795,9 +2096,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" dependencies = [ "cfg-if", "once_cell", @@ -1808,23 +2109,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1832,9 +2129,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" dependencies = [ "bumpalo", "proc-macro2", @@ -1845,18 +2142,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" dependencies = [ "js-sys", "wasm-bindgen", @@ -1881,6 +2178,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index e88dfdb..1b8e39f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,29 +1,48 @@ [package] name = "numa" -version = "0.1.0" +version = "0.7.2" authors = ["razvandimescu "] edition = "2021" -description = "Ephemeral DNS overrides for development and testing. Point any hostname to any endpoint. Auto-revert when you're done." +description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS" license = "MIT" repository = "https://github.com/razvandimescu/numa" -keywords = ["dns", "proxy", "override", "development", "networking"] +keywords = ["dns", "dns-server", "ad-blocking", "reverse-proxy", "developer-tools"] categories = ["network-programming", "development-tools"] [dependencies] -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] } axum = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" log = "0.4" env_logger = "0.11" -reqwest = { version = "0.12", features = ["rustls-tls", "gzip"], default-features = false } +reqwest = { version = "0.12", features = ["rustls-tls", "gzip", "http2"], default-features = false } hyper = { version = "1", features = ["client", "http1", "server"] } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } http-body-util = "0.1" futures = "0.3" +socket2 = { version = "0.5", features = ["all"] } rcgen = { version = "0.13", features = ["pem", "x509-parser"] } time = "0.3" rustls = "0.23" tokio-rustls = "0.26" -rustls-pemfile = "2" +arc-swap = "1" +ring = "0.17" + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } +tower = { version = "0.5", features = ["util"] } +http = "1" + +[[bench]] +name = "hot_path" +harness = false + +[[bench]] +name = "throughput" +harness = false + +[[bench]] +name = "dnssec" +harness = false diff --git a/Makefile b/Makefile index 5b0165c..f84761a 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,11 @@ -.PHONY: all build lint fmt check test clean deploy +.PHONY: all build lint fmt check audit test coverage bench clean deploy blog release -all: lint build +all: lint build test build: cargo build -lint: fmt check +lint: fmt check audit fmt: cargo fmt --check @@ -13,9 +13,32 @@ fmt: check: cargo clippy -- -D warnings +audit: + cargo audit + test: cargo test +coverage: + cargo tarpaulin --skip-clean --out stdout + +bench: + cargo bench + +blog: + @mkdir -p site/blog/posts + @for f in blog/*.md; do \ + name=$$(basename "$$f" .md); \ + pandoc "$$f" --template=site/blog-template.html -o "site/blog/posts/$$name.html"; \ + echo " $$f → site/blog/posts/$$name.html"; \ + done + +release: +ifndef VERSION + $(error Usage: make release VERSION=0.8.0) +endif + ./scripts/release.sh $(VERSION) + clean: cargo clean diff --git a/README.md b/README.md index 600ee17..4db97be 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,100 @@ # Numa -**DNS you own. Everywhere you go.** +[![CI](https://github.com/razvandimescu/numa/actions/workflows/ci.yml/badge.svg)](https://github.com/razvandimescu/numa/actions) +[![crates.io](https://img.shields.io/crates/v/numa.svg)](https://crates.io/crates/numa) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +**DNS you own. Everywhere you go.** — [numa.rs](https://numa.rs) A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required. -Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. +Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Recursive resolution from root nameservers with full DNSSEC chain-of-trust validation. One ~8MB binary, everything embedded. ![Numa dashboard](assets/hero-demo.gif) ## Quick Start ```bash -# Install -curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh - -# Run (port 53 requires root) -sudo numa - -# Try it -dig @127.0.0.1 google.com # ✓ resolves normally -dig @127.0.0.1 ads.google.com # ✗ blocked → 0.0.0.0 +brew install razvandimescu/tap/numa # or: cargo install numa +sudo numa # port 53 requires root ``` Open the dashboard: **http://numa.numa** (or `http://localhost:5380`) -Or build from source: -```bash -git clone https://github.com/razvandimescu/numa.git && cd numa -cargo build --release -sudo ./target/release/numa -``` +Set as system DNS: `sudo numa install && sudo numa service start` -## Why Numa +## Local Services -- **Ad blocking that travels with you** — 385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network: coffee shops, hotels, airports. -- **Local service proxy** — `https://frontend.numa` instead of `localhost:5173`. Auto-generated TLS certs, WebSocket support for HMR. Like `/etc/hosts` but with a dashboard and auto-revert. -- **Developer overrides** — point any hostname to any IP, auto-reverts after N minutes. REST API with 22 endpoints. -- **Sub-millisecond caching** — cached lookups in 0ms. Faster than any public resolver. -- **Live dashboard** — real-time stats, query log, blocking controls, service management. -- **macOS + Linux** — `numa install` configures system DNS, `numa service start` runs as launchd/systemd service. - -## Local Service Proxy - -Name your local dev services with `.numa` domains: +Name your dev services instead of remembering port numbers: ```bash curl -X POST localhost:5380/services \ - -H 'Content-Type: application/json' \ -d '{"name":"frontend","target_port":5173}' - -open http://frontend.numa # → proxied to localhost:5173 ``` -- **HTTPS with green lock** — auto-generated local CA + per-service TLS certs -- **WebSocket** — Vite/webpack HMR works through the proxy -- **Health checks** — dashboard shows green/red status per service -- **Persistent** — services survive restarts -- Or configure in `numa.toml`: +Now `https://frontend.numa` works in your browser — green lock, valid cert, WebSocket passthrough for HMR. No mkcert, no nginx, no `/etc/hosts`. + +Add path-based routing (`app.numa/api → :5001`), share services across machines via LAN discovery, or configure everything in [`numa.toml`](numa.toml). + +## Ad Blocking & Privacy + +385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network — coffee shops, hotels, airports. Travels with your laptop. + +Two resolution modes: **forward** (relay to Quad9/Cloudflare via encrypted DoH) or **recursive** (resolve from root nameservers — no upstream dependency, no single entity sees your full query pattern). DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, DS delegation, NSEC/NSEC3 denial proofs. [Read how it works →](https://numa.rs/blog/posts/dnssec-from-scratch.html) + +## LAN Discovery + +Run Numa on multiple machines. They find each other automatically via mDNS: -```toml -[[services]] -name = "frontend" -target_port = 5173 ``` +Machine A (192.168.1.5) Machine B (192.168.1.20) +┌──────────────────────┐ ┌──────────────────────┐ +│ Numa │ mDNS │ Numa │ +│ - api (port 8000) │◄───────────►│ - grafana (3000) │ +│ - frontend (5173) │ discovery │ │ +└──────────────────────┘ └──────────────────────┘ +``` + +From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Enable with `numa lan on`. + +**Hub mode**: run one instance with `bind_addr = "0.0.0.0:53"` and point other devices' DNS to it — they get ad blocking + `.numa` resolution without installing anything. ## How It Compares -| | Pi-hole | AdGuard Home | NextDNS | Cloudflare | Numa | -|---|---|---|---|---|---| -| Ad blocking | Yes | Yes | Yes | Limited | 385K+ domains | -| Portable (travels with laptop) | No (appliance) | No (appliance) | Cloud only | Cloud only | Single binary | -| Developer overrides | No | No | No | No | REST API + auto-expiry | -| Local service proxy | No | No | No | No | `.numa` + HTTPS + WS | -| Data stays local | Yes | Yes | Cloud | Cloud | 100% local | -| Zero config | Complex | Docker/setup | Yes | Yes | Works out of the box | -| Self-sovereign DNS | No | No | No | No | pkarr/DHT roadmap | +| | Pi-hole | AdGuard Home | Unbound | Numa | +|---|---|---|---|---| +| Local service proxy + auto TLS | — | — | — | `.numa` domains, HTTPS, WebSocket | +| LAN service discovery | — | — | — | mDNS, zero config | +| Developer overrides (REST API) | — | — | — | Auto-revert, scriptable | +| Recursive resolver | — | — | Yes | Yes, with SRTT selection | +| DNSSEC validation | — | — | Yes | Yes (RSA, ECDSA, Ed25519) | +| Ad blocking | Yes | Yes | — | 385K+ domains | +| Web admin UI | Full | Full | — | Dashboard | +| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native | +| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary | +| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New | -## How It Works +## Performance -``` -Query → Overrides → .numa TLD → Blocklist → Local Zones → Cache → Upstream -``` +691ns cached round-trip. ~2.0M qps throughput. Zero heap allocations in the hot path. Recursive queries average 237ms after SRTT warmup (12x improvement over round-robin). ECDSA P-256 DNSSEC verification: 174ns. [Benchmarks →](bench/) -No DNS libraries. The wire protocol — headers, labels, compression pointers, record types — is parsed and serialized by hand. Runs on `tokio` + `axum`, async per-query task spawning. +## Learn More -[Configuration reference](numa.toml) +- [Blog: Implementing DNSSEC from Scratch in Rust](https://numa.rs/blog/posts/dnssec-from-scratch.html) +- [Blog: I Built a DNS Resolver from Scratch](https://numa.rs/blog/posts/dns-from-scratch.html) +- [Configuration reference](numa.toml) — all options documented inline +- [REST API](src/api.rs) — 27 endpoints across overrides, cache, blocking, services, diagnostics ## Roadmap -- [x] DNS proxy core — forwarding, caching, local zones -- [x] Developer overrides — REST API with auto-expiry -- [x] Ad blocking — 385K+ domains, live dashboard, allowlist -- [x] System integration — macOS + Linux, launchd/systemd, Tailscale/VPN auto-discovery -- [x] Local service proxy — `.numa` domains, HTTP/HTTPS proxy, auto TLS, WebSocket -- [ ] pkarr integration — self-sovereign DNS via Mainline DHT (15M nodes) -- [ ] Global `.numa` names — self-publish, DHT-backed, first-come-first-served +- [x] DNS forwarding, caching, ad blocking, developer overrides +- [x] `.numa` local domains — auto TLS, path routing, WebSocket proxy +- [x] LAN service discovery — mDNS, cross-machine DNS + proxy +- [x] DNS-over-HTTPS — encrypted upstream +- [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3 +- [x] SRTT-based nameserver selection +- [ ] pkarr integration — self-sovereign DNS via Mainline DHT +- [ ] Global `.numa` names — DHT-backed, no registrar ## License diff --git a/assets/devto-cover.png b/assets/devto-cover.png new file mode 100644 index 0000000..4797b02 Binary files /dev/null and b/assets/devto-cover.png differ diff --git a/assets/hero-demo.gif b/assets/hero-demo.gif index 40326fa..e85f58a 100644 Binary files a/assets/hero-demo.gif and b/assets/hero-demo.gif differ diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..7307369 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,87 @@ +# Benchmarks + +Numa has two benchmark suites measuring different layers of performance. + +## Micro-benchmarks (`benches/`, criterion) + +Nanosecond-precision measurement of individual operations on the hot path. +No running server required — these are pure Rust unit-level benchmarks. + +```sh +cargo bench # run all +cargo bench --bench hot_path # parse, serialize, cache, clone +cargo bench --bench throughput # pipeline QPS, buffer alloc +``` + +### What's measured + +**hot_path** — individual operations: + +| Benchmark | What it measures | +|-----------|-----------------| +| `buffer_parse` | Wire bytes → DnsPacket (typical response with 4 records) | +| `buffer_serialize` | DnsPacket → wire bytes | +| `packet_clone` | Full DnsPacket clone (what cache hit costs) | +| `cache_lookup_hit` | Cache lookup on a single-entry cache | +| `cache_lookup_hit_populated` | Cache lookup with 1000 entries | +| `cache_lookup_miss` | HashMap miss (baseline) | +| `cache_insert` | Insert into cache with packet clone | +| `round_trip_cached` | Full cached path: parse query → cache hit → serialize response | + +**throughput** — pipeline capacity: + +| Benchmark | What it measures | +|-----------|-----------------| +| `pipeline_throughput/N` | N cached queries end-to-end (parse → lookup → serialize) | +| `buffer_alloc` | BytePacketBuffer 4KB zero-init cost | + +### Reading results + +Criterion auto-compares against the previous run: + +``` +round_trip_cached time: [710.5 ns 715.2 ns 720.1 ns] + change: [-2.48% -1.85% -1.21%] (p = 0.00 < 0.05) + Performance has improved. +``` + +- The three values are [lower bound, estimate, upper bound] of the mean +- `change` shows the delta vs the last saved baseline +- HTML reports with charts: `target/criterion/report/index.html` + +To save a named baseline for comparison: + +```sh +cargo bench -- --save-baseline before +# ... make changes ... +cargo bench -- --baseline before +``` + +## End-to-end benchmark (`bench/dns-bench.sh`) + +Real-world latency comparison using `dig` against a running Numa instance +and public resolvers. Measures millisecond-level latency including network I/O. + +```sh +# Start Numa first (default port 15353 for testing) +python3 bench/dns-bench.sh [port] [rounds] +python3 bench/dns-bench.sh 15353 20 # default +``` + +### What's measured + +- **Numa (cold)**: cache flushed before each query — measures upstream forwarding +- **Numa (cached)**: queries hit cache — measures local processing +- **System / Google / Cloudflare / Quad9**: public resolver comparison + +Results saved to `bench/results.json`. + +### When to use which + +| Question | Use | +|----------|-----| +| Did my code change make parsing faster? | `cargo bench --bench hot_path` | +| Is the cached path still sub-microsecond? | `cargo bench --bench hot_path` (round_trip_cached) | +| How many queries/sec can we handle? | `cargo bench --bench throughput` | +| Is Numa still competitive with system resolver? | `bench/dns-bench.sh` | +| Did upstream forwarding regress? | `bench/dns-bench.sh` | diff --git a/bench/results.json b/bench/results.json new file mode 100644 index 0000000..934835e --- /dev/null +++ b/bench/results.json @@ -0,0 +1,50 @@ +{ + "Numa(cold)": { + "avg": 9, + "p50": 9, + "p99": 18, + "min": 8, + "max": 18, + "count": 50 + }, + "Numa(cached)": { + "avg": 0, + "p50": 0, + "p99": 0, + "min": 0, + "max": 0, + "count": 50 + }, + "System": { + "avg": 9.1, + "p50": 8, + "p99": 44, + "min": 7, + "max": 44, + "count": 50 + }, + "Google": { + "avg": 22.4, + "p50": 17, + "p99": 37, + "min": 13, + "max": 37, + "count": 50 + }, + "Cloudflare": { + "avg": 18.7, + "p50": 14, + "p99": 132, + "min": 12, + "max": 132, + "count": 50 + }, + "Quad9": { + "avg": 14.5, + "p50": 13, + "p99": 43, + "min": 12, + "max": 43, + "count": 50 + } +} \ No newline at end of file diff --git a/benches/dnssec.rs b/benches/dnssec.rs new file mode 100644 index 0000000..270710a --- /dev/null +++ b/benches/dnssec.rs @@ -0,0 +1,183 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +use numa::dnssec; +use numa::question::QueryType; +use numa::record::DnsRecord; + +// Realistic ECDSA P-256 key (64 bytes) and signature (64 bytes) +fn make_ecdsa_key() -> Vec { + vec![0xAB; 64] +} +fn make_ecdsa_sig() -> Vec { + vec![0xCD; 64] +} + +// Realistic RSA-2048 key (RFC 3110 format: exp_len=3, exp=65537, mod=256 bytes) +fn make_rsa_key() -> Vec { + let mut key = vec![3u8]; // exponent length + key.extend(&[0x01, 0x00, 0x01]); // exponent = 65537 + key.extend(vec![0xFF; 256]); // modulus (256 bytes = 2048 bits) + key +} + +fn make_ed25519_key() -> Vec { + vec![0xEF; 32] +} + +fn make_dnskey(algorithm: u8, public_key: Vec) -> DnsRecord { + DnsRecord::DNSKEY { + domain: "example.com".into(), + flags: 257, + protocol: 3, + algorithm, + public_key, + ttl: 3600, + } +} + +fn make_rrsig(algorithm: u8, signature: Vec) -> DnsRecord { + DnsRecord::RRSIG { + domain: "example.com".into(), + type_covered: QueryType::A.to_num(), + algorithm, + labels: 2, + original_ttl: 300, + expiration: 2000000000, + inception: 1600000000, + key_tag: 12345, + signer_name: "example.com".into(), + signature, + ttl: 300, + } +} + +fn make_rrset() -> Vec { + vec![ + DnsRecord::A { + domain: "example.com".into(), + addr: "93.184.216.34".parse().unwrap(), + ttl: 300, + }, + DnsRecord::A { + domain: "example.com".into(), + addr: "93.184.216.35".parse().unwrap(), + ttl: 300, + }, + ] +} + +fn bench_key_tag(c: &mut Criterion) { + let key = make_rsa_key(); + c.bench_function("key_tag_rsa2048", |b| { + b.iter(|| { + dnssec::compute_key_tag(black_box(257), black_box(3), black_box(8), black_box(&key)) + }) + }); + + let key = make_ecdsa_key(); + c.bench_function("key_tag_ecdsa_p256", |b| { + b.iter(|| { + dnssec::compute_key_tag(black_box(257), black_box(3), black_box(13), black_box(&key)) + }) + }); +} + +fn bench_name_to_wire(c: &mut Criterion) { + c.bench_function("name_to_wire_short", |b| { + b.iter(|| dnssec::name_to_wire(black_box("example.com"))) + }); + c.bench_function("name_to_wire_long", |b| { + b.iter(|| dnssec::name_to_wire(black_box("sub.deep.nested.example.co.uk"))) + }); +} + +fn bench_build_signed_data(c: &mut Criterion) { + let rrsig = make_rrsig(13, make_ecdsa_sig()); + let rrset = make_rrset(); + let rrset_refs: Vec<&DnsRecord> = rrset.iter().collect(); + + c.bench_function("build_signed_data_2_A_records", |b| { + b.iter(|| dnssec::build_signed_data(black_box(&rrsig), black_box(&rrset_refs))) + }); +} + +fn bench_verify_signature(c: &mut Criterion) { + // These will fail verification (keys/sigs are random), but we measure the + // crypto overhead — ring still does the full algorithm before returning error. + let data = vec![0u8; 128]; // typical signed data size + + let rsa_key = make_rsa_key(); + let rsa_sig = vec![0xAA; 256]; // RSA-2048 signature + c.bench_function("verify_rsa_sha256_2048", |b| { + b.iter(|| { + dnssec::verify_signature( + black_box(8), + black_box(&rsa_key), + black_box(&data), + black_box(&rsa_sig), + ) + }) + }); + + let ecdsa_key = make_ecdsa_key(); + let ecdsa_sig = make_ecdsa_sig(); + c.bench_function("verify_ecdsa_p256", |b| { + b.iter(|| { + dnssec::verify_signature( + black_box(13), + black_box(&ecdsa_key), + black_box(&data), + black_box(&ecdsa_sig), + ) + }) + }); + + let ed_key = make_ed25519_key(); + let ed_sig = vec![0xBB; 64]; + c.bench_function("verify_ed25519", |b| { + b.iter(|| { + dnssec::verify_signature( + black_box(15), + black_box(&ed_key), + black_box(&data), + black_box(&ed_sig), + ) + }) + }); +} + +fn bench_ds_verification(c: &mut Criterion) { + let dk = make_dnskey(8, make_rsa_key()); + + // Compute correct DS digest + let owner_wire = dnssec::name_to_wire("example.com"); + let mut dnskey_rdata = vec![1u8, 1, 3, 8]; // flags=257, proto=3, algo=8 + dnskey_rdata.extend(&make_rsa_key()); + let mut input = Vec::new(); + input.extend(&owner_wire); + input.extend(&dnskey_rdata); + let digest = ring::digest::digest(&ring::digest::SHA256, &input); + + let ds = DnsRecord::DS { + domain: "example.com".into(), + key_tag: dnssec::compute_key_tag(257, 3, 8, &make_rsa_key()), + algorithm: 8, + digest_type: 2, + digest: digest.as_ref().to_vec(), + ttl: 86400, + }; + + c.bench_function("verify_ds_sha256", |b| { + b.iter(|| dnssec::verify_ds(black_box(&ds), black_box(&dk), black_box("example.com"))) + }); +} + +criterion_group!( + dnssec_benches, + bench_key_tag, + bench_name_to_wire, + bench_build_signed_data, + bench_verify_signature, + bench_ds_verification, +); +criterion_main!(dnssec_benches); diff --git a/benches/hot_path.rs b/benches/hot_path.rs new file mode 100644 index 0000000..accfcf2 --- /dev/null +++ b/benches/hot_path.rs @@ -0,0 +1,185 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use std::net::Ipv4Addr; + +use numa::buffer::BytePacketBuffer; +use numa::cache::DnsCache; +use numa::header::ResultCode; +use numa::packet::DnsPacket; +use numa::question::{DnsQuestion, QueryType}; +use numa::record::DnsRecord; + +fn make_response(domain: &str) -> DnsPacket { + let mut pkt = DnsPacket::new(); + pkt.header.id = 0x1234; + pkt.header.response = true; + pkt.header.recursion_desired = true; + pkt.header.recursion_available = true; + pkt.header.rescode = ResultCode::NOERROR; + pkt.questions + .push(DnsQuestion::new(domain.to_string(), QueryType::A)); + pkt.answers.push(DnsRecord::A { + domain: domain.to_string(), + addr: Ipv4Addr::new(93, 184, 216, 34), + ttl: 300, + }); + // Typical response includes authority + additional records + pkt.authorities.push(DnsRecord::NS { + domain: domain.to_string(), + host: format!("ns1.{domain}"), + ttl: 172800, + }); + pkt.authorities.push(DnsRecord::NS { + domain: domain.to_string(), + host: format!("ns2.{domain}"), + ttl: 172800, + }); + pkt.resources.push(DnsRecord::A { + domain: format!("ns1.{domain}"), + addr: Ipv4Addr::new(198, 51, 100, 1), + ttl: 172800, + }); + pkt +} + +fn to_wire(pkt: &DnsPacket) -> Vec { + let mut buf = BytePacketBuffer::new(); + pkt.write(&mut buf).unwrap(); + buf.filled().to_vec() +} + +fn bench_buffer_parse(c: &mut Criterion) { + let pkt = make_response("example.com"); + let wire = to_wire(&pkt); + + c.bench_function("buffer_parse", |b| { + b.iter(|| { + let mut buf = BytePacketBuffer::from_bytes(black_box(&wire)); + DnsPacket::from_buffer(&mut buf).unwrap() + }) + }); +} + +fn bench_buffer_serialize(c: &mut Criterion) { + let pkt = make_response("example.com"); + + c.bench_function("buffer_serialize", |b| { + b.iter(|| { + let mut buf = BytePacketBuffer::new(); + black_box(&pkt).write(&mut buf).unwrap(); + black_box(buf.pos()); + }) + }); +} + +fn bench_packet_clone(c: &mut Criterion) { + let pkt = make_response("example.com"); + + c.bench_function("packet_clone", |b| b.iter(|| black_box(&pkt).clone())); +} + +fn bench_cache_lookup_hit(c: &mut Criterion) { + let mut cache = DnsCache::new(10_000, 60, 86400); + let pkt = make_response("example.com"); + cache.insert("example.com", QueryType::A, &pkt); + + c.bench_function("cache_lookup_hit", |b| { + b.iter(|| { + cache + .lookup(black_box("example.com"), QueryType::A) + .unwrap() + }) + }); +} + +fn bench_cache_lookup_miss(c: &mut Criterion) { + let cache = DnsCache::new(10_000, 60, 86400); + + c.bench_function("cache_lookup_miss", |b| { + b.iter(|| cache.lookup(black_box("nonexistent.com"), QueryType::A)) + }); +} + +fn bench_cache_insert(c: &mut Criterion) { + let pkt = make_response("example.com"); + + c.bench_function("cache_insert", |b| { + let mut cache = DnsCache::new(10_000, 60, 86400); + let mut i = 0u64; + b.iter(|| { + let domain = format!("bench-{i}.example.com"); + cache.insert(&domain, QueryType::A, black_box(&pkt)); + i += 1; + // Reset cache periodically to avoid filling up + if i % 5000 == 0 { + cache.clear(); + } + }) + }); +} + +fn bench_round_trip(c: &mut Criterion) { + // Simulates the cached hot path: parse query → cache hit → serialize response + let query_pkt = { + let mut q = DnsPacket::new(); + q.header.id = 0xABCD; + q.header.recursion_desired = true; + q.questions + .push(DnsQuestion::new("example.com".to_string(), QueryType::A)); + q + }; + let query_wire = to_wire(&query_pkt); + + let response = make_response("example.com"); + let mut cache = DnsCache::new(10_000, 60, 86400); + cache.insert("example.com", QueryType::A, &response); + + c.bench_function("round_trip_cached", |b| { + b.iter(|| { + // 1. Parse incoming query + let mut buf = BytePacketBuffer::from_bytes(black_box(&query_wire)); + let query = DnsPacket::from_buffer(&mut buf).unwrap(); + let qname = &query.questions[0].name; + let qtype = query.questions[0].qtype; + + // 2. Cache lookup + let mut resp = cache.lookup(qname, qtype).unwrap(); + resp.header.id = query.header.id; + + // 3. Serialize response + let mut resp_buf = BytePacketBuffer::new(); + resp.write(&mut resp_buf).unwrap(); + black_box(resp_buf.pos()); + }) + }); +} + +fn bench_cache_populated_lookup(c: &mut Criterion) { + // Benchmark with a realistically populated cache (1000 entries) + let mut cache = DnsCache::new(10_000, 60, 86400); + for i in 0..1000 { + let domain = format!("domain-{i}.example.com"); + let pkt = make_response(&domain); + cache.insert(&domain, QueryType::A, &pkt); + } + + c.bench_function("cache_lookup_hit_populated", |b| { + b.iter(|| { + cache + .lookup(black_box("domain-500.example.com"), QueryType::A) + .unwrap() + }) + }); +} + +criterion_group!( + benches, + bench_buffer_parse, + bench_buffer_serialize, + bench_packet_clone, + bench_cache_lookup_hit, + bench_cache_lookup_miss, + bench_cache_insert, + bench_round_trip, + bench_cache_populated_lookup, +); +criterion_main!(benches); diff --git a/benches/throughput.rs b/benches/throughput.rs new file mode 100644 index 0000000..e01a25c --- /dev/null +++ b/benches/throughput.rs @@ -0,0 +1,94 @@ +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use std::net::Ipv4Addr; + +use numa::buffer::BytePacketBuffer; +use numa::header::ResultCode; +use numa::packet::DnsPacket; +use numa::question::{DnsQuestion, QueryType}; +use numa::record::DnsRecord; + +fn make_query_wire(domain: &str) -> Vec { + let mut q = DnsPacket::new(); + q.header.id = 0xABCD; + q.header.recursion_desired = true; + q.questions + .push(DnsQuestion::new(domain.to_string(), QueryType::A)); + let mut buf = BytePacketBuffer::new(); + q.write(&mut buf).unwrap(); + buf.filled().to_vec() +} + +fn make_response(domain: &str) -> DnsPacket { + let mut pkt = DnsPacket::new(); + pkt.header.id = 0xABCD; + pkt.header.response = true; + pkt.header.recursion_desired = true; + pkt.header.recursion_available = true; + pkt.header.rescode = ResultCode::NOERROR; + pkt.questions + .push(DnsQuestion::new(domain.to_string(), QueryType::A)); + pkt.answers.push(DnsRecord::A { + domain: domain.to_string(), + addr: Ipv4Addr::new(93, 184, 216, 34), + ttl: 300, + }); + pkt +} + +/// Simulates the complete cached query pipeline (sans network I/O): +/// parse → cache lookup → TTL adjust → serialize response +fn simulate_cached_pipeline(query_wire: &[u8], cache: &numa::cache::DnsCache) -> usize { + let mut buf = BytePacketBuffer::from_bytes(query_wire); + let query = DnsPacket::from_buffer(&mut buf).unwrap(); + let q = &query.questions[0]; + + let mut resp = cache.lookup(&q.name, q.qtype).unwrap(); + resp.header.id = query.header.id; + + let mut resp_buf = BytePacketBuffer::new(); + resp.write(&mut resp_buf).unwrap(); + resp_buf.pos() +} + +fn bench_pipeline_throughput(c: &mut Criterion) { + let domains: Vec = (0..100) + .map(|i| format!("domain-{i}.example.com")) + .collect(); + + let mut cache = numa::cache::DnsCache::new(10_000, 60, 86400); + for d in &domains { + cache.insert(d, QueryType::A, &make_response(d)); + } + + let query_wires: Vec> = domains.iter().map(|d| make_query_wire(d)).collect(); + + let mut group = c.benchmark_group("pipeline_throughput"); + + for count in [1, 10, 100] { + group.throughput(Throughput::Elements(count)); + group.bench_with_input(BenchmarkId::from_parameter(count), &count, |b, &count| { + let mut idx = 0usize; + b.iter(|| { + for _ in 0..count { + let wire = &query_wires[idx % query_wires.len()]; + simulate_cached_pipeline(wire, &cache); + idx += 1; + } + }); + }); + } + group.finish(); +} + +/// Measures the overhead of BytePacketBuffer allocation + zero-init +fn bench_buffer_alloc(c: &mut Criterion) { + c.bench_function("buffer_alloc", |b| { + b.iter(|| { + let buf = BytePacketBuffer::new(); + criterion::black_box(buf.pos()); + }) + }); +} + +criterion_group!(benches, bench_pipeline_throughput, bench_buffer_alloc,); +criterion_main!(benches); diff --git a/blog/dns-from-scratch.md b/blog/dns-from-scratch.md new file mode 100644 index 0000000..7bf666c --- /dev/null +++ b/blog/dns-from-scratch.md @@ -0,0 +1,327 @@ +--- +title: I Built a DNS Resolver from Scratch in Rust +description: How DNS actually works at the wire level — label compression, TTL tricks, DoH, and what surprised me building a resolver with zero DNS libraries. +date: March 2026 +--- + +I wanted to understand how DNS actually works. Not the "it translates domain names to IP addresses" explanation — the actual bytes on the wire. What does a DNS packet look like? How does label compression work? Why is everything crammed into 512 bytes? + +So I built one from scratch in Rust. No `hickory-dns`, no `trust-dns`, no `simple-dns`. The entire RFC 1035 wire protocol — headers, labels, compression pointers, record types — parsed and serialized by hand. It started as a weekend learning project, became a side project I kept coming back to over 6 years, and eventually turned into [Numa](https://github.com/razvandimescu/numa) — which I now use as my actual system DNS. + +A note on terminology: Numa supports two resolution modes. *Forward* mode relays queries to an upstream (Quad9, Cloudflare, or any DoH provider). *Recursive* mode walks the delegation chain from root servers itself — iterative queries to root, TLD, and authoritative nameservers, with full DNSSEC validation. In both modes, Numa does useful things with your DNS traffic locally (caching, ad blocking, overrides, local service domains) before resolving what it can't answer. This post covers the wire protocol and forwarding path; [the next post](/blog/posts/dnssec-from-scratch.html) covers recursive resolution and DNSSEC. + +Here's what surprised me along the way. + +## What does a DNS packet actually look like? + +You can see a real one yourself. Run this: + +```bash +dig @127.0.0.1 example.com A +noedns +``` + +``` +;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 15242 +;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 0 + +;; QUESTION SECTION: +;example.com. IN A + +;; ANSWER SECTION: +example.com. 53 IN A 104.18.27.120 +example.com. 53 IN A 104.18.26.120 +``` + +That's the human-readable version. But what's actually on the wire? A DNS query for `example.com A` is just 29 bytes: + +``` + ID Flags QCount ACount NSCount ARCount + ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ +Header: AB CD 01 00 00 01 00 00 00 00 00 00 + └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ + ↑ ↑ ↑ + │ │ └─ 1 question, 0 answers, 0 authority, 0 additional + │ └─ Standard query, recursion desired + └─ Random ID (we'll match this in the response) + +Question: 07 65 78 61 6D 70 6C 65 03 63 6F 6D 00 00 01 00 01 + ── ───────────────────── ── ───────── ── ───── ───── + 7 e x a m p l e 3 c o m end A IN + ↑ ↑ ↑ + └─ length prefix └─ length └─ root label (end of name) +``` + +12 bytes of header + 17 bytes of question = 29 bytes to ask "what's the IP for example.com?" Compare that to an HTTP request for the same information — you'd need hundreds of bytes just for headers. + +We can send exactly those bytes and capture what comes back: + +```python +python3 -c " +import socket +# Hand-craft a DNS query: header (12 bytes) + question (17 bytes) +q = b'\xab\xcd\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00' # header +q += b'\x07example\x03com\x00\x00\x01\x00\x01' # question +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.sendto(q, ('127.0.0.1', 53)) +resp = s.recv(512) +for i in range(0, len(resp), 16): + h = ' '.join(f'{b:02x}' for b in resp[i:i+16]) + a = ''.join(chr(b) if 32<=b<127 else '.' for b in resp[i:i+16]) + print(f'{i:08x} {h:<48s} {a}') +" +``` + +``` +00000000 ab cd 81 80 00 01 00 02 00 00 00 00 07 65 78 61 .............exa +00000010 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01 07 65 78 mple.com......ex +00000020 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01 00 00 ample.com....... +00000030 00 19 00 04 68 12 1b 78 07 65 78 61 6d 70 6c 65 ....h..x.example +00000040 03 63 6f 6d 00 00 01 00 01 00 00 00 19 00 04 68 .com...........h +00000050 12 1a 78 ..x +``` + +83 bytes back. Let's annotate the response: + +``` + ID Flags QCount ACount NSCount ARCount + ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ +Header: AB CD 81 80 00 01 00 02 00 00 00 00 + └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ + ↑ ↑ ↑ ↑ + │ │ │ └─ 2 answers + │ │ └─ 1 question (echoed back) + │ └─ Response flag set, recursion available + └─ Same ID as our query + +Question: 07 65 78 61 6D 70 6C 65 03 63 6F 6D 00 00 01 00 01 + (same as our query — echoed back) + +Answer 1: 07 65 78 61 6D 70 6C 65 03 63 6F 6D 00 00 01 00 01 + ───────────────────────────────────── ── ───── ───── + e x a m p l e . c o m end A IN + + 00 00 00 19 00 04 68 12 1B 78 + ─────────── ───── ─────────── + TTL: 25s len:4 104.18.27.120 + +Answer 2: (same domain repeated) 00 01 00 01 00 00 00 19 00 04 68 12 1A 78 + ─────────── + 104.18.26.120 +``` + +Notice something wasteful? The domain `example.com` appears *three times* — once in the question, twice in the answers. That's 39 bytes of repeated names in an 83-byte packet. DNS has a solution for this — but first, the overall structure. + +The whole thing fits in a single UDP datagram. The structure is: + +``` ++--+--+--+--+--+--+--+--+ +| Header | 12 bytes: ID, flags, counts ++--+--+--+--+--+--+--+--+ +| Questions | What you're asking ++--+--+--+--+--+--+--+--+ +| Answers | The response records ++--+--+--+--+--+--+--+--+ +| Authorities | NS records for the zone ++--+--+--+--+--+--+--+--+ +| Additional | Extra helpful records ++--+--+--+--+--+--+--+--+ +``` + +In Rust, parsing the header is just reading 12 bytes and unpacking the flags: + +```rust +pub fn read(buffer: &mut BytePacketBuffer) -> Result { + let id = buffer.read_u16()?; + let flags = buffer.read_u16()?; + // Flags pack 9 fields into 16 bits + let recursion_desired = (flags & (1 << 8)) > 0; + let truncated_message = (flags & (1 << 9)) > 0; + let authoritative_answer = (flags & (1 << 10)) > 0; + let opcode = (flags >> 11) & 0x0F; + let response = (flags & (1 << 15)) > 0; + // ... and so on +} +``` + +No padding, no alignment, no JSON overhead. DNS was designed in 1987 when every byte counted, and honestly? The wire format is kind of beautiful in its efficiency. + +## Label compression is the clever part + +Remember how `example.com` appeared three times in that 83-byte response? Domain names in DNS are stored as a sequence of **labels** — length-prefixed segments: + +``` +example.com → [7]example[3]com[0] +``` + +The `[7]` means "the next 7 bytes are a label." The `[0]` is the root label (end of name). That's 13 bytes per occurrence, 39 bytes for three repetitions. In a response with authority and additional records, domain names can account for half the packet. + +DNS solves this with **compression pointers** — if the top two bits of a length byte are `11`, the remaining 14 bits are an offset back into the packet where the rest of the name can be found. A well-compressed version of our response would replace the answer names with `C0 0C` — a 2-byte pointer to offset 12 where `example.com` first appears in the question section. That turns 39 bytes of names into 15 (13 + 2 + 2). Our upstream didn't bother compressing, but many do — especially when related domains appear: + +``` +Offset 0x20: [6]google[3]com[0] ← full name +Offset 0x40: [4]mail[0xC0][0x20] ← "mail" + pointer to offset 0x20 +Offset 0x50: [3]www[0xC0][0x20] ← "www" + pointer to offset 0x20 +``` + +Pointers can chain — a pointer can point to another pointer. Parsing this correctly requires tracking your position in the buffer and handling jumps: + +```rust +pub fn read_qname(&mut self, outstr: &mut String) -> Result<()> { + let mut pos = self.pos(); + let mut jumped = false; + let mut delim = ""; + + loop { + let len = self.get(pos)?; + + // Top two bits set = compression pointer + if (len & 0xC0) == 0xC0 { + if !jumped { + self.seek(pos + 2)?; // advance past the pointer + } + let offset = (((len as u16) ^ 0xC0) << 8) | self.get(pos + 1)? as u16; + pos = offset as usize; + jumped = true; + continue; + } + + pos += 1; + if len == 0 { break; } // root label + + outstr.push_str(delim); + outstr.push_str(&self.get_range(pos, len as usize)? + .iter().map(|&b| b as char).collect::()); + delim = "."; + pos += len as usize; + } + + if !jumped { + self.seek(pos)?; + } + Ok(()) +} +``` + +This one bit me: when you follow a pointer, you must *not* advance the buffer's read position past where you jumped from. The pointer is 2 bytes, so you advance by 2, but the actual label data lives elsewhere in the packet. If you follow the pointer and also advance past it, you'll skip over the next record entirely. I spent a fun evening debugging that one. + +## TTL adjustment on read, not write + +This is my favorite trick in the whole codebase. I initially stored the remaining TTL and decremented it, which meant I needed a background thread to sweep expired entries. It worked, but it felt wrong — too much machinery for something simple. + +The cleaner approach: store the original TTL and the timestamp when the record was cached. On read, compute `remaining = original_ttl - elapsed`. If it's zero or negative, the entry is stale — evict it lazily. + +```rust +pub fn lookup(&mut self, domain: &str, qtype: QueryType) -> Option { + let key = (domain.to_lowercase(), qtype); + let entry = self.entries.get(&key)?; + let elapsed = entry.cached_at.elapsed().as_secs() as u32; + + if elapsed >= entry.original_ttl { + self.entries.remove(&key); + return None; + } + + // Adjust TTLs in the response to reflect remaining time + let mut packet = entry.packet.clone(); + for answer in &mut packet.answers { + answer.set_ttl(entry.original_ttl.saturating_sub(elapsed)); + } + Some(packet) +} +``` + +No background thread. No timer. Entries expire lazily. The cache stays consistent because every consumer sees the adjusted TTL. + +## The resolution pipeline + +Each incoming UDP packet spawns a tokio task. Each task walks a deterministic pipeline — every step either answers or passes to the next: + +``` + ┌─────────────────────────────────────────────────────┐ + │ Numa Resolution Pipeline │ + └─────────────────────────────────────────────────────┘ + + Query ──→ Overrides ──→ .numa TLD ──→ Blocklist ──→ Zones ──→ Cache ──→ DoH + │ │ │ │ │ │ │ + │ │ match? │ match? │ blocked? │ match? │ hit? │ + │ ↓ ↓ ↓ ↓ ↓ ↓ + │ respond respond 0.0.0.0 respond respond forward + │ (auto-reverts (reverse (ad gone) (static (TTL to upstream + │ after N min) proxy+TLS) records) adjusted) (encrypted) + │ + └──→ Each step either answers or passes to the next. +``` + +This is where "from scratch" pays off. Want conditional forwarding for Tailscale? Insert a step before the upstream. Want to override `api.example.com` for 5 minutes while debugging? Add an entry in the overrides step — it auto-expires. A DNS library would have hidden this pipeline behind an opaque `resolve()` call. + +## DNS-over-HTTPS: the "wait, that's it?" moment + +The most recent addition, and honestly the one that surprised me with how little code it needed. DoH (RFC 8484) is conceptually simple: take the exact same DNS wire-format packet you'd send over UDP, POST it to an HTTPS endpoint with `Content-Type: application/dns-message`, and parse the response the same way. Same bytes, different transport. + +```rust +async fn forward_doh( + query: &DnsPacket, + url: &str, + client: &reqwest::Client, + timeout_duration: Duration, +) -> Result { + let mut send_buffer = BytePacketBuffer::new(); + query.write(&mut send_buffer)?; + + let resp = timeout(timeout_duration, client + .post(url) + .header("content-type", "application/dns-message") + .header("accept", "application/dns-message") + .body(send_buffer.filled().to_vec()) + .send()) + .await??.error_for_status()?; + + let bytes = resp.bytes().await?; + let mut recv_buffer = BytePacketBuffer::from_bytes(&bytes); + DnsPacket::from_buffer(&mut recv_buffer) +} +``` + +The one gotcha that cost me an hour: Quad9 and other DoH providers require HTTP/2. My first attempt used HTTP/1.1 and got a cryptic 400 Bad Request. Adding the `http2` feature to reqwest fixed it. The upside of HTTP/2? Connection multiplexing means subsequent queries reuse the TLS session — ~16ms vs ~50ms for the first query. Free performance. + +The `Upstream` enum dispatches between UDP and DoH based on the URL scheme: + +```rust +pub enum Upstream { + Udp(SocketAddr), + Doh { url: String, client: reqwest::Client }, +} +``` + +If the configured address starts with `https://`, it's DoH. Otherwise, plain UDP. Simple, no toggles. + +## "Why not just use dnsmasq + nginx + mkcert?" + +You absolutely can — those are mature, battle-tested tools. The difference is integration: with dnsmasq + nginx + mkcert, you're configuring three tools with three config formats. Numa puts the DNS record, reverse proxy, and TLS cert behind one API call: + +```bash +curl -X POST localhost:5380/services -d '{"name":"frontend","target_port":5173}' +``` + +That creates the DNS entry, generates a TLS certificate, and starts proxying — including WebSocket upgrade for Vite HMR. One command, no config files. Having full control over the resolution pipeline is what makes auto-revert overrides and LAN discovery possible. + +## What I learned + +**DNS is a 40-year-old protocol that works remarkably well.** The wire format is tight, the caching model is elegant, and the hierarchical delegation system has scaled to billions of queries per day. The things people complain about (DNSSEC complexity, lack of encryption) are extensions bolted on decades later, not flaws in the original design. + +**The hard parts aren't where you'd expect.** Parsing the wire protocol was straightforward (RFC 1035 is well-written). The hard parts were: browsers rejecting wildcard certs under single-label TLDs, macOS resolver quirks (`scutil` vs `/etc/resolv.conf`), and getting multiple processes to bind the same multicast port (`SO_REUSEPORT` on macOS, `SO_REUSEADDR` on Linux). + +**Learn the vocabulary before you show up.** I initially called Numa a "DNS resolver" and got corrected — it's a forwarding resolver. The distinction matters to people who work with DNS professionally, and being sloppy about it cost me credibility in my first community posts. + +## What's next + +**Update (March 2026):** Recursive resolution and DNSSEC validation are now shipped. Numa resolves from root nameservers with full chain-of-trust verification (RSA/SHA-256, ECDSA P-256, Ed25519) and NSEC/NSEC3 authenticated denial of existence. + +**[Read the follow-up: Implementing DNSSEC from Scratch in Rust →](/blog/posts/dnssec-from-scratch.html)** + +Still on the roadmap: + +- **DoT (DNS-over-TLS)** — DoH was first because it passes through captive portals and corporate firewalls (port 443 vs 853). DoT has less framing overhead, so it's faster. Both will be available. +- **[pkarr](https://github.com/pubky/pkarr) integration** — self-sovereign DNS via the Mainline BitTorrent DHT. Publish DNS records signed with your Ed25519 key, no registrar needed. + +[github.com/razvandimescu/numa](https://github.com/razvandimescu/numa) diff --git a/blog/dnssec-from-scratch.md b/blog/dnssec-from-scratch.md new file mode 100644 index 0000000..208bc53 --- /dev/null +++ b/blog/dnssec-from-scratch.md @@ -0,0 +1,189 @@ +--- +title: Implementing DNSSEC from Scratch in Rust +description: Recursive resolution from root hints, chain-of-trust validation, NSEC/NSEC3 denial proofs, and what I learned implementing DNSSEC with zero DNS libraries. +date: March 2026 +--- + +In the [previous post](/blog/posts/dns-from-scratch.html) I covered how DNS works at the wire level — packet format, label compression, TTL caching, DoH. Numa was a forwarding resolver: it parsed packets, did useful things locally, and relayed the rest to Cloudflare or Quad9. + +That post ended with "recursive resolution and DNSSEC are on the roadmap." This post is about building both. + +The short version: Numa now resolves from root nameservers with iterative queries, validates the full DNSSEC chain of trust, and cryptographically proves that non-existent domains don't exist. No upstream dependency. No DNS libraries. Just `ring` for the crypto primitives and a lot of RFC reading. + +## Why recursive? + +A forwarding resolver trusts its upstream. When you ask Quad9 for `cloudflare.com`, you trust that Quad9 returns the real answer. If Quad9 lies, gets compromised, or is legally compelled to redirect you — you have no way to know. + +A recursive resolver doesn't trust anyone. It starts at the root nameservers (operated by 12 independent organizations) and follows the delegation chain: root → `.com` TLD → `cloudflare.com` authoritative servers. Each server only answers for its own zone. No single entity sees your full query pattern. + +DNSSEC adds cryptographic proof to each step. The root signs `.com`'s key. `.com` signs `cloudflare.com`'s key. `cloudflare.com` signs its own records. If any step is tampered with, the chain breaks and Numa rejects the response. + +## The iterative resolution loop + +Recursive resolution is a misnomer — the resolver actually uses *iterative* queries. It asks root "where is `cloudflare.com`?", root says "I don't know, but here are the `.com` nameservers." It asks `.com`, which says "here are cloudflare's nameservers." It asks those, and gets the answer. + +``` +resolve("cloudflare.com", A) + → ask 198.41.0.4 (a.root-servers.net) + ← "try .com: ns1.gtld-servers.net (192.5.6.30)" [referral + glue] + → ask 192.5.6.30 (ns1.gtld-servers.net) + ← "try cloudflare: ns1.cloudflare.com (173.245.58.51)" [referral + glue] + → ask 173.245.58.51 (ns1.cloudflare.com) + ← "104.16.132.229" [answer] +``` + +The implementation (`src/recursive.rs`) is a loop with three possible outcomes per query: + +1. **Answer** — the server knows the record. Cache it, return it. +2. **Referral** — the server delegates to another zone. Extract NS records and glue (A/AAAA records for the nameservers, included in the additional section to avoid a chicken-and-egg problem), then query the next server. +3. **NXDOMAIN/REFUSED** — the name doesn't exist or the server refuses. Cache the negative result. + +CNAME chasing adds complexity: if you ask for `www.cloudflare.com` and get a CNAME to `cloudflare.com`, you need to restart resolution for the new name. I cap this at 8 levels. + +### TLD priming + +Cold-cache resolution is slow. Every query needs root → TLD → authoritative, each with its own network round-trip. For the first query to `example.com`, that's three serial UDP round-trips before you get an answer. + +TLD priming solves this. On startup, Numa queries root for NS records of 34 common TLDs (`.com`, `.org`, `.net`, `.io`, `.dev`, plus EU ccTLDs), caching NS records, glue addresses, DS records, and DNSKEY records. After priming, the first query to any `.com` domain skips root entirely — it already knows where `.com`'s nameservers are, and already has the DNSSEC keys needed to validate the response. + +## DNSSEC chain of trust + +DNSSEC doesn't encrypt DNS traffic. It *signs* it. Every DNS record can have an accompanying RRSIG (signature) record. The resolver verifies the signature against the zone's DNSKEY, then verifies that DNSKEY against the parent zone's DS (delegation signer) record, walking up until it reaches the root trust anchor — a hardcoded public key that IANA publishes and the entire internet agrees on. + +DNSSEC chain of trust diagram — verifying cloudflare.com from answer through .com TLD to root trust anchor + +### How keys get there + +The domain owner generates the DNSKEY keypair — typically their DNS provider (Cloudflare, etc.) does this. The owner then submits the DS record (a hash of their DNSKEY) to their registrar (Namecheap, GoDaddy), who passes it to the registry (Verisign for `.com`). The registry signs it into the TLD zone, and IANA signs the TLD's DS into the root. Trust flows up; keys flow down. + +The irony: you "own" your DNSSEC keys, but your registrar controls whether the DS record gets published. If they remove it — by mistake, by policy, or by court order — your DNSSEC chain breaks silently. + +### The trust anchor + +IANA's root KSK (Key Signing Key) has key tag 20326, algorithm 8 (RSA/SHA-256), and a 256-byte public key. It was last rolled in 2018. I hardcode it as a `const` array — this is the one thing in the entire system that requires out-of-band trust. + +```rust +const ROOT_KSK_PUBLIC_KEY: &[u8] = &[ + 0x03, 0x01, 0x00, 0x01, 0xac, 0xff, 0xb4, 0x09, + // ... 256 bytes total +]; +``` + +When IANA rolls this key (rare — the previous key lasted from 2010 to 2018), every DNSSEC validator on the internet needs updating. For Numa, that means a binary update. Something to watch. Every DNSKEY also has a key tag — a 16-bit checksum over its RDATA. The first test I wrote: compute the root KSK's key tag and assert it equals 20326. Instant confidence that the encoding is correct. + +## The crypto + +Numa uses `ring` for all cryptographic operations. Three algorithms cover the vast majority of signed zones: + +| Algorithm | ID | Usage | Verify time | +|---|---|---|---| +| RSA/SHA-256 | 8 | Root, most TLDs | 10.9 µs | +| ECDSA P-256 | 13 | Cloudflare, many modern zones | 174 ns | +| Ed25519 | 15 | Newer zones | ~200 ns | + +### RSA key format conversion + +DNS stores RSA public keys in RFC 3110 format (exponent length, exponent, modulus). `ring` expects PKCS#1 DER (ASN.1 encoded). Converting between them means writing a minimal ASN.1 encoder with leading-zero stripping and sign-bit padding. Getting this wrong produces keys that `ring` silently rejects — one of the harder bugs to track down. + +### ECDSA is simpler + +ECDSA P-256 keys in DNS are 64 bytes (x + y coordinates). `ring` expects uncompressed point format: `0x04` prefix + 64 bytes. One line: + +```rust +let mut uncompressed = Vec::with_capacity(65); +uncompressed.push(0x04); +uncompressed.extend_from_slice(public_key); // 64 bytes from DNS +``` + +Signatures are also 64 bytes (r + s), used directly. No format conversion needed. + +### Building the signed data + +RRSIG verification doesn't sign the DNS packet — it signs a canonical form of the records. Building this correctly is the most detail-sensitive part of DNSSEC. The signed data is: + +1. RRSIG RDATA fields (type covered, algorithm, labels, original TTL, expiration, inception, key tag, signer name) — *without* the signature itself +2. For each record in the RRset: owner name (lowercased, uncompressed) + type + class + original TTL (from the RRSIG, not the record's current TTL) + RDATA length + canonical RDATA + +The records must be sorted by their canonical wire-format representation. Owner names must be lowercased. The TTL must be the *original* TTL from the RRSIG, not the decremented TTL from caching. + +Getting any of these details wrong — wrong TTL, wrong case, wrong sort order, wrong RDATA encoding — produces a valid-looking but incorrect signed data blob, and `ring` returns a signature mismatch with no diagnostic information. I spent more time debugging signed data construction than any other part of DNSSEC. + +## Proving a name doesn't exist + +Verifying that `cloudflare.com` has a valid A record is one thing. Proving that `doesnotexist.cloudflare.com` *doesn't* exist — cryptographically, in a way that can't be forged — is harder. + +### NSEC + +NSEC records form a chain. Each NSEC says "the next name in this zone after me is X, and at my name these record types exist." If you query `beta.example.com` and the zone has `alpha.example.com → NSEC → gamma.example.com`, the gap proves `beta` doesn't exist — there's nothing between `alpha` and `gamma`. + +For NXDOMAIN proofs, RFC 4035 §5.4 requires two things: +1. An NSEC record whose gap covers the queried name +2. An NSEC record proving no wildcard exists at the closest encloser + +The canonical DNS name ordering (RFC 4034 §6.1) compares labels right-to-left, case-insensitive. `a.example.com` < `b.example.com` because at the `example.com` level they're equal, then `a` < `b`. But `z.example.com` < `a.example.org` because `.com` < `.org` at the TLD level. + +### NSEC3 + +NSEC3 solves NSEC's zone enumeration problem — with NSEC, you can walk the chain and discover every name in the zone. NSEC3 hashes the names first (iterated SHA-1 with a salt), so the NSEC3 chain reveals hashes, not names. + +The proof is a 3-part closest encloser proof (RFC 5155 §8.4): find an ancestor whose hash matches an NSEC3 owner, prove the next-closer name falls within a hash range gap, and prove the wildcard at the closest encloser also falls within a gap. All three must hold, or the denial is rejected. + +I cap NSEC3 iterations at 500 (RFC 9276 recommends 0). Higher iteration counts are a DoS vector — each verification requires `iterations + 1` SHA-1 hashes. + +## Making it fast + +Cold-cache DNSSEC validation initially required ~5 network fetches per query (DNSKEY for each zone in the chain, plus DS records). Three optimizations brought this down to ~1: + +**TLD priming** (startup) — fetch root DNSKEY + each TLD's NS/DS/DNSKEY. After priming, the trust chain from root to any `.com` zone is fully cached. + +**Referral DS piggybacking** — when a TLD server refers you to `cloudflare.com`'s nameservers, the authority section often includes DS records for the child zone. Cache them during resolution instead of fetching separately during validation. + +**DNSKEY prefetch** — before the validation loop, scan all RRSIGs for signer zones and batch-fetch any missing DNSKEYs. This avoids serial DNSKEY fetches inside the per-RRset verification loop. + +Result: a cold-cache query for `cloudflare.com` with full DNSSEC validation takes ~90ms. The TLD chain is already warm; only one DNSKEY fetch is needed (for `cloudflare.com` itself). + +| Operation | Time | +|---|---| +| ECDSA P-256 verify | 174 ns | +| Ed25519 verify | ~200 ns | +| RSA/SHA-256 verify | 10.9 µs | +| DS digest (SHA-256) | 257 ns | +| Key tag computation | 20–63 ns | +| Cold-cache validation (1 fetch) | ~90 ms | + +The network fetch dominates. The crypto is noise. + +## Surviving hostile networks + +I deployed Numa as my system DNS and switched networks. Everything broke — every query SERVFAIL, 3-second timeout. The ISP blocks outbound UDP port 53 to everything except whitelisted public resolvers. Root servers, TLD servers, authoritative servers — all unreachable over UDP. + +But TCP port 53 worked. Every DNS server is required to support TCP (RFC 1035 section 4.2.2). The ISP only filters UDP. + +The fix has three parts: + +**TCP fallback.** Every outbound query tries UDP first (800ms timeout). If UDP fails or the response is truncated, retry immediately over TCP. TCP uses a 2-byte length prefix before the DNS message — trivial to implement, and it handles DNSSEC responses that exceed the UDP payload limit. + +**UDP auto-disable.** After 3 consecutive UDP failures, flip a global `AtomicBool` and skip UDP entirely — go TCP-first for all queries. This avoids burning 800ms per hop on a network where UDP will never work. The flag resets when the network changes (detected via LAN IP monitoring). + +**Query minimization (RFC 7816).** When querying root servers, send only the TLD — `com` instead of `secret-project.example.com`. Root servers handle trillions of queries and are operated by 12 organizations. Minimization reduces what they learn from yours. + +The result: on a network that blocks UDP:53, Numa detects the block within the first 3 queries, switches to TCP, and resolves normally at 300-500ms per cold query. Cached queries remain 0ms. No manual config change needed — switch networks and it adapts. + +I wouldn't have found this without dogfooding. The code worked perfectly on my home network. It took a real hostile network to expose the assumption that UDP always works. + +## What I learned + +**DNSSEC is a verification system, not an encryption system.** It proves authenticity — this record was signed by the zone owner. It doesn't hide what you're querying. For privacy, you still need encrypted transport (DoH/DoT) or recursive resolution (no single upstream). + +**The hardest bugs are in data serialization, not crypto.** `ring` either verifies or it doesn't — a binary answer. But getting the signed data blob exactly right (correct TTL, correct case, correct sort, correct RDATA encoding for each record type) requires extreme precision. A single wrong byte means verification fails with no hint about what's wrong. + +**Negative proofs are harder than positive proofs.** Verifying a record exists: verify one RRSIG. Proving a record doesn't exist: find the right NSEC/NSEC3 records, verify their RRSIGs, check gap coverage, check wildcard denial, compute hashes. The NSEC3 closest encloser proof alone has three sub-proofs, each requiring hash computation and range checking. + +**Performance optimization is about avoiding network, not avoiding CPU.** The crypto takes nanoseconds to microseconds. The network fetch takes tens of milliseconds. Every optimization that matters — TLD priming, DS piggybacking, DNSKEY prefetch — is about eliminating a round trip, not speeding up a hash. + +## What's next + +- **[pkarr](https://github.com/pubky/pkarr) integration** — self-sovereign DNS via the Mainline BitTorrent DHT. Your Ed25519 key is your domain. No registrar, no ICANN. +- **DoT (DNS-over-TLS)** — the last encrypted transport we don't support + +The code is at [github.com/razvandimescu/numa](https://github.com/razvandimescu/numa) — the DNSSEC validation is in [`src/dnssec.rs`](https://github.com/razvandimescu/numa/blob/main/src/dnssec.rs) and the recursive resolver in [`src/recursive.rs`](https://github.com/razvandimescu/numa/blob/main/src/recursive.rs). MIT license. diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..7b5e6ba --- /dev/null +++ b/deploy.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="${1:-}" + +if [ -z "$VERSION" ]; then + echo "Usage: ./deploy.sh v0.5.1" + exit 1 +fi + +# Strip leading 'v' for Cargo.toml (accepts both "v0.5.1" and "0.5.1") +SEMVER="${VERSION#v}" +TAG="v${SEMVER}" + +# Validate semver format +if ! [[ "$SEMVER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: '$SEMVER' is not a valid semver (expected: X.Y.Z)" + exit 1 +fi + +# Check we're on main +BRANCH=$(git branch --show-current) +if [ "$BRANCH" != "main" ]; then + echo "Error: must be on main branch (currently on '$BRANCH')" + exit 1 +fi + +# Check working tree is clean +if [ -n "$(git status --porcelain -- ':!deploy.sh' ':!Cargo.toml' ':!Cargo.lock')" ]; then + echo "Error: working tree has uncommitted changes" + git status --short + exit 1 +fi + +# Check tag doesn't already exist +if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Error: tag '$TAG' already exists" + exit 1 +fi + +CURRENT=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') +echo "Bumping $CURRENT → $SEMVER" + +# Update Cargo.toml version +sed -i '' "s/^version = \"$CURRENT\"/version = \"$SEMVER\"/" Cargo.toml + +# Update Cargo.lock +cargo check --quiet 2>/dev/null + +# Commit, tag, push +git add Cargo.toml Cargo.lock +git commit -m "bump version to $SEMVER" +git tag "$TAG" +git push +git push origin "$TAG" + +echo "" +echo "✓ Tagged $TAG and pushed" +echo " → GitHub Actions: release binaries + crates.io publish" +echo " → Watch: gh run list --limit 1" diff --git a/numa.toml b/numa.toml index faa455d..4fa0a3d 100644 --- a/numa.toml +++ b/numa.toml @@ -1,12 +1,48 @@ [server] bind_addr = "0.0.0.0:53" api_port = 5380 +# api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access # [upstream] -# address = "" # auto-detect from system resolver (default) -# address = "9.9.9.9" # or set explicitly -# port = 53 +# mode = "forward" # "forward" (default) — relay to upstream +# # "recursive" — resolve from root hints (no address needed) +# address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted) +# address = "https://cloudflare-dns.com/dns-query" # Cloudflare DoH +# address = "9.9.9.9" # plain UDP +# port = 53 # only for forward mode, plain UDP # timeout_ms = 3000 +# root_hints = [ # only used in recursive mode +# "198.41.0.4", # a.root-servers.net (Verisign) +# "199.9.14.201", # b.root-servers.net (USC-ISI) +# "192.33.4.12", # c.root-servers.net (Cogent) +# "199.7.91.13", # d.root-servers.net (UMD) +# "192.203.230.10", # e.root-servers.net (NASA) +# "192.5.5.241", # f.root-servers.net (ISC) +# "192.112.36.4", # g.root-servers.net (US DoD) +# "198.97.190.53", # h.root-servers.net (US Army) +# "192.36.148.17", # i.root-servers.net (Netnod) +# "192.58.128.30", # j.root-servers.net (Verisign) +# "193.0.14.129", # k.root-servers.net (RIPE NCC) +# "199.7.83.42", # l.root-servers.net (ICANN) +# "202.12.27.33", # m.root-servers.net (WIDE) +# ] +# prime_tlds = [ # TLDs to pre-warm on startup (recursive mode) +# "com", "net", "org", "info", # gTLDs +# "io", "dev", "app", "xyz", "me", +# "eu", "uk", "de", "fr", "nl", # EU + European ccTLDs +# "it", "es", "pl", "se", "no", +# "dk", "fi", "at", "be", "ie", +# "pt", "cz", "ro", "gr", "hu", +# "bg", "hr", "sk", "si", "lt", +# "lv", "ee", "ch", "is", +# "co", "br", "au", "ca", "jp", # other major ccTLDs +# ] + +# [blocking] +# enabled = true # set to false to disable ad blocking +# refresh_hours = 24 +# lists = ["https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/hosts/pro.txt"] +# allowlist = ["example.com"] # domains to never block [cache] max_entries = 10000 @@ -18,6 +54,7 @@ enabled = true port = 80 tls_port = 443 tld = "numa" +# bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN access to .numa services # Pre-configured services (numa.numa is always added automatically) # [[services]] @@ -40,3 +77,14 @@ tld = "numa" # record_type = "A" # value = "127.0.0.1" # ttl = 60 + +# DNSSEC signature validation (requires mode = "recursive") +# [dnssec] +# enabled = false # opt-in: verify chain of trust from root KSK +# strict = false # true = SERVFAIL on bogus signatures + +# LAN service discovery via mDNS (disabled by default — no network traffic unless enabled) +# [lan] +# enabled = true # discover other Numa instances via mDNS (_numa._tcp.local) +# broadcast_interval_secs = 30 +# peer_timeout_secs = 90 diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh new file mode 100755 index 0000000..f255b86 --- /dev/null +++ b/scripts/benchmark.sh @@ -0,0 +1,306 @@ +#!/usr/bin/env bash +set -euo pipefail + +API="${NUMA_API:-http://127.0.0.1:5380}" +DNS="${NUMA_DNS:-127.0.0.1}" +NUMA_BIN="${NUMA_BIN:-/usr/local/bin/numa}" +LAUNCHD_PLIST="/Library/LaunchDaemons/com.numa.dns.plist" + +DOMAINS=( + paypal.com ebay.com zoom.us slack.com discord.com + microsoft.com apple.com meta.com oracle.com ibm.com + docker.com kubernetes.io prometheus.io grafana.com terraform.io + python.org nodejs.org golang.org wikipedia.org reddit.com + stackoverflow.com stripe.com linear.app nytimes.com bbc.co.uk + rust-lang.org fastly.com hetzner.com uber.com airbnb.com + notion.so figma.com netflix.com spotify.com dropbox.com + gitlab.com twitch.tv shopify.com vercel.app mozilla.org +) + +stats() { + curl -s "$API/query-log" | python3 -c " +import sys, json + +data = json.load(sys.stdin) +rec = [q for q in data if q['path'] == 'RECURSIVE'] +if not rec: + print('No recursive queries in log.') + sys.exit() + +vals = sorted([q['latency_ms'] for q in rec]) +n = len(vals) + +print(f'Recursive queries: {n}') +print(f' Avg: {sum(vals)/n:.1f}ms') +print(f' Median: {vals[n//2]:.1f}ms') +print(f' P95: {vals[int(n*0.95)]:.1f}ms') +print(f' P99: {vals[int(n*0.99)]:.1f}ms') +print(f' Min: {min(vals):.1f}ms') +print(f' Max: {max(vals):.1f}ms') +print(f' <100ms: {sum(1 for v in vals if v < 100)}') +print(f' <200ms: {sum(1 for v in vals if v < 200)}') +print(f' <500ms: {sum(1 for v in vals if v < 500)}') +print(f' >1s: {sum(1 for v in vals if v >= 1000)}') +print() +print('Slowest 5:') +for q in sorted(rec, key=lambda q: q['latency_ms'], reverse=True)[:5]: + print(f' {q[\"latency_ms\"]:>8.1f}ms {q[\"query_type\"]:5s} {q[\"domain\"]:35s} {q[\"rescode\"]}') +print() +print('Fastest 5:') +for q in sorted(rec, key=lambda q: q['latency_ms'])[:5]: + print(f' {q[\"latency_ms\"]:>8.1f}ms {q[\"query_type\"]:5s} {q[\"domain\"]:35s} {q[\"rescode\"]}') +" +} + +query_all() { + local label="$1" + echo "=== $label ===" + for d in "${DOMAINS[@]}"; do + printf " %-25s " "$d" + dig "@$DNS" "$d" A +noall +stats 2>/dev/null | grep "Query time" + done + echo +} + +flush_cache() { + curl -s -X DELETE "$API/cache" > /dev/null + echo "Cache flushed ($(curl -s "$API/stats" | python3 -c "import sys,json; print(json.load(sys.stdin)['cache']['entries'])" 2>/dev/null || echo '?') entries)." +} + +wait_for_api() { + local attempts=0 + while ! curl -sf "$API/health" > /dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ $attempts -ge 20 ]; then + echo "ERROR: API not reachable at $API after 10s" >&2 + exit 1 + fi + sleep 0.5 + done +} + +wait_for_priming() { + echo -n "Waiting for TLD priming..." + local prev=0 + local stable=0 + for _ in $(seq 1 60); do + local entries + entries=$(curl -s "$API/stats" | python3 -c "import sys,json; print(json.load(sys.stdin)['cache']['entries'])" 2>/dev/null || echo 0) + if [ "$entries" -gt 0 ] && [ "$entries" = "$prev" ]; then + stable=$((stable + 1)) + if [ $stable -ge 3 ]; then + echo " done ($entries cache entries)." + return + fi + else + stable=0 + fi + prev="$entries" + sleep 1 + done + echo " timeout (cache: $prev entries)." +} + +# restart_numa +# Writes config to a temp file, stops numa (launchd or manual), starts with that config. +restart_numa() { + local config_body="$1" + local tmpconf + tmpconf=$(mktemp /tmp/numa-bench-XXXXXX) + mv "$tmpconf" "${tmpconf}.toml" + tmpconf="${tmpconf}.toml" + echo "$config_body" > "$tmpconf" + + # Stop launchd-managed numa if active + if sudo launchctl list com.numa.dns &>/dev/null; then + sudo launchctl unload "$LAUNCHD_PLIST" 2>/dev/null || true + sleep 1 + fi + + # Kill any remaining + sudo killall numa 2>/dev/null || true + sleep 2 + + sudo "$NUMA_BIN" "$tmpconf" & + wait_for_api + wait_for_priming + echo "numa ready (pid $(pgrep numa | head -1), config: $tmpconf)." +} + +# Restore the launchd service +restore_launchd() { + sudo killall numa 2>/dev/null || true + sleep 1 + if [ -f "$LAUNCHD_PLIST" ]; then + sudo launchctl load "$LAUNCHD_PLIST" 2>/dev/null || true + echo "Restored launchd service." + fi +} + +run_pass() { + local label="$1" + flush_cache + sleep 0.5 + query_all "$label" + echo "=== $label — stats ===" + stats +} + +case "${1:-full}" in + cold) + echo "--- Cold cache benchmark ---" + run_pass "Cold SRTT + Cold cache" + ;; + warm) + echo "--- Warm SRTT benchmark ---" + echo "Priming SRTT..." + for d in "${DOMAINS[@]}"; do dig "@$DNS" "$d" A +short > /dev/null 2>&1; done + run_pass "Warm SRTT + Cold cache" + ;; + stats) + stats + ;; + compare-srtt) + echo "============================================" + echo " A/B: SRTT OFF vs ON (dnssec off)" + echo "============================================" + echo + + restart_numa "$(cat <<'TOML' +[upstream] +mode = "recursive" +srtt = false +TOML +)" + echo + run_pass "SRTT OFF" + + echo + echo "--------------------------------------------" + echo + + restart_numa "$(cat <<'TOML' +[upstream] +mode = "recursive" +srtt = true +TOML +)" + echo + run_pass "SRTT ON" + + echo + restore_launchd + ;; + compare-dnssec) + echo "============================================" + echo " A/B: DNSSEC OFF vs ON (srtt on)" + echo "============================================" + echo + + restart_numa "$(cat <<'TOML' +[upstream] +mode = "recursive" +srtt = true + +[dnssec] +enabled = false +TOML +)" + echo + run_pass "DNSSEC OFF" + + echo + echo "--------------------------------------------" + echo + + restart_numa "$(cat <<'TOML' +[upstream] +mode = "recursive" +srtt = true + +[dnssec] +enabled = true +TOML +)" + echo + run_pass "DNSSEC ON" + + echo + restore_launchd + ;; + compare-all) + echo "============================================" + echo " Full A/B matrix" + echo " 1. SRTT OFF + DNSSEC OFF (baseline)" + echo " 2. SRTT ON + DNSSEC OFF" + echo " 3. SRTT ON + DNSSEC ON" + echo "============================================" + echo + + # --- 1. Baseline --- + restart_numa "$(cat <<'TOML' +[upstream] +mode = "recursive" +srtt = false + +[dnssec] +enabled = false +TOML +)" + echo + run_pass "SRTT OFF + DNSSEC OFF" + + echo + echo "--------------------------------------------" + echo + + # --- 2. SRTT only --- + restart_numa "$(cat <<'TOML' +[upstream] +mode = "recursive" +srtt = true + +[dnssec] +enabled = false +TOML +)" + echo + run_pass "SRTT ON + DNSSEC OFF" + + echo + echo "--------------------------------------------" + echo + + # --- 3. Both --- + restart_numa "$(cat <<'TOML' +[upstream] +mode = "recursive" +srtt = true + +[dnssec] +enabled = true +TOML +)" + echo + run_pass "SRTT ON + DNSSEC ON" + + echo + restore_launchd + ;; + full|*) + echo "--- Full benchmark (cold → warm → SRTT-only) ---" + echo + + wait_for_priming + flush_cache + sleep 0.5 + query_all "Pass 1: Cold SRTT + Cold cache" + + flush_cache + sleep 0.5 + query_all "Pass 2: Warm SRTT + Cold cache" + + echo "=== Pass 2 stats (SRTT-warm) ===" + stats + ;; +esac diff --git a/scripts/record-demo.sh b/scripts/record-demo.sh index 2e875db..bb84ead 100755 --- a/scripts/record-demo.sh +++ b/scripts/record-demo.sh @@ -8,8 +8,10 @@ # 1. Opens the dashboard in Chrome --app mode (clean, no address bar) # 2. Generates DNS traffic (forward, cache hit, blocked) # 3. Types "peekm" / "6419" into the Local Services form on camera -# 4. Opens peekm.numa to show the proxy working -# 5. Records via ffmpeg and converts to optimized GIF +# 4. Shows LAN accessibility badge ("local only" / "LAN") +# 5. Checks a blocked domain +# 6. Opens peekm.numa to show the proxy working +# 7. Records via ffmpeg and converts to optimized GIF set -euo pipefail @@ -228,18 +230,10 @@ dig @127.0.0.1 github.com +short > /dev/null 2>&1 dig @127.0.0.1 ad.doubleclick.net +short > /dev/null 2>&1 sleep 3 -# --------------- Scene 2: Check Domain blocker (3-6s) --------------- -log "Scene 2: Check Domain — blocked tracker..." -type_into "#checkDomainInput" "ads.doubleclick.net" 0.04 -sleep 0.3 -# Click Check button -run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();" -sleep 2 +# --------------- Scene 2: Add peekm service via UI (3-7s) --------------- +log "Scene 2: Adding peekm.numa service..." -# --------------- Scene 3: Add peekm service via UI (6-10s) --------------- -log "Scene 3: Adding peekm.numa service..." - -# Scroll to Local Services form +# Services panel is now first — scroll to it run_js " var svcPanel = document.getElementById('serviceForm'); if (svcPanel) svcPanel.scrollIntoView({behavior: 'smooth', block: 'center'}); @@ -251,20 +245,34 @@ sleep 0.2 type_into "#svcPort" "6419" 0.1 sleep 0.3 -# Click "Add Service" +# Click "Add Service" — LAN badge ("local only" or "LAN") will appear run_js "document.querySelector('#serviceForm .btn-add').click();" -sleep 1.5 +sleep 2 -# --------------- Scene 4: Open peekm.numa (10-14s) --------------- -log "Scene 4: Opening peekm.numa in browser..." +# --------------- Scene 3: Open peekm.numa (7-11s) --------------- +log "Scene 3: Opening peekm.numa in browser..." open "http://peekm.numa/view/peekm/README.md" 2>/dev/null || true sleep 4 -# --------------- Scene 5: Back to dashboard (14-17s) --------------- -log "Scene 5: Back to dashboard — LOCAL queries visible..." +# --------------- Scene 4: Back to dashboard (11-14s) --------------- +log "Scene 4: Back to dashboard — LAN badges + LOCAL queries visible..." osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $CHROME_PID) to true" 2>/dev/null || true sleep 3 +# --------------- Scene 5: Check Domain blocker (14-17s) --------------- +log "Scene 5: Check Domain — blocked tracker..." +# Scroll down to blocking panel +run_js " + var blockPanel = document.getElementById('blockingPanel'); + if (blockPanel) blockPanel.scrollIntoView({behavior: 'smooth', block: 'center'}); +" +sleep 0.5 +type_into "#checkDomainInput" "ads.doubleclick.net" 0.04 +sleep 0.3 +# Click Check button +run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();" +sleep 2 + # --------------- Scene 6: Terminal-style dig overlay (17-20s) --------------- log "Scene 6: dig proof overlay..." DIG_RESULT=$(dig @127.0.0.1 peekm.numa +short 2>/dev/null | head -1) diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..d4ee882 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ $# -ne 1 ]; then + echo "Usage: $0 (e.g. 0.7.0)" >&2 + exit 1 +fi + +VERSION="$1" +TAG="v$VERSION" + +# Sanity checks +if ! git diff --quiet || ! git diff --cached --quiet; then + echo "ERROR: working tree is dirty — commit or stash first" >&2 + exit 1 +fi + +if [ "$(git branch --show-current)" != "main" ]; then + echo "ERROR: must be on main branch" >&2 + exit 1 +fi + +if git tag -l "$TAG" | grep -q .; then + echo "ERROR: tag $TAG already exists" >&2 + exit 1 +fi + +CURRENT=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') +echo "Bumping $CURRENT -> $VERSION" + +# Bump version +sed -i.bak "s/^version = \"$CURRENT\"/version = \"$VERSION\"/" Cargo.toml +rm -f Cargo.toml.bak +cargo update --workspace + +# Commit, tag, push +git add Cargo.toml Cargo.lock +git commit -m "chore: bump version to $VERSION" +git tag "$TAG" +git push origin main --tags + +echo +echo "Released $TAG — GitHub Actions will build, publish to crates.io, and create the release." diff --git a/site/CNAME b/site/CNAME new file mode 100644 index 0000000..3004d18 --- /dev/null +++ b/site/CNAME @@ -0,0 +1 @@ +numa.rs \ No newline at end of file diff --git a/site/blog-template.html b/site/blog-template.html new file mode 100644 index 0000000..0275c1f --- /dev/null +++ b/site/blog-template.html @@ -0,0 +1,301 @@ + + + + + +$title$ — Numa + + + + + + + + + + + + + + diff --git a/site/blog/dnssec-chain.svg b/site/blog/dnssec-chain.svg new file mode 100644 index 0000000..f28845d --- /dev/null +++ b/site/blog/dnssec-chain.svg @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + DNSSEC Chain of Trust + Verifying cloudflare.com — from answer to root trust anchor + + + + + Verify signature (RRSIG → DNSKEY) + + Vouch for key (DS → parent DNSKEY) + + DNS record / key + + + + + CLOUDFLARE.COM ZONE + + + + cloudflare.com A 104.16.132.229 + The answer we want to verify + + + + signed by + + + RRSIG + tag=34505, algo=13 + signer: cloudflare.com + + + + DNSKEY + cloudflare.com, tag=34505 + ECDSA P-256 + — 174ns to verify + + + + verified with + + + + .COM TLD ZONE + + + + vouched for by + + + + DS + tag=2371, digest=SHA-256 + hash of cloudflare.com DNSKEY + + + + signed by + + + RRSIG + tag=19718, signer=com + + + + DNSKEY + com, tag=19718 + + + + verified with + + + + ROOT ZONE (.) + + + + vouched for by + + + + DS + tag=30909, digest=SHA-256 + hash of com DNSKEY + + + + signed by + + + RRSIG + signer=. + + + + DNSKEY + root (.), tag=20326, RSA/SHA-256 + + + + verified with + + + + + + ROOT TRUST ANCHOR + IANA KSK, key_tag=20326 — hardcoded in Numa as const [u8; 256] + + + Trust flows up (DS records). Keys flow down (DNSKEY → RRSIG). + If any link breaks — wrong signature, missing DS, expired RRSIG — Numa rejects the response. + + diff --git a/site/blog/index.html b/site/blog/index.html new file mode 100644 index 0000000..f19149c --- /dev/null +++ b/site/blog/index.html @@ -0,0 +1,193 @@ + + + + + +Blog — Numa + + + + + + + + +
+

Blog

+ +
+ + + + + diff --git a/site/dashboard.html b/site/dashboard.html index ccbb4b5..e90fbea 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -4,9 +4,7 @@ Numa — Dashboard - - - +
-
404
+{body} +
+"## + ) +} + +fn extract_host(req: &Request) -> Option { + req.headers() + .get(hyper::header::HOST) + .and_then(|v| v.to_str().ok()) + .map(|h| h.split(':').next().unwrap_or(h).to_lowercase()) +} + +async fn proxy_handler(State(state): State, req: Request) -> axum::response::Response { + let hostname = match extract_host(&req) { + Some(h) => h, + None => { + return (StatusCode::BAD_REQUEST, "missing Host header").into_response(); + } + }; + + let service_name = match hostname.strip_suffix(state.ctx.proxy_tld_suffix.as_str()) { + Some(name) => name.to_string(), + None => { + // Check if this domain was blocked — show a helpful styled page + if state.ctx.blocklist.read().unwrap().is_blocked(&hostname) { + let body = format!( + r#"
🛡
+
Blocked by Numa
+
{0}
+

This domain is on the ad & tracker blocklist.
To allow it, use the dashboard or:

+
$ curl -X POST localhost:5380/blocking/allowlist \
+    -d '{{"domain":"{0}"}}'
"#, + hostname + ); + return ( + StatusCode::FORBIDDEN, + [(hyper::header::CONTENT_TYPE, "text/html; charset=utf-8")], + error_page(&format!("Blocked — {}", hostname), &body), + ) + .into_response(); + } + return ( + StatusCode::BAD_GATEWAY, + format!("not a {} domain: {}", state.ctx.proxy_tld_suffix, hostname), + ) + .into_response(); + } + }; + + let request_path = req.uri().path().to_string(); + + let (target_host, target_port, rewritten_path) = { + let store = state.ctx.services.lock().unwrap(); + if let Some(entry) = store.lookup(&service_name) { + let (port, path) = entry.resolve_route(&request_path); + ("localhost".to_string(), port, path) + } else { + let mut peers = state.ctx.lan_peers.lock().unwrap(); + match peers.lookup(&service_name) { + Some((ip, port)) => (ip.to_string(), port, request_path.clone()), + None => { + let body = format!( + r#"
404
{0}{1}

This service isn't registered yet.
Add it from the dashboard or:

$ curl -X POST numa.numa:5380/services \
     -H 'Content-Type: application/json' \
     -d '{{"name":"{0}","target_port":3000}}'
-
ma-ia hii, ma-ia huu, ma-ia haa, ma-ia ha-ha
- -"##, +
ma-ia hii, ma-ia huu, ma-ia haa, ma-ia ha-ha
"#, service_name, state.ctx.proxy_tld_suffix - ), - ) - .into_response() + ); + return ( + StatusCode::NOT_FOUND, + [(hyper::header::CONTENT_TYPE, "text/html; charset=utf-8")], + error_page( + &format!("404 — {}{}", service_name, state.ctx.proxy_tld_suffix), + &body, + ), + ) + .into_response(); + } } } }; - let path_and_query = req + let query_string = req .uri() - .path_and_query() - .map(|pq| pq.as_str()) - .unwrap_or("/"); - let target_uri: hyper::Uri = format!("http://localhost:{}{}", target_port, path_and_query) - .parse() - .unwrap(); + .query() + .map(|q| format!("?{}", q)) + .unwrap_or_default(); + let target_uri: hyper::Uri = format!( + "http://{}:{}{}{}", + target_host, target_port, rewritten_path, query_string + ) + .parse() + .unwrap(); // Check for upgrade request (WebSocket, etc.) let is_upgrade = req.headers().get(hyper::header::UPGRADE).is_some(); diff --git a/src/query_log.rs b/src/query_log.rs index 2f15d7a..dff606e 100644 --- a/src/query_log.rs +++ b/src/query_log.rs @@ -2,6 +2,7 @@ use std::collections::VecDeque; use std::net::SocketAddr; use std::time::SystemTime; +use crate::cache::DnssecStatus; use crate::header::ResultCode; use crate::question::QueryType; use crate::stats::QueryPath; @@ -14,6 +15,7 @@ pub struct QueryLogEntry { pub path: QueryPath, pub rescode: ResultCode, pub latency_us: u64, + pub dnssec: DnssecStatus, } pub struct QueryLog { diff --git a/src/question.rs b/src/question.rs index 30fe0ce..dc23dd1 100644 --- a/src/question.rs +++ b/src/question.rs @@ -4,16 +4,22 @@ use crate::Result; #[derive(PartialEq, Eq, Debug, Clone, Hash, Copy)] pub enum QueryType { UNKNOWN(u16), - A, // 1 - NS, // 2 - CNAME, // 5 - SOA, // 6 - PTR, // 12 - MX, // 15 - TXT, // 16 - AAAA, // 28 - SRV, // 33 - HTTPS, // 65 + A, // 1 + NS, // 2 + CNAME, // 5 + SOA, // 6 + PTR, // 12 + MX, // 15 + TXT, // 16 + AAAA, // 28 + SRV, // 33 + DS, // 43 + RRSIG, // 46 + NSEC, // 47 + DNSKEY, // 48 + NSEC3, // 50 + OPT, // 41 (EDNS0 pseudo-type) + HTTPS, // 65 } impl QueryType { @@ -29,6 +35,12 @@ impl QueryType { QueryType::TXT => 16, QueryType::AAAA => 28, QueryType::SRV => 33, + QueryType::OPT => 41, + QueryType::DS => 43, + QueryType::RRSIG => 46, + QueryType::NSEC => 47, + QueryType::DNSKEY => 48, + QueryType::NSEC3 => 50, QueryType::HTTPS => 65, } } @@ -44,6 +56,12 @@ impl QueryType { 16 => QueryType::TXT, 28 => QueryType::AAAA, 33 => QueryType::SRV, + 41 => QueryType::OPT, + 43 => QueryType::DS, + 46 => QueryType::RRSIG, + 47 => QueryType::NSEC, + 48 => QueryType::DNSKEY, + 50 => QueryType::NSEC3, 65 => QueryType::HTTPS, _ => QueryType::UNKNOWN(num), } @@ -60,6 +78,12 @@ impl QueryType { QueryType::TXT => "TXT", QueryType::AAAA => "AAAA", QueryType::SRV => "SRV", + QueryType::OPT => "OPT", + QueryType::DS => "DS", + QueryType::RRSIG => "RRSIG", + QueryType::NSEC => "NSEC", + QueryType::DNSKEY => "DNSKEY", + QueryType::NSEC3 => "NSEC3", QueryType::HTTPS => "HTTPS", QueryType::UNKNOWN(_) => "UNKNOWN", } @@ -76,6 +100,11 @@ impl QueryType { "TXT" => Some(QueryType::TXT), "AAAA" => Some(QueryType::AAAA), "SRV" => Some(QueryType::SRV), + "DS" => Some(QueryType::DS), + "RRSIG" => Some(QueryType::RRSIG), + "DNSKEY" => Some(QueryType::DNSKEY), + "NSEC" => Some(QueryType::NSEC), + "NSEC3" => Some(QueryType::NSEC3), "HTTPS" => Some(QueryType::HTTPS), _ => None, } diff --git a/src/record.rs b/src/record.rs index f525cbb..21fbdf0 100644 --- a/src/record.rs +++ b/src/record.rs @@ -11,7 +11,7 @@ pub enum DnsRecord { UNKNOWN { domain: String, qtype: u16, - data_len: u16, + data: Vec, ttl: u32, }, A { @@ -40,11 +40,84 @@ pub enum DnsRecord { addr: Ipv6Addr, ttl: u32, }, + DNSKEY { + domain: String, + flags: u16, + protocol: u8, + algorithm: u8, + public_key: Vec, + ttl: u32, + }, + DS { + domain: String, + key_tag: u16, + algorithm: u8, + digest_type: u8, + digest: Vec, + ttl: u32, + }, + RRSIG { + domain: String, + type_covered: u16, + algorithm: u8, + labels: u8, + original_ttl: u32, + expiration: u32, + inception: u32, + key_tag: u16, + signer_name: String, + signature: Vec, + ttl: u32, + }, + NSEC { + domain: String, + next_domain: String, + type_bitmap: Vec, + ttl: u32, + }, + NSEC3 { + domain: String, + hash_algorithm: u8, + flags: u8, + iterations: u16, + salt: Vec, + next_hashed_owner: Vec, + type_bitmap: Vec, + ttl: u32, + }, } impl DnsRecord { - pub fn is_unknown(&self) -> bool { - matches!(self, DnsRecord::UNKNOWN { .. }) + pub fn domain(&self) -> &str { + match self { + DnsRecord::A { domain, .. } + | DnsRecord::NS { domain, .. } + | DnsRecord::CNAME { domain, .. } + | DnsRecord::MX { domain, .. } + | DnsRecord::AAAA { domain, .. } + | DnsRecord::DNSKEY { domain, .. } + | DnsRecord::DS { domain, .. } + | DnsRecord::RRSIG { domain, .. } + | DnsRecord::NSEC { domain, .. } + | DnsRecord::NSEC3 { domain, .. } + | DnsRecord::UNKNOWN { domain, .. } => domain, + } + } + + pub fn query_type(&self) -> QueryType { + match self { + DnsRecord::A { .. } => QueryType::A, + DnsRecord::AAAA { .. } => QueryType::AAAA, + DnsRecord::NS { .. } => QueryType::NS, + DnsRecord::CNAME { .. } => QueryType::CNAME, + DnsRecord::MX { .. } => QueryType::MX, + DnsRecord::DNSKEY { .. } => QueryType::DNSKEY, + DnsRecord::DS { .. } => QueryType::DS, + DnsRecord::RRSIG { .. } => QueryType::RRSIG, + DnsRecord::NSEC { .. } => QueryType::NSEC, + DnsRecord::NSEC3 { .. } => QueryType::NSEC3, + DnsRecord::UNKNOWN { qtype, .. } => QueryType::UNKNOWN(*qtype), + } } pub fn ttl(&self) -> u32 { @@ -54,6 +127,11 @@ impl DnsRecord { | DnsRecord::CNAME { ttl, .. } | DnsRecord::MX { ttl, .. } | DnsRecord::AAAA { ttl, .. } + | DnsRecord::DNSKEY { ttl, .. } + | DnsRecord::DS { ttl, .. } + | DnsRecord::RRSIG { ttl, .. } + | DnsRecord::NSEC { ttl, .. } + | DnsRecord::NSEC3 { ttl, .. } | DnsRecord::UNKNOWN { ttl, .. } => *ttl, } } @@ -65,19 +143,25 @@ impl DnsRecord { | DnsRecord::CNAME { ttl, .. } | DnsRecord::MX { ttl, .. } | DnsRecord::AAAA { ttl, .. } + | DnsRecord::DNSKEY { ttl, .. } + | DnsRecord::DS { ttl, .. } + | DnsRecord::RRSIG { ttl, .. } + | DnsRecord::NSEC { ttl, .. } + | DnsRecord::NSEC3 { ttl, .. } | DnsRecord::UNKNOWN { ttl, .. } => *ttl = new_ttl, } } pub fn read(buffer: &mut BytePacketBuffer) -> Result { - let mut domain = String::new(); + let mut domain = String::with_capacity(64); buffer.read_qname(&mut domain)?; let qtype_num = buffer.read_u16()?; let qtype = QueryType::from_num(qtype_num); - let _ = buffer.read_u16()?; + let _ = buffer.read_u16()?; // class let ttl = buffer.read_u32()?; let data_len = buffer.read_u16()?; + let rdata_start = buffer.pos(); match qtype { QueryType::A => { @@ -88,7 +172,6 @@ impl DnsRecord { ((raw_addr >> 8) & 0xFF) as u8, (raw_addr & 0xFF) as u8, ); - Ok(DnsRecord::A { domain, addr, ttl }) } QueryType::AAAA => { @@ -106,13 +189,11 @@ impl DnsRecord { ((raw_addr4 >> 16) & 0xFFFF) as u16, (raw_addr4 & 0xFFFF) as u16, ); - Ok(DnsRecord::AAAA { domain, addr, ttl }) } QueryType::NS => { - let mut ns = String::new(); + let mut ns = String::with_capacity(64); buffer.read_qname(&mut ns)?; - Ok(DnsRecord::NS { domain, host: ns, @@ -120,9 +201,8 @@ impl DnsRecord { }) } QueryType::CNAME => { - let mut cname = String::new(); + let mut cname = String::with_capacity(64); buffer.read_qname(&mut cname)?; - Ok(DnsRecord::CNAME { domain, host: cname, @@ -131,9 +211,8 @@ impl DnsRecord { } QueryType::MX => { let priority = buffer.read_u16()?; - let mut mx = String::new(); + let mut mx = String::with_capacity(64); buffer.read_qname(&mut mx)?; - Ok(DnsRecord::MX { domain, priority, @@ -141,13 +220,119 @@ impl DnsRecord { ttl, }) } + QueryType::DNSKEY => { + let flags = buffer.read_u16()?; + let protocol = buffer.read()?; + let algorithm = buffer.read()?; + let key_len = data_len as usize - 4; // flags(2) + protocol(1) + algorithm(1) + let public_key = buffer.get_range(buffer.pos(), key_len)?.to_vec(); + buffer.step(key_len)?; + Ok(DnsRecord::DNSKEY { + domain, + flags, + protocol, + algorithm, + public_key, + ttl, + }) + } + QueryType::DS => { + let key_tag = buffer.read_u16()?; + let algorithm = buffer.read()?; + let digest_type = buffer.read()?; + let digest_len = data_len as usize - 4; // key_tag(2) + algorithm(1) + digest_type(1) + let digest = buffer.get_range(buffer.pos(), digest_len)?.to_vec(); + buffer.step(digest_len)?; + Ok(DnsRecord::DS { + domain, + key_tag, + algorithm, + digest_type, + digest, + ttl, + }) + } + QueryType::RRSIG => { + let type_covered = buffer.read_u16()?; + let algorithm = buffer.read()?; + let labels = buffer.read()?; + let original_ttl = buffer.read_u32()?; + let expiration = buffer.read_u32()?; + let inception = buffer.read_u32()?; + let key_tag = buffer.read_u16()?; + let mut signer_name = String::with_capacity(64); + buffer.read_qname(&mut signer_name)?; + let rdata_end = rdata_start + data_len as usize; + let sig_len = rdata_end + .checked_sub(buffer.pos()) + .ok_or("RRSIG data_len too short for fixed fields + signer_name")?; + let signature = buffer.get_range(buffer.pos(), sig_len)?.to_vec(); + buffer.step(sig_len)?; + Ok(DnsRecord::RRSIG { + domain, + type_covered, + algorithm, + labels, + original_ttl, + expiration, + inception, + key_tag, + signer_name, + signature, + ttl, + }) + } + QueryType::NSEC => { + let rdata_end = rdata_start + data_len as usize; + let mut next_domain = String::with_capacity(64); + buffer.read_qname(&mut next_domain)?; + let bitmap_len = rdata_end + .checked_sub(buffer.pos()) + .ok_or("NSEC data_len too short for type bitmap")?; + let type_bitmap = buffer.get_range(buffer.pos(), bitmap_len)?.to_vec(); + buffer.step(bitmap_len)?; + Ok(DnsRecord::NSEC { + domain, + next_domain, + type_bitmap, + ttl, + }) + } + QueryType::NSEC3 => { + let rdata_end = rdata_start + data_len as usize; + let hash_algorithm = buffer.read()?; + let flags = buffer.read()?; + let iterations = buffer.read_u16()?; + let salt_length = buffer.read()? as usize; + let salt = buffer.get_range(buffer.pos(), salt_length)?.to_vec(); + buffer.step(salt_length)?; + let hash_length = buffer.read()? as usize; + let next_hashed_owner = buffer.get_range(buffer.pos(), hash_length)?.to_vec(); + buffer.step(hash_length)?; + let bitmap_len = rdata_end + .checked_sub(buffer.pos()) + .ok_or("NSEC3 data_len too short for type bitmap")?; + let type_bitmap = buffer.get_range(buffer.pos(), bitmap_len)?.to_vec(); + buffer.step(bitmap_len)?; + Ok(DnsRecord::NSEC3 { + domain, + hash_algorithm, + flags, + iterations, + salt, + next_hashed_owner, + type_bitmap, + ttl, + }) + } _ => { + // SOA, TXT, SRV, etc. — stored as opaque bytes until parsed natively + let data = buffer.get_range(buffer.pos(), data_len as usize)?.to_vec(); buffer.step(data_len as usize)?; - Ok(DnsRecord::UNKNOWN { domain, qtype: qtype_num, - data_len, + data, ttl, }) } @@ -163,32 +348,19 @@ impl DnsRecord { ref addr, ttl, } => { - buffer.write_qname(domain)?; - buffer.write_u16(QueryType::A.to_num())?; - buffer.write_u16(1)?; - buffer.write_u32(ttl)?; + write_header(buffer, domain, QueryType::A.to_num(), ttl)?; buffer.write_u16(4)?; - - let octets = addr.octets(); - buffer.write_u8(octets[0])?; - buffer.write_u8(octets[1])?; - buffer.write_u8(octets[2])?; - buffer.write_u8(octets[3])?; + buffer.write_bytes(&addr.octets())?; } DnsRecord::NS { ref domain, ref host, ttl, } => { - buffer.write_qname(domain)?; - buffer.write_u16(QueryType::NS.to_num())?; - buffer.write_u16(1)?; - buffer.write_u32(ttl)?; - + write_header(buffer, domain, QueryType::NS.to_num(), ttl)?; let pos = buffer.pos(); buffer.write_u16(0)?; buffer.write_qname(host)?; - let size = buffer.pos() - (pos + 2); buffer.set_u16(pos, size as u16)?; } @@ -197,15 +369,10 @@ impl DnsRecord { ref host, ttl, } => { - buffer.write_qname(domain)?; - buffer.write_u16(QueryType::CNAME.to_num())?; - buffer.write_u16(1)?; - buffer.write_u32(ttl)?; - + write_header(buffer, domain, QueryType::CNAME.to_num(), ttl)?; let pos = buffer.pos(); buffer.write_u16(0)?; buffer.write_qname(host)?; - let size = buffer.pos() - (pos + 2); buffer.set_u16(pos, size as u16)?; } @@ -215,16 +382,11 @@ impl DnsRecord { ref host, ttl, } => { - buffer.write_qname(domain)?; - buffer.write_u16(QueryType::MX.to_num())?; - buffer.write_u16(1)?; - buffer.write_u32(ttl)?; - + write_header(buffer, domain, QueryType::MX.to_num(), ttl)?; let pos = buffer.pos(); buffer.write_u16(0)?; buffer.write_u16(priority)?; buffer.write_qname(host)?; - let size = buffer.pos() - (pos + 2); buffer.set_u16(pos, size as u16)?; } @@ -233,21 +395,259 @@ impl DnsRecord { ref addr, ttl, } => { - buffer.write_qname(domain)?; - buffer.write_u16(QueryType::AAAA.to_num())?; - buffer.write_u16(1)?; - buffer.write_u32(ttl)?; + write_header(buffer, domain, QueryType::AAAA.to_num(), ttl)?; buffer.write_u16(16)?; - for octet in &addr.segments() { buffer.write_u16(*octet)?; } } - DnsRecord::UNKNOWN { .. } => { - log::debug!("Skipping record: {:?}", self); + DnsRecord::DNSKEY { + ref domain, + flags, + protocol, + algorithm, + ref public_key, + ttl, + } => { + write_header(buffer, domain, QueryType::DNSKEY.to_num(), ttl)?; + buffer.write_u16((4 + public_key.len()) as u16)?; + buffer.write_u16(flags)?; + buffer.write_u8(protocol)?; + buffer.write_u8(algorithm)?; + buffer.write_bytes(public_key)?; + } + DnsRecord::DS { + ref domain, + key_tag, + algorithm, + digest_type, + ref digest, + ttl, + } => { + write_header(buffer, domain, QueryType::DS.to_num(), ttl)?; + buffer.write_u16((4 + digest.len()) as u16)?; + buffer.write_u16(key_tag)?; + buffer.write_u8(algorithm)?; + buffer.write_u8(digest_type)?; + buffer.write_bytes(digest)?; + } + DnsRecord::RRSIG { + ref domain, + type_covered, + algorithm, + labels, + original_ttl, + expiration, + inception, + key_tag, + ref signer_name, + ref signature, + ttl, + } => { + write_header(buffer, domain, QueryType::RRSIG.to_num(), ttl)?; + let rdlen_pos = buffer.pos(); + buffer.write_u16(0)?; // RDLENGTH placeholder + buffer.write_u16(type_covered)?; + buffer.write_u8(algorithm)?; + buffer.write_u8(labels)?; + buffer.write_u32(original_ttl)?; + buffer.write_u32(expiration)?; + buffer.write_u32(inception)?; + buffer.write_u16(key_tag)?; + buffer.write_qname(signer_name)?; + buffer.write_bytes(signature)?; + let rdlen = buffer.pos() - (rdlen_pos + 2); + buffer.set_u16(rdlen_pos, rdlen as u16)?; + } + DnsRecord::NSEC { + ref domain, + ref next_domain, + ref type_bitmap, + ttl, + } => { + write_header(buffer, domain, QueryType::NSEC.to_num(), ttl)?; + let rdlen_pos = buffer.pos(); + buffer.write_u16(0)?; + buffer.write_qname(next_domain)?; + buffer.write_bytes(type_bitmap)?; + let rdlen = buffer.pos() - (rdlen_pos + 2); + buffer.set_u16(rdlen_pos, rdlen as u16)?; + } + DnsRecord::NSEC3 { + ref domain, + hash_algorithm, + flags, + iterations, + ref salt, + ref next_hashed_owner, + ref type_bitmap, + ttl, + } => { + write_header(buffer, domain, QueryType::NSEC3.to_num(), ttl)?; + let rdlen = + 1 + 1 + 2 + 1 + salt.len() + 1 + next_hashed_owner.len() + type_bitmap.len(); + buffer.write_u16(rdlen as u16)?; + buffer.write_u8(hash_algorithm)?; + buffer.write_u8(flags)?; + buffer.write_u16(iterations)?; + buffer.write_u8(salt.len() as u8)?; + buffer.write_bytes(salt)?; + buffer.write_u8(next_hashed_owner.len() as u8)?; + buffer.write_bytes(next_hashed_owner)?; + buffer.write_bytes(type_bitmap)?; + } + DnsRecord::UNKNOWN { + ref domain, + qtype, + ref data, + ttl, + } => { + write_header(buffer, domain, qtype, ttl)?; + buffer.write_u16(data.len() as u16)?; + buffer.write_bytes(data)?; } } Ok(buffer.pos() - start_pos) } } + +fn write_header(buffer: &mut BytePacketBuffer, domain: &str, qtype: u16, ttl: u32) -> Result<()> { + buffer.write_qname(domain)?; + buffer.write_u16(qtype)?; + buffer.write_u16(1)?; // class IN + buffer.write_u32(ttl)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn round_trip(record: &DnsRecord) -> DnsRecord { + let mut buf = BytePacketBuffer::new(); + record.write(&mut buf).unwrap(); + buf.seek(0).unwrap(); + DnsRecord::read(&mut buf).unwrap() + } + + #[test] + fn unknown_preserves_raw_bytes() { + let rec = DnsRecord::UNKNOWN { + domain: "example.com".into(), + qtype: 99, + data: vec![0xDE, 0xAD, 0xBE, 0xEF], + ttl: 300, + }; + let parsed = round_trip(&rec); + if let DnsRecord::UNKNOWN { data, .. } = &parsed { + assert_eq!(data.len(), 4); + assert_eq!(data, &[0xDE, 0xAD, 0xBE, 0xEF]); + } else { + panic!("expected UNKNOWN"); + } + } + + #[test] + fn dnskey_round_trip() { + let rec = DnsRecord::DNSKEY { + domain: "example.com".into(), + flags: 257, // KSK + protocol: 3, + algorithm: 13, // ECDSAP256SHA256 + public_key: vec![1, 2, 3, 4, 5, 6, 7, 8], + ttl: 3600, + }; + let parsed = round_trip(&rec); + assert_eq!(rec, parsed); + } + + #[test] + fn ds_round_trip() { + let rec = DnsRecord::DS { + domain: "example.com".into(), + key_tag: 12345, + algorithm: 8, + digest_type: 2, + digest: vec![0xAA, 0xBB, 0xCC, 0xDD], + ttl: 86400, + }; + let parsed = round_trip(&rec); + assert_eq!(rec, parsed); + } + + #[test] + fn rrsig_round_trip() { + let rec = DnsRecord::RRSIG { + domain: "example.com".into(), + type_covered: 1, // A + algorithm: 13, + labels: 2, + original_ttl: 300, + expiration: 1700000000, + inception: 1690000000, + key_tag: 54321, + signer_name: "example.com".into(), + signature: vec![0x01, 0x02, 0x03, 0x04, 0x05], + ttl: 300, + }; + let parsed = round_trip(&rec); + assert_eq!(rec, parsed); + } + + #[test] + fn query_type_method() { + assert_eq!( + DnsRecord::DNSKEY { + domain: String::new(), + flags: 0, + protocol: 3, + algorithm: 8, + public_key: vec![], + ttl: 0, + } + .query_type(), + QueryType::DNSKEY + ); + assert_eq!( + DnsRecord::DS { + domain: String::new(), + key_tag: 0, + algorithm: 0, + digest_type: 0, + digest: vec![], + ttl: 0, + } + .query_type(), + QueryType::DS + ); + } + + #[test] + fn nsec_round_trip() { + let rec = DnsRecord::NSEC { + domain: "alpha.example.com".into(), + next_domain: "gamma.example.com".into(), + type_bitmap: vec![0, 2, 0x40, 0x01], // A(1), MX(15) + ttl: 3600, + }; + let parsed = round_trip(&rec); + assert_eq!(rec, parsed); + } + + #[test] + fn nsec3_round_trip() { + let rec = DnsRecord::NSEC3 { + domain: "abc123.example.com".into(), + hash_algorithm: 1, + flags: 0, + iterations: 10, + salt: vec![0xAB, 0xCD], + next_hashed_owner: vec![0x01, 0x02, 0x03, 0x04, 0x05], + type_bitmap: vec![0, 1, 0x40], // A(1) + ttl: 3600, + }; + let parsed = round_trip(&rec); + assert_eq!(rec, parsed); + } +} diff --git a/src/recursive.rs b/src/recursive.rs new file mode 100644 index 0000000..82f9879 --- /dev/null +++ b/src/recursive.rs @@ -0,0 +1,1109 @@ +use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::{AtomicU16, Ordering}; +use std::sync::RwLock; +use std::time::{Duration, Instant}; + +use log::{debug, info}; + +use crate::cache::DnsCache; +use crate::forward::forward_udp; +use crate::header::ResultCode; +use crate::packet::DnsPacket; +use crate::question::QueryType; +use crate::record::DnsRecord; +use crate::srtt::SrttCache; + +const MAX_REFERRAL_DEPTH: u8 = 10; +const MAX_CNAME_DEPTH: u8 = 8; +const NS_QUERY_TIMEOUT: Duration = Duration::from_millis(800); +const TCP_TIMEOUT: Duration = Duration::from_millis(1500); +const UDP_FAIL_THRESHOLD: u8 = 3; + +static QUERY_ID: AtomicU16 = AtomicU16::new(1); +static UDP_FAILURES: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0); +pub(crate) static UDP_DISABLED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +fn next_id() -> u16 { + QUERY_ID.fetch_add(1, Ordering::Relaxed) +} + +fn dns_addr(ip: impl Into) -> SocketAddr { + SocketAddr::new(ip.into(), 53) +} + +fn record_to_addr(rec: &DnsRecord) -> Option { + match rec { + DnsRecord::A { addr, .. } => Some(dns_addr(*addr)), + DnsRecord::AAAA { addr, .. } => Some(dns_addr(*addr)), + _ => None, + } +} + +pub fn reset_udp_state() { + UDP_DISABLED.store(false, Ordering::Release); + UDP_FAILURES.store(0, Ordering::Release); +} + +/// Probe whether UDP works again. Called periodically from the network watch loop. +pub async fn probe_udp(root_hints: &[SocketAddr]) { + if !UDP_DISABLED.load(Ordering::Acquire) { + return; + } + let hint = match root_hints.first() { + Some(h) => *h, + None => return, + }; + let mut probe = DnsPacket::query(next_id(), ".", QueryType::NS); + probe.header.recursion_desired = false; + if forward_udp(&probe, hint, Duration::from_millis(1500)) + .await + .is_ok() + { + info!("UDP probe succeeded — re-enabling UDP"); + reset_udp_state(); + } +} + +pub async fn prime_tld_cache( + cache: &RwLock, + root_hints: &[SocketAddr], + tlds: &[String], + srtt: &RwLock, +) { + if root_hints.is_empty() || tlds.is_empty() { + return; + } + + let mut root_addr = root_hints[0]; + for hint in root_hints { + info!("prime: probing root {}", hint); + match send_query(".", QueryType::NS, *hint, srtt).await { + Ok(_) => { + info!("prime: root {} reachable", hint); + root_addr = *hint; + break; + } + Err(e) => { + info!("prime: root {} failed: {}, trying next", hint, e); + } + } + } + + // Fetch root DNSKEY (needed for DNSSEC chain-of-trust terminus) + if let Ok(root_dnskey) = send_query(".", QueryType::DNSKEY, root_addr, srtt).await { + cache + .write() + .unwrap() + .insert(".", QueryType::DNSKEY, &root_dnskey); + debug!("prime: cached root DNSKEY"); + } + + let mut primed = 0u16; + + for tld in tlds { + // Fetch NS referral (includes DS in authority section from root) + let response = match send_query(tld, QueryType::NS, root_addr, srtt).await { + Ok(r) => r, + Err(e) => { + debug!("prime: failed to query NS for .{}: {}", tld, e); + continue; + } + }; + + let ns_names = extract_ns_names(&response); + if ns_names.is_empty() { + continue; + } + + { + let mut cache_w = cache.write().unwrap(); + cache_w.insert(tld, QueryType::NS, &response); + cache_glue(&mut cache_w, &response, &ns_names); + cache_ds_from_authority(&mut cache_w, &response); + } + + // Fetch DNSKEY for this TLD (needed for DNSSEC chain validation) + let first_ns_name = ns_names.first().map(|s| s.as_str()).unwrap_or(""); + let first_ns = glue_addrs_for(&response, first_ns_name); + if let Some(ns_addr) = first_ns.first() { + if let Ok(dnskey_resp) = send_query(tld, QueryType::DNSKEY, *ns_addr, srtt).await { + cache + .write() + .unwrap() + .insert(tld, QueryType::DNSKEY, &dnskey_resp); + } + } + + primed += 1; + } + + info!( + "primed {}/{} TLD caches (NS + glue + DS + DNSKEY)", + primed, + tlds.len() + ); +} + +pub async fn resolve_recursive( + qname: &str, + qtype: QueryType, + cache: &RwLock, + original_query: &DnsPacket, + root_hints: &[SocketAddr], + srtt: &RwLock, +) -> crate::Result { + // No overall timeout — each hop is bounded by NS_QUERY_TIMEOUT (UDP + TCP fallback), + // and MAX_REFERRAL_DEPTH caps the chain length. + let mut resp = resolve_iterative(qname, qtype, cache, root_hints, srtt, 0, 0).await?; + + resp.header.id = original_query.header.id; + resp.header.recursion_available = true; + resp.header.recursion_desired = original_query.header.recursion_desired; + resp.questions = original_query.questions.clone(); + Ok(resp) +} + +pub(crate) fn resolve_iterative<'a>( + qname: &'a str, + qtype: QueryType, + cache: &'a RwLock, + root_hints: &'a [SocketAddr], + srtt: &'a RwLock, + referral_depth: u8, + cname_depth: u8, +) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + if referral_depth > MAX_REFERRAL_DEPTH { + return Err("max referral depth exceeded".into()); + } + + if let Some(cached) = cache.read().unwrap().lookup(qname, qtype) { + return Ok(cached); + } + + let (mut current_zone, mut ns_addrs) = find_closest_ns(qname, cache, root_hints); + srtt.read().unwrap().sort_by_rtt(&mut ns_addrs); + let mut ns_idx = 0; + + for _ in 0..MAX_REFERRAL_DEPTH { + let ns_addr = match ns_addrs.get(ns_idx) { + Some(addr) => *addr, + None => return Err("no nameserver available".into()), + }; + + let (q_name, q_type) = minimize_query(qname, qtype, ¤t_zone); + + debug!( + "recursive: querying {} for {:?} {} (zone: {}, depth {})", + ns_addr, q_type, q_name, current_zone, referral_depth + ); + + let response = match send_query(q_name, q_type, ns_addr, srtt).await { + Ok(r) => r, + Err(e) => { + debug!("recursive: NS {} failed: {}", ns_addr, e); + ns_idx += 1; + continue; + } + }; + + if (q_type != qtype || !q_name.eq_ignore_ascii_case(qname)) + && (!response.authorities.is_empty() || !response.answers.is_empty()) + { + if let Some(zone) = referral_zone(&response) { + current_zone = zone; + } + let mut all_ns = extract_ns_from_records(&response.answers); + if all_ns.is_empty() { + all_ns = extract_ns_names(&response); + } + let mut new_addrs = resolve_ns_addrs_from_glue(&response, &all_ns, cache); + if !new_addrs.is_empty() { + srtt.read().unwrap().sort_by_rtt(&mut new_addrs); + ns_addrs = new_addrs; + ns_idx = 0; + continue; + } + ns_idx += 1; + continue; + } + + if !response.answers.is_empty() { + let has_target = response.answers.iter().any(|r| r.query_type() == qtype); + + if has_target || qtype == QueryType::CNAME { + cache.write().unwrap().insert(qname, qtype, &response); + return Ok(response); + } + + if let Some(cname_target) = extract_cname_target(&response, qname) { + if cname_depth >= MAX_CNAME_DEPTH { + return Err("max CNAME depth exceeded".into()); + } + debug!("recursive: chasing CNAME {} -> {}", qname, cname_target); + let final_resp = resolve_iterative( + &cname_target, + qtype, + cache, + root_hints, + srtt, + 0, + cname_depth + 1, + ) + .await?; + + let mut combined = response; + combined.answers.extend(final_resp.answers); + combined.header.rescode = final_resp.header.rescode; + cache.write().unwrap().insert(qname, qtype, &combined); + return Ok(combined); + } + + cache.write().unwrap().insert(qname, qtype, &response); + return Ok(response); + } + + if response.header.rescode == ResultCode::NXDOMAIN + || response.header.rescode == ResultCode::REFUSED + { + cache.write().unwrap().insert(qname, qtype, &response); + return Ok(response); + } + + if let Some(zone) = referral_zone(&response) { + current_zone = zone; + } + let ns_names = extract_ns_names(&response); + if ns_names.is_empty() { + return Ok(response); + } + + { + let mut cache_w = cache.write().unwrap(); + cache_ds_from_authority(&mut cache_w, &response); + } + let mut new_ns_addrs = resolve_ns_addrs_from_glue(&response, &ns_names, cache); + + if new_ns_addrs.is_empty() { + for ns_name in &ns_names { + if referral_depth < MAX_REFERRAL_DEPTH { + debug!("recursive: resolving glue-less NS {}", ns_name); + for qt in [QueryType::A, QueryType::AAAA] { + if let Ok(ns_resp) = resolve_iterative( + ns_name, + qt, + cache, + root_hints, + srtt, + referral_depth + 1, + cname_depth, + ) + .await + { + new_ns_addrs + .extend(ns_resp.answers.iter().filter_map(record_to_addr)); + } + if !new_ns_addrs.is_empty() { + break; + } + } + } + + if !new_ns_addrs.is_empty() { + break; + } + } + } + + if new_ns_addrs.is_empty() { + return Err(format!("could not resolve any NS for {}", qname).into()); + } + + srtt.read().unwrap().sort_by_rtt(&mut new_ns_addrs); + ns_addrs = new_ns_addrs; + ns_idx = 0; + } + + Err(format!("recursive resolution exhausted for {}", qname).into()) + }) +} + +/// Find the closest cached NS zone and its resolved addresses. +/// Returns (zone_name, ns_addresses). Falls back to (".", root_hints). +fn find_closest_ns( + qname: &str, + cache: &RwLock, + root_hints: &[SocketAddr], +) -> (String, Vec) { + let guard = cache.read().unwrap(); + + let mut pos = 0; + loop { + let zone = &qname[pos..]; + if let Some(cached) = guard.lookup(zone, QueryType::NS) { + let mut addrs = Vec::new(); + let ns_records = if cached + .answers + .iter() + .any(|r| matches!(r, DnsRecord::NS { .. })) + { + &cached.answers + } else { + &cached.authorities + }; + for ns_rec in ns_records { + if let DnsRecord::NS { host, .. } = ns_rec { + for qt in [QueryType::A, QueryType::AAAA] { + if let Some(resp) = guard.lookup(host, qt) { + addrs.extend(resp.answers.iter().filter_map(record_to_addr)); + } + } + } + } + if !addrs.is_empty() { + debug!("recursive: starting from cached NS for zone '{}'", zone); + return (zone.to_string(), addrs); + } + } + + match qname[pos..].find('.') { + Some(dot) => pos += dot + 1, + None => break, + } + } + + drop(guard); + debug!( + "recursive: starting from root hints ({} servers)", + root_hints.len() + ); + (".".to_string(), root_hints.to_vec()) +} + +/// Extract NS hostnames from any record section (answers or authorities). +fn extract_ns_from_records(records: &[DnsRecord]) -> Vec { + records + .iter() + .filter_map(|r| match r { + DnsRecord::NS { host, .. } => Some(host.clone()), + _ => None, + }) + .collect() +} + +/// Resolve NS addresses from glue records, then cache fallback. +fn resolve_ns_addrs_from_glue( + response: &DnsPacket, + ns_names: &[String], + cache: &RwLock, +) -> Vec { + let mut addrs = Vec::new(); + { + let mut cache_w = cache.write().unwrap(); + cache_glue(&mut cache_w, response, ns_names); + } + for ns_name in ns_names { + addrs.extend_from_slice(&glue_addrs_for(response, ns_name)); + } + if addrs.is_empty() { + for ns_name in ns_names { + addrs.extend(addrs_from_cache(cache, ns_name)); + } + } + addrs +} + +fn referral_zone(response: &DnsPacket) -> Option { + response.authorities.iter().find_map(|r| match r { + DnsRecord::NS { domain, .. } => Some(domain.clone()), + _ => None, + }) +} + +/// RFC 7816 query minimization (conservative): only minimize at root. +fn minimize_query<'a>( + qname: &'a str, + qtype: QueryType, + current_zone: &str, +) -> (&'a str, QueryType) { + if current_zone != "." { + return (qname, qtype); + } + // At root: extract TLD (last label) + match qname.rfind('.') { + Some(dot) if dot > 0 => (&qname[dot + 1..], QueryType::NS), + _ => (qname, qtype), + } +} + +fn addrs_from_cache(cache: &RwLock, name: &str) -> Vec { + let guard = cache.read().unwrap(); + let mut addrs = Vec::new(); + for qt in [QueryType::A, QueryType::AAAA] { + if let Some(pkt) = guard.lookup(name, qt) { + addrs.extend(pkt.answers.iter().filter_map(record_to_addr)); + } + } + addrs +} + +fn glue_addrs_for(response: &DnsPacket, ns_name: &str) -> Vec { + response + .resources + .iter() + .filter(|r| match r { + DnsRecord::A { domain, .. } | DnsRecord::AAAA { domain, .. } => { + domain.eq_ignore_ascii_case(ns_name) + } + _ => false, + }) + .filter_map(record_to_addr) + .collect() +} + +fn cache_glue(cache: &mut DnsCache, response: &DnsPacket, ns_names: &[String]) { + for ns_name in ns_names { + let mut a_pkt: Option = None; + let mut aaaa_pkt: Option = None; + + for r in &response.resources { + match r { + DnsRecord::A { domain, addr, ttl } if domain.eq_ignore_ascii_case(ns_name) => { + a_pkt + .get_or_insert_with(make_glue_packet) + .answers + .push(DnsRecord::A { + domain: ns_name.clone(), + addr: *addr, + ttl: *ttl, + }); + } + DnsRecord::AAAA { domain, addr, ttl } if domain.eq_ignore_ascii_case(ns_name) => { + aaaa_pkt + .get_or_insert_with(make_glue_packet) + .answers + .push(DnsRecord::AAAA { + domain: ns_name.clone(), + addr: *addr, + ttl: *ttl, + }); + } + _ => {} + } + } + + if let Some(pkt) = a_pkt { + cache.insert(ns_name, QueryType::A, &pkt); + } + if let Some(pkt) = aaaa_pkt { + cache.insert(ns_name, QueryType::AAAA, &pkt); + } + } +} + +/// Cache DS + DS-covering RRSIG records from referral authority sections. +fn cache_ds_from_authority(cache: &mut DnsCache, response: &DnsPacket) { + let mut ds_by_domain: Vec<(String, DnsPacket)> = Vec::new(); + + for r in &response.authorities { + match r { + DnsRecord::DS { domain, .. } => { + let key = domain.to_lowercase(); + let pkt = match ds_by_domain.iter_mut().find(|(d, _)| *d == key) { + Some((_, pkt)) => pkt, + None => { + ds_by_domain.push((key, make_glue_packet())); + &mut ds_by_domain.last_mut().unwrap().1 + } + }; + pkt.answers.push(r.clone()); + } + DnsRecord::RRSIG { + domain, + type_covered, + .. + } if QueryType::from_num(*type_covered) == QueryType::DS => { + let key = domain.to_lowercase(); + let pkt = match ds_by_domain.iter_mut().find(|(d, _)| *d == key) { + Some((_, pkt)) => pkt, + None => { + ds_by_domain.push((key, make_glue_packet())); + &mut ds_by_domain.last_mut().unwrap().1 + } + }; + pkt.answers.push(r.clone()); + } + _ => {} + } + } + + for (domain, pkt) in &ds_by_domain { + if !pkt.answers.is_empty() { + cache.insert(domain, QueryType::DS, pkt); + } + } +} + +fn make_glue_packet() -> DnsPacket { + let mut pkt = DnsPacket::new(); + pkt.header.response = true; + pkt.header.rescode = ResultCode::NOERROR; + pkt +} + +async fn tcp_with_srtt( + query: &DnsPacket, + server: SocketAddr, + srtt: &RwLock, + start: Instant, +) -> crate::Result { + match crate::forward::forward_tcp(query, server, TCP_TIMEOUT).await { + Ok(resp) => { + srtt.write() + .unwrap() + .record_rtt(server.ip(), start.elapsed().as_millis() as u64, true); + Ok(resp) + } + Err(e) => { + srtt.write().unwrap().record_failure(server.ip()); + Err(e) + } + } +} + +async fn send_query( + qname: &str, + qtype: QueryType, + server: SocketAddr, + srtt: &RwLock, +) -> crate::Result { + let mut query = DnsPacket::query(next_id(), qname, qtype); + query.header.recursion_desired = false; + query.edns = Some(crate::packet::EdnsOpt { + do_bit: true, + ..Default::default() + }); + + let start = Instant::now(); + + // IPv6 forced to TCP — our UDP socket is bound to 0.0.0.0 + if server.is_ipv6() { + return tcp_with_srtt(&query, server, srtt, start).await; + } + + // UDP detected as blocked — go TCP-first + if UDP_DISABLED.load(Ordering::Acquire) { + return tcp_with_srtt(&query, server, srtt, start).await; + } + + match forward_udp(&query, server, NS_QUERY_TIMEOUT).await { + Ok(resp) if resp.header.truncated_message => { + debug!("send_query: truncated from {}, retrying TCP", server); + tcp_with_srtt(&query, server, srtt, start).await + } + Ok(resp) => { + UDP_FAILURES.store(0, Ordering::Release); + srtt.write().unwrap().record_rtt( + server.ip(), + start.elapsed().as_millis() as u64, + false, + ); + Ok(resp) + } + Err(e) => { + let fails = UDP_FAILURES.fetch_add(1, Ordering::AcqRel) + 1; + if fails >= UDP_FAIL_THRESHOLD && !UDP_DISABLED.load(Ordering::Acquire) { + UDP_DISABLED.store(true, Ordering::Release); + info!( + "send_query: {} consecutive UDP failures — switching to TCP-first", + fails + ); + } + debug!("send_query: UDP failed for {}: {}, trying TCP", server, e); + tcp_with_srtt(&query, server, srtt, start).await + } + } +} + +fn extract_cname_target(response: &DnsPacket, qname: &str) -> Option { + response.answers.iter().find_map(|r| match r { + DnsRecord::CNAME { domain, host, .. } if domain.eq_ignore_ascii_case(qname) => { + Some(host.clone()) + } + _ => None, + }) +} + +fn extract_ns_names(response: &DnsPacket) -> Vec { + response + .authorities + .iter() + .filter_map(|r| match r { + DnsRecord::NS { host, .. } => Some(host.clone()), + _ => None, + }) + .collect() +} + +pub fn parse_root_hints(hints: &[String]) -> Vec { + hints + .iter() + .filter_map(|s| { + s.parse::() + .map(|ip| SocketAddr::new(ip, 53)) + .map_err(|e| log::warn!("invalid root hint '{}': {}", s, e)) + .ok() + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{Ipv4Addr, Ipv6Addr}; + + #[test] + fn extract_ns_from_authority() { + let mut pkt = DnsPacket::new(); + pkt.authorities.push(DnsRecord::NS { + domain: "example.com".into(), + host: "ns1.example.com".into(), + ttl: 3600, + }); + pkt.authorities.push(DnsRecord::NS { + domain: "example.com".into(), + host: "ns2.example.com".into(), + ttl: 3600, + }); + let names = extract_ns_names(&pkt); + assert_eq!(names, vec!["ns1.example.com", "ns2.example.com"]); + } + + #[test] + fn glue_extraction_a() { + let mut pkt = DnsPacket::new(); + pkt.resources.push(DnsRecord::A { + domain: "ns1.example.com".into(), + addr: Ipv4Addr::new(1, 2, 3, 4), + ttl: 3600, + }); + let addrs = glue_addrs_for(&pkt, "ns1.example.com"); + assert_eq!(addrs, vec![dns_addr(Ipv4Addr::new(1, 2, 3, 4))]); + assert!(glue_addrs_for(&pkt, "ns3.example.com").is_empty()); + } + + #[test] + fn glue_extraction_aaaa() { + let mut pkt = DnsPacket::new(); + pkt.resources.push(DnsRecord::AAAA { + domain: "ns1.example.com".into(), + addr: "2001:db8::1".parse().unwrap(), + ttl: 3600, + }); + pkt.resources.push(DnsRecord::A { + domain: "ns1.example.com".into(), + addr: Ipv4Addr::new(1, 2, 3, 4), + ttl: 3600, + }); + let addrs = glue_addrs_for(&pkt, "ns1.example.com"); + assert_eq!(addrs.len(), 2); + // AAAA first (order matches resources), then A + assert_eq!( + addrs[0], + dns_addr("2001:db8::1".parse::().unwrap()) + ); + assert_eq!(addrs[1], dns_addr(Ipv4Addr::new(1, 2, 3, 4))); + } + + #[test] + fn cname_extraction() { + let mut pkt = DnsPacket::new(); + pkt.answers.push(DnsRecord::CNAME { + domain: "www.example.com".into(), + host: "example.com".into(), + ttl: 300, + }); + assert_eq!( + extract_cname_target(&pkt, "www.example.com"), + Some("example.com".into()) + ); + assert_eq!(extract_cname_target(&pkt, "other.com"), None); + } + + #[test] + fn parse_root_hints_valid() { + let hints = vec!["198.41.0.4".into(), "199.9.14.201".into()]; + let addrs = parse_root_hints(&hints); + assert_eq!(addrs.len(), 2); + assert_eq!(addrs[0], dns_addr(Ipv4Addr::new(198, 41, 0, 4))); + } + + #[test] + fn parse_root_hints_skips_invalid() { + let hints = vec![ + "198.41.0.4".into(), + "not-an-ip".into(), + "192.33.4.12".into(), + ]; + let addrs = parse_root_hints(&hints); + assert_eq!(addrs.len(), 2); + } + + #[test] + fn find_closest_ns_falls_back_to_hints() { + let cache = RwLock::new(DnsCache::new(100, 60, 86400)); + let hints = vec![ + dns_addr(Ipv4Addr::new(198, 41, 0, 4)), + dns_addr(Ipv4Addr::new(199, 9, 14, 201)), + ]; + let (zone, addrs) = find_closest_ns("example.com", &cache, &hints); + assert_eq!(zone, "."); + assert_eq!(addrs, hints); + } + + #[test] + fn find_closest_ns_uses_authority_ns_records() { + // Simulate what TLD priming does: cache a referral response where + // NS records are in authorities (not answers), with glue in resources. + let cache = RwLock::new(DnsCache::new(100, 60, 86400)); + let hints = vec![dns_addr(Ipv4Addr::new(198, 41, 0, 4))]; + + // Build a referral-style response (NS in authorities, glue in resources) + let mut referral = DnsPacket::new(); + referral.header.response = true; + referral.authorities.push(DnsRecord::NS { + domain: "com".into(), + host: "ns1.com".into(), + ttl: 3600, + }); + referral.resources.push(DnsRecord::A { + domain: "ns1.com".into(), + addr: Ipv4Addr::new(192, 5, 6, 30), + ttl: 3600, + }); + + // Cache the referral under "com" NS (same as prime_tld_cache does) + { + let mut c = cache.write().unwrap(); + c.insert("com", QueryType::NS, &referral); + // Cache glue separately (as prime_tld_cache does) + let mut glue_pkt = DnsPacket::new(); + glue_pkt.header.response = true; + glue_pkt.answers.push(DnsRecord::A { + domain: "ns1.com".into(), + addr: Ipv4Addr::new(192, 5, 6, 30), + ttl: 3600, + }); + c.insert("ns1.com", QueryType::A, &glue_pkt); + } + + // find_closest_ns should find "com" zone from authority NS records + let (zone, addrs) = find_closest_ns("www.example.com", &cache, &hints); + assert_eq!(zone, "com"); + assert_eq!(addrs, vec![dns_addr(Ipv4Addr::new(192, 5, 6, 30))]); + } + + #[test] + fn minimize_query_from_root() { + // At root, only reveal TLD + let (name, qt) = minimize_query("www.example.com", QueryType::A, "."); + assert_eq!(name, "com"); + assert_eq!(qt, QueryType::NS); + } + + #[test] + fn minimize_query_beyond_root_sends_full() { + // Beyond root, send full query (conservative minimization) + let (name, qt) = minimize_query("www.example.com", QueryType::A, "com"); + assert_eq!(name, "www.example.com"); + assert_eq!(qt, QueryType::A); + + let (name, qt) = minimize_query("www.example.com", QueryType::A, "example.com"); + assert_eq!(name, "www.example.com"); + assert_eq!(qt, QueryType::A); + } + + #[test] + fn minimize_query_single_label() { + // Single label (e.g., "com") from root — send as-is + let (name, qt) = minimize_query("com", QueryType::NS, "."); + assert_eq!(name, "com"); + assert_eq!(qt, QueryType::NS); + } + + // ---- Mock DNS server (TCP-only) for fallback tests ---- + + use crate::buffer::BytePacketBuffer; + use crate::header::ResultCode; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + /// Spawn a TCP-only DNS server on localhost. Returns the address. + /// The handler receives each query and returns a response packet. + async fn spawn_tcp_dns_server( + handler: impl Fn(&DnsPacket) -> DnsPacket + Send + Sync + 'static, + ) -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handler = std::sync::Arc::new(handler); + tokio::spawn(async move { + loop { + let (mut stream, _) = match listener.accept().await { + Ok(c) => c, + Err(_) => break, + }; + let handler = handler.clone(); + tokio::spawn(async move { + // Read length-prefixed DNS query + let mut len_buf = [0u8; 2]; + if stream.read_exact(&mut len_buf).await.is_err() { + return; + } + let len = u16::from_be_bytes(len_buf) as usize; + let mut data = vec![0u8; len]; + if stream.read_exact(&mut data).await.is_err() { + return; + } + + let mut buf = BytePacketBuffer::from_bytes(&data); + let query = match DnsPacket::from_buffer(&mut buf) { + Ok(q) => q, + Err(_) => return, + }; + + let response = handler(&query); + + let mut resp_buf = BytePacketBuffer::new(); + if response.write(&mut resp_buf).is_err() { + return; + } + let resp_bytes = resp_buf.filled(); + let mut out = Vec::with_capacity(2 + resp_bytes.len()); + out.extend_from_slice(&(resp_bytes.len() as u16).to_be_bytes()); + out.extend_from_slice(resp_bytes); + let _ = stream.write_all(&out).await; + }); + } + }); + addr + } + + /// TCP-only server returns authoritative answer directly. + /// Verifies: UDP fails → TCP fallback → resolves. + #[tokio::test] + async fn tcp_fallback_resolves_when_udp_blocked() { + UDP_DISABLED.store(false, Ordering::Relaxed); + UDP_FAILURES.store(0, Ordering::Release); + + let server_addr = spawn_tcp_dns_server(|query| { + let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR); + resp.header.authoritative_answer = true; + if let Some(q) = query.questions.first() { + if q.qtype == QueryType::A || q.qtype == QueryType::NS { + resp.answers.push(DnsRecord::A { + domain: q.name.clone(), + addr: Ipv4Addr::new(10, 0, 0, 1), + ttl: 300, + }); + } + } + resp + }) + .await; + + let srtt = RwLock::new(SrttCache::new(true)); + let result = send_query("test.example.com", QueryType::A, server_addr, &srtt).await; + + let resp = result.expect("should resolve via TCP fallback"); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + assert!(!resp.answers.is_empty()); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 0, 0, 1)), + other => panic!("expected A record, got {:?}", other), + } + } + + /// Full iterative resolution through TCP-only mock: root referral → authoritative answer. + /// The mock plays both roles (returns referral for NS queries, answer for A queries). + #[tokio::test] + async fn tcp_only_iterative_resolution() { + UDP_DISABLED.store(true, Ordering::Release); // Skip UDP entirely for speed + + let server_addr = spawn_tcp_dns_server(|query| { + let q = match query.questions.first() { + Some(q) => q, + None => return DnsPacket::response_from(query, ResultCode::SERVFAIL), + }; + + if q.qtype == QueryType::NS || q.name == "com" { + // Return referral — NS points back to ourselves (same IP, port 53 in glue + // won't work, but cache will have our address from root_hints) + let mut resp = DnsPacket::new(); + resp.header.id = query.header.id; + resp.header.response = true; + resp.header.rescode = ResultCode::NOERROR; + resp.questions = query.questions.clone(); + resp.authorities.push(DnsRecord::NS { + domain: "com".into(), + host: "ns1.com".into(), + ttl: 3600, + }); + resp + } else { + // Return authoritative answer + let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR); + resp.header.authoritative_answer = true; + resp.answers.push(DnsRecord::A { + domain: q.name.clone(), + addr: Ipv4Addr::new(10, 0, 0, 42), + ttl: 300, + }); + resp + } + }) + .await; + + let srtt = RwLock::new(SrttCache::new(true)); + let result = send_query("hello.example.com", QueryType::A, server_addr, &srtt).await; + let resp = result.expect("TCP-only send_query should work"); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 0, 0, 42)), + other => panic!("expected A, got {:?}", other), + } + } + + #[tokio::test] + async fn tcp_fallback_handles_nxdomain() { + UDP_DISABLED.store(false, Ordering::Relaxed); + UDP_FAILURES.store(0, Ordering::Release); + + let server_addr = spawn_tcp_dns_server(|query| { + let mut resp = DnsPacket::response_from(query, ResultCode::NXDOMAIN); + resp.header.authoritative_answer = true; + resp + }) + .await; + + let cache = RwLock::new(DnsCache::new(100, 60, 86400)); + let srtt = RwLock::new(SrttCache::new(true)); + let root_hints = vec![server_addr]; + + let result = resolve_iterative( + "nonexistent.test", + QueryType::A, + &cache, + &root_hints, + &srtt, + 0, + 0, + ) + .await; + + let resp = result.expect("NXDOMAIN should still return a response"); + assert_eq!(resp.header.rescode, ResultCode::NXDOMAIN); + assert!(resp.answers.is_empty()); + } + + #[tokio::test] + async fn udp_auto_disable_resets() { + UDP_DISABLED.store(true, Ordering::Release); + UDP_FAILURES.store(5, Ordering::Relaxed); + + reset_udp_state(); + + assert!(!UDP_DISABLED.load(Ordering::Acquire)); + assert_eq!(UDP_FAILURES.load(Ordering::Relaxed), 0); + } + + /// Test forward_tcp directly — verifies the length-prefixed wire format. + #[tokio::test] + async fn forward_tcp_wire_format() { + let server_addr = spawn_tcp_dns_server(|query| { + let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR); + resp.header.authoritative_answer = true; + if let Some(q) = query.questions.first() { + resp.answers.push(DnsRecord::A { + domain: q.name.clone(), + addr: Ipv4Addr::new(1, 2, 3, 4), + ttl: 60, + }); + } + resp + }) + .await; + + let query = DnsPacket::query(0xBEEF, "test.com", QueryType::A); + + let resp = crate::forward::forward_tcp(&query, server_addr, Duration::from_secs(2)) + .await + .expect("forward_tcp should succeed"); + + assert_eq!(resp.header.id, 0xBEEF); + assert_eq!(resp.header.rescode, ResultCode::NOERROR); + assert!(!resp.answers.is_empty()); + } + + /// Strict server: reads with a single read() call, rejecting split writes. + /// Simulates Microsoft Azure DNS behavior that caused the early-eof bug. + #[tokio::test] + async fn forward_tcp_single_segment_write() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + + // Single read — if length prefix arrives separately, this gets + // only 2 bytes and the parse fails (simulating the Microsoft bug). + let mut buf = vec![0u8; 4096]; + let n = tokio::io::AsyncReadExt::read(&mut stream, &mut buf) + .await + .unwrap(); + + assert!( + n >= 2 + 12, // length prefix + DNS header minimum + "got only {} bytes in first read — split write bug", + n + ); + + let msg_len = u16::from_be_bytes([buf[0], buf[1]]) as usize; + assert_eq!(msg_len, n - 2, "length prefix doesn't match payload"); + + // Parse and respond + let mut pkt_buf = BytePacketBuffer::from_bytes(&buf[2..n]); + let query = DnsPacket::from_buffer(&mut pkt_buf).unwrap(); + + let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); + resp.answers.push(DnsRecord::A { + domain: query.questions[0].name.clone(), + addr: Ipv4Addr::new(5, 6, 7, 8), + ttl: 60, + }); + + let mut resp_buf = BytePacketBuffer::new(); + resp.write(&mut resp_buf).unwrap(); + let resp_bytes = resp_buf.filled(); + + let mut out = Vec::with_capacity(2 + resp_bytes.len()); + out.extend_from_slice(&(resp_bytes.len() as u16).to_be_bytes()); + out.extend_from_slice(resp_bytes); + tokio::io::AsyncWriteExt::write_all(&mut stream, &out) + .await + .unwrap(); + }); + + let query = DnsPacket::query(0xCAFE, "strict.test", QueryType::A); + + let resp = crate::forward::forward_tcp(&query, addr, Duration::from_secs(2)) + .await + .expect("forward_tcp must send length+message in single segment"); + + assert_eq!(resp.header.id, 0xCAFE); + match &resp.answers[0] { + DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(5, 6, 7, 8)), + other => panic!("expected A, got {:?}", other), + } + } +} diff --git a/src/service_store.rs b/src/service_store.rs index 26b2daf..f2c72c7 100644 --- a/src/service_store.rs +++ b/src/service_store.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use log::{info, warn}; @@ -8,12 +8,56 @@ use serde::{Deserialize, Serialize}; pub struct ServiceEntry { pub name: String, pub target_port: u16, + #[serde(default)] + pub routes: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct RouteEntry { + pub path: String, + pub port: u16, + #[serde(default)] + pub strip: bool, +} + +impl ServiceEntry { + /// Resolve backend port and (possibly rewritten) path for a request + pub fn resolve_route(&self, request_path: &str) -> (u16, String) { + // Longest prefix match + let matched = self + .routes + .iter() + .filter(|r| { + request_path == r.path + || (request_path.starts_with(&r.path) + && (r.path.ends_with('/') + || request_path.as_bytes().get(r.path.len()) == Some(&b'/'))) + }) + .max_by_key(|r| r.path.len()); + + match matched { + Some(route) => { + let path = if route.strip { + let stripped = &request_path[route.path.len()..]; + if stripped.is_empty() || !stripped.starts_with('/') { + format!("/{}", stripped.trim_start_matches('/')) + } else { + stripped.to_string() + } + } else { + request_path.to_string() + }; + (route.port, path) + } + None => (self.target_port, request_path.to_string()), + } + } } pub struct ServiceStore { entries: HashMap, /// Services defined in numa.toml (not persisted to user file) - config_services: std::collections::HashSet, + config_services: HashSet, persist_path: PathBuf, } @@ -28,13 +72,13 @@ impl ServiceStore { let persist_path = dirs_path(); ServiceStore { entries: HashMap::new(), - config_services: std::collections::HashSet::new(), + config_services: HashSet::new(), persist_path, } } /// Insert a service from numa.toml config (not persisted) - pub fn insert_from_config(&mut self, name: &str, target_port: u16) { + pub fn insert_from_config(&mut self, name: &str, target_port: u16, routes: Vec) { let key = name.to_lowercase(); self.config_services.insert(key.clone()); self.entries.insert( @@ -42,6 +86,7 @@ impl ServiceStore { ServiceEntry { name: key, target_port, + routes, }, ); } @@ -54,11 +99,37 @@ impl ServiceStore { ServiceEntry { name: key, target_port, + routes: Vec::new(), }, ); self.save(); } + pub fn add_route(&mut self, service: &str, path: String, port: u16, strip: bool) -> bool { + let key = service.to_lowercase(); + if let Some(entry) = self.entries.get_mut(&key) { + entry.routes.retain(|r| r.path != path); + entry.routes.push(RouteEntry { path, port, strip }); + self.save(); + true + } else { + false + } + } + + pub fn remove_route(&mut self, service: &str, path: &str) -> bool { + let key = service.to_lowercase(); + if let Some(entry) = self.entries.get_mut(&key) { + let before = entry.routes.len(); + entry.routes.retain(|r| r.path != path); + if entry.routes.len() < before { + self.save(); + return true; + } + } + false + } + pub fn lookup(&self, name: &str) -> Option<&ServiceEntry> { self.entries.get(&name.to_lowercase()) } @@ -72,12 +143,26 @@ impl ServiceStore { removed } + /// Names are always stored lowercased, so callers must pass lowercase keys. + pub fn is_config_service(&self, name: &str) -> bool { + self.config_services.contains(name) + } + pub fn list(&self) -> Vec<&ServiceEntry> { let mut entries: Vec<_> = self.entries.values().collect(); entries.sort_by(|a, b| a.name.cmp(&b.name)); entries } + pub fn names(&self) -> Vec { + self.entries.keys().cloned().collect() + } + + /// Returns true if the name is new (not already registered). + pub fn has_name(&self, name: &str) -> bool { + self.entries.contains_key(&name.to_lowercase()) + } + /// Load user-defined services from ~/.config/numa/services.json pub fn load_persisted(&mut self) { if !self.persist_path.exists() { @@ -133,3 +218,157 @@ impl ServiceStore { fn dirs_path() -> PathBuf { crate::config_dir().join("services.json") } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn entry(port: u16, routes: Vec) -> ServiceEntry { + ServiceEntry { + name: "app".into(), + target_port: port, + routes, + } + } + + fn route(path: &str, port: u16, strip: bool) -> RouteEntry { + RouteEntry { + path: path.into(), + port, + strip, + } + } + + fn test_store() -> ServiceStore { + ServiceStore { + entries: HashMap::new(), + config_services: HashSet::new(), + persist_path: PathBuf::from("/dev/null"), + } + } + + // --- resolve_route --- + + #[test] + fn no_routes_returns_default_port() { + let e = entry(3000, vec![]); + assert_eq!(e.resolve_route("/anything"), (3000, "/anything".into())); + } + + #[test] + fn exact_match() { + let e = entry(3000, vec![route("/api", 4000, false)]); + assert_eq!(e.resolve_route("/api"), (4000, "/api".into())); + } + + #[test] + fn prefix_match() { + let e = entry(3000, vec![route("/api", 4000, false)]); + assert_eq!(e.resolve_route("/api/users"), (4000, "/api/users".into())); + } + + #[test] + fn segment_boundary_rejects_partial() { + let e = entry(3000, vec![route("/api", 4000, false)]); + // /apiary must NOT match /api — different segment + assert_eq!(e.resolve_route("/apiary"), (3000, "/apiary".into())); + } + + #[test] + fn segment_boundary_rejects_apikey() { + let e = entry(3000, vec![route("/api", 4000, false)]); + assert_eq!(e.resolve_route("/apikey"), (3000, "/apikey".into())); + } + + #[test] + fn longest_prefix_wins() { + let e = entry( + 3000, + vec![route("/api", 4000, false), route("/api/v2", 5000, false)], + ); + assert_eq!( + e.resolve_route("/api/v2/users"), + (5000, "/api/v2/users".into()) + ); + // shorter prefix still works for non-v2 paths + assert_eq!( + e.resolve_route("/api/v1/users"), + (4000, "/api/v1/users".into()) + ); + } + + #[test] + fn strip_removes_prefix() { + let e = entry(3000, vec![route("/api", 4000, true)]); + assert_eq!(e.resolve_route("/api/users"), (4000, "/users".into())); + } + + #[test] + fn strip_exact_path_gives_root() { + let e = entry(3000, vec![route("/api", 4000, true)]); + assert_eq!(e.resolve_route("/api"), (4000, "/".into())); + } + + #[test] + fn trailing_slash_route_matches() { + let e = entry(3000, vec![route("/app/", 4000, false)]); + assert_eq!( + e.resolve_route("/app/dashboard"), + (4000, "/app/dashboard".into()) + ); + } + + // --- ServiceStore: add_route / remove_route --- + + #[test] + fn add_route_to_existing_service() { + let mut store = test_store(); + store.insert_from_config("app", 3000, vec![]); + assert!(store.add_route("app", "/api".into(), 4000, false)); + let entry = store.lookup("app").unwrap(); + assert_eq!(entry.routes.len(), 1); + assert_eq!(entry.routes[0].path, "/api"); + } + + #[test] + fn add_route_to_missing_service_returns_false() { + let mut store = test_store(); + assert!(!store.add_route("ghost", "/api".into(), 4000, false)); + } + + #[test] + fn add_route_deduplicates_by_path() { + let mut store = test_store(); + store.insert_from_config("app", 3000, vec![]); + store.add_route("app", "/api".into(), 4000, false); + store.add_route("app", "/api".into(), 5000, true); + let entry = store.lookup("app").unwrap(); + assert_eq!(entry.routes.len(), 1); + assert_eq!(entry.routes[0].port, 5000); + assert!(entry.routes[0].strip); + } + + #[test] + fn remove_route_returns_true_when_found() { + let mut store = test_store(); + store.insert_from_config("app", 3000, vec![route("/api", 4000, false)]); + assert!(store.remove_route("app", "/api")); + assert!(store.lookup("app").unwrap().routes.is_empty()); + } + + #[test] + fn remove_route_returns_false_when_missing() { + let mut store = test_store(); + store.insert_from_config("app", 3000, vec![]); + assert!(!store.remove_route("app", "/nope")); + } + + #[test] + fn lookup_is_case_insensitive() { + let mut store = test_store(); + store.insert_from_config("MyApp", 3000, vec![]); + assert!(store.lookup("myapp").is_some()); + assert!(store.lookup("MYAPP").is_some()); + } +} diff --git a/src/srtt.rs b/src/srtt.rs new file mode 100644 index 0000000..e44efbb --- /dev/null +++ b/src/srtt.rs @@ -0,0 +1,327 @@ +use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; +use std::time::Instant; + +const INITIAL_SRTT_MS: u64 = 200; +const FAILURE_PENALTY_MS: u64 = 5000; +const TCP_PENALTY_MS: u64 = 100; +const DECAY_AFTER_SECS: u64 = 300; +const MAX_ENTRIES: usize = 4096; +const EVICT_BATCH: usize = 64; + +struct SrttEntry { + srtt_ms: u64, + updated_at: Instant, +} + +pub struct SrttCache { + entries: HashMap, + enabled: bool, +} + +impl Default for SrttCache { + fn default() -> Self { + Self::new(true) + } +} + +impl SrttCache { + pub fn new(enabled: bool) -> Self { + Self { + entries: HashMap::new(), + enabled, + } + } + + pub fn is_enabled(&self) -> bool { + self.enabled + } + + /// Get current SRTT for an IP, applying decay if stale. Returns INITIAL for unknown. + pub fn get(&self, ip: IpAddr) -> u64 { + match self.entries.get(&ip) { + Some(entry) => Self::decayed_srtt(entry), + None => INITIAL_SRTT_MS, + } + } + + /// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL. + fn decayed_srtt(entry: &SrttEntry) -> u64 { + let age_secs = entry.updated_at.elapsed().as_secs(); + if age_secs > DECAY_AFTER_SECS { + let periods = (age_secs / DECAY_AFTER_SECS).min(8); + let mut srtt = entry.srtt_ms; + for _ in 0..periods { + srtt = (srtt + INITIAL_SRTT_MS) / 2; + } + srtt + } else { + entry.srtt_ms + } + } + + /// Record a successful query RTT. No-op when disabled. + pub fn record_rtt(&mut self, ip: IpAddr, rtt_ms: u64, tcp: bool) { + if !self.enabled { + return; + } + let effective = if tcp { rtt_ms + TCP_PENALTY_MS } else { rtt_ms }; + self.maybe_evict(); + let entry = self.entries.entry(ip).or_insert(SrttEntry { + srtt_ms: effective, + updated_at: Instant::now(), + }); + // Apply decay before EWMA so recovered servers aren't stuck at stale penalties + let base = Self::decayed_srtt(entry); + // BIND EWMA: new = (old * 7 + sample) / 8 + entry.srtt_ms = (base * 7 + effective) / 8; + entry.updated_at = Instant::now(); + } + + /// Record a failure (timeout or error). No-op when disabled. + pub fn record_failure(&mut self, ip: IpAddr) { + if !self.enabled { + return; + } + self.maybe_evict(); + let entry = self.entries.entry(ip).or_insert(SrttEntry { + srtt_ms: FAILURE_PENALTY_MS, + updated_at: Instant::now(), + }); + entry.srtt_ms = FAILURE_PENALTY_MS; + entry.updated_at = Instant::now(); + } + + /// Sort addresses by SRTT ascending (lowest/fastest first). No-op when disabled. + pub fn sort_by_rtt(&self, addrs: &mut [SocketAddr]) { + if !self.enabled { + return; + } + addrs.sort_by_key(|a| self.get(a.ip())); + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + #[cfg(test)] + fn set_updated_at(&mut self, ip: IpAddr, at: Instant) { + if let Some(entry) = self.entries.get_mut(&ip) { + entry.updated_at = at; + } + } + + fn maybe_evict(&mut self) { + if self.entries.len() < MAX_ENTRIES { + return; + } + // Batch eviction: remove the oldest EVICT_BATCH entries at once + let mut by_age: Vec = self.entries.keys().copied().collect(); + by_age.sort_by_key(|ip| self.entries[ip].updated_at); + for ip in by_age.into_iter().take(EVICT_BATCH) { + self.entries.remove(&ip); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + fn ip(last: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(192, 0, 2, last)) + } + + fn sock(last: u8) -> SocketAddr { + SocketAddr::new(ip(last), 53) + } + + #[test] + fn unknown_returns_initial() { + let cache = SrttCache::new(true); + assert_eq!(cache.get(ip(1)), INITIAL_SRTT_MS); + } + + #[test] + fn ewma_converges() { + let mut cache = SrttCache::new(true); + for _ in 0..20 { + cache.record_rtt(ip(1), 100, false); + } + let srtt = cache.get(ip(1)); + assert!(srtt >= 98 && srtt <= 102, "srtt={}", srtt); + } + + #[test] + fn failure_sets_penalty() { + let mut cache = SrttCache::new(true); + cache.record_rtt(ip(1), 50, false); + cache.record_failure(ip(1)); + assert_eq!(cache.get(ip(1)), FAILURE_PENALTY_MS); + } + + #[test] + fn tcp_penalty_added() { + let mut cache = SrttCache::new(true); + for _ in 0..20 { + cache.record_rtt(ip(1), 50, true); + } + let srtt = cache.get(ip(1)); + assert!(srtt >= 148 && srtt <= 152, "srtt={}", srtt); + } + + #[test] + fn sort_by_rtt_orders_correctly() { + let mut cache = SrttCache::new(true); + for _ in 0..20 { + cache.record_rtt(ip(1), 500, false); + cache.record_rtt(ip(2), 100, false); + cache.record_rtt(ip(3), 10, false); + } + let mut addrs = vec![sock(1), sock(2), sock(3)]; + cache.sort_by_rtt(&mut addrs); + assert_eq!(addrs, vec![sock(3), sock(2), sock(1)]); + } + + #[test] + fn unknown_servers_sort_equal() { + let cache = SrttCache::new(true); + let mut addrs = vec![sock(1), sock(2), sock(3)]; + let original = addrs.clone(); + cache.sort_by_rtt(&mut addrs); + assert_eq!(addrs, original); + } + + #[test] + fn disabled_is_noop() { + let mut cache = SrttCache::new(false); + cache.record_rtt(ip(1), 50, false); + cache.record_failure(ip(2)); + assert_eq!(cache.len(), 0); + + let mut addrs = vec![sock(2), sock(1)]; + let original = addrs.clone(); + cache.sort_by_rtt(&mut addrs); + assert_eq!(addrs, original); + } + + fn age(secs: u64) -> Instant { + Instant::now() - std::time::Duration::from_secs(secs) + } + + /// Cache with ip(1) saturated at FAILURE_PENALTY_MS + fn saturated_penalty_cache() -> SrttCache { + let mut cache = SrttCache::new(true); + for _ in 0..30 { + cache.record_rtt(ip(1), FAILURE_PENALTY_MS, false); + } + cache + } + + #[test] + fn no_decay_within_threshold() { + let mut cache = SrttCache::new(true); + cache.record_rtt(ip(1), 5000, false); + cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS)); + assert_eq!(cache.get(ip(1)), cache.entries[&ip(1)].srtt_ms); + } + + #[test] + fn one_decay_period() { + let mut cache = saturated_penalty_cache(); + let raw = cache.entries[&ip(1)].srtt_ms; + cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS + 1)); + let expected = (raw + INITIAL_SRTT_MS) / 2; + assert_eq!(cache.get(ip(1)), expected); + } + + #[test] + fn multiple_decay_periods() { + let mut cache = saturated_penalty_cache(); + let raw = cache.entries[&ip(1)].srtt_ms; + cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 4 + 1)); + let mut expected = raw; + for _ in 0..4 { + expected = (expected + INITIAL_SRTT_MS) / 2; + } + assert_eq!(cache.get(ip(1)), expected); + } + + #[test] + fn decay_caps_at_8_periods() { + // 9 periods and 100 periods should produce the same result (capped at 8) + let mut cache_a = saturated_penalty_cache(); + let mut cache_b = saturated_penalty_cache(); + cache_a.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 9 + 1)); + cache_b.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100)); + assert_eq!(cache_a.get(ip(1)), cache_b.get(ip(1))); + } + + #[test] + fn decay_converges_toward_initial() { + let mut cache = saturated_penalty_cache(); + cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100)); + let decayed = cache.get(ip(1)); + let diff = decayed.abs_diff(INITIAL_SRTT_MS); + assert!( + diff < 25, + "expected near INITIAL_SRTT_MS, got {} (diff={})", + decayed, + diff + ); + } + + #[test] + fn record_rtt_applies_decay_before_ewma() { + let mut cache = saturated_penalty_cache(); + cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 8)); + cache.record_rtt(ip(1), 50, false); + let srtt = cache.get(ip(1)); + // Without decay-before-EWMA, result would be ~(5000*7+50)/8 ≈ 4381 + assert!(srtt < 500, "expected decay before EWMA, got srtt={}", srtt); + } + + #[test] + fn decay_reranks_stale_failures() { + let mut cache = saturated_penalty_cache(); + for _ in 0..30 { + cache.record_rtt(ip(2), 300, false); + } + let mut addrs = vec![sock(1), sock(2)]; + cache.sort_by_rtt(&mut addrs); + assert_eq!(addrs, vec![sock(2), sock(1)]); + + // Age server 1 so it decays toward INITIAL (200ms) — below server 2's 300ms + cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100)); + let mut addrs = vec![sock(1), sock(2)]; + cache.sort_by_rtt(&mut addrs); + assert_eq!(addrs, vec![sock(1), sock(2)]); + } + + #[test] + fn eviction_removes_oldest() { + let mut cache = SrttCache::new(true); + for i in 0..MAX_ENTRIES { + let octets = [ + 10, + ((i >> 16) & 0xFF) as u8, + ((i >> 8) & 0xFF) as u8, + (i & 0xFF) as u8, + ]; + cache.record_rtt( + IpAddr::V4(Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3])), + 100, + false, + ); + } + assert_eq!(cache.len(), MAX_ENTRIES); + cache.record_rtt(ip(1), 100, false); + // Batch eviction removes EVICT_BATCH entries + assert!(cache.len() <= MAX_ENTRIES - EVICT_BATCH + 1); + } +} diff --git a/src/stats.rs b/src/stats.rs index 0336cbb..67ac56d 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -3,6 +3,8 @@ use std::time::Instant; pub struct ServerStats { queries_total: u64, queries_forwarded: u64, + queries_recursive: u64, + queries_coalesced: u64, queries_cached: u64, queries_blocked: u64, queries_local: u64, @@ -11,11 +13,13 @@ pub struct ServerStats { started_at: Instant, } -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum QueryPath { Local, Cached, Forwarded, + Recursive, + Coalesced, Blocked, Overridden, UpstreamError, @@ -27,6 +31,8 @@ impl QueryPath { QueryPath::Local => "LOCAL", QueryPath::Cached => "CACHED", QueryPath::Forwarded => "FORWARD", + QueryPath::Recursive => "RECURSIVE", + QueryPath::Coalesced => "COALESCED", QueryPath::Blocked => "BLOCKED", QueryPath::Overridden => "OVERRIDE", QueryPath::UpstreamError => "SERVFAIL", @@ -40,6 +46,10 @@ impl QueryPath { Some(QueryPath::Cached) } else if s.eq_ignore_ascii_case("FORWARD") { Some(QueryPath::Forwarded) + } else if s.eq_ignore_ascii_case("RECURSIVE") { + Some(QueryPath::Recursive) + } else if s.eq_ignore_ascii_case("COALESCED") { + Some(QueryPath::Coalesced) } else if s.eq_ignore_ascii_case("BLOCKED") { Some(QueryPath::Blocked) } else if s.eq_ignore_ascii_case("OVERRIDE") { @@ -63,6 +73,8 @@ impl ServerStats { ServerStats { queries_total: 0, queries_forwarded: 0, + queries_recursive: 0, + queries_coalesced: 0, queries_cached: 0, queries_blocked: 0, queries_local: 0, @@ -78,6 +90,8 @@ impl ServerStats { QueryPath::Local => self.queries_local += 1, QueryPath::Cached => self.queries_cached += 1, QueryPath::Forwarded => self.queries_forwarded += 1, + QueryPath::Recursive => self.queries_recursive += 1, + QueryPath::Coalesced => self.queries_coalesced += 1, QueryPath::Blocked => self.queries_blocked += 1, QueryPath::Overridden => self.queries_overridden += 1, QueryPath::UpstreamError => self.upstream_errors += 1, @@ -98,6 +112,8 @@ impl ServerStats { uptime_secs: self.uptime_secs(), total: self.queries_total, forwarded: self.queries_forwarded, + recursive: self.queries_recursive, + coalesced: self.queries_coalesced, cached: self.queries_cached, local: self.queries_local, overridden: self.queries_overridden, @@ -113,10 +129,12 @@ impl ServerStats { let secs = uptime.as_secs() % 60; log::info!( - "STATS | uptime {}h{}m{}s | total {} | fwd {} | cached {} | local {} | override {} | blocked {} | errors {}", + "STATS | uptime {}h{}m{}s | total {} | fwd {} | recursive {} | coalesced {} | cached {} | local {} | override {} | blocked {} | errors {}", hours, mins, secs, self.queries_total, self.queries_forwarded, + self.queries_recursive, + self.queries_coalesced, self.queries_cached, self.queries_local, self.queries_overridden, @@ -130,6 +148,8 @@ pub struct StatsSnapshot { pub uptime_secs: u64, pub total: u64, pub forwarded: u64, + pub recursive: u64, + pub coalesced: u64, pub cached: u64, pub local: u64, pub overridden: u64, diff --git a/src/system_dns.rs b/src/system_dns.rs index 6b63c48..57559b5 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -24,13 +24,25 @@ pub fn discover_system_dns() -> SystemDnsInfo { { discover_macos() } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] { SystemDnsInfo { default_upstream: detect_upstream_linux_or_backup(), forwarding_rules: Vec::new(), } } + #[cfg(windows)] + { + discover_windows() + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + { + log::debug!("no conditional forwarding rules discovered"); + SystemDnsInfo { + default_upstream: None, + forwarding_rules: Vec::new(), + } + } } #[cfg(target_os = "macos")] @@ -156,7 +168,7 @@ fn make_rule(domain: &str, nameserver: &str) -> Option { /// Detect upstream from /etc/resolv.conf, falling back to backup file if resolv.conf /// only has loopback (meaning numa install already ran). -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] fn detect_upstream_linux_or_backup() -> Option { // Try /etc/resolv.conf first if let Some(ns) = read_upstream_from_file("/etc/resolv.conf") { @@ -177,7 +189,7 @@ fn detect_upstream_linux_or_backup() -> Option { None } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] fn read_upstream_from_file(path: &str) -> Option { let text = std::fs::read_to_string(path).ok()?; for line in text.lines() { @@ -193,6 +205,103 @@ fn read_upstream_from_file(path: &str) -> Option { None } +/// Detect DNS server from DHCP lease — fallback when scutil/resolv.conf only shows 127.0.0.1. +/// On macOS: parses `ipconfig getpacket en0` for domain_name_server. +/// On Linux/Windows: returns None (not implemented yet). +pub fn detect_dhcp_dns() -> Option { + #[cfg(target_os = "macos")] + { + detect_dhcp_dns_macos() + } + #[cfg(not(target_os = "macos"))] + { + None + } +} + +#[cfg(target_os = "macos")] +fn detect_dhcp_dns_macos() -> Option { + // Try common interfaces + for iface in &["en0", "en1"] { + let output = std::process::Command::new("ipconfig") + .args(["getpacket", iface]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if line.contains("domain_name_server") { + // Format: "domain_name_server (ip_mult): {213.154.124.25, 1.0.0.1}" + if let Some(braces) = line.split('{').nth(1) { + let inner = braces.trim_end_matches('}').trim(); + // Take the first non-loopback DNS server + for addr in inner.split(',') { + let addr = addr.trim(); + if !addr.is_empty() + && addr != "127.0.0.1" + && addr != "0.0.0.0" + && addr.parse::().is_ok() + { + log::info!("detected DHCP DNS: {}", addr); + return Some(addr.to_string()); + } + } + } + } + } + } + None +} + +// --- Windows implementation --- + +#[cfg(windows)] +fn discover_windows() -> SystemDnsInfo { + use log::{debug, warn}; + + let output = match std::process::Command::new("ipconfig").arg("/all").output() { + Ok(o) => o, + Err(e) => { + warn!("failed to run ipconfig /all: {}", e); + return SystemDnsInfo { + default_upstream: None, + forwarding_rules: Vec::new(), + }; + } + }; + + let text = String::from_utf8_lossy(&output.stdout); + let mut upstream = None; + + for line in text.lines() { + let trimmed = line.trim(); + // Match "DNS Servers" line (English) or similar localized variants + if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") { + if let Some(ip) = trimmed.split(':').next_back() { + let ip = ip.trim(); + if !ip.is_empty() && ip != "127.0.0.1" && ip != "::1" { + upstream = Some(ip.to_string()); + break; + } + } + } + // Continuation lines (indented IPs after DNS Servers line) + if upstream.is_none() && trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) { + // Skip continuation lines — we only need the first DNS server + } + } + + if let Some(ref ns) = upstream { + info!("detected Windows upstream: {}", ns); + } else { + debug!("no DNS servers found in ipconfig output"); + } + + SystemDnsInfo { + default_upstream: upstream, + forwarding_rules: Vec::new(), + } +} + /// Find the upstream for a domain by checking forwarding rules. /// Returns None if no rule matches (use default upstream). /// Zero-allocation on the hot path — dot_suffix is pre-computed. @@ -422,13 +531,15 @@ pub fn uninstall_service() -> Result<(), String> { /// Restart the service (kill process, launchd/systemd auto-restarts with new binary). pub fn restart_service() -> Result<(), String> { - // Show version of the binary that will be running after restart - let version = match std::process::Command::new("/usr/local/bin/numa") - .arg("--version") - .output() - { - Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(), - Err(_) => "unknown".to_string(), + #[cfg(any(target_os = "macos", target_os = "linux"))] + let version = { + match std::process::Command::new("/usr/local/bin/numa") + .arg("--version") + .output() + { + Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(), + Err(_) => "unknown".to_string(), + } }; #[cfg(target_os = "macos")] @@ -769,7 +880,7 @@ fn run_systemctl(args: &[&str]) -> Result<(), String> { // --- CA trust management --- fn trust_ca() -> Result<(), String> { - let ca_path = std::path::PathBuf::from("/usr/local/var/numa/ca.pem"); + let ca_path = crate::data_dir().join("ca.pem"); if !ca_path.exists() { return Err("CA not generated yet — start numa first to create certificates".into()); } @@ -809,14 +920,15 @@ fn trust_ca() -> Result<(), String> { #[cfg(not(any(target_os = "macos", target_os = "linux")))] { - return Err("CA trust not supported on this OS".into()); + Err("CA trust not supported on this OS".into()) } + #[cfg(any(target_os = "macos", target_os = "linux"))] Ok(()) } fn untrust_ca() -> Result<(), String> { - let ca_path = std::path::PathBuf::from("/usr/local/var/numa/ca.pem"); + let ca_path = crate::data_dir().join("ca.pem"); #[cfg(target_os = "macos")] { diff --git a/src/tls.rs b/src/tls.rs index 5fdada5..a4d91bf 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -1,7 +1,10 @@ +use std::collections::HashSet; use std::path::Path; use std::sync::Arc; use log::{info, warn}; + +use crate::ctx::ServerCtx; use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType}; use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; use rustls::ServerConfig; @@ -10,14 +13,31 @@ use time::{Duration, OffsetDateTime}; const CA_VALIDITY_DAYS: i64 = 3650; // 10 years const CERT_VALIDITY_DAYS: i64 = 365; // 1 year -/// TLS certs use a fixed system path — both the daemon and `sudo numa install` must agree. -pub const TLS_DIR: &str = "/usr/local/var/numa"; +/// Collect all service + LAN peer names and regenerate the TLS cert. +pub fn regenerate_tls(ctx: &ServerCtx) { + let tls = match &ctx.tls_config { + Some(t) => t, + None => return, + }; + + let mut names: HashSet = ctx.services.lock().unwrap().names().into_iter().collect(); + names.extend(ctx.lan_peers.lock().unwrap().names()); + let names: Vec = names.into_iter().collect(); + + match build_tls_config(&ctx.proxy_tld, &names) { + Ok(new_config) => { + tls.store(new_config); + info!("TLS cert regenerated for {} services", names.len()); + } + Err(e) => warn!("TLS regeneration failed: {}", e), + } +} /// Build a TLS config with a cert covering all provided service names. /// Wildcards under single-label TLDs (*.numa) are rejected by browsers, /// so we list each service explicitly as a SAN. pub fn build_tls_config(tld: &str, service_names: &[String]) -> crate::Result> { - let dir = std::path::PathBuf::from(TLS_DIR); + let dir = crate::data_dir(); let (ca_cert, ca_key) = ensure_ca(&dir)?; let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?; @@ -92,8 +112,15 @@ fn generate_service_cert( .distinguished_name .push(DnType::CommonName, format!("Numa .{} services", tld)); - // Add each service as an explicit SAN: numa.numa, peekm.numa, api.numa, etc. + // Add a wildcard SAN so any .numa domain gets a valid cert (including + // unregistered services — lets the proxy show a styled 404 over HTTPS). + // Also add each service explicitly for clients that don't match wildcards. let mut sans = Vec::new(); + let wildcard = format!("*.{}", tld); + match wildcard.clone().try_into() { + Ok(ia5) => sans.push(SanType::DnsName(ia5)), + Err(e) => warn!("invalid wildcard SAN {}: {}", wildcard, e), + } for name in service_names { let fqdn = format!("{}.{}", name, tld); match fqdn.clone().try_into() { diff --git a/tests/integration.sh b/tests/integration.sh new file mode 100755 index 0000000..c83dd61 --- /dev/null +++ b/tests/integration.sh @@ -0,0 +1,419 @@ +#!/usr/bin/env bash +# Integration test suite for Numa +# Runs a test instance on port 5354, validates all features, exits with status. +# Usage: ./tests/integration.sh [release|debug] + +set -euo pipefail + +MODE="${1:-release}" +BINARY="./target/$MODE/numa" +PORT=5354 +API_PORT=5381 +CONFIG="/tmp/numa-integration-test.toml" +LOG="/tmp/numa-integration-test.log" +PASSED=0 +FAILED=0 + +# Colors +GREEN="\033[32m" +RED="\033[31m" +DIM="\033[90m" +RESET="\033[0m" + +check() { + local name="$1" + local expected="$2" + local actual="$3" + + if echo "$actual" | grep -q "$expected"; then + PASSED=$((PASSED + 1)) + printf " ${GREEN}✓${RESET} %s\n" "$name" + else + FAILED=$((FAILED + 1)) + printf " ${RED}✗${RESET} %s\n" "$name" + printf " ${DIM}expected: %s${RESET}\n" "$expected" + printf " ${DIM} got: %s${RESET}\n" "$actual" + fi +} + +# Build if needed +if [ ! -f "$BINARY" ]; then + echo "Building $MODE..." + cargo build --$MODE +fi + +run_test_suite() { + local SUITE_NAME="$1" + local SUITE_CONFIG="$2" + + cat > "$CONFIG" << CONF +$SUITE_CONFIG +CONF + + echo "Starting Numa on :$PORT ($SUITE_NAME)..." + RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & + NUMA_PID=$! + sleep 4 + + if ! kill -0 "$NUMA_PID" 2>/dev/null; then + echo "Failed to start Numa:" + tail -5 "$LOG" + return 1 + fi + + DIG="dig @127.0.0.1 -p $PORT +time=5 +tries=1" + + echo "" + echo "=== Resolution ===" + + check "A record (google.com)" \ + "." \ + "$($DIG google.com A +short)" + + check "AAAA record (google.com)" \ + ":" \ + "$($DIG google.com AAAA +short)" + + check "CNAME chasing (www.github.com)" \ + "github.com" \ + "$($DIG www.github.com A +short)" + + check "MX records (gmail.com)" \ + "gmail-smtp-in" \ + "$($DIG gmail.com MX +short)" + + check "NS records (cloudflare.com)" \ + "cloudflare.com" \ + "$($DIG cloudflare.com NS +short)" + + check "NXDOMAIN" \ + "NXDOMAIN" \ + "$($DIG nope12345678.com A 2>&1 | grep status:)" + + echo "" + echo "=== Ad Blocking ===" + + if echo "$SUITE_CONFIG" | grep -q 'enabled = true'; then + check "Blocked domain → 0.0.0.0" \ + "0.0.0.0" \ + "$($DIG ads.google.com A +short)" + else + local ADS=$($DIG ads.google.com A +short 2>/dev/null) + if echo "$ADS" | grep -q "0.0.0.0"; then + check "Blocking disabled but domain blocked" "should-resolve" "0.0.0.0" + else + check "Blocking disabled — domain resolves normally" "." "$ADS" + fi + fi + + echo "" + echo "=== Cache ===" + + $DIG example.com A +short > /dev/null 2>&1 + sleep 1 + check "Cache hit returns result" \ + "." \ + "$($DIG example.com A +short)" + + echo "" + echo "=== Connectivity ===" + + # Apple captive portal can be slow/flaky on some networks + local CAPTIVE + CAPTIVE=$($DIG captive.apple.com A +short 2>/dev/null || echo "timeout") + if echo "$CAPTIVE" | grep -q "apple\|17\.\|timeout"; then + check "Apple captive portal" "." "$CAPTIVE" + else + check "Apple captive portal" "apple" "$CAPTIVE" + fi + + check "CDN (jsdelivr)" \ + "." \ + "$($DIG cdn.jsdelivr.net A +short)" + + echo "" + echo "=== API ===" + + check "Health endpoint" \ + "ok" \ + "$(curl -s http://127.0.0.1:$API_PORT/health)" + + check "Stats endpoint" \ + "uptime_secs" \ + "$(curl -s http://127.0.0.1:$API_PORT/stats)" + + echo "" + echo "=== Log Health ===" + + ERRORS=$(grep -c 'RECURSIVE ERROR\|PARSE ERROR\|HANDLER ERROR\|panic' "$LOG" 2>/dev/null || echo 0) + check "No critical errors in log" \ + "0" \ + "$ERRORS" + + kill "$NUMA_PID" 2>/dev/null || true + wait "$NUMA_PID" 2>/dev/null || true + sleep 1 +} + +# ---- Suite 1: Recursive mode + DNSSEC ---- +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Suite 1: Recursive + DNSSEC + Blocking ║" +echo "╚══════════════════════════════════════════╝" + +run_test_suite "recursive + DNSSEC + blocking" " +[server] +bind_addr = \"127.0.0.1:5354\" +api_port = 5381 + +[upstream] +mode = \"recursive\" + +[cache] +max_entries = 10000 +min_ttl = 60 +max_ttl = 86400 + +[blocking] +enabled = true + +[proxy] +enabled = false + +[dnssec] +enabled = true +" + +DIG="dig @127.0.0.1 -p $PORT +time=5 +tries=1" + +echo "" +echo "=== DNSSEC (recursive only) ===" + +# Re-start for DNSSEC checks (suite 1 instance was killed) +RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & +NUMA_PID=$! +sleep 4 + +check "AD bit set (cloudflare.com)" \ + " ad" \ + "$($DIG cloudflare.com A +dnssec 2>&1 | grep flags:)" + +check "EDNS DO bit echoed" \ + "flags: do" \ + "$($DIG cloudflare.com A +dnssec 2>&1 | grep 'EDNS:')" + +echo "" +echo "=== TCP wire format (real servers) ===" + +# Microsoft's Azure DNS servers require length+message in a single TCP segment. +# This test catches the split-write bug that caused early-eof SERVFAILs. +check "Microsoft domain (update.code.visualstudio.com)" \ + "NOERROR" \ + "$($DIG update.code.visualstudio.com A 2>&1 | grep status:)" + +check "Office domain (ecs.office.com)" \ + "NOERROR" \ + "$($DIG ecs.office.com A 2>&1 | grep status:)" + +# Azure Application Insights — another strict TCP server +check "Azure telemetry (eastus2-3.in.applicationinsights.azure.com)" \ + "." \ + "$($DIG eastus2-3.in.applicationinsights.azure.com A +short 2>/dev/null || echo 'timeout')" + +kill "$NUMA_PID" 2>/dev/null || true +wait "$NUMA_PID" 2>/dev/null || true +sleep 1 + +# ---- Suite 2: Forward mode (backward compat) ---- +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Suite 2: Forward (DoH) + Blocking ║" +echo "╚══════════════════════════════════════════╝" + +run_test_suite "forward DoH + blocking" " +[server] +bind_addr = \"127.0.0.1:5354\" +api_port = 5381 + +[upstream] +mode = \"forward\" +address = \"https://9.9.9.9/dns-query\" + +[cache] +max_entries = 10000 +min_ttl = 60 +max_ttl = 86400 + +[blocking] +enabled = true + +[proxy] +enabled = false +" + +# ---- Suite 3: Forward UDP (plain, no DoH) ---- +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Suite 3: Forward (UDP) + No Blocking ║" +echo "╚══════════════════════════════════════════╝" + +run_test_suite "forward UDP, no blocking" " +[server] +bind_addr = \"127.0.0.1:5354\" +api_port = 5381 + +[upstream] +mode = \"forward\" +address = \"9.9.9.9\" +port = 53 + +[cache] +max_entries = 10000 +min_ttl = 60 +max_ttl = 86400 + +[blocking] +enabled = false + +[proxy] +enabled = false +" + +# Verify blocking is actually off +RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & +NUMA_PID=$! +sleep 3 + +echo "" +echo "=== Blocking disabled ===" +ADS_RESULT=$($DIG ads.google.com A +short 2>/dev/null) +if echo "$ADS_RESULT" | grep -q "0.0.0.0"; then + check "ads.google.com NOT blocked (blocking disabled)" "not-0.0.0.0" "0.0.0.0" +else + check "ads.google.com NOT blocked (blocking disabled)" "." "$ADS_RESULT" +fi + +kill "$NUMA_PID" 2>/dev/null || true +wait "$NUMA_PID" 2>/dev/null || true +sleep 1 + +# ---- Suite 4: Local zones + Overrides API ---- +echo "" +echo "╔══════════════════════════════════════════╗" +echo "║ Suite 4: Local Zones + Overrides API ║" +echo "╚══════════════════════════════════════════╝" + +cat > "$CONFIG" << 'CONF' +[server] +bind_addr = "127.0.0.1:5354" +api_port = 5381 + +[upstream] +mode = "forward" +address = "9.9.9.9" +port = 53 + +[cache] +max_entries = 10000 + +[blocking] +enabled = false + +[proxy] +enabled = false + +[[zones]] +domain = "test.local" +record_type = "A" +value = "10.0.0.1" +ttl = 60 + +[[zones]] +domain = "mail.local" +record_type = "MX" +value = "10 smtp.local" +ttl = 60 +CONF + +RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & +NUMA_PID=$! +sleep 3 + +echo "" +echo "=== Local Zones ===" + +check "Local A record (test.local)" \ + "10.0.0.1" \ + "$($DIG test.local A +short)" + +check "Local MX record (mail.local)" \ + "smtp.local" \ + "$($DIG mail.local MX +short)" + +check "Non-local domain still resolves" \ + "." \ + "$($DIG example.com A +short)" + +echo "" +echo "=== Overrides API ===" + +# Create override +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:$API_PORT/overrides \ + -H 'Content-Type: application/json' \ + -d '{"domain":"override.test","target":"192.168.1.100","duration_secs":60}') +check "Create override (HTTP 200/201)" \ + "20" \ + "$HTTP_CODE" + +sleep 1 + +check "Override resolves" \ + "192.168.1.100" \ + "$($DIG override.test A +short)" + +# List overrides +check "List overrides" \ + "override.test" \ + "$(curl -s http://127.0.0.1:$API_PORT/overrides)" + +# Delete override +curl -s -X DELETE http://127.0.0.1:$API_PORT/overrides/override.test > /dev/null + +sleep 1 + +# After delete, should not resolve to override +AFTER_DELETE=$($DIG override.test A +short 2>/dev/null) +if echo "$AFTER_DELETE" | grep -q "192.168.1.100"; then + check "Override deleted" "not-192.168.1.100" "$AFTER_DELETE" +else + check "Override deleted" "." "deleted" +fi + +echo "" +echo "=== Cache API ===" + +check "Cache list" \ + "domain" \ + "$(curl -s http://127.0.0.1:$API_PORT/cache)" + +# Flush cache +curl -s -X DELETE http://127.0.0.1:$API_PORT/cache > /dev/null +check "Cache flushed" \ + "0" \ + "$(curl -s http://127.0.0.1:$API_PORT/stats | grep -o '"entries":[0-9]*' | grep -o '[0-9]*')" + +kill "$NUMA_PID" 2>/dev/null || true +wait "$NUMA_PID" 2>/dev/null || true + +# Summary +echo "" +TOTAL=$((PASSED + FAILED)) +if [ "$FAILED" -eq 0 ]; then + printf "${GREEN}All %d tests passed.${RESET}\n" "$TOTAL" + exit 0 +else + printf "${RED}%d/%d tests failed.${RESET}\n" "$FAILED" "$TOTAL" + echo "" + echo "Log: $LOG" + exit 1 +fi diff --git a/tests/network-probe.sh b/tests/network-probe.sh new file mode 100755 index 0000000..afcd383 --- /dev/null +++ b/tests/network-probe.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Network probe: tests which DNS transports are available on the current network. +# Run on a problematic network to diagnose what's blocked. +# Usage: ./tests/network-probe.sh + +set -euo pipefail + +GREEN="\033[32m" +RED="\033[31m" +DIM="\033[90m" +RESET="\033[0m" + +PASSED=0 +FAILED=0 + +probe() { + local name="$1" + local cmd="$2" + local expect="$3" + + local result + result=$(eval "$cmd" 2>&1) || true + + if echo "$result" | grep -q "$expect"; then + PASSED=$((PASSED + 1)) + printf " ${GREEN}✓${RESET} %-45s ${DIM}%s${RESET}\n" "$name" "$(echo "$result" | head -1 | cut -c1-60)" + else + FAILED=$((FAILED + 1)) + printf " ${RED}✗${RESET} %-45s ${DIM}blocked/timeout${RESET}\n" "$name" + fi +} + +echo "" +echo "Network DNS Transport Probe" +echo "===========================" +echo "Network: $(networksetup -getairportnetwork en0 2>/dev/null | sed 's/Current Wi-Fi Network: //' || echo 'unknown')" +echo "Local IP: $(ipconfig getifaddr en0 2>/dev/null || echo 'unknown')" +echo "Gateway: $(route -n get default 2>/dev/null | grep gateway | awk '{print $2}' || echo 'unknown')" +echo "" + +echo "=== UDP port 53 (recursive resolution) ===" +probe "Root server a (198.41.0.4)" \ + "dig @198.41.0.4 . NS +short +time=5 +tries=1" \ + "root-servers" + +probe "Root server k (193.0.14.129)" \ + "dig @193.0.14.129 . NS +short +time=5 +tries=1" \ + "root-servers" + +probe "Google DNS (8.8.8.8)" \ + "dig @8.8.8.8 google.com A +short +time=5 +tries=1" \ + "\." + +probe "Cloudflare (1.1.1.1)" \ + "dig @1.1.1.1 cloudflare.com A +short +time=5 +tries=1" \ + "\." + +probe ".com TLD (192.5.6.30)" \ + "dig @192.5.6.30 google.com NS +short +time=5 +tries=1" \ + "google" + +echo "" +echo "=== TCP port 53 ===" +probe "Google DNS TCP (8.8.8.8)" \ + "dig @8.8.8.8 google.com A +short +tcp +time=5 +tries=1" \ + "\." + +probe "Root server TCP (198.41.0.4)" \ + "dig @198.41.0.4 . NS +short +tcp +time=5 +tries=1" \ + "root-servers" + +echo "" +echo "=== DoT port 853 (DNS-over-TLS) ===" +probe "Quad9 DoT (9.9.9.9:853)" \ + "echo Q | openssl s_client -connect 9.9.9.9:853 -servername dns.quad9.net 2>&1 | grep 'verify return'" \ + "verify return" + +probe "Cloudflare DoT (1.1.1.1:853)" \ + "echo Q | openssl s_client -connect 1.1.1.1:853 -servername cloudflare-dns.com 2>&1 | grep 'verify return'" \ + "verify return" + +echo "" +echo "=== DoH port 443 (DNS-over-HTTPS) ===" +probe "Quad9 DoH (dns.quad9.net)" \ + "curl -s -m 5 -H 'accept: application/dns-json' 'https://dns.quad9.net:443/dns-query?name=google.com&type=A'" \ + "Answer" + +probe "Cloudflare DoH (1.1.1.1)" \ + "curl -s -m 5 -H 'accept: application/dns-json' 'https://1.1.1.1/dns-query?name=google.com&type=A'" \ + "Answer" + +probe "Google DoH (dns.google)" \ + "curl -s -m 5 'https://dns.google/resolve?name=google.com&type=A'" \ + "Answer" + +echo "" +echo "=== ISP DNS ===" +# Detect system DNS +SYS_DNS=$(scutil --dns 2>/dev/null | grep "nameserver\[0\]" | head -1 | awk '{print $3}' || echo "unknown") +if [ "$SYS_DNS" != "unknown" ] && [ "$SYS_DNS" != "127.0.0.1" ]; then + probe "ISP DNS ($SYS_DNS)" \ + "dig @$SYS_DNS google.com A +short +time=5 +tries=1" \ + "\." +else + printf " ${DIM}– System DNS is $SYS_DNS (skipped)${RESET}\n" +fi + +echo "" +echo "===========================" +TOTAL=$((PASSED + FAILED)) +printf "Results: ${GREEN}%d passed${RESET}, ${RED}%d blocked${RESET} / %d total\n" "$PASSED" "$FAILED" "$TOTAL" + +echo "" +echo "Recommendation:" +if [ "$FAILED" -eq 0 ]; then + echo " All transports available. Recursive mode will work." +elif dig @198.41.0.4 . NS +short +time=5 +tries=1 2>&1 | grep -q "root-servers"; then + echo " UDP:53 works. Recursive mode will work." +else + echo " UDP:53 blocked — recursive mode will NOT work on this network." + if curl -s -m 5 'https://dns.quad9.net:443/dns-query?name=test.com&type=A' 2>&1 | grep -q "Answer"; then + echo " DoH (port 443) works — use mode = \"forward\" with DoH upstream." + elif echo Q | openssl s_client -connect 9.9.9.9:853 2>&1 | grep -q "verify return"; then + echo " DoT (port 853) works — DoT upstream would work (not yet implemented)." + else + echo " Only ISP DNS available. Use mode = \"forward\" with ISP auto-detect." + fi +fi