diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..681b93c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: fmt + run: cargo fmt --check + - name: clippy + run: cargo clippy -- -D warnings + - name: test + run: cargo test diff --git a/Cargo.lock b/Cargo.lock index 208c6e5..b8a07dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,9 +58,113 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "colorchoice" version = "1.0.4" @@ -68,14 +172,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] -name = "dns_fun" -version = "0.1.0" +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ - "env_logger", - "log", - "serde", - "tokio", - "toml", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -107,12 +211,296 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -123,12 +511,34 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "jiff" version = "0.2.23" @@ -153,24 +563,58 @@ dependencies = [ "syn", ] +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "mio" version = "1.1.1" @@ -179,21 +623,53 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "numa" +version = "0.1.0" +dependencies = [ + "axum", + "env_logger", + "log", + "reqwest", + "serde", + "serde_json", + "tokio", + "toml", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -209,6 +685,24 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -218,6 +712,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -227,6 +776,41 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "regex" version = "1.12.3" @@ -256,6 +840,111 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.228" @@ -286,6 +975,30 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -295,6 +1008,36 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "socket2" version = "0.6.3" @@ -302,9 +1045,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -316,18 +1071,84 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -341,6 +1162,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -382,30 +1213,250 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -415,6 +1466,135 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -423,3 +1603,124 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 5e7a2d6..32079a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,20 @@ [package] -name = "dns_fun" +name = "numa" version = "0.1.0" authors = ["razvandimescu "] -edition = "2018" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +edition = "2021" +description = "Ephemeral DNS overrides for development and testing. Point any hostname to any endpoint. Auto-revert when you're done." +license = "MIT" +repository = "https://github.com/razvandimescu/numa" +keywords = ["dns", "proxy", "override", "development", "networking"] +categories = ["network-programming", "development-tools"] [dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] } -toml = "0.8" +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"], default-features = false } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9b0a102 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM rust:1.85-alpine AS builder +RUN apk add --no-cache musl-dev +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo 'fn main() {}' > src/main.rs && echo '' > src/lib.rs +RUN cargo build --release 2>/dev/null || true +RUN rm -rf src +COPY src/ src/ +RUN touch src/main.rs src/lib.rs +RUN cargo build --release + +FROM scratch +COPY --from=builder /app/target/release/numa /numa +EXPOSE 53/udp 5380/tcp +ENTRYPOINT ["/numa"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2a11e0f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Razvan Dimescu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a0bc077..f5cb828 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,96 @@ -# dns_fun +# Numa -A DNS forwarding/caching proxy written from scratch in Rust. Parses and serializes DNS wire protocol (RFC 1035), serves local zone records from TOML config, caches upstream responses with TTL-aware expiration, and logs every query with structured output. +**DNS you own. Everywhere you go.** -No DNS libraries — just `tokio::net::UdpSocket` and manual packet parsing. Each query is handled concurrently via `tokio::spawn`. +Block ads and trackers. Override DNS for development. Cache for speed. A single portable binary built from scratch in Rust — no Raspberry Pi, no cloud, no account. -## Record Types +## Why -A, NS, CNAME, MX, AAAA +- **Ad blocking that travels with you** — 385K+ domains blocked out of the box. Works on any network: coffee shops, hotels, airports. +- **Developer overrides** — point any hostname to any IP with auto-revert. No more editing `/etc/hosts`. +- **Sub-millisecond caching** — cached lookups in 0ms. Faster than any public resolver. +- **Live dashboard** — real-time query stats, blocking controls, override management at `http://localhost:5380`. +- **Single binary, zero config** — just run it. -## Usage +## Quick Start + +### From source ```bash -# Run with default config (dns_fun.toml) -sudo cargo run - -# Run with custom config path -sudo cargo run -- path/to/config.toml - -# Test -dig @127.0.0.1 google.com -dig @127.0.0.1 mysite.local +git clone https://github.com/razvandimescu/numa.git +cd numa +cargo build +sudo cargo run # binds to port 53, downloads blocklists on first run ``` -Requires root/sudo for binding to port 53. +### Docker + +```bash +docker build -t numa . +docker run -p 53:53/udp -p 5380:5380 numa +``` + +### Try it + +Open the dashboard: **http://localhost:5380** + +```bash +dig @127.0.0.1 google.com # ✓ resolves normally +dig @127.0.0.1 ads.google.com # ✗ blocked → 0.0.0.0 +``` + +Set Numa as your system DNS (all traffic goes through Numa): +```bash +sudo cargo run -- install # saves current DNS, sets system to 127.0.0.1 +sudo cargo run -- uninstall # restores original DNS settings + +# Or if installed to PATH: +sudo cp target/release/numa /usr/local/bin/ +sudo numa install +sudo numa uninstall +``` + +Create an override: +```bash +curl -X POST http://localhost:5380/overrides \ + -H 'Content-Type: application/json' \ + -d '{"domain":"api.dev","target":"127.0.0.1","ttl":60,"duration_secs":300}' + +dig @127.0.0.1 api.dev # → 127.0.0.1 (auto-reverts in 5 min) +``` + +## Resolution Pipeline + +``` +Query → Overrides → Blocklist → Local Zones → Cache → Upstream → Respond +``` + +1. **Overrides** — ephemeral, time-scoped redirects (highest priority) +2. **Blocklist** — 385K+ ad/tracker domains → returns `0.0.0.0` / `::` +3. **Local zones** — records defined in `[[zones]]` config +4. **Cache** — TTL-adjusted cached upstream responses (sub-ms) +5. **Forward** — query upstream resolver, cache the result +6. **SERVFAIL** — returned on upstream failure + +## Dashboard + +Live at `http://localhost:5380` when Numa is running: + +- Total queries, cache hit rate, blocked count, uptime +- Resolution path breakdown (forward / cached / local / override / blocked) +- Scrolling query log with colored path tags +- Active overrides with create/edit/delete +- Blocking controls: toggle on/off, pause 5 minutes, one-click allowlist +- Cached domains list ## Configuration -Edit `dns_fun.toml`: +`numa.toml` (all sections optional, sensible defaults if missing): ```toml [server] bind_addr = "0.0.0.0:53" +api_port = 5380 [upstream] address = "8.8.8.8" @@ -39,85 +99,86 @@ timeout_ms = 3000 [cache] max_entries = 10000 -min_ttl = 60 # floor: cache at least 60s -max_ttl = 86400 # ceiling: never cache longer than 24h +min_ttl = 60 +max_ttl = 86400 + +[blocking] +enabled = true +lists = [ + "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/hosts/pro.txt", +] +refresh_hours = 24 +allowlist = [] [[zones]] domain = "mysite.local" record_type = "A" value = "127.0.0.1" ttl = 60 - -[[zones]] -domain = "other.local" -record_type = "AAAA" -value = "::1" -ttl = 120 ``` -All sections are optional — sensible defaults are used if the config file is missing. +## HTTP API -## Request Pipeline +REST API on port 5380 (18 endpoints): -``` -Query -> Parse -> Local Zones -> Cache -> Upstream Forward -> Respond -``` +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/` | GET | Live dashboard | +| `/overrides` | POST | Create override(s) | +| `/overrides` | GET | List active overrides | +| `/overrides` | DELETE | Clear all overrides | +| `/overrides/environment` | POST | Batch load overrides | +| `/overrides/{domain}` | GET | Get specific override | +| `/overrides/{domain}` | DELETE | Remove specific override | +| `/blocking/stats` | GET | Blocklist stats (domains loaded, sources, enabled) | +| `/blocking/toggle` | PUT | Enable/disable blocking | +| `/blocking/pause` | POST | Pause blocking for N minutes | +| `/blocking/allowlist` | GET | List allowlisted domains | +| `/blocking/allowlist` | POST | Add domain to allowlist | +| `/blocking/allowlist/{domain}` | DELETE | Remove from allowlist | +| `/diagnose/{domain}` | GET | Trace resolution path | +| `/query-log` | GET | Recent queries (filterable) | +| `/stats` | GET | Server statistics | +| `/cache` | GET | List cached entries | +| `/cache` | DELETE | Flush cache | +| `/cache/{domain}` | DELETE | Flush specific domain | +| `/health` | GET | Health check | -1. **Local zones** — match against records defined in `[[zones]]`, respond immediately -2. **Cache** — return TTL-adjusted cached response if available -3. **Forward** — send query to upstream resolver, cache the response -4. **SERVFAIL** — returned to client on upstream failure +## How It Compares -## Caching +| | Pi-hole | NextDNS | Cloudflare | Numa | +|---|---|---|---|---| +| Ad blocking | Yes | Yes | Limited | 385K+ domains | +| Portable | No (Raspberry Pi) | Cloud only | Cloud only | Single binary | +| Developer overrides | No | No | No | REST API + auto-expiry | +| Data stays local | Yes | Cloud | Cloud | 100% local | +| Zero config | Complex setup | Yes | Yes | Works out of the box | +| Self-sovereign DNS | No | No | No | pkarr/DHT roadmap | -- TTL derived from minimum TTL across answer records -- Clamped to configured `min_ttl`/`max_ttl` bounds -- TTLs in cached responses decrease over time (adjusted on serve) -- Lazy eviction on capacity overflow + periodic sweep every 1000 queries +## Use Cases -## Logging +**Block ads everywhere** — Run Numa on your laptop. Your ad blocker works on any network. -Controlled via `RUST_LOG` environment variable: +**Mock external services** — `Point api.stripe.com to localhost:8080 for 30 minutes` -```bash -RUST_LOG=info sudo cargo run # default — one line per query -RUST_LOG=debug sudo cargo run # includes response details -RUST_LOG=warn sudo cargo run # errors only -``` +**Provision dev environments** — Create overrides for `db.dev`, `api.dev`, `cache.dev` -Log output: +**Debug DNS** — `/diagnose/example.com` traces the full resolution path -``` -2026-03-10T14:23:01.123Z INFO 192.168.1.5:41234 | A google.com | FORWARD | NOERROR | 12ms -2026-03-10T14:23:01.456Z INFO 192.168.1.5:41235 | A mysite.local | LOCAL | NOERROR | 0ms -2026-03-10T14:23:02.789Z INFO 192.168.1.5:41236 | A google.com | CACHED | NOERROR | 0ms -``` +## Built From Scratch -Stats summary (total, forwarded, cached, local, blocked, errors) logged every 1000 queries. +Zero external DNS libraries. RFC 1035 wire protocol parsed by hand. Dependencies: `tokio`, `axum`, `serde`, `toml`, `reqwest` (for blocklist downloads). -## Project Structure +## Roadmap -``` -src/ - main.rs # async startup, tokio event loop, ServerCtx, per-query task spawn - lib.rs # module declarations, Error/Result type aliases - buffer.rs # BytePacketBuffer — 512-byte DNS wire format read/write - header.rs # DnsHeader, ResultCode - question.rs # DnsQuestion, QueryType - record.rs # DnsRecord (A, NS, CNAME, MX, AAAA, UNKNOWN) - packet.rs # DnsPacket — full DNS message parse/serialize - config.rs # TOML config loading, zone map builder - cache.rs # TTL-aware DNS response cache with lazy eviction - forward.rs # async upstream forwarding - stats.rs # query counters and periodic summary -``` +- [x] DNS proxy core — forwarding, caching, local zones +- [x] Developer overrides — REST API with auto-expiry +- [x] Ad blocking — 385K+ domains, dashboard, allowlist +- [x] System DNS auto-discovery — Tailscale, VPN split-DNS +- [x] System DNS auto-configuration — `numa install` / `numa uninstall` +- [ ] pkarr integration — self-sovereign DNS via Mainline DHT +- [ ] Decentralized resolver network — staking, auditing, token economics -## Dependencies +## License -```toml -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] } -toml = "0.8" -serde = { version = "1", features = ["derive"] } -log = "0.4" -env_logger = "0.11" -``` +MIT diff --git a/bench/dns-bench.sh b/bench/dns-bench.sh new file mode 100755 index 0000000..c511ca0 --- /dev/null +++ b/bench/dns-bench.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""DNS performance benchmark — compares Numa against public resolvers.""" + +import subprocess +import sys +import re +import statistics +import json + +NUMA_PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 15353 +ROUNDS = int(sys.argv[2]) if len(sys.argv) > 2 else 20 +DOMAINS = [ + "google.com", "github.com", "amazon.com", "cloudflare.com", + "reddit.com", "stackoverflow.com", "rust-lang.org", "wikipedia.org", + "netflix.com", "twitter.com", +] + +RESOLVERS = [ + ("Numa(cold)", "127.0.0.1", NUMA_PORT), + ("Numa(cached)", "127.0.0.1", NUMA_PORT), + ("System", "", 53), +] + +# Detect system resolver +try: + out = subprocess.run(["scutil", "--dns"], capture_output=True, text=True) + m = re.search(r"nameserver\[0\]\s*:\s*([\d.]+)", out.stdout) + if m: + RESOLVERS[2] = ("System", m.group(1), 53) +except Exception: + pass + +# Add public resolvers — skip if unreachable +for name, ip in [("Google", "8.8.8.8"), ("Cloudflare", "1.1.1.1"), ("Quad9", "9.9.9.9")]: + try: + out = subprocess.run( + ["dig", f"@{ip}", "example.com", "+short", "+time=2", "+tries=1"], + capture_output=True, text=True, timeout=4 + ) + if out.stdout.strip(): + RESOLVERS.append((name, ip, 53)) + except Exception: + pass + +A = "\033[38;2;192;98;58m" +T = "\033[38;2;107;124;78m" +D = "\033[38;2;163;152;136m" +B = "\033[1m" +R = "\033[0m" + + +def query_ms(server, port, domain): + try: + out = subprocess.run( + ["dig", f"@{server}", "-p", str(port), domain, + "+noall", "+stats", "+tries=1", "+time=3"], + capture_output=True, text=True, timeout=5 + ) + m = re.search(r"Query time:\s+(\d+)\s+msec", out.stdout) + return int(m.group(1)) if m else None + except Exception: + return None + + +def flush_cache(domain=None): + try: + url = f"http://localhost:5380/cache/{domain}" if domain else "http://localhost:5380/cache" + subprocess.run(["curl", "-s", "-X", "DELETE", url], + capture_output=True, timeout=3) + except Exception: + pass + + +print() +print(f"{A} ╔══════════════════════════════════════════════════════════╗{R}") +print(f"{A} ║{R} {B}{A}NUMA{R} DNS Performance Benchmark {A}║{R}") +print(f"{A} ╚══════════════════════════════════════════════════════════╝{R}") +print() +print(f"{D} Domains: {len(DOMAINS)} | Rounds: {ROUNDS} | Total: {len(DOMAINS) * ROUNDS} queries per resolver{R}") +print() + +results = {} + +for name, server, port in RESOLVERS: + print(f" {T}Testing{R} {B}{name}{R}...", end="", flush=True) + + if name == "Numa(cold)": + flush_cache() + + latencies = [] + for r in range(ROUNDS): + for domain in DOMAINS: + if name == "Numa(cold)": + flush_cache(domain) + ms = query_ms(server, port, domain) + if ms is not None: + latencies.append(ms) + + if latencies: + latencies.sort() + n = len(latencies) + results[name] = { + "avg": round(statistics.mean(latencies), 1), + "p50": latencies[n // 2], + "p99": latencies[int(n * 0.99)], + "min": min(latencies), + "max": max(latencies), + "count": n, + } + print(f" {D}done ({len(latencies)} queries){R}") + +print() +print(f"{A} ┌──────────────┬────────┬────────┬────────┬────────┬────────┐{R}") +print(f"{A} │{R} {B}Resolver{R} {A}│{R} {B}Avg{R} {A}│{R} {B}P50{R} {A}│{R} {B}P99{R} {A}│{R} {B}Min{R} {A}│{R} {B}Max{R} {A}│{R}") +print(f"{A} ├──────────────┼────────┼────────┼────────┼────────┼────────┤{R}") + +for name, _, _ in RESOLVERS: + if name not in results: + continue + r = results[name] + if "cached" in name.lower(): + c = T + elif "cold" in name.lower(): + c = A + else: + c = D + print(f"{c} │ {name:<12s} │ {r['avg']:5.1f}ms │ {r['p50']:4d}ms │ {r['p99']:4d}ms │ {r['min']:4d}ms │ {r['max']:4d}ms │{R}") + +print(f"{A} └──────────────┴────────┴────────┴────────┴────────┴────────┘{R}") + +# Summary comparison +cached = results.get("Numa(cached)", {}) +cold = results.get("Numa(cold)", {}) + +print() +if cached and cached["avg"] > 0: + for name in [n for n, _, _ in RESOLVERS if n not in ("Numa(cold)", "Numa(cached)")]: + other = results.get(name, {}) + if other and other["avg"] > 0: + x = other["avg"] / max(cached["avg"], 0.1) + print(f" {T}Numa cached is ~{x:.0f}x faster than {name} (avg){R}") + if cold and cold["avg"] > 0: + x = cold["avg"] / max(cached["avg"], 0.1) + print(f" {T}Numa cached is ~{x:.0f}x faster than Numa cold (avg){R}") + +# Save raw results as JSON +out_path = "bench/results.json" +with open(out_path, "w") as f: + json.dump(results, f, indent=2) +print(f"\n {D}Raw results saved to {out_path}{R}") +print() diff --git a/dns_fun.toml b/numa.toml similarity index 95% rename from dns_fun.toml rename to numa.toml index 68355e0..27dc1f6 100644 --- a/dns_fun.toml +++ b/numa.toml @@ -1,5 +1,6 @@ [server] bind_addr = "0.0.0.0:53" +api_port = 5380 [upstream] address = "8.8.8.8" diff --git a/site/dashboard.html b/site/dashboard.html new file mode 100644 index 0000000..c39b7c3 --- /dev/null +++ b/site/dashboard.html @@ -0,0 +1,842 @@ + + + + + +Numa — Dashboard + + + + + + + +
+
+ +
DNS that governs itself
+
+
+ + +
+ + connecting... +
+
+
+ +
+ +
+
+
Total Queries
+
+
+
+
+
Cache Hit Rate
+
+
+
+
+
Blocked
+
+
 
+
+
+
Active Overrides
+
+
 
+
+
+
Uptime
+
+
 
+
+
+ + +
+
+ Resolution Paths +
+
+ +
+
+ + +
+ +
+
+ Recent Queries + +
+
+ + + + + + + + + + + + + +
TimeTypeDomainPathResultLatency
+
+
+ + + +
+
+ + + + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..2e2c6b5 --- /dev/null +++ b/site/index.html @@ -0,0 +1,1395 @@ + + + + + +Numa — DNS that governs itself + + + + + + + + + + +
+ +
+

Numa

+
DNS you own. Everywhere you go.
+

After Numa Pompilius, who built institutions that outlasted kings.

+

+ Block ads and trackers. Override DNS for development. Cache for speed. A single portable binary built from scratch in Rust — no Raspberry Pi, no cloud, no account. Your DNS travels with you. +

+ +
+ +
+ + + + +
+
+
+ +

DNS is a single point of control

+
+
+
+

Every time you visit a website, you ask a DNS resolver where to go. That resolver sees every domain you visit, when, and how often.

+

Today, a handful of operators control this infrastructure. ICANN governs the root. Registrars can seize domains. Governments compel censorship. Your ISP logs your queries by default.

+

The protocol that underpins the entire internet has no built-in privacy, no cryptographic ownership, and no way for users to choose who they trust.

+
+
+
Your browser
+
Your ISP / OS resolver
+
|
+
Single point of failure
+
Cloudflare 1.1.1.1 / Google 8.8.8.8
+
|
+
ICANN root servers
+
TLD registrars (.com, .io, ...)
+
Authoritative nameservers
+
+
+
+
+ + + + +
+
+
+ +

Three layers, built incrementally

+

Numa starts as a practical developer tool and evolves toward a decentralized network. Each layer stands on its own.

+
+
+
+
Today
+

DNS You Control

+
    +
  • Ad & tracker blocking — 385K+ domains, zero config
  • +
  • Ephemeral DNS overrides with auto-revert
  • +
  • Live dashboard with real-time stats and controls
  • +
  • REST API — 18 endpoints for programmatic control
  • +
  • TTL-aware caching (sub-ms lookups)
  • +
  • Single binary, portable — your ad blocker travels with you
  • +
+
+
+
Next
+

Self-Sovereign DNS

+
    +
  • pkarr integration: Ed25519 keys as domains
  • +
  • Resolve via Mainline BitTorrent DHT (10M+ nodes)
  • +
  • No registrar, no blockchain, no ICANN
  • +
  • Cryptographic verification built-in
  • +
  • Human-readable aliases for pkarr domains
  • +
+
+
+
Vision
+

Decentralized Resolver Network

+
    +
  • Operators run Numa nodes and stake tokens
  • +
  • Earn rewards for uptime, correctness, latency
  • +
  • Independent auditors send challenge queries
  • +
  • Slashing for NXDOMAIN hijacking or poisoned records
  • +
  • Geographic diversity bonuses
  • +
  • Privacy-preserving resolution (DoH/DoT)
  • +
+
+
+
+
+ + + + +
+
+
+ +

Resolution pipeline

+

Every query walks through the same deterministic pipeline. Local data takes priority; the network is the fallback.

+
+ +
+
+
Query
+ +
Overrides
+ +
Local Zones
+ +
Cache
+ +
pkarr / DHT
+ +
Upstream
+ +
Respond
+
+
+ +
+

Layered resilience

+
+
+
L4 Permanence
+
Arweave immutable zone snapshots (future)
+
+
+
L3 Distribution
+
Mainline DHT via pkarr — 10M+ nodes
+
+
+
L2 Serving
+
Numa instances worldwide
+
+
+
L1 Compatibility
+
Standard DNS wire protocol — RFC 1035
+
+
+
+ +
+

Network actors

+
+
+ +

Users

+

Choose resolvers from a decentralized marketplace based on latency, privacy, and reputation

+
+
+ +

Operators

+

Stake tokens, run Numa nodes, earn rewards proportional to verified service quality

+
+
+ +

Auditors

+

Send challenge queries from diverse locations, verify correctness and latency

+
+
+ +

Chain

+

Accounting, reputation scores, reward distribution, slashing proofs

+
+
+ +
+
+
+ + + + +
+
+
+ +

Why Numa is different

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Comparison of Numa with existing DNS solutions
Pi-holeNextDNSCloudflareAdGuard HomeNuma
Ad & tracker blockingYesYesLimitedYes385K+ domains
Portable (travels with laptop)No (Raspberry Pi)Cloud onlyCloud onlyNo (network appliance)Single binary
Developer overridesNoNoNoNoREST API + auto-expiry
Data stays localYesCloudCloudYes100% local
Live dashboardYesYesNoYesReal-time + controls
Zero config neededComplex setupYesYesDocker/setupWorks out of the box
Self-sovereign DNS roadmapNoNoNoNopkarr / DHT
+
+
+
+ + + + +
+
+
+ +

Technical details

+
+
+
+
Runtime
+
Rust + tokio async (rt-multi-thread)
+ +
DNS Libraries
+
Zero — wire protocol parsed from scratch
+ +
Dependencies
+
6 runtime crates (tokio, axum, serde, serde_json, toml, log)
+ +
Packet Format
+
RFC 1035 compliant, 512-byte UDP
+ +
Concurrency
+
Arc<ServerCtx> + std::sync::Mutex (sub-µs holds, never across .await)
+ +
Signatures
+
Ed25519 via pkarr for self-sovereign domains
+
+
+$ cargo install numa +$ sudo numa # bind to :53 +$ dig @127.0.0.1 google.com # test resolution +$ curl localhost:5380/overrides # REST API +$ curl -X POST localhost:5380/overrides \ + -d '{"domain":"api.stripe.com", + "target":"127.0.0.1", + "duration_secs":1800}' # 30-min override +
+
+
+
+ + + + +
+
+
+ +

Where we're going

+
+
+
+ Phase 0 + DNS proxy core — zones, caching, forwarding, async tokio runtime +
+
+ Phase 1 + Override layer + REST API with 18 endpoints +
+
+ Phase 2 + Ad & tracker blocking — 385K+ domains, live dashboard, one-click allowlist +
+
+ Phase 3 + System integration — auto-discovery of OS DNS routing, one-command install +
+
+ Phase 4 + pkarr spike — DHT resolution and publish endpoint +
+
+ Phase 5 + pkarr product — human-readable aliases, re-publish daemon, key management +
+
+ Phase 4 + Challenge and audit protocol for verifiable resolver behavior +
+
+ Phase 5 + Token economics, staking, and slashing mechanism +
+
+ Phase 6 + Decentralized resolver marketplace +
+
+
+
+ + + + + + + + diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..148e725 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,565 @@ +use std::sync::Arc; +use std::time::UNIX_EPOCH; + +use axum::extract::{Path, Query, State}; +use axum::http::{header, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::{delete, get, post, put}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; + +use crate::ctx::ServerCtx; +use crate::forward::forward_query; +use crate::query_log::QueryLogFilter; +use crate::question::QueryType; +use crate::stats::QueryPath; + +const DASHBOARD_HTML: &str = include_str!("../site/dashboard.html"); + +pub fn router(ctx: Arc) -> Router { + Router::new() + .route("/", get(dashboard)) + .route("/overrides", post(create_overrides)) + .route("/overrides", get(list_overrides)) + .route("/overrides", delete(clear_overrides)) + .route("/overrides/environment", post(load_environment)) + .route("/overrides/{domain}", get(get_override)) + .route("/overrides/{domain}", delete(remove_override)) + .route("/diagnose/{domain}", get(diagnose)) + .route("/query-log", get(query_log)) + .route("/stats", get(stats)) + .route("/cache", get(list_cache)) + .route("/cache", delete(flush_cache)) + .route("/cache/{domain}", delete(flush_cache_domain)) + .route("/health", get(health)) + .route("/blocking/stats", get(blocking_stats)) + .route("/blocking/toggle", put(blocking_toggle)) + .route("/blocking/pause", post(blocking_pause)) + .route("/blocking/allowlist", get(blocking_allowlist)) + .route("/blocking/allowlist", post(blocking_allowlist_add)) + .route( + "/blocking/allowlist/{domain}", + delete(blocking_allowlist_remove), + ) + .with_state(ctx) +} + +async fn dashboard() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + DASHBOARD_HTML, + ) +} + +// --- Request/Response DTOs --- + +#[derive(Deserialize)] +struct CreateOverrideRequest { + domain: String, + target: String, + #[serde(default = "default_ttl")] + ttl: u32, + duration_secs: Option, +} + +fn default_ttl() -> u32 { + 60 +} + +#[derive(Serialize)] +struct OverrideResponse { + domain: String, + target: String, + record_type: String, + ttl: u32, + remaining_secs: Option, +} + +impl From<&crate::override_store::OverrideEntry> for OverrideResponse { + fn from(e: &crate::override_store::OverrideEntry) -> Self { + OverrideResponse { + domain: e.domain.clone(), + target: e.target.clone(), + record_type: e.query_type.as_str().to_string(), + ttl: e.ttl, + remaining_secs: e.remaining_secs(), + } + } +} + +#[derive(Deserialize)] +struct EnvironmentRequest { + #[serde(default)] + duration_secs: Option, + overrides: Vec, +} + +#[derive(Serialize)] +struct EnvironmentResponse { + created: usize, +} + +#[derive(Deserialize)] +struct QueryLogParams { + domain: Option, + r#type: Option, + path: Option, + limit: Option, +} + +#[derive(Serialize)] +struct QueryLogResponse { + timestamp_epoch: f64, + src: String, + domain: String, + query_type: String, + path: String, + rescode: String, + latency_ms: f64, +} + +#[derive(Serialize)] +struct StatsResponse { + uptime_secs: u64, + queries: QueriesStats, + cache: CacheStats, + overrides: OverrideStats, + blocking: BlockingStatsResponse, +} + +#[derive(Serialize)] +struct QueriesStats { + total: u64, + forwarded: u64, + cached: u64, + local: u64, + overridden: u64, + blocked: u64, + errors: u64, +} + +#[derive(Serialize)] +struct CacheStats { + entries: usize, + max_entries: usize, +} + +#[derive(Serialize)] +struct OverrideStats { + active: usize, +} + +#[derive(Serialize)] +struct BlockingStatsResponse { + enabled: bool, + paused: bool, + domains_loaded: usize, + allowlist_size: usize, +} + +#[derive(Serialize)] +struct DiagnoseResponse { + domain: String, + query_type: String, + steps: Vec, +} + +#[derive(Serialize)] +struct DiagnoseStep { + source: String, + matched: bool, + detail: Option, +} + +#[derive(Serialize)] +struct CacheEntryResponse { + domain: String, + query_type: String, + ttl_remaining: u32, +} + +// --- Handlers --- + +async fn create_overrides( + State(ctx): State>, + Json(req): Json, +) -> Result<(StatusCode, Json>), (StatusCode, String)> { + let requests: Vec = if req.is_array() { + serde_json::from_value(req).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))? + } else { + let single: CreateOverrideRequest = + serde_json::from_value(req).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + vec![single] + }; + + // Parse and validate all requests before acquiring the lock + let parsed: Vec<_> = requests + .into_iter() + .map(|req| { + let domain_lower = req.domain.to_lowercase(); + Ok((domain_lower, req.target, req.ttl, req.duration_secs)) + }) + .collect::, (StatusCode, String)>>()?; + + let mut store = ctx.overrides.lock().unwrap(); + let mut responses = Vec::with_capacity(parsed.len()); + + for (domain, target, ttl, duration_secs) in parsed { + let qtype = store + .insert(&domain, &target, ttl, duration_secs) + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + + responses.push(OverrideResponse { + domain, + target, + record_type: qtype.as_str().to_string(), + ttl, + remaining_secs: duration_secs, + }); + } + + Ok((StatusCode::CREATED, Json(responses))) +} + +async fn list_overrides(State(ctx): State>) -> Json> { + let store = ctx.overrides.lock().unwrap(); + let entries: Vec = store + .list() + .into_iter() + .map(OverrideResponse::from) + .collect(); + Json(entries) +} + +async fn get_override( + State(ctx): State>, + Path(domain): Path, +) -> Result, StatusCode> { + let store = ctx.overrides.lock().unwrap(); + let entry = store.get(&domain).ok_or(StatusCode::NOT_FOUND)?; + Ok(Json(OverrideResponse::from(entry))) +} + +async fn remove_override( + State(ctx): State>, + Path(domain): Path, +) -> StatusCode { + let mut store = ctx.overrides.lock().unwrap(); + if store.remove(&domain) { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} + +async fn clear_overrides(State(ctx): State>) -> StatusCode { + ctx.overrides.lock().unwrap().clear(); + StatusCode::NO_CONTENT +} + +async fn load_environment( + State(ctx): State>, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + let mut store = ctx.overrides.lock().unwrap(); + + for entry in &req.overrides { + let duration = entry.duration_secs.or(req.duration_secs); + store + .insert(&entry.domain, &entry.target, entry.ttl, duration) + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; + } + + Ok(( + StatusCode::CREATED, + Json(EnvironmentResponse { + created: req.overrides.len(), + }), + )) +} + +async fn diagnose( + State(ctx): State>, + Path(domain): Path, +) -> Json { + let domain_lower = domain.to_lowercase(); + let qtype = QueryType::A; + let mut steps = Vec::new(); + + // Check overrides + { + let store = ctx.overrides.lock().unwrap(); + let entry = store.get(&domain_lower); + steps.push(DiagnoseStep { + source: "override".to_string(), + matched: entry.is_some(), + detail: entry + .map(|e| format!("{} -> {} ({})", e.domain, e.target, e.query_type.as_str())), + }); + } + + // Check blocklist + { + let bl = ctx.blocklist.lock().unwrap(); + let blocked = bl.is_blocked(&domain_lower); + steps.push(DiagnoseStep { + source: "blocklist".to_string(), + matched: blocked, + detail: if blocked { + Some("domain is in blocklist".to_string()) + } else { + None + }, + }); + } + + // Check local zones + let zone_match = ctx + .zone_map + .get(domain_lower.as_str()) + .and_then(|m| m.get(&qtype)); + steps.push(DiagnoseStep { + source: "local_zone".to_string(), + matched: zone_match.is_some(), + detail: zone_match.map(|records| format!("{} records", records.len())), + }); + + // Check cache + { + let mut cache = ctx.cache.lock().unwrap(); + let cached = cache.lookup(&domain_lower, qtype); + steps.push(DiagnoseStep { + source: "cache".to_string(), + matched: cached.is_some(), + detail: cached.map(|p| format!("{} answers", p.answers.len())), + }); + } + + // Check upstream (async, no locks held) + let (upstream_matched, upstream_detail) = + forward_query_for_diagnose(&domain_lower, ctx.upstream, ctx.timeout).await; + steps.push(DiagnoseStep { + source: "upstream".to_string(), + matched: upstream_matched, + detail: Some(upstream_detail), + }); + + Json(DiagnoseResponse { + domain: domain_lower, + query_type: qtype.as_str().to_string(), + steps, + }) +} + +async fn forward_query_for_diagnose( + domain: &str, + upstream: std::net::SocketAddr, + timeout: std::time::Duration, +) -> (bool, String) { + use crate::packet::DnsPacket; + use crate::question::DnsQuestion; + + let mut query = DnsPacket::new(); + query.header.id = 0xBEEF; + query.header.recursion_desired = true; + query + .questions + .push(DnsQuestion::new(domain.to_string(), QueryType::A)); + + match forward_query(&query, upstream, timeout).await { + Ok(resp) => ( + true, + format!( + "{} ({} answers)", + resp.header.rescode.as_str(), + resp.answers.len() + ), + ), + Err(e) => (false, format!("error: {}", e)), + } +} + +async fn query_log( + State(ctx): State>, + Query(params): Query, +) -> Json> { + let qtype = params.r#type.as_deref().and_then(QueryType::parse_str); + let path = params.path.as_deref().and_then(QueryPath::parse_str); + + let filter = QueryLogFilter { + domain: params.domain, + query_type: qtype, + path, + since: None, + limit: params.limit, + }; + + let raw_entries: Vec = { + let log = ctx.query_log.lock().unwrap(); + log.query(&filter) + .into_iter() + .map(|e| { + let epoch = e + .timestamp + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + QueryLogResponse { + timestamp_epoch: epoch, + src: e.src_addr.to_string(), + domain: e.domain.clone(), + query_type: e.query_type.as_str().to_string(), + path: e.path.as_str().to_string(), + rescode: e.rescode.as_str().to_string(), + latency_ms: e.latency_us as f64 / 1000.0, + } + }) + .collect() + }; + + Json(raw_entries) +} + +async fn stats(State(ctx): State>) -> Json { + let snap = ctx.stats.lock().unwrap().snapshot(); + let (cache_len, cache_max) = { + let cache = ctx.cache.lock().unwrap(); + (cache.len(), cache.max_entries()) + }; + let override_count = ctx.overrides.lock().unwrap().active_count(); + let bl_stats = ctx.blocklist.lock().unwrap().stats(); + + Json(StatsResponse { + uptime_secs: snap.uptime_secs, + queries: QueriesStats { + total: snap.total, + forwarded: snap.forwarded, + cached: snap.cached, + local: snap.local, + overridden: snap.overridden, + blocked: snap.blocked, + errors: snap.errors, + }, + cache: CacheStats { + entries: cache_len, + max_entries: cache_max, + }, + overrides: OverrideStats { + active: override_count, + }, + blocking: BlockingStatsResponse { + enabled: bl_stats.enabled, + paused: bl_stats.paused, + domains_loaded: bl_stats.domains_loaded, + allowlist_size: bl_stats.allowlist_size, + }, + }) +} + +async fn list_cache(State(ctx): State>) -> Json> { + let cache = ctx.cache.lock().unwrap(); + let entries: Vec = cache + .list() + .into_iter() + .map(|info| CacheEntryResponse { + domain: info.domain, + query_type: info.query_type.as_str().to_string(), + ttl_remaining: info.ttl_remaining, + }) + .collect(); + Json(entries) +} + +async fn flush_cache(State(ctx): State>) -> StatusCode { + ctx.cache.lock().unwrap().clear(); + StatusCode::NO_CONTENT +} + +async fn flush_cache_domain( + State(ctx): State>, + Path(domain): Path, +) -> StatusCode { + ctx.cache.lock().unwrap().remove(&domain); + StatusCode::NO_CONTENT +} + +async fn health() -> Json { + Json(serde_json::json!({ "status": "ok" })) +} + +// --- Blocking handlers --- + +async fn blocking_stats(State(ctx): State>) -> Json { + let stats = ctx.blocklist.lock().unwrap().stats(); + Json(serde_json::json!({ + "enabled": stats.enabled, + "paused": stats.paused, + "domains_loaded": stats.domains_loaded, + "allowlist_size": stats.allowlist_size, + "list_sources": stats.list_sources, + "last_refresh_secs_ago": stats.last_refresh_secs_ago, + })) +} + +#[derive(Deserialize)] +struct BlockingToggleRequest { + enabled: bool, +} + +async fn blocking_toggle( + State(ctx): State>, + Json(req): Json, +) -> Json { + ctx.blocklist.lock().unwrap().set_enabled(req.enabled); + Json(serde_json::json!({ "enabled": req.enabled })) +} + +#[derive(Deserialize)] +struct BlockingPauseRequest { + #[serde(default = "default_pause_minutes")] + minutes: u64, +} + +fn default_pause_minutes() -> u64 { + 5 +} + +async fn blocking_pause( + State(ctx): State>, + Json(req): Json, +) -> Json { + ctx.blocklist.lock().unwrap().pause(req.minutes * 60); + Json(serde_json::json!({ "paused_minutes": req.minutes })) +} + +async fn blocking_allowlist(State(ctx): State>) -> Json> { + let list = ctx.blocklist.lock().unwrap().allowlist(); + Json(list) +} + +#[derive(Deserialize)] +struct AllowlistRequest { + domain: String, +} + +async fn blocking_allowlist_add( + State(ctx): State>, + Json(req): Json, +) -> (StatusCode, Json) { + ctx.blocklist.lock().unwrap().add_to_allowlist(&req.domain); + ( + StatusCode::CREATED, + Json(serde_json::json!({ "allowed": req.domain })), + ) +} + +async fn blocking_allowlist_remove( + State(ctx): State>, + Path(domain): Path, +) -> StatusCode { + if ctx.blocklist.lock().unwrap().remove_from_allowlist(&domain) { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} diff --git a/src/blocklist.rs b/src/blocklist.rs new file mode 100644 index 0000000..89ebaee --- /dev/null +++ b/src/blocklist.rs @@ -0,0 +1,187 @@ +use std::collections::HashSet; +use std::time::Instant; + +use log::{info, warn}; + +pub struct BlocklistStore { + domains: HashSet, + allowlist: HashSet, + enabled: bool, + paused_until: Option, + list_sources: Vec, + last_refresh: Option, +} + +pub struct BlocklistStats { + pub enabled: bool, + pub paused: bool, + pub domains_loaded: usize, + pub allowlist_size: usize, + pub list_sources: Vec, + pub last_refresh_secs_ago: Option, +} + +impl Default for BlocklistStore { + fn default() -> Self { + Self::new() + } +} + +impl BlocklistStore { + pub fn new() -> Self { + BlocklistStore { + domains: HashSet::new(), + allowlist: HashSet::new(), + enabled: true, + paused_until: None, + list_sources: Vec::new(), + last_refresh: None, + } + } + + pub fn is_blocked(&self, domain: &str) -> bool { + if !self.enabled { + return false; + } + + if let Some(until) = self.paused_until { + if Instant::now() < until { + return false; + } + } + + if self.allowlist.contains(domain) { + return false; + } + + if self.domains.contains(domain) { + return true; + } + + // Walk up: ads.tracker.example.com → tracker.example.com → example.com + let mut d = domain; + while let Some(dot) = d.find('.') { + d = &d[dot + 1..]; + if self.allowlist.contains(d) { + return false; + } + if self.domains.contains(d) { + return true; + } + } + + false + } + + /// Atomically swap in a new domain set. Build the set outside the lock, + /// then call this to swap — keeps lock hold time sub-microsecond. + pub fn swap_domains(&mut self, domains: HashSet, sources: Vec) { + self.domains = domains; + self.list_sources = sources; + self.last_refresh = Some(Instant::now()); + } + + pub fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + pub fn is_enabled(&self) -> bool { + self.enabled + } + + pub fn pause(&mut self, seconds: u64) { + self.paused_until = Some(Instant::now() + std::time::Duration::from_secs(seconds)); + } + + pub fn is_paused(&self) -> bool { + self.paused_until + .map(|until| Instant::now() < until) + .unwrap_or(false) + } + + pub fn add_to_allowlist(&mut self, domain: &str) { + self.allowlist.insert(domain.to_lowercase()); + } + + pub fn remove_from_allowlist(&mut self, domain: &str) -> bool { + self.allowlist.remove(&domain.to_lowercase()) + } + + pub fn allowlist(&self) -> Vec { + self.allowlist.iter().cloned().collect() + } + + pub fn stats(&self) -> BlocklistStats { + BlocklistStats { + enabled: self.is_enabled(), + paused: self.is_paused(), + domains_loaded: self.domains.len(), + allowlist_size: self.allowlist.len(), + list_sources: self.list_sources.clone(), + last_refresh_secs_ago: self.last_refresh.map(|t| t.elapsed().as_secs()), + } + } +} + +/// Parse a blocklist text file into a set of domains. +pub fn parse_blocklist(text: &str) -> HashSet { + let mut domains = HashSet::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with('!') { + continue; + } + + // Handle hosts-file format: "0.0.0.0 domain" or "127.0.0.1 domain" (space or tab) + let domain = if line.starts_with("0.0.0.0") + || line.starts_with("127.0.0.1") + || line.starts_with("::") + { + line.split_whitespace() + .nth(1) + .unwrap_or("") + .trim_end_matches('.') + } else if line.contains(' ') || line.contains('\t') { + continue; + } else { + // Plain domain or adblock filter syntax + let d = line.trim_start_matches("*.").trim_start_matches("||"); + let d = d.split('$').next().unwrap_or(d); // strip adblock $options + d.trim_end_matches('^').trim_end_matches('.') + }; + + let domain = domain.to_lowercase(); + if !domain.is_empty() + && domain.contains('.') + && domain != "localhost" + && domain != "localhost.localdomain" + { + domains.insert(domain); + } + } + domains +} + +pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .unwrap_or_default(); + + let mut results = Vec::new(); + + for url in lists { + match client.get(url).send().await { + Ok(resp) => match resp.text().await { + Ok(text) => { + info!("downloaded blocklist: {} ({} bytes)", url, text.len()); + results.push((url.clone(), text)); + } + Err(e) => warn!("failed to read blocklist body {}: {}", url, e), + }, + Err(e) => warn!("failed to download blocklist {}: {}", url, e), + } + } + + results +} diff --git a/src/cache.rs b/src/cache.rs index 65629fc..0586bc9 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -11,8 +11,11 @@ struct CacheEntry { ttl: Duration, } +/// DNS cache using a two-level map (domain -> query_type -> entry) so that +/// lookups can borrow `&str` instead of allocating a `String` key. pub struct DnsCache { - entries: HashMap<(String, QueryType), CacheEntry>, + entries: HashMap>, + entry_count: usize, max_entries: usize, min_ttl: u32, max_ttl: u32, @@ -23,6 +26,7 @@ impl DnsCache { pub fn new(max_entries: usize, min_ttl: u32, max_ttl: u32) -> Self { DnsCache { entries: HashMap::new(), + entry_count: 0, max_entries, min_ttl, max_ttl, @@ -33,17 +37,22 @@ impl DnsCache { pub fn lookup(&mut self, domain: &str, qtype: QueryType) -> Option { self.query_count += 1; - // Periodic eviction every 1000 queries if self.query_count.is_multiple_of(1000) { self.evict_expired(); } - let key = (domain.to_string(), qtype); - let entry = self.entries.get(&key)?; + let type_map = self.entries.get(domain)?; + let entry = type_map.get(&qtype)?; let elapsed = entry.inserted_at.elapsed(); if elapsed >= entry.ttl { - self.entries.remove(&key); + // Expired: remove this entry + let type_map = self.entries.get_mut(domain).unwrap(); + type_map.remove(&qtype); + self.entry_count -= 1; + if type_map.is_empty() { + self.entries.remove(domain); + } return None; } @@ -59,10 +68,9 @@ impl DnsCache { } pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) { - if self.entries.len() >= self.max_entries { + if self.entry_count >= self.max_entries { self.evict_expired(); - // If still full after eviction, skip insertion - if self.entries.len() >= self.max_entries { + if self.entry_count >= self.max_entries { return; } } @@ -71,9 +79,18 @@ impl DnsCache { .unwrap_or(self.min_ttl) .clamp(self.min_ttl, self.max_ttl); - let key = (domain.to_string(), qtype); - self.entries.insert( - key, + let type_map = if let Some(existing) = self.entries.get_mut(domain) { + existing + } else { + self.entries.entry(domain.to_string()).or_default() + }; + + if !type_map.contains_key(&qtype) { + self.entry_count += 1; + } + + type_map.insert( + qtype, CacheEntry { packet: packet.clone(), inserted_at: Instant::now(), @@ -82,10 +99,64 @@ impl DnsCache { ); } - fn evict_expired(&mut self) { - self.entries - .retain(|_, entry| entry.inserted_at.elapsed() < entry.ttl); + pub fn len(&self) -> usize { + self.entry_count } + + pub fn is_empty(&self) -> bool { + self.entry_count == 0 + } + + pub fn max_entries(&self) -> usize { + self.max_entries + } + + pub fn clear(&mut self) { + self.entries.clear(); + self.entry_count = 0; + } + + pub fn remove(&mut self, domain: &str) { + let domain_lower = domain.to_lowercase(); + if let Some(type_map) = self.entries.remove(&domain_lower) { + self.entry_count -= type_map.len(); + } + } + + pub fn list(&self) -> Vec { + let mut result = Vec::new(); + for (domain, type_map) in &self.entries { + for (qtype, entry) in type_map { + let elapsed = entry.inserted_at.elapsed(); + if elapsed < entry.ttl { + let remaining = (entry.ttl - elapsed).as_secs() as u32; + result.push(CacheInfo { + domain: domain.clone(), + query_type: *qtype, + ttl_remaining: remaining, + }); + } + } + } + result + } + + fn evict_expired(&mut self) { + let mut count = 0; + self.entries.retain(|_, type_map| { + let before = type_map.len(); + type_map.retain(|_, entry| entry.inserted_at.elapsed() < entry.ttl); + count += before - type_map.len(); + !type_map.is_empty() + }); + self.entry_count -= count; + } +} + +pub struct CacheInfo { + pub domain: String, + pub query_type: QueryType, + pub ttl_remaining: u32, } fn extract_min_ttl(records: &[DnsRecord]) -> Option { diff --git a/src/config.rs b/src/config.rs index 1cd5c61..56beaec 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,8 @@ pub struct Config { #[serde(default)] pub cache: CacheConfig, #[serde(default)] + pub blocking: BlockingConfig, + #[serde(default)] pub zones: Vec, } @@ -25,12 +27,15 @@ pub struct Config { pub struct ServerConfig { #[serde(default = "default_bind_addr")] pub bind_addr: String, + #[serde(default = "default_api_port")] + pub api_port: u16, } impl Default for ServerConfig { fn default() -> Self { ServerConfig { bind_addr: default_bind_addr(), + api_port: default_api_port(), } } } @@ -39,6 +44,10 @@ fn default_bind_addr() -> String { "0.0.0.0:53".to_string() } +fn default_api_port() -> u16 { + 5380 +} + #[derive(Deserialize)] pub struct UpstreamConfig { #[serde(default = "default_upstream_addr")] @@ -108,6 +117,41 @@ pub struct ZoneRecord { pub ttl: u32, } +#[derive(Deserialize)] +pub struct BlockingConfig { + #[serde(default = "default_blocking_enabled")] + pub enabled: bool, + #[serde(default = "default_blocklists")] + pub lists: Vec, + #[serde(default = "default_refresh_hours")] + pub refresh_hours: u64, + #[serde(default)] + pub allowlist: Vec, +} + +impl Default for BlockingConfig { + fn default() -> Self { + BlockingConfig { + enabled: default_blocking_enabled(), + lists: default_blocklists(), + refresh_hours: default_refresh_hours(), + allowlist: Vec::new(), + } + } +} + +fn default_blocking_enabled() -> bool { + true +} + +fn default_blocklists() -> Vec { + vec!["https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/hosts/pro.txt".to_string()] +} + +fn default_refresh_hours() -> u64 { + 24 +} + fn default_zone_ttl() -> u32 { 300 } @@ -118,6 +162,7 @@ pub fn load_config(path: &str) -> Result { server: ServerConfig::default(), upstream: UpstreamConfig::default(), cache: CacheConfig::default(), + blocking: BlockingConfig::default(), zones: Vec::new(), }); } @@ -126,10 +171,10 @@ pub fn load_config(path: &str) -> Result { Ok(config) } -pub fn build_zone_map( - zones: &[ZoneRecord], -) -> Result>> { - let mut map: HashMap<(String, QueryType), Vec> = HashMap::new(); +pub type ZoneMap = HashMap>>; + +pub fn build_zone_map(zones: &[ZoneRecord]) -> Result { + let mut map: ZoneMap = HashMap::new(); for zone in zones { let domain = zone.domain.to_lowercase(); @@ -203,7 +248,11 @@ pub fn build_zone_map( } }; - map.entry((domain, qtype)).or_default().push(record); + map.entry(domain) + .or_default() + .entry(qtype) + .or_default() + .push(record); } Ok(map) diff --git a/src/ctx.rs b/src/ctx.rs new file mode 100644 index 0000000..a407f83 --- /dev/null +++ b/src/ctx.rs @@ -0,0 +1,155 @@ +use std::net::SocketAddr; +use std::sync::Mutex; +use std::time::{Duration, Instant, SystemTime}; + +use log::{debug, error, info, warn}; +use tokio::net::UdpSocket; + +use crate::blocklist::BlocklistStore; +use crate::buffer::BytePacketBuffer; +use crate::cache::DnsCache; +use crate::config::ZoneMap; +use crate::forward::forward_query; +use crate::header::ResultCode; +use crate::override_store::OverrideStore; +use crate::packet::DnsPacket; +use crate::query_log::{QueryLog, QueryLogEntry}; +use crate::record::DnsRecord; +use crate::stats::{QueryPath, ServerStats}; +use crate::system_dns::ForwardingRule; + +pub struct ServerCtx { + pub socket: UdpSocket, + pub zone_map: ZoneMap, + pub cache: Mutex, + pub stats: Mutex, + pub overrides: Mutex, + pub blocklist: Mutex, + pub query_log: Mutex, + pub forwarding_rules: Vec, + pub upstream: SocketAddr, + pub timeout: Duration, +} + +pub async fn handle_query( + mut buffer: BytePacketBuffer, + src_addr: SocketAddr, + ctx: &ServerCtx, +) -> crate::Result<()> { + let start = Instant::now(); + + let query = match DnsPacket::from_buffer(&mut buffer) { + Ok(packet) => packet, + Err(e) => { + warn!("{} | PARSE ERROR | {}", src_addr, e); + return Ok(()); + } + }; + + let (qname, qtype) = match query.questions.first() { + Some(q) => (q.name.clone(), q.qtype), + None => return Ok(()), + }; + + // Pipeline: overrides -> blocklist -> local zones -> cache -> upstream + // Each lock is scoped to avoid holding MutexGuard across await points. + let (response, path) = { + let override_record = ctx.overrides.lock().unwrap().lookup(&qname); + if let Some(record) = override_record { + let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); + resp.answers.push(record); + (resp, QueryPath::Overridden) + } else if ctx.blocklist.lock().unwrap().is_blocked(&qname) { + use crate::question::QueryType; + let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); + match qtype { + QueryType::AAAA => resp.answers.push(DnsRecord::AAAA { + domain: qname.clone(), + addr: std::net::Ipv6Addr::UNSPECIFIED, + ttl: 60, + }), + _ => resp.answers.push(DnsRecord::A { + domain: qname.clone(), + addr: std::net::Ipv4Addr::UNSPECIFIED, + ttl: 60, + }), + } + (resp, QueryPath::Blocked) + } else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) { + let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); + resp.answers = records.clone(); + (resp, QueryPath::Local) + } else { + let cached = ctx.cache.lock().unwrap().lookup(&qname, qtype); + if let Some(cached) = cached { + let mut resp = cached; + resp.header.id = query.header.id; + (resp, QueryPath::Cached) + } else { + let upstream = + crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) + .unwrap_or(ctx.upstream); + match forward_query(&query, upstream, ctx.timeout).await { + Ok(resp) => { + ctx.cache.lock().unwrap().insert(&qname, qtype, &resp); + (resp, QueryPath::Forwarded) + } + Err(e) => { + error!( + "{} | {:?} {} | UPSTREAM ERROR | {}", + src_addr, qtype, qname, e + ); + ( + DnsPacket::response_from(&query, ResultCode::SERVFAIL), + QueryPath::UpstreamError, + ) + } + } + } + } + }; + + let elapsed = start.elapsed(); + + info!( + "{} | {:?} {} | {} | {} | {}ms", + src_addr, + qtype, + qname, + path.as_str(), + response.header.rescode.as_str(), + elapsed.as_millis(), + ); + + debug!( + "response: {} answers, {} authorities, {} resources", + response.answers.len(), + response.authorities.len(), + response.resources.len(), + ); + + let mut resp_buffer = BytePacketBuffer::new(); + response.write(&mut resp_buffer)?; + ctx.socket.send_to(resp_buffer.filled(), src_addr).await?; + + // Record stats and query log + { + let mut s = ctx.stats.lock().unwrap(); + let total = s.record(path); + if total.is_multiple_of(1000) { + s.log_summary(); + } + } + + ctx.query_log.lock().unwrap().push(QueryLogEntry { + timestamp: SystemTime::now(), + src_addr, + domain: qname, + query_type: qtype, + path, + rescode: response.header.rescode, + latency_us: elapsed.as_micros() as u64, + }); + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index e43c565..60a175d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,12 +1,18 @@ +pub mod api; +pub mod blocklist; pub mod buffer; pub mod cache; pub mod config; +pub mod ctx; pub mod forward; pub mod header; +pub mod override_store; pub mod packet; +pub mod query_log; pub mod question; pub mod record; pub mod stats; +pub mod system_dns; pub type Error = Box; pub type Result = std::result::Result; diff --git a/src/main.rs b/src/main.rs index 39e7811..95d8719 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,47 +1,78 @@ -use std::collections::HashMap; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; +use std::time::Duration; -use log::{debug, error, info, warn}; +use log::{error, info}; use tokio::net::UdpSocket; -use dns_fun::buffer::BytePacketBuffer; -use dns_fun::cache::DnsCache; -use dns_fun::config::{build_zone_map, load_config}; -use dns_fun::forward::forward_query; -use dns_fun::header::ResultCode; -use dns_fun::packet::DnsPacket; -use dns_fun::question::QueryType; -use dns_fun::record::DnsRecord; -use dns_fun::stats::{QueryPath, ServerStats}; - -struct ServerCtx { - socket: Arc, - zone_map: HashMap<(String, QueryType), Vec>, - cache: Mutex, - stats: Mutex, - upstream: SocketAddr, - timeout: Duration, -} +use numa::blocklist::{download_blocklists, parse_blocklist, BlocklistStore}; +use numa::buffer::BytePacketBuffer; +use numa::cache::DnsCache; +use numa::config::{build_zone_map, load_config}; +use numa::ctx::{handle_query, ServerCtx}; +use numa::override_store::OverrideStore; +use numa::query_log::QueryLog; +use numa::stats::ServerStats; +use numa::system_dns::{discover_forwarding_rules, install_system_dns, uninstall_system_dns}; #[tokio::main] -async fn main() -> dns_fun::Result<()> { +async fn main() -> numa::Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) .format_timestamp_millis() .init(); - let config_path = std::env::args() - .nth(1) - .unwrap_or_else(|| "dns_fun.toml".to_string()); + // Handle CLI subcommands + let arg1 = std::env::args().nth(1).unwrap_or_default(); + match arg1.as_str() { + "install" => { + eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — configuring system DNS\n"); + return install_system_dns().map_err(|e| e.into()); + } + "uninstall" => { + eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — restoring system DNS\n"); + return uninstall_system_dns().map_err(|e| e.into()); + } + "help" | "--help" | "-h" => { + eprintln!("Usage: numa [command] [config-path]"); + eprintln!(); + eprintln!("Commands:"); + eprintln!(" (none) Start the DNS server (default)"); + eprintln!(" install Set system DNS to 127.0.0.1 (requires sudo)"); + eprintln!(" uninstall Restore original system DNS settings"); + eprintln!(" help Show this help"); + eprintln!(); + eprintln!("Config path defaults to numa.toml"); + return Ok(()); + } + _ => {} + } + + let config_path = if arg1.is_empty() || arg1 == "run" { + std::env::args() + .nth(2) + .unwrap_or_else(|| "numa.toml".to_string()) + } else { + arg1 // treat as config path for backwards compatibility + }; let config = load_config(&config_path)?; let upstream: SocketAddr = format!("{}:{}", config.upstream.address, config.upstream.port).parse()?; - let socket = Arc::new(UdpSocket::bind(&config.server.bind_addr).await?); + let api_port = config.server.api_port; + + let mut blocklist = BlocklistStore::new(); + for domain in &config.blocking.allowlist { + blocklist.add_to_allowlist(domain); + } + if !config.blocking.enabled { + blocklist.set_enabled(false); + } + + // Auto-discover conditional forwarding rules from OS (Tailscale, VPN, etc.) + let forwarding_rules = discover_forwarding_rules(); let ctx = Arc::new(ServerCtx { - socket: Arc::clone(&socket), + socket: UdpSocket::bind(&config.server.bind_addr).await?, zone_map: build_zone_map(&config.zones)?, cache: Mutex::new(DnsCache::new( config.cache.max_entries, @@ -49,21 +80,72 @@ async fn main() -> dns_fun::Result<()> { config.cache.max_ttl, )), stats: Mutex::new(ServerStats::new()), + overrides: Mutex::new(OverrideStore::new()), + blocklist: Mutex::new(blocklist), + query_log: Mutex::new(QueryLog::new(1000)), + forwarding_rules, upstream, timeout: Duration::from_millis(config.upstream.timeout_ms), }); + let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); + eprintln!("\n\x1b[38;2;192;98;58m ╔══════════════════════════════════════════╗\x1b[0m"); + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[1;38;2;192;98;58mNUMA\x1b[0m \x1b[3;38;2;163;152;136mDNS that governs itself\x1b[0m \x1b[38;2;192;98;58m║\x1b[0m"); + eprintln!("\x1b[38;2;192;98;58m ╠══════════════════════════════════════════╣\x1b[0m"); + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mDNS\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", config.server.bind_addr); + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mAPI\x1b[0m http://localhost:{:<16}\x1b[38;2;192;98;58m║\x1b[0m", api_port); + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mDashboard\x1b[0m http://localhost:{:<16}\x1b[38;2;192;98;58m║\x1b[0m", api_port); + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mUpstream\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", upstream); + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mZones\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", format!("{} records", zone_count)); + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mCache\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", format!("max {} entries", config.cache.max_entries)); + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mBlocking\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", + if config.blocking.enabled { format!("{} lists", config.blocking.lists.len()) } else { "disabled".to_string() }); + if !ctx.forwarding_rules.is_empty() { + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mRouting\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", + format!("{} conditional rules", ctx.forwarding_rules.len())); + } + eprintln!("\x1b[38;2;192;98;58m ╚══════════════════════════════════════════╝\x1b[0m\n"); + info!( - "dns_fun starting on {}, upstream {}, {} zone records, cache max {}", - config.server.bind_addr, - upstream, - ctx.zone_map.len(), - config.cache.max_entries, + "numa listening on {}, upstream {}, {} zone records, cache max {}, API on port {}", + config.server.bind_addr, upstream, zone_count, config.cache.max_entries, api_port, ); + // Download blocklists on startup + let blocklist_lists = config.blocking.lists.clone(); + let refresh_hours = config.blocking.refresh_hours; + if config.blocking.enabled && !blocklist_lists.is_empty() { + let bl_ctx = Arc::clone(&ctx); + let bl_lists = blocklist_lists.clone(); + tokio::spawn(async move { + load_blocklists(&bl_ctx, &bl_lists).await; + + // Periodic refresh + let mut interval = tokio::time::interval(Duration::from_secs(refresh_hours * 3600)); + interval.tick().await; // skip immediate tick + loop { + interval.tick().await; + info!("refreshing blocklists..."); + load_blocklists(&bl_ctx, &bl_lists).await; + } + }); + } + + // Spawn HTTP API server + let api_ctx = Arc::clone(&ctx); + let api_addr: SocketAddr = format!("0.0.0.0:{}", api_port).parse()?; + tokio::spawn(async move { + let app = numa::api::router(api_ctx); + let listener = tokio::net::TcpListener::bind(api_addr).await.unwrap(); + info!("HTTP API listening on {}", api_addr); + axum::serve(listener, app).await.unwrap(); + }); + + // UDP DNS listener + #[allow(clippy::infinite_loop)] loop { let mut buffer = BytePacketBuffer::new(); - let (_, src_addr) = socket.recv_from(&mut buffer.buf).await?; + let (_, src_addr) = ctx.socket.recv_from(&mut buffer.buf).await?; let ctx = Arc::clone(&ctx); tokio::spawn(async move { @@ -74,87 +156,28 @@ async fn main() -> dns_fun::Result<()> { } } -async fn handle_query( - mut buffer: BytePacketBuffer, - src_addr: SocketAddr, - ctx: &ServerCtx, -) -> dns_fun::Result<()> { - let start = Instant::now(); +async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) { + let downloaded = download_blocklists(lists).await; - let query = match DnsPacket::from_buffer(&mut buffer) { - Ok(packet) => packet, - Err(e) => { - warn!("{} | PARSE ERROR | {}", src_addr, e); - return Ok(()); - } - }; - - let (qname, qtype) = match query.questions.first() { - Some(q) => (q.name.clone(), q.qtype), - None => return Ok(()), - }; - - // Pipeline: local zones -> cache -> upstream - // Each lock is scoped to avoid holding MutexGuard across await points. - let (response, path) = if let Some(records) = ctx.zone_map.get(&(qname.to_lowercase(), qtype)) { - let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); - resp.answers = records.clone(); - (resp, QueryPath::Local) - } else { - let cached = ctx.cache.lock().unwrap().lookup(&qname, qtype); - if let Some(cached) = cached { - let mut resp = cached; - resp.header.id = query.header.id; - (resp, QueryPath::Cached) - } else { - match forward_query(&query, ctx.upstream, ctx.timeout).await { - Ok(resp) => { - ctx.cache.lock().unwrap().insert(&qname, qtype, &resp); - (resp, QueryPath::Forwarded) - } - Err(e) => { - error!( - "{} | {:?} {} | UPSTREAM ERROR | {}", - src_addr, qtype, qname, e - ); - ( - DnsPacket::response_from(&query, ResultCode::SERVFAIL), - QueryPath::UpstreamError, - ) - } - } - } - }; - - let elapsed = start.elapsed(); - - info!( - "{} | {:?} {} | {} | {} | {}ms", - src_addr, - qtype, - qname, - path.as_str(), - response.header.rescode.as_str(), - elapsed.as_millis(), - ); - - debug!( - "response: {} answers, {} authorities, {} resources", - response.answers.len(), - response.authorities.len(), - response.resources.len(), - ); - - let mut resp_buffer = BytePacketBuffer::new(); - response.write(&mut resp_buffer)?; - ctx.socket.send_to(resp_buffer.filled(), src_addr).await?; - - // Record stats and log summary every 1000 queries (single lock acquisition) - let mut s = ctx.stats.lock().unwrap(); - let total = s.record(path); - if total.is_multiple_of(1000) { - s.log_summary(); + // Parse outside the lock to avoid blocking DNS queries during parse (~100ms) + let mut all_domains = std::collections::HashSet::new(); + let mut sources = Vec::new(); + for (source, text) in &downloaded { + let domains = parse_blocklist(text); + info!("blocklist: {} domains from {}", domains.len(), source); + all_domains.extend(domains); + sources.push(source.clone()); } + let total = all_domains.len(); - Ok(()) + // Swap under lock — sub-microsecond + ctx.blocklist + .lock() + .unwrap() + .swap_domains(all_domains, sources); + info!( + "blocking enabled: {} unique domains from {} lists", + total, + downloaded.len() + ); } diff --git a/src/override_store.rs b/src/override_store.rs new file mode 100644 index 0000000..a1c7bf8 --- /dev/null +++ b/src/override_store.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::time::Instant; + +use crate::question::QueryType; +use crate::record::DnsRecord; +use crate::Result; + +pub struct OverrideEntry { + pub domain: String, + pub target: String, + pub record: DnsRecord, + pub query_type: QueryType, + pub ttl: u32, + pub created_at: Instant, + pub duration_secs: Option, +} + +impl OverrideEntry { + pub fn expires_at(&self) -> Option { + self.duration_secs + .map(|d| self.created_at + std::time::Duration::from_secs(d)) + } + + pub fn is_expired(&self) -> bool { + self.expires_at() + .map(|exp| Instant::now() >= exp) + .unwrap_or(false) + } + + pub fn remaining_secs(&self) -> Option { + self.expires_at().map(|exp| { + let now = Instant::now(); + if now >= exp { + 0 + } else { + (exp - now).as_secs() + } + }) + } +} + +pub struct OverrideStore { + entries: HashMap, +} + +impl Default for OverrideStore { + fn default() -> Self { + Self::new() + } +} + +impl OverrideStore { + pub fn new() -> Self { + OverrideStore { + entries: HashMap::new(), + } + } + + pub fn insert( + &mut self, + domain: &str, + target: &str, + ttl: u32, + duration_secs: Option, + ) -> Result { + let domain_lower = domain.to_lowercase(); + let (qtype, record) = parse_target(&domain_lower, target, ttl)?; + + self.entries.insert( + domain_lower.clone(), + OverrideEntry { + domain: domain_lower, + target: target.to_string(), + record, + query_type: qtype, + ttl, + created_at: Instant::now(), + duration_secs, + }, + ); + + Ok(qtype) + } + + /// Hot path: assumes `domain` is already lowercased (the parser does this). + pub fn lookup(&mut self, domain: &str) -> Option { + let entry = self.entries.get(domain)?; + if entry.is_expired() { + self.entries.remove(domain); + return None; + } + Some(entry.record.clone()) + } + + pub fn get(&self, domain: &str) -> Option<&OverrideEntry> { + let key = domain.to_lowercase(); + let entry = self.entries.get(&key)?; + if entry.is_expired() { + return None; + } + Some(entry) + } + + pub fn remove(&mut self, domain: &str) -> bool { + self.entries.remove(&domain.to_lowercase()).is_some() + } + + pub fn list(&self) -> Vec<&OverrideEntry> { + self.entries.values().filter(|e| !e.is_expired()).collect() + } + + pub fn clear(&mut self) { + self.entries.clear(); + } + + pub fn active_count(&self) -> usize { + self.entries.values().filter(|e| !e.is_expired()).count() + } +} + +fn parse_target(domain: &str, target: &str, ttl: u32) -> Result<(QueryType, DnsRecord)> { + if let Ok(addr) = target.parse::() { + return Ok(( + QueryType::A, + DnsRecord::A { + domain: domain.to_string(), + addr, + ttl, + }, + )); + } + + if let Ok(addr) = target.parse::() { + return Ok(( + QueryType::AAAA, + DnsRecord::AAAA { + domain: domain.to_string(), + addr, + ttl, + }, + )); + } + + Ok(( + QueryType::CNAME, + DnsRecord::CNAME { + domain: domain.to_string(), + host: target.to_string(), + ttl, + }, + )) +} diff --git a/src/query_log.rs b/src/query_log.rs new file mode 100644 index 0000000..2f15d7a --- /dev/null +++ b/src/query_log.rs @@ -0,0 +1,77 @@ +use std::collections::VecDeque; +use std::net::SocketAddr; +use std::time::SystemTime; + +use crate::header::ResultCode; +use crate::question::QueryType; +use crate::stats::QueryPath; + +pub struct QueryLogEntry { + pub timestamp: SystemTime, + pub src_addr: SocketAddr, + pub domain: String, + pub query_type: QueryType, + pub path: QueryPath, + pub rescode: ResultCode, + pub latency_us: u64, +} + +pub struct QueryLog { + entries: VecDeque, + capacity: usize, +} + +impl QueryLog { + pub fn new(capacity: usize) -> Self { + QueryLog { + entries: VecDeque::with_capacity(capacity), + capacity, + } + } + + pub fn push(&mut self, entry: QueryLogEntry) { + if self.entries.len() >= self.capacity { + self.entries.pop_front(); + } + self.entries.push_back(entry); + } + + pub fn query(&self, filter: &QueryLogFilter) -> Vec<&QueryLogEntry> { + self.entries + .iter() + .rev() + .filter(|e| { + if let Some(ref domain) = filter.domain { + if !e.domain.contains(domain.as_str()) { + return false; + } + } + if let Some(qtype) = filter.query_type { + if e.query_type != qtype { + return false; + } + } + if let Some(path) = filter.path { + if e.path != path { + return false; + } + } + if let Some(since) = filter.since { + if e.timestamp < since { + return false; + } + } + true + }) + .take(filter.limit.unwrap_or(50)) + .collect() + } +} + +pub struct QueryLogFilter { + pub domain: Option, + pub query_type: Option, + pub path: Option, + pub since: Option, + pub limit: Option, +} diff --git a/src/question.rs b/src/question.rs index b142153..4baebf4 100644 --- a/src/question.rs +++ b/src/question.rs @@ -33,6 +33,33 @@ impl QueryType { _ => QueryType::UNKNOWN(num), } } + + pub fn as_str(&self) -> &'static str { + match self { + QueryType::A => "A", + QueryType::NS => "NS", + QueryType::CNAME => "CNAME", + QueryType::MX => "MX", + QueryType::AAAA => "AAAA", + QueryType::UNKNOWN(_) => "UNKNOWN", + } + } + + pub fn parse_str(s: &str) -> Option { + if s.eq_ignore_ascii_case("A") { + Some(QueryType::A) + } else if s.eq_ignore_ascii_case("NS") { + Some(QueryType::NS) + } else if s.eq_ignore_ascii_case("CNAME") { + Some(QueryType::CNAME) + } else if s.eq_ignore_ascii_case("MX") { + Some(QueryType::MX) + } else if s.eq_ignore_ascii_case("AAAA") { + Some(QueryType::AAAA) + } else { + None + } + } } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src/record.rs b/src/record.rs index b138b79..8d65879 100644 --- a/src/record.rs +++ b/src/record.rs @@ -240,7 +240,7 @@ impl DnsRecord { } } DnsRecord::UNKNOWN { .. } => { - println!("Skipping record: {:?}", self); + log::debug!("Skipping record: {:?}", self); } } diff --git a/src/stats.rs b/src/stats.rs index 3f50e85..0336cbb 100644 --- a/src/stats.rs +++ b/src/stats.rs @@ -6,15 +6,18 @@ pub struct ServerStats { queries_cached: u64, queries_blocked: u64, queries_local: u64, + queries_overridden: u64, upstream_errors: u64, started_at: Instant, } +#[derive(Clone, Copy, PartialEq, Eq)] pub enum QueryPath { Local, Cached, Forwarded, Blocked, + Overridden, UpstreamError, } @@ -25,9 +28,28 @@ impl QueryPath { QueryPath::Cached => "CACHED", QueryPath::Forwarded => "FORWARD", QueryPath::Blocked => "BLOCKED", + QueryPath::Overridden => "OVERRIDE", QueryPath::UpstreamError => "SERVFAIL", } } + + pub fn parse_str(s: &str) -> Option { + if s.eq_ignore_ascii_case("LOCAL") { + Some(QueryPath::Local) + } else if s.eq_ignore_ascii_case("CACHED") { + Some(QueryPath::Cached) + } else if s.eq_ignore_ascii_case("FORWARD") { + Some(QueryPath::Forwarded) + } else if s.eq_ignore_ascii_case("BLOCKED") { + Some(QueryPath::Blocked) + } else if s.eq_ignore_ascii_case("OVERRIDE") { + Some(QueryPath::Overridden) + } else if s.eq_ignore_ascii_case("SERVFAIL") { + Some(QueryPath::UpstreamError) + } else { + None + } + } } impl Default for ServerStats { @@ -44,6 +66,7 @@ impl ServerStats { queries_cached: 0, queries_blocked: 0, queries_local: 0, + queries_overridden: 0, upstream_errors: 0, started_at: Instant::now(), } @@ -56,6 +79,7 @@ impl ServerStats { QueryPath::Cached => self.queries_cached += 1, QueryPath::Forwarded => self.queries_forwarded += 1, QueryPath::Blocked => self.queries_blocked += 1, + QueryPath::Overridden => self.queries_overridden += 1, QueryPath::UpstreamError => self.upstream_errors += 1, } self.queries_total @@ -65,6 +89,23 @@ impl ServerStats { self.queries_total } + pub fn uptime_secs(&self) -> u64 { + self.started_at.elapsed().as_secs() + } + + pub fn snapshot(&self) -> StatsSnapshot { + StatsSnapshot { + uptime_secs: self.uptime_secs(), + total: self.queries_total, + forwarded: self.queries_forwarded, + cached: self.queries_cached, + local: self.queries_local, + overridden: self.queries_overridden, + blocked: self.queries_blocked, + errors: self.upstream_errors, + } + } + pub fn log_summary(&self) { let uptime = self.started_at.elapsed(); let hours = uptime.as_secs() / 3600; @@ -72,14 +113,26 @@ impl ServerStats { let secs = uptime.as_secs() % 60; log::info!( - "STATS | uptime {}h{}m{}s | total {} | fwd {} | cached {} | local {} | blocked {} | errors {}", + "STATS | uptime {}h{}m{}s | total {} | fwd {} | cached {} | local {} | override {} | blocked {} | errors {}", hours, mins, secs, self.queries_total, self.queries_forwarded, self.queries_cached, self.queries_local, + self.queries_overridden, self.queries_blocked, self.upstream_errors, ); } } + +pub struct StatsSnapshot { + pub uptime_secs: u64, + pub total: u64, + pub forwarded: u64, + pub cached: u64, + pub local: u64, + pub overridden: u64, + pub blocked: u64, + pub errors: u64, +} diff --git a/src/system_dns.rs b/src/system_dns.rs new file mode 100644 index 0000000..3e9d4e6 --- /dev/null +++ b/src/system_dns.rs @@ -0,0 +1,331 @@ +use std::net::SocketAddr; + +use log::info; + +/// A conditional forwarding rule: domains matching `suffix` are forwarded to `upstream`. +#[derive(Debug, Clone)] +pub struct ForwardingRule { + pub suffix: String, + dot_suffix: String, // pre-computed ".suffix" for zero-alloc matching + pub upstream: SocketAddr, +} + +/// Discover system DNS forwarding rules from the OS. +/// On macOS, parses `scutil --dns`. Returns rules sorted longest-suffix-first +/// so more specific matches take priority. +pub fn discover_forwarding_rules() -> Vec { + #[cfg(target_os = "macos")] + { + discover_macos() + } + #[cfg(not(target_os = "macos"))] + { + info!("system DNS auto-discovery not implemented for this OS"); + Vec::new() + } +} + +#[cfg(target_os = "macos")] +fn discover_macos() -> Vec { + use log::{debug, warn}; + + let output = match std::process::Command::new("scutil").arg("--dns").output() { + Ok(o) => o, + Err(e) => { + warn!("failed to run scutil --dns: {}", e); + return Vec::new(); + } + }; + + let text = String::from_utf8_lossy(&output.stdout); + let mut rules = Vec::new(); + + // Parse resolver blocks: look for blocks with both `domain` and `nameserver[0]` + // that have the `Supplemental` flag (conditional forwarding, not default) + let mut current_domain: Option = None; + let mut current_nameserver: Option = None; + let mut is_supplemental = false; + + for line in text.lines() { + let line = line.trim(); + + if line.starts_with("resolver #") { + // Emit previous block if valid + if let (Some(domain), Some(ns), true) = ( + current_domain.take(), + current_nameserver.take(), + is_supplemental, + ) { + if let Some(rule) = make_rule(&domain, &ns) { + rules.push(rule); + } + } + current_domain = None; + current_nameserver = None; + is_supplemental = false; + } else if line.starts_with("domain") && line.contains(':') { + // "domain : tailcee7cc.ts.net." + if let Some(val) = line.split(':').nth(1) { + let domain = val.trim().trim_end_matches('.').to_lowercase(); + if !domain.is_empty() + && domain != "local" + && !domain.ends_with("in-addr.arpa") + && !domain.ends_with("ip6.arpa") + { + current_domain = Some(domain); + } + } + } else if line.starts_with("nameserver[0]") && line.contains(':') { + if let Some(val) = line.split(':').nth(1) { + let ns = val.trim().to_string(); + // Only use IPv4 nameservers for now + if ns.parse::().is_ok() { + current_nameserver = Some(ns); + } + } + } else if line.starts_with("flags") && line.contains("Supplemental") { + is_supplemental = true; + } else if line.starts_with("DNS configuration (for scoped") { + // Stop at scoped section — those are interface-specific, not conditional + if let (Some(domain), Some(ns), true) = ( + current_domain.take(), + current_nameserver.take(), + is_supplemental, + ) { + if let Some(rule) = make_rule(&domain, &ns) { + rules.push(rule); + } + } + break; + } + } + + // Emit last block + if let (Some(domain), Some(ns), true) = (current_domain, current_nameserver, is_supplemental) { + if let Some(rule) = make_rule(&domain, &ns) { + rules.push(rule); + } + } + + // Sort longest suffix first for most-specific matching + rules.sort_by(|a, b| b.suffix.len().cmp(&a.suffix.len())); + + for rule in &rules { + info!( + "auto-discovered forwarding: *.{} -> {}", + rule.suffix, rule.upstream + ); + } + + if rules.is_empty() { + debug!("no conditional forwarding rules discovered from scutil --dns"); + } + + rules +} + +#[cfg(target_os = "macos")] +fn make_rule(domain: &str, nameserver: &str) -> Option { + let addr: SocketAddr = format!("{}:53", nameserver).parse().ok()?; + Some(ForwardingRule { + dot_suffix: format!(".{}", domain), + suffix: domain.to_string(), + upstream: addr, + }) +} + +/// 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. +pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option { + for rule in rules { + if domain == rule.suffix || domain.ends_with(&rule.dot_suffix) { + return Some(rule.upstream); + } + } + None +} + +// --- System DNS configuration (install/uninstall) --- + +/// Set the system DNS to 127.0.0.1 so all queries go through Numa. +/// Saves the original DNS settings for later restoration. +pub fn install_system_dns() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + install_macos() + } + #[cfg(target_os = "linux")] + { + install_linux() + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("system DNS configuration not supported on this OS".to_string()) + } +} + +/// Restore the original system DNS settings saved during install. +pub fn uninstall_system_dns() -> Result<(), String> { + #[cfg(target_os = "macos")] + { + uninstall_macos() + } + #[cfg(target_os = "linux")] + { + uninstall_linux() + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + Err("system DNS configuration not supported on this OS".to_string()) + } +} + +// --- macOS implementation --- + +#[cfg(target_os = "macos")] +fn numa_data_dir() -> std::path::PathBuf { + let home = std::env::var("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|_| std::path::PathBuf::from("/tmp")); + home.join(".numa") +} + +#[cfg(target_os = "macos")] +fn backup_path() -> std::path::PathBuf { + numa_data_dir().join("original-dns.json") +} + +#[cfg(target_os = "macos")] +fn get_network_services() -> Result, String> { + let output = std::process::Command::new("networksetup") + .arg("-listallnetworkservices") + .output() + .map_err(|e| format!("failed to run networksetup: {}", e))?; + + let text = String::from_utf8_lossy(&output.stdout); + let services: Vec = text + .lines() + .skip(1) // first line is "An asterisk (*) denotes..." + .map(|l| l.trim_start_matches('*').trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + + Ok(services) +} + +#[cfg(target_os = "macos")] +fn get_dns_servers(service: &str) -> Result, String> { + let output = std::process::Command::new("networksetup") + .args(["-getdnsservers", service]) + .output() + .map_err(|e| format!("failed to get DNS for {}: {}", service, e))?; + + let text = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if text.contains("aren't any DNS Servers") { + Ok(vec![]) // using DHCP defaults + } else { + Ok(text.lines().map(|l| l.trim().to_string()).collect()) + } +} + +#[cfg(target_os = "macos")] +fn install_macos() -> Result<(), String> { + use std::collections::HashMap; + + let services = get_network_services()?; + let mut original: HashMap> = HashMap::new(); + + // Save current DNS for each service + for service in &services { + let servers = get_dns_servers(service)?; + original.insert(service.clone(), servers); + } + + // Save backup + let dir = numa_data_dir(); + std::fs::create_dir_all(&dir) + .map_err(|e| format!("failed to create {}: {}", dir.display(), e))?; + + let json = serde_json::to_string_pretty(&original) + .map_err(|e| format!("failed to serialize backup: {}", e))?; + std::fs::write(backup_path(), json).map_err(|e| format!("failed to write backup: {}", e))?; + + // Set DNS to 127.0.0.1 for each service + for service in &services { + let status = std::process::Command::new("networksetup") + .args(["-setdnsservers", service, "127.0.0.1"]) + .status() + .map_err(|e| format!("failed to set DNS for {}: {}", service, e))?; + + if status.success() { + eprintln!(" set DNS for \"{}\" -> 127.0.0.1", service); + } else { + eprintln!(" warning: failed to set DNS for \"{}\"", service); + } + } + + eprintln!("\n Original DNS saved to {}", backup_path().display()); + eprintln!(" Run 'sudo numa uninstall' to restore.\n"); + + Ok(()) +} + +#[cfg(target_os = "macos")] +fn uninstall_macos() -> Result<(), String> { + use std::collections::HashMap; + + let path = backup_path(); + let json = std::fs::read_to_string(&path) + .map_err(|e| format!("no backup found at {}: {}", path.display(), e))?; + + let original: HashMap> = + serde_json::from_str(&json).map_err(|e| format!("invalid backup file: {}", e))?; + + for (service, servers) in &original { + let args = if servers.is_empty() { + // Restore to "empty" (DHCP default) by setting to "Empty" + vec!["-setdnsservers", service, "Empty"] + } else { + let mut a = vec!["-setdnsservers", service]; + a.extend(servers.iter().map(|s| s.as_str())); + a + }; + + let status = std::process::Command::new("networksetup") + .args(&args) + .status() + .map_err(|e| format!("failed to restore DNS for {}: {}", service, e))?; + + if status.success() { + let display = if servers.is_empty() { + "DHCP default".to_string() + } else { + servers.join(", ") + }; + eprintln!(" restored DNS for \"{}\" -> {}", service, display); + } else { + eprintln!(" warning: failed to restore DNS for \"{}\"", service); + } + } + + std::fs::remove_file(&path).ok(); + eprintln!("\n System DNS restored. Backup removed.\n"); + + Ok(()) +} + +// --- Linux stubs --- + +#[cfg(target_os = "linux")] +fn install_linux() -> Result<(), String> { + Err( + "Linux auto-configuration not yet implemented. Manually set your DNS to 127.0.0.1" + .to_string(), + ) +} + +#[cfg(target_os = "linux")] +fn uninstall_linux() -> Result<(), String> { + Err("Linux auto-configuration not yet implemented.".to_string()) +}