Compare commits
5 Commits
v0.13.0
...
fix/allowl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3138990a8 | ||
|
|
e5c6caba1f | ||
|
|
ec44829c30 | ||
|
|
c452f99a45 | ||
|
|
d66a88f467 |
2
.github/workflows/static.yml
vendored
2
.github/workflows/static.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Install pandoc
|
||||
uses: pandoc/actions/setup@v1
|
||||
run: sudo apt-get install -y pandoc
|
||||
- name: Generate blog HTML
|
||||
run: make blog
|
||||
- name: Setup Pages
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,4 +3,3 @@
|
||||
CLAUDE.md
|
||||
docs/
|
||||
site/blog/posts/
|
||||
ios/
|
||||
|
||||
462
Cargo.lock
generated
462
Cargo.lock
generated
@@ -82,12 +82,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.0"
|
||||
@@ -148,17 +142,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -427,21 +410,6 @@ dependencies = [
|
||||
"itertools",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "critical-section"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.6"
|
||||
@@ -525,18 +493,6 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "enum-as-inner"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "1.0.1"
|
||||
@@ -598,12 +554,6 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -729,24 +679,11 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
@@ -777,82 +714,12 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hickory-proto"
|
||||
version = "0.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
"data-encoding",
|
||||
"enum-as-inner",
|
||||
"futures-channel",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"once_cell",
|
||||
"rand",
|
||||
"ring",
|
||||
"rustls",
|
||||
"thiserror",
|
||||
"tinyvec",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hickory-resolver"
|
||||
version = "0.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"hickory-proto",
|
||||
"ipconfig",
|
||||
"moka",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand",
|
||||
"resolv-conf",
|
||||
"rustls",
|
||||
"smallvec",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -935,7 +802,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.6",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1042,12 +909,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -1076,22 +937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipconfig"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
|
||||
dependencies = [
|
||||
"socket2",
|
||||
"widestring",
|
||||
"windows-registry",
|
||||
"windows-result",
|
||||
"windows-sys 0.61.2",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1183,12 +1029,6 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
@@ -1201,15 +1041,6 @@ version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
@@ -1267,23 +1098,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moka"
|
||||
version = "0.12.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
"equivalent",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"smallvec",
|
||||
"tagptr",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -1330,15 +1144,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "numa"
|
||||
version = "0.13.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
"criterion",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"hickory-proto",
|
||||
"hickory-resolver",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
@@ -1358,8 +1170,6 @@ dependencies = [
|
||||
"tokio-rustls",
|
||||
"toml",
|
||||
"tower",
|
||||
"webpki-roots 1.0.6",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1376,10 +1186,6 @@ name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
dependencies = [
|
||||
"critical-section",
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
@@ -1403,29 +1209,6 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
@@ -1521,16 +1304,6 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -1616,12 +1389,6 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
@@ -1685,15 +1452,6 @@ dependencies = [
|
||||
"yasna",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
@@ -1759,15 +1517,9 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.6",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resolv-conf"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -1865,18 +1617,6 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@@ -2039,12 +1779,6 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tagptr"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
@@ -2303,12 +2037,6 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -2339,17 +2067,6 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -2384,15 +2101,6 @@ dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.115"
|
||||
@@ -2448,40 +2156,6 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.92"
|
||||
@@ -2502,15 +2176,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
@@ -2520,12 +2185,6 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -2563,35 +2222,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -2759,88 +2389,6 @@ name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
|
||||
12
Cargo.toml
12
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "numa"
|
||||
version = "0.13.0"
|
||||
version = "0.11.0"
|
||||
authors = ["razvandimescu <razvan@dimescu.com>"]
|
||||
edition = "2021"
|
||||
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
||||
@@ -30,16 +30,12 @@ tokio-rustls = "0.26"
|
||||
arc-swap = "1"
|
||||
ring = "0.17"
|
||||
rustls-pemfile = "2.2.0"
|
||||
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||
webpki-roots = "1"
|
||||
qrcode = { version = "0.14", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
http = "1"
|
||||
hickory-resolver = { version = "0.25", features = ["https-ring", "webpki-roots"] }
|
||||
hickory-proto = "0.25"
|
||||
x509-parser = "0.18"
|
||||
|
||||
[[bench]]
|
||||
name = "hot_path"
|
||||
@@ -52,7 +48,3 @@ harness = false
|
||||
[[bench]]
|
||||
name = "dnssec"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "recursive_compare"
|
||||
harness = false
|
||||
|
||||
17
README.md
17
README.md
@@ -113,18 +113,14 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
|
||||
| DNSSEC validation | — | — | Yes | Yes (RSA, ECDSA, Ed25519) |
|
||||
| Ad blocking | Yes | Yes | — | 385K+ domains |
|
||||
| Web admin UI | Full | Full | — | Dashboard |
|
||||
| Encrypted upstream (DoH/DoT) | Needs cloudflared | DoH only | DoT only | DoH + DoT (`tls://`) |
|
||||
| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native |
|
||||
| Encrypted clients (DoT listener) | Needs stunnel sidecar | Yes | Yes | Native (RFC 7858) |
|
||||
| DoH server endpoint | — | Yes | — | Yes (RFC 8484) |
|
||||
| Request hedging | — | — | — | All protocols (UDP, DoH, DoT) |
|
||||
| Serve-stale + prefetch | — | — | Prefetch at 90% TTL | RFC 8767, prefetch at 90% TTL |
|
||||
| Conditional forwarding | — | Yes | Yes | Yes (per-suffix rules) |
|
||||
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows |
|
||||
| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New |
|
||||
|
||||
## Performance
|
||||
|
||||
0.1ms cached queries — matches Unbound and AdGuard Home. Wire-level cache stores raw bytes with in-place TTL patching. Request hedging eliminates p99 spikes: cold recursive p99 538ms vs Unbound 748ms (−28%), σ 4× tighter. [Benchmarks →](benches/)
|
||||
691ns cached round-trip. ~2.0M qps throughput. Zero heap allocations in the hot path. Recursive queries average 237ms after SRTT warmup (12x improvement over round-robin). ECDSA P-256 DNSSEC verification: 174ns. [Benchmarks →](bench/)
|
||||
|
||||
## Learn More
|
||||
|
||||
@@ -139,15 +135,10 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
|
||||
- [x] DNS forwarding, caching, ad blocking, developer overrides
|
||||
- [x] `.numa` local domains — auto TLS, path routing, WebSocket proxy
|
||||
- [x] LAN service discovery — mDNS, cross-machine DNS + proxy
|
||||
- [x] DNS-over-HTTPS — encrypted upstream + server endpoint (RFC 8484)
|
||||
- [x] DNS-over-TLS — encrypted client listener (RFC 7858) + upstream forwarding (`tls://`)
|
||||
- [x] DNS-over-HTTPS — encrypted upstream
|
||||
- [x] DNS-over-TLS listener — encrypted client connections (RFC 7858, ALPN strict)
|
||||
- [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3
|
||||
- [x] SRTT-based nameserver selection
|
||||
- [x] Multi-forwarder failover — multiple upstreams with SRTT ranking, fallback pool
|
||||
- [x] Request hedging — parallel requests rescue packet loss and tail latency (all protocols)
|
||||
- [x] Serve-stale + prefetch — RFC 8767, background refresh at <10% TTL and on stale serve
|
||||
- [x] Conditional forwarding — per-suffix rules for split-horizon DNS (Tailscale, VPNs)
|
||||
- [x] Cache warming — proactive resolution for configured domains
|
||||
- [x] Mobile onboarding — `setup-phone` QR flow, mobile API, mobileconfig profiles
|
||||
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT
|
||||
- [ ] Global `.numa` names — DHT-backed, no registrar
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
[server]
|
||||
bind_addr = "127.0.0.1:5454"
|
||||
api_port = 5381
|
||||
api_bind_addr = "127.0.0.1"
|
||||
data_dir = "/tmp/numa-bench"
|
||||
|
||||
[upstream]
|
||||
mode = "recursive"
|
||||
timeout_ms = 10000
|
||||
|
||||
[cache]
|
||||
min_ttl = 60
|
||||
max_ttl = 3600
|
||||
|
||||
[blocking]
|
||||
enabled = false
|
||||
|
||||
[proxy]
|
||||
port = 8080
|
||||
tls_port = 8443
|
||||
|
||||
[dot]
|
||||
enabled = true
|
||||
port = 8530
|
||||
|
||||
[mobile]
|
||||
enabled = false
|
||||
|
||||
[lan]
|
||||
enabled = false
|
||||
@@ -1,31 +0,0 @@
|
||||
[server]
|
||||
bind_addr = "127.0.0.1:5454"
|
||||
api_port = 5381
|
||||
api_bind_addr = "127.0.0.1"
|
||||
data_dir = "/tmp/numa-bench"
|
||||
|
||||
[upstream]
|
||||
mode = "forward"
|
||||
address = ["https://9.9.9.9/dns-query"]
|
||||
timeout_ms = 10000
|
||||
|
||||
[cache]
|
||||
min_ttl = 60
|
||||
max_ttl = 3600
|
||||
|
||||
[blocking]
|
||||
enabled = false
|
||||
|
||||
[proxy]
|
||||
port = 8080
|
||||
tls_port = 8443
|
||||
|
||||
[dot]
|
||||
enabled = true
|
||||
port = 8530
|
||||
|
||||
[mobile]
|
||||
enabled = false
|
||||
|
||||
[lan]
|
||||
enabled = false
|
||||
File diff suppressed because it is too large
Load Diff
@@ -132,29 +132,20 @@ $ numa setup-phone
|
||||
|
||||
Numa Phone Setup
|
||||
|
||||
Profile URL: http://192.168.1.10:8765/mobileconfig
|
||||
Profile URL: http://192.168.1.16:8765/mobileconfig
|
||||
|
||||
██████████████████████████████
|
||||
██ ██
|
||||
██ [QR code rendered in ██
|
||||
██ your terminal] ██
|
||||
██ ██
|
||||
██████████████████████████████
|
||||
█▀▀▀▀▀▀▀█▀▀██ ██ ▀█▀▀▀▀▀▀▀█
|
||||
█ █▀▀▀█ █▀▄▀▀▀▀▄▄█ █▀▀▀█ █
|
||||
...
|
||||
|
||||
On your iPhone:
|
||||
1. Open Camera, point at the QR code, tap the yellow banner
|
||||
2. Allow the download when Safari asks
|
||||
3. Open Settings — tap "Profile Downloaded" near the top
|
||||
(or: Settings → General → VPN & Device Management → Numa DNS)
|
||||
4. Tap Install (top right), enter passcode, Install again
|
||||
5. Settings → General → About → Certificate Trust Settings
|
||||
3. Settings → "Profile Downloaded" → Install
|
||||
4. Settings → General → About → Certificate Trust Settings
|
||||
Toggle ON "Numa Local CA" — required for DoT to work
|
||||
```
|
||||
|
||||
The same QR is available in the dashboard — click "Phone Setup" in the header and the popover renders an SVG QR code pointing at the mobileconfig URL. On mobile viewports it shows a direct download link instead.
|
||||
|
||||
<img src="../phone-setup-dashboard.png" alt="Numa dashboard with Phone Setup popover showing QR code and install instructions">
|
||||
|
||||
Step 4 is non-negotiable. Even though the CA is bundled in the same profile that installs the DNS settings, iOS still requires the user to explicitly toggle trust in Certificate Trust Settings. It's a deliberate iOS policy to prevent profile-based trust injection — annoying, and correct.
|
||||
|
||||
I've been dogfooding this since v0.10 shipped in early April. The phone resolves through Numa over DoT whenever I'm home; persistent connections are visible in the log as a single source port living through dozens of queries. The one real caveat: if the laptop's LAN IP changes, the profile breaks. [RFC 9462 DDR](https://datatracker.ietf.org/doc/html/rfc9462) fixes that — Numa can respond to `_dns.resolver.arpa IN SVCB` with its current IP and iOS picks it up on each network join. Next piece of work.
|
||||
@@ -169,7 +160,7 @@ I've been dogfooding this since v0.10 shipped in early April. The phone resolves
|
||||
|
||||
## What's next
|
||||
|
||||
- ~~**DoH server**~~ — shipped in v0.12.0. `POST /dns-query` accepts [RFC 8484](https://datatracker.ietf.org/doc/html/rfc8484) wire-format queries, so Firefox/Chrome can point their built-in DoH at Numa.
|
||||
- **DoH server** — Numa already has a DoH client; the other half unlocks Firefox's built-in DoH setting pointing at Numa.
|
||||
- **DoQ server (RFC 9250)** — DNS over QUIC. Android 14+ supports it natively.
|
||||
- **DDR (RFC 9462)** — auto-discovery via `_dns.resolver.arpa IN SVCB`, so phones pick up a moved Numa instance without the installed profile going stale.
|
||||
|
||||
|
||||
26
numa.toml
26
numa.toml
@@ -12,18 +12,11 @@ api_port = 5380
|
||||
# [upstream]
|
||||
# mode = "forward" # "forward" (default) — relay to upstream
|
||||
# # "recursive" — resolve from root hints (no address needed)
|
||||
# address = "9.9.9.9" # single upstream (plain UDP)
|
||||
# address = ["192.168.1.1", "9.9.9.9:5353"] # multiple upstreams — SRTT picks fastest
|
||||
# address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted)
|
||||
# address = "tls://9.9.9.9#dns.quad9.net" # DNS-over-TLS (encrypted, port 853)
|
||||
# fallback = ["8.8.8.8", "1.1.1.1"] # tried only when all primaries fail
|
||||
# port = 53 # default port for addresses without :port
|
||||
# address = "https://cloudflare-dns.com/dns-query" # Cloudflare DoH
|
||||
# address = "9.9.9.9" # plain UDP
|
||||
# port = 53 # only for forward mode, plain UDP
|
||||
# timeout_ms = 3000
|
||||
# hedge_ms = 10 # request hedging delay (ms). After this delay
|
||||
# # without a response, fires a parallel request
|
||||
# # to the same upstream. Rescues packet loss (UDP),
|
||||
# # dispatch spikes (DoH), TLS stalls (DoT).
|
||||
# # Set to 0 to disable. Default: 10
|
||||
# root_hints = [ # only used in recursive mode
|
||||
# "198.41.0.4", # a.root-servers.net (Verisign)
|
||||
# "199.9.14.201", # b.root-servers.net (USC-ISI)
|
||||
@@ -51,14 +44,6 @@ api_port = 5380
|
||||
# "co", "br", "au", "ca", "jp", # other major ccTLDs
|
||||
# ]
|
||||
|
||||
# [[forwarding]] # per-suffix conditional forwarding rules
|
||||
# suffix = "168.192.in-addr.arpa" # single suffix → one upstream
|
||||
# upstream = "100.90.1.63:5361"
|
||||
#
|
||||
# [[forwarding]]
|
||||
# suffix = ["home.local", "home.arpa"] # multiple suffixes → same upstream
|
||||
# upstream = "10.0.0.1" # port 53 default
|
||||
|
||||
# [blocking]
|
||||
# enabled = true # set to false to disable ad blocking
|
||||
# refresh_hours = 24
|
||||
@@ -66,10 +51,9 @@ api_port = 5380
|
||||
# allowlist = ["example.com"] # domains to never block
|
||||
|
||||
[cache]
|
||||
max_entries = 100000
|
||||
max_entries = 10000
|
||||
min_ttl = 60
|
||||
max_ttl = 86400
|
||||
# warm = ["google.com", "github.com"] # resolve at startup, refresh before TTL expiry
|
||||
|
||||
[proxy]
|
||||
enabled = true
|
||||
@@ -107,7 +91,7 @@ tld = "numa"
|
||||
|
||||
# DNS-over-TLS listener (RFC 7858) — encrypted DNS on port 853
|
||||
# [dot]
|
||||
# enabled = true # on by default; set false to disable
|
||||
# enabled = false # opt-in: accept DoT queries
|
||||
# port = 853 # standard DoT port
|
||||
# bind_addr = "0.0.0.0" # IPv4 or IPv6; unspecified binds all interfaces
|
||||
# cert_path = "/etc/numa/dot.crt" # PEM cert; omit to use self-signed (proxy CA if available)
|
||||
|
||||
@@ -7,19 +7,18 @@
|
||||
# The script:
|
||||
# 1. Opens the dashboard in Chrome --app mode (clean, no address bar)
|
||||
# 2. Generates DNS traffic (forward, cache hit, blocked)
|
||||
# 3. Opens Phone Setup QR popover
|
||||
# 4. Types "peekm" / "6419" into the Local Services form on camera
|
||||
# 5. Shows LAN accessibility badge ("local only" / "LAN")
|
||||
# 6. Checks a blocked domain
|
||||
# 7. Opens peekm.numa to show the proxy working
|
||||
# 8. Records via ffmpeg and converts to optimized GIF
|
||||
# 3. Types "peekm" / "6419" into the Local Services form on camera
|
||||
# 4. Shows LAN accessibility badge ("local only" / "LAN")
|
||||
# 5. Checks a blocked domain
|
||||
# 6. Opens peekm.numa to show the proxy working
|
||||
# 7. Records via ffmpeg and converts to optimized GIF
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --------------- Configuration ---------------
|
||||
OUTPUT="${1:-assets/hero-demo.gif}"
|
||||
PORT=5380
|
||||
RECORD_SECONDS=24
|
||||
RECORD_SECONDS=20
|
||||
VIEWPORT_W=1800
|
||||
VIEWPORT_H=1100
|
||||
FPS=12
|
||||
@@ -231,16 +230,8 @@ dig @127.0.0.1 github.com +short > /dev/null 2>&1
|
||||
dig @127.0.0.1 ad.doubleclick.net +short > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
# --------------- Scene 2: Phone Setup popover (3-7s) ---------------
|
||||
log "Scene 2: Phone Setup QR popover..."
|
||||
run_js "document.querySelector('#phoneSetup button').click();"
|
||||
sleep 3
|
||||
# Dismiss popover
|
||||
run_js "document.getElementById('phoneSetupPopover').style.display = 'none';"
|
||||
sleep 1
|
||||
|
||||
# --------------- Scene 3: Add peekm service via UI (7-11s) ---------------
|
||||
log "Scene 3: Adding peekm.numa service..."
|
||||
# --------------- Scene 2: Add peekm service via UI (3-7s) ---------------
|
||||
log "Scene 2: Adding peekm.numa service..."
|
||||
|
||||
# Services panel is now first — scroll to it
|
||||
run_js "
|
||||
@@ -258,18 +249,18 @@ sleep 0.3
|
||||
run_js "document.querySelector('#serviceForm .btn-add').click();"
|
||||
sleep 2
|
||||
|
||||
# --------------- Scene 4: Open peekm.numa (11-15s) ---------------
|
||||
log "Scene 4: Opening peekm.numa in browser..."
|
||||
# --------------- Scene 3: Open peekm.numa (7-11s) ---------------
|
||||
log "Scene 3: Opening peekm.numa in browser..."
|
||||
open "http://peekm.numa/view/peekm/README.md" 2>/dev/null || true
|
||||
sleep 4
|
||||
|
||||
# --------------- Scene 5: Back to dashboard (15-18s) ---------------
|
||||
log "Scene 5: Back to dashboard — LAN badges + LOCAL queries visible..."
|
||||
# --------------- Scene 4: Back to dashboard (11-14s) ---------------
|
||||
log "Scene 4: Back to dashboard — LAN badges + LOCAL queries visible..."
|
||||
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $CHROME_PID) to true" 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
# --------------- Scene 6: Check Domain blocker (18-21s) ---------------
|
||||
log "Scene 6: Check Domain — blocked tracker..."
|
||||
# --------------- Scene 5: Check Domain blocker (14-17s) ---------------
|
||||
log "Scene 5: Check Domain — blocked tracker..."
|
||||
# Scroll down to blocking panel
|
||||
run_js "
|
||||
var blockPanel = document.getElementById('blockingPanel');
|
||||
@@ -282,8 +273,8 @@ sleep 0.3
|
||||
run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();"
|
||||
sleep 2
|
||||
|
||||
# --------------- Scene 7: Terminal-style dig overlay (21-24s) ---------------
|
||||
log "Scene 7: dig proof overlay..."
|
||||
# --------------- Scene 6: Terminal-style dig overlay (17-20s) ---------------
|
||||
log "Scene 6: dig proof overlay..."
|
||||
DIG_RESULT=$(dig @127.0.0.1 peekm.numa +short 2>/dev/null | head -1)
|
||||
run_js "
|
||||
var overlay = document.createElement('div');
|
||||
|
||||
@@ -298,5 +298,7 @@ $body$
|
||||
<a href="/blog/">Blog</a>
|
||||
</footer>
|
||||
|
||||
<script data-goatcounter="https://razvandimescu.goatcounter.com/count"
|
||||
async src="//gc.zgo.at/count.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -197,5 +197,7 @@ body::before {
|
||||
<a href="/">Home</a>
|
||||
</footer>
|
||||
|
||||
<script data-goatcounter="https://razvandimescu.goatcounter.com/count"
|
||||
async src="//gc.zgo.at/count.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 310 KiB |
@@ -223,10 +223,6 @@ body {
|
||||
.path-bar-fill.override { background: var(--emerald); }
|
||||
.path-bar-fill.error { background: var(--rose); }
|
||||
.path-bar-fill.blocked { background: var(--text-dim); }
|
||||
.path-bar-fill.udp { background: var(--text-dim); }
|
||||
.path-bar-fill.tcp { background: var(--violet); }
|
||||
.path-bar-fill.dot { background: var(--emerald); }
|
||||
.path-bar-fill.doh { background: var(--teal); }
|
||||
.path-pct {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
@@ -292,10 +288,6 @@ body {
|
||||
.path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); }
|
||||
.path-tag.BLOCKED { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); }
|
||||
.path-tag.COALESCED { background: rgba(138, 104, 158, 0.12); color: var(--violet-dim); }
|
||||
.path-tag.UDP { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); }
|
||||
.path-tag.TCP { background: rgba(100, 116, 139, 0.12); color: var(--violet-dim); }
|
||||
.path-tag.DOT { background: rgba(82, 122, 82, 0.12); color: var(--emerald); }
|
||||
.path-tag.DOH { background: rgba(107, 124, 78, 0.12); color: var(--teal); }
|
||||
.src-tag { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.02em; }
|
||||
|
||||
/* Sidebar panels */
|
||||
@@ -562,20 +554,6 @@ body {
|
||||
<div class="tagline">DNS that governs itself</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:1.2rem;">
|
||||
<div id="phoneSetup" style="position:relative;display:none;">
|
||||
<button class="btn" onclick="togglePhoneSetup()" style="background:var(--bg-surface);color:var(--text-secondary);font-family:var(--font-mono);font-size:0.7rem;padding:0.35rem 0.6rem;border:1px solid var(--border);" title="Set up phone">Phone Setup</button>
|
||||
<div id="phoneSetupPopover" style="display:none;position:absolute;top:calc(100% + 8px);right:0;z-index:100;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.2rem;width:260px;box-shadow:0 4px 20px rgba(0,0,0,0.08);">
|
||||
<div style="font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;color:var(--text-secondary);margin-bottom:0.8rem;">Phone Setup</div>
|
||||
<div id="qrContainer" style="display:flex;justify-content:center;margin-bottom:0.8rem;"></div>
|
||||
<div id="phoneSetupLink" style="display:none;text-align:center;margin-bottom:0.8rem;"></div>
|
||||
<div style="font-family:var(--font-mono);font-size:0.62rem;color:var(--text-dim);line-height:1.6;">
|
||||
1. Scan QR → allow download<br>
|
||||
2. Settings → Profile Downloaded → Install<br>
|
||||
3. Settings → General → About →<br>
|
||||
Certificate Trust Settings → toggle ON
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" id="pauseBtn" style="background:var(--amber);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;">Pause 5m</button>
|
||||
<button class="btn" id="toggleBtn" onclick="toggleBlocking()" style="background:var(--rose);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;"></button>
|
||||
<div class="status-badge">
|
||||
@@ -630,16 +608,6 @@ body {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transport breakdown -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Transport</span>
|
||||
<span class="panel-title" id="transportEncrypted" style="color: var(--text-dim)"></span>
|
||||
</div>
|
||||
<div class="panel-body" id="transportBars">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main grid: query log + sidebar -->
|
||||
<div class="main-grid">
|
||||
<!-- Query log -->
|
||||
@@ -661,14 +629,6 @@ body {
|
||||
<option value="LOCAL">local</option>
|
||||
<option value="SERVFAIL">error</option>
|
||||
</select>
|
||||
<select id="logFilterTransport" onchange="applyLogFilter()"
|
||||
style="font-family:var(--font-mono);font-size:0.7rem;padding:0.25rem 0.4rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-surface);color:var(--text-secondary);outline:none;">
|
||||
<option value="">all transports</option>
|
||||
<option value="UDP">UDP</option>
|
||||
<option value="TCP">TCP</option>
|
||||
<option value="DOT">DoT</option>
|
||||
<option value="DOH">DoH</option>
|
||||
</select>
|
||||
<span class="panel-title" id="queryCount" style="color: var(--text-dim)"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -680,7 +640,6 @@ body {
|
||||
<th>Type</th>
|
||||
<th>Domain</th>
|
||||
<th>Path</th>
|
||||
<th>Transport</th>
|
||||
<th>Result</th>
|
||||
<th>Latency</th>
|
||||
</tr>
|
||||
@@ -829,34 +788,6 @@ function formatTime(epoch) {
|
||||
return d.toLocaleTimeString([], { hour12: false });
|
||||
}
|
||||
|
||||
let mobilePort = 8765;
|
||||
function togglePhoneSetup() {
|
||||
const pop = document.getElementById('phoneSetupPopover');
|
||||
const isOpen = pop.style.display !== 'none';
|
||||
pop.style.display = isOpen ? 'none' : 'block';
|
||||
if (!isOpen) {
|
||||
if (window.innerWidth <= 700) {
|
||||
document.getElementById('qrContainer').style.display = 'none';
|
||||
const linkEl = document.getElementById('phoneSetupLink');
|
||||
const host = window.location.hostname;
|
||||
linkEl.style.display = 'block';
|
||||
linkEl.innerHTML = `<a href="http://${host}:${mobilePort}/mobileconfig" style="display:inline-block;padding:0.5rem 1rem;background:var(--amber);color:white;border-radius:6px;text-decoration:none;font-family:var(--font-mono);font-size:0.75rem;">Install Profile</a>`;
|
||||
} else {
|
||||
fetch(API + '/qr').then(r => r.text()).then(svg => {
|
||||
document.getElementById('qrContainer').innerHTML = svg;
|
||||
}).catch(() => {
|
||||
document.getElementById('qrContainer').innerHTML = '<div class="empty-state">Could not load QR</div>';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', (e) => {
|
||||
const setup = document.getElementById('phoneSetup');
|
||||
if (setup && !setup.contains(e.target)) {
|
||||
document.getElementById('phoneSetupPopover').style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
function shortSrc(addr) {
|
||||
if (!addr) return '';
|
||||
const ip = addr.replace(/:\d+$/, '');
|
||||
@@ -934,27 +865,6 @@ function renderMemory(mem, stats) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderBarChart(containerId, defs, data, total) {
|
||||
total = total || 1;
|
||||
document.getElementById(containerId).innerHTML = defs.map(d => {
|
||||
const count = data[d.key] || 0;
|
||||
const pct = ((count / total) * 100).toFixed(1);
|
||||
return `
|
||||
<div class="path-bar-row">
|
||||
<span class="path-label">${d.label}</span>
|
||||
<div class="path-bar-track">
|
||||
<div class="path-bar-fill ${d.cls}" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
<span class="path-pct">${pct}%</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function encryptionPct(transport) {
|
||||
const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1;
|
||||
return (((transport.dot + transport.doh) / total) * 100).toFixed(0);
|
||||
}
|
||||
|
||||
const PATH_DEFS = [
|
||||
{ key: 'forwarded', label: 'Forward', cls: 'forward' },
|
||||
{ key: 'recursive', label: 'Recursive', cls: 'recursive' },
|
||||
@@ -966,23 +876,20 @@ const PATH_DEFS = [
|
||||
];
|
||||
|
||||
function renderPaths(queries) {
|
||||
renderBarChart('pathBars', PATH_DEFS, queries, queries.total);
|
||||
}
|
||||
|
||||
const TRANSPORT_DEFS = [
|
||||
{ key: 'udp', label: 'UDP', cls: 'udp' },
|
||||
{ key: 'tcp', label: 'TCP', cls: 'tcp' },
|
||||
{ key: 'dot', label: 'DoT', cls: 'dot' },
|
||||
{ key: 'doh', label: 'DoH', cls: 'doh' },
|
||||
];
|
||||
|
||||
function renderTransport(transport) {
|
||||
const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1;
|
||||
renderBarChart('transportBars', TRANSPORT_DEFS, transport, total);
|
||||
const encPct = encryptionPct(transport);
|
||||
const el = document.getElementById('transportEncrypted');
|
||||
el.textContent = `${encPct}% encrypted`;
|
||||
el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)';
|
||||
const total = queries.total || 1;
|
||||
const container = document.getElementById('pathBars');
|
||||
container.innerHTML = PATH_DEFS.map(p => {
|
||||
const count = queries[p.key] || 0;
|
||||
const pct = ((count / total) * 100).toFixed(1);
|
||||
return `
|
||||
<div class="path-bar-row">
|
||||
<span class="path-label">${p.label}</span>
|
||||
<div class="path-bar-track">
|
||||
<div class="path-bar-fill ${p.cls}" style="width: ${pct}%"></div>
|
||||
</div>
|
||||
<span class="path-pct">${pct}%</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderQueryLog(entries) {
|
||||
@@ -993,7 +900,6 @@ function renderQueryLog(entries) {
|
||||
function applyLogFilter() {
|
||||
const domainFilter = document.getElementById('logFilterDomain').value.trim().toLowerCase();
|
||||
const pathFilter = document.getElementById('logFilterPath').value;
|
||||
const transportFilter = document.getElementById('logFilterTransport').value;
|
||||
|
||||
let filtered = lastLogEntries;
|
||||
if (domainFilter) {
|
||||
@@ -1002,9 +908,6 @@ function applyLogFilter() {
|
||||
if (pathFilter) {
|
||||
filtered = filtered.filter(e => e.path === pathFilter);
|
||||
}
|
||||
if (transportFilter) {
|
||||
filtered = filtered.filter(e => e.transport === transportFilter);
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('queryLogBody');
|
||||
document.getElementById('queryCount').textContent =
|
||||
@@ -1022,7 +925,6 @@ function applyLogFilter() {
|
||||
<td>${e.query_type}</td>
|
||||
<td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td>
|
||||
<td><span class="path-tag ${e.path}">${e.path}</span></td>
|
||||
<td><span class="path-tag ${e.transport}">${e.transport}</span></td>
|
||||
<td style="white-space:nowrap;"><span style="display:inline-block;width:15px;text-align:center;">${e.dnssec === 'secure' ? '<svg title="DNSSEC verified" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--emerald)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>' : ''}</span>${e.rescode}</td>
|
||||
<td>${e.latency_ms.toFixed(1)}ms</td>
|
||||
</tr>`;
|
||||
@@ -1156,14 +1058,6 @@ async function refresh() {
|
||||
}
|
||||
}
|
||||
|
||||
const phoneSetupEl = document.getElementById('phoneSetup');
|
||||
if (stats.mobile && stats.mobile.enabled) {
|
||||
phoneSetupEl.style.display = '';
|
||||
mobilePort = stats.mobile.port;
|
||||
} else {
|
||||
phoneSetupEl.style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('overrideCount').textContent = stats.overrides.active;
|
||||
document.getElementById('blockedCount').textContent = formatNumber(q.blocked);
|
||||
const bl = stats.blocking;
|
||||
@@ -1197,13 +1091,11 @@ async function refresh() {
|
||||
|
||||
// QPS calculation
|
||||
const now = Date.now();
|
||||
const encPct = encryptionPct(stats.transport);
|
||||
if (prevTotal !== null && prevTime !== null) {
|
||||
const dt = (now - prevTime) / 1000;
|
||||
const dq = q.total - prevTotal;
|
||||
const qps = dt > 0 ? (dq / dt).toFixed(1) : '0.0';
|
||||
const encTag = q.total > 0 ? ` · ${encPct}% enc` : '';
|
||||
document.getElementById('qps').textContent = `~${qps}/s${encTag}`;
|
||||
document.getElementById('qps').textContent = `~${qps}/s`;
|
||||
}
|
||||
prevTotal = q.total;
|
||||
prevTime = now;
|
||||
@@ -1215,7 +1107,6 @@ async function refresh() {
|
||||
|
||||
// Panels
|
||||
renderPaths(q);
|
||||
renderTransport(stats.transport);
|
||||
renderQueryLog(logs);
|
||||
renderOverrides(overrides);
|
||||
renderCache(cache);
|
||||
|
||||
@@ -1769,5 +1769,7 @@ const observer = new IntersectionObserver((entries) => {
|
||||
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
|
||||
</script>
|
||||
|
||||
<script data-goatcounter="https://razvandimescu.goatcounter.com/count"
|
||||
async src="//gc.zgo.at/count.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
73
src/api.rs
73
src/api.rs
@@ -57,7 +57,6 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
|
||||
.route("/services/{name}/routes", post(add_route))
|
||||
.route("/services/{name}/routes", delete(remove_route))
|
||||
.route("/ca.pem", get(serve_ca))
|
||||
.route("/qr", get(serve_qr))
|
||||
.route("/fonts/fonts.css", get(serve_fonts_css))
|
||||
.route(
|
||||
"/fonts/dm-sans-latin.woff2",
|
||||
@@ -152,7 +151,6 @@ struct QueryLogResponse {
|
||||
domain: String,
|
||||
query_type: String,
|
||||
path: String,
|
||||
transport: String,
|
||||
rescode: String,
|
||||
latency_ms: f64,
|
||||
dnssec: String,
|
||||
@@ -168,29 +166,13 @@ struct StatsResponse {
|
||||
dnssec: bool,
|
||||
srtt: bool,
|
||||
queries: QueriesStats,
|
||||
transport: TransportStats,
|
||||
cache: CacheStats,
|
||||
overrides: OverrideStats,
|
||||
blocking: BlockingStatsResponse,
|
||||
lan: LanStatsResponse,
|
||||
mobile: MobileStatsResponse,
|
||||
memory: MemoryStats,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TransportStats {
|
||||
udp: u64,
|
||||
tcp: u64,
|
||||
dot: u64,
|
||||
doh: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MobileStatsResponse {
|
||||
enabled: bool,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LanStatsResponse {
|
||||
enabled: bool,
|
||||
@@ -421,12 +403,9 @@ async fn diagnose(
|
||||
}
|
||||
|
||||
// Check upstream (async, no locks held)
|
||||
let upstream = ctx.upstream_pool.lock().unwrap().preferred().cloned();
|
||||
let (upstream_matched, upstream_detail) = if let Some(ref u) = upstream {
|
||||
forward_query_for_diagnose(&domain_lower, u, ctx.timeout).await
|
||||
} else {
|
||||
(false, "no upstream configured".to_string())
|
||||
};
|
||||
let upstream = ctx.upstream.lock().unwrap().clone();
|
||||
let (upstream_matched, upstream_detail) =
|
||||
forward_query_for_diagnose(&domain_lower, &upstream, ctx.timeout).await;
|
||||
steps.push(DiagnoseStep {
|
||||
source: "upstream".to_string(),
|
||||
matched: upstream_matched,
|
||||
@@ -493,7 +472,6 @@ async fn query_log(
|
||||
domain: e.domain.clone(),
|
||||
query_type: e.query_type.as_str().to_string(),
|
||||
path: e.path.as_str().to_string(),
|
||||
transport: e.transport.as_str().to_string(),
|
||||
rescode: e.rescode.as_str().to_string(),
|
||||
latency_ms: e.latency_us as f64 / 1000.0,
|
||||
dnssec: e.dnssec.as_str().to_string(),
|
||||
@@ -534,7 +512,7 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
||||
let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive {
|
||||
"recursive (root hints)".to_string()
|
||||
} else {
|
||||
ctx.upstream_pool.lock().unwrap().label()
|
||||
ctx.upstream.lock().unwrap().to_string()
|
||||
};
|
||||
|
||||
Json(StatsResponse {
|
||||
@@ -556,12 +534,6 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
||||
blocked: snap.blocked,
|
||||
errors: snap.errors,
|
||||
},
|
||||
transport: TransportStats {
|
||||
udp: snap.transport_udp,
|
||||
tcp: snap.transport_tcp,
|
||||
dot: snap.transport_dot,
|
||||
doh: snap.transport_doh,
|
||||
},
|
||||
cache: CacheStats {
|
||||
entries: cache_len,
|
||||
max_entries: cache_max,
|
||||
@@ -579,10 +551,6 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
||||
enabled: ctx.lan_enabled,
|
||||
peers: ctx.lan_peers.lock().unwrap().list().len(),
|
||||
},
|
||||
mobile: MobileStatsResponse {
|
||||
enabled: ctx.mobile_enabled,
|
||||
port: ctx.mobile_port,
|
||||
},
|
||||
memory: MemoryStats {
|
||||
cache_bytes,
|
||||
blocklist_bytes,
|
||||
@@ -963,28 +931,6 @@ pub async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResp
|
||||
))
|
||||
}
|
||||
|
||||
async fn serve_qr(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
|
||||
if !ctx.mobile_enabled {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
let lan_ip = *ctx.lan_ip.lock().unwrap();
|
||||
let url = format!("http://{}:{}/mobileconfig", lan_ip, ctx.mobile_port);
|
||||
let code = qrcode::QrCode::new(&url).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let svg = code
|
||||
.render::<qrcode::render::svg::Color>()
|
||||
.min_dimensions(180, 180)
|
||||
.dark_color(qrcode::render::svg::Color("#2c2418"))
|
||||
.light_color(qrcode::render::svg::Color("#faf7f2"))
|
||||
.build();
|
||||
Ok((
|
||||
[
|
||||
(header::CONTENT_TYPE, "image/svg+xml"),
|
||||
(header::CACHE_CONTROL, "no-store"),
|
||||
],
|
||||
svg,
|
||||
))
|
||||
}
|
||||
|
||||
async fn serve_fonts_css() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
@@ -1029,7 +975,6 @@ mod tests {
|
||||
socket,
|
||||
zone_map: std::collections::HashMap::new(),
|
||||
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
|
||||
refreshing: Mutex::new(std::collections::HashSet::new()),
|
||||
stats: Mutex::new(crate::stats::ServerStats::new()),
|
||||
overrides: RwLock::new(crate::override_store::OverrideStore::new()),
|
||||
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
|
||||
@@ -1037,17 +982,13 @@ mod tests {
|
||||
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
||||
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
||||
forwarding_rules: Vec::new(),
|
||||
upstream_pool: Mutex::new(crate::forward::UpstreamPool::new(
|
||||
vec![crate::forward::Upstream::Udp(
|
||||
"127.0.0.1:53".parse().unwrap(),
|
||||
)],
|
||||
vec![],
|
||||
upstream: Mutex::new(crate::forward::Upstream::Udp(
|
||||
"127.0.0.1:53".parse().unwrap(),
|
||||
)),
|
||||
upstream_auto: false,
|
||||
upstream_port: 53,
|
||||
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
|
||||
timeout: std::time::Duration::from_secs(3),
|
||||
hedge_delay: std::time::Duration::ZERO,
|
||||
proxy_tld: "numa".to_string(),
|
||||
proxy_tld_suffix: ".numa".to_string(),
|
||||
lan_enabled: false,
|
||||
@@ -1064,8 +1005,6 @@ mod tests {
|
||||
dnssec_strict: false,
|
||||
health_meta: crate::health::HealthMeta::test_fixture(),
|
||||
ca_pem: None,
|
||||
mobile_enabled: false,
|
||||
mobile_port: 8765,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
248
src/cache.rs
248
src/cache.rs
@@ -1,26 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::question::QueryType;
|
||||
use crate::wire::WireMeta;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Freshness {
|
||||
/// Within TTL, no action needed.
|
||||
Fresh,
|
||||
/// Within TTL but <10% remaining — trigger background prefetch.
|
||||
NearExpiry,
|
||||
/// Past TTL but within stale window — serve with TTL=1, trigger background refresh.
|
||||
Stale,
|
||||
}
|
||||
|
||||
impl Freshness {
|
||||
pub fn needs_refresh(self) -> bool {
|
||||
matches!(self, Freshness::NearExpiry | Freshness::Stale)
|
||||
}
|
||||
}
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum DnssecStatus {
|
||||
@@ -43,16 +26,14 @@ impl DnssecStatus {
|
||||
}
|
||||
|
||||
struct CacheEntry {
|
||||
wire: Vec<u8>,
|
||||
meta: WireMeta,
|
||||
packet: DnsPacket,
|
||||
inserted_at: Instant,
|
||||
ttl: Duration,
|
||||
dnssec_status: DnssecStatus,
|
||||
}
|
||||
|
||||
const STALE_WINDOW: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// DNS cache with serve-stale (RFC 8767). Stores raw wire bytes.
|
||||
/// 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, HashMap<QueryType, CacheEntry>>,
|
||||
entry_count: usize,
|
||||
@@ -72,60 +53,54 @@ impl DnsCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up cached wire bytes, patching ID and TTLs in the returned copy.
|
||||
/// Implements serve-stale (RFC 8767): expired entries within STALE_WINDOW
|
||||
/// are returned with TTL=1 and `stale=true` so callers can revalidate.
|
||||
pub fn lookup_wire(
|
||||
/// Read-only lookup — expired entries are left in place (cleaned up on insert).
|
||||
pub fn lookup(&self, domain: &str, qtype: QueryType) -> Option<DnsPacket> {
|
||||
self.lookup_with_status(domain, qtype).map(|(pkt, _)| pkt)
|
||||
}
|
||||
|
||||
pub fn lookup_with_status(
|
||||
&self,
|
||||
domain: &str,
|
||||
qtype: QueryType,
|
||||
new_id: u16,
|
||||
) -> Option<(Vec<u8>, DnssecStatus, Freshness)> {
|
||||
) -> Option<(DnsPacket, DnssecStatus)> {
|
||||
let type_map = self.entries.get(domain)?;
|
||||
let entry = type_map.get(&qtype)?;
|
||||
|
||||
let elapsed = entry.inserted_at.elapsed();
|
||||
let (remaining, freshness) = if elapsed < entry.ttl {
|
||||
let secs = (entry.ttl - elapsed).as_secs() as u32;
|
||||
let f = if elapsed * 10 >= entry.ttl * 9 {
|
||||
Freshness::NearExpiry
|
||||
} else {
|
||||
Freshness::Fresh
|
||||
};
|
||||
(secs.max(1), f)
|
||||
} else if elapsed < entry.ttl + STALE_WINDOW {
|
||||
(1, Freshness::Stale)
|
||||
} else {
|
||||
if elapsed >= entry.ttl {
|
||||
return None;
|
||||
};
|
||||
}
|
||||
|
||||
let mut wire = entry.wire.clone();
|
||||
crate::wire::patch_id(&mut wire, new_id);
|
||||
crate::wire::patch_ttls(&mut wire, &entry.meta.ttl_offsets, remaining);
|
||||
let remaining_secs = (entry.ttl - elapsed).as_secs() as u32;
|
||||
let remaining = remaining_secs.max(1);
|
||||
|
||||
Some((wire, entry.dnssec_status, freshness))
|
||||
let mut packet = entry.packet.clone();
|
||||
adjust_ttls(&mut packet.answers, remaining);
|
||||
adjust_ttls(&mut packet.authorities, remaining);
|
||||
adjust_ttls(&mut packet.resources, remaining);
|
||||
|
||||
Some((packet, entry.dnssec_status))
|
||||
}
|
||||
|
||||
pub fn insert_wire(
|
||||
pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) {
|
||||
self.insert_with_status(domain, qtype, packet, DnssecStatus::Indeterminate);
|
||||
}
|
||||
|
||||
pub fn insert_with_status(
|
||||
&mut self,
|
||||
domain: &str,
|
||||
qtype: QueryType,
|
||||
wire: &[u8],
|
||||
packet: &DnsPacket,
|
||||
dnssec_status: DnssecStatus,
|
||||
) {
|
||||
let meta = match crate::wire::scan_ttl_offsets(wire) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return, // malformed wire, skip
|
||||
};
|
||||
|
||||
if self.entry_count >= self.max_entries {
|
||||
self.evict_expired();
|
||||
if self.entry_count >= self.max_entries {
|
||||
self.evict_stalest();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let min_ttl = crate::wire::min_ttl_from_wire(wire, &meta)
|
||||
let min_ttl = extract_min_ttl(&packet.answers)
|
||||
.unwrap_or(self.min_ttl)
|
||||
.clamp(self.min_ttl, self.max_ttl);
|
||||
|
||||
@@ -142,8 +117,7 @@ impl DnsCache {
|
||||
type_map.insert(
|
||||
qtype,
|
||||
CacheEntry {
|
||||
wire: wire.to_vec(),
|
||||
meta,
|
||||
packet: packet.clone(),
|
||||
inserted_at: Instant::now(),
|
||||
ttl: Duration::from_secs(min_ttl as u64),
|
||||
dnssec_status,
|
||||
@@ -151,64 +125,6 @@ impl DnsCache {
|
||||
);
|
||||
}
|
||||
|
||||
/// Read-only lookup — expired entries are left in place (cleaned up on insert).
|
||||
pub fn lookup(&self, domain: &str, qtype: QueryType) -> Option<DnsPacket> {
|
||||
self.lookup_with_status(domain, qtype)
|
||||
.map(|(pkt, _, _)| pkt)
|
||||
}
|
||||
|
||||
pub fn lookup_with_status(
|
||||
&self,
|
||||
domain: &str,
|
||||
qtype: QueryType,
|
||||
) -> Option<(DnsPacket, DnssecStatus, Freshness)> {
|
||||
let (wire, status, freshness) = self.lookup_wire(domain, qtype, 0)?;
|
||||
let mut buf = BytePacketBuffer::from_bytes(&wire);
|
||||
let pkt = DnsPacket::from_buffer(&mut buf).ok()?;
|
||||
Some((pkt, status, freshness))
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) {
|
||||
self.insert_with_status(domain, qtype, packet, DnssecStatus::Indeterminate);
|
||||
}
|
||||
|
||||
pub fn insert_with_status(
|
||||
&mut self,
|
||||
domain: &str,
|
||||
qtype: QueryType,
|
||||
packet: &DnsPacket,
|
||||
dnssec_status: DnssecStatus,
|
||||
) {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
if packet.write(&mut buf).is_err() {
|
||||
return;
|
||||
}
|
||||
self.insert_wire(domain, qtype, buf.filled(), dnssec_status);
|
||||
}
|
||||
|
||||
pub fn ttl_remaining(&self, domain: &str, qtype: QueryType) -> Option<(u32, u32)> {
|
||||
let type_map = self.entries.get(domain)?;
|
||||
let entry = type_map.get(&qtype)?;
|
||||
let elapsed = entry.inserted_at.elapsed();
|
||||
if elapsed >= entry.ttl {
|
||||
return None;
|
||||
}
|
||||
let total = entry.ttl.as_secs() as u32;
|
||||
let remaining = (entry.ttl - elapsed).as_secs() as u32;
|
||||
Some((remaining, total))
|
||||
}
|
||||
|
||||
pub fn needs_warm(&self, domain: &str) -> bool {
|
||||
for qtype in [QueryType::A, QueryType::AAAA] {
|
||||
match self.ttl_remaining(domain, qtype) {
|
||||
None => return true,
|
||||
Some((remaining, total)) if remaining < total / 4 => return true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entry_count
|
||||
}
|
||||
@@ -240,8 +156,7 @@ impl DnsCache {
|
||||
+ 1;
|
||||
total += type_map.capacity() * inner_slot;
|
||||
for entry in type_map.values() {
|
||||
total += entry.wire.capacity()
|
||||
+ entry.meta.ttl_offsets.capacity() * std::mem::size_of::<usize>();
|
||||
total += entry.packet.heap_bytes();
|
||||
}
|
||||
}
|
||||
total
|
||||
@@ -282,34 +197,6 @@ impl DnsCache {
|
||||
});
|
||||
self.entry_count -= count;
|
||||
}
|
||||
|
||||
/// Evict the single entry closest to (or furthest past) expiry.
|
||||
fn evict_stalest(&mut self) {
|
||||
let mut worst: Option<(String, QueryType, Duration)> = None;
|
||||
for (domain, type_map) in &self.entries {
|
||||
for (qtype, entry) in type_map {
|
||||
let age = entry.inserted_at.elapsed();
|
||||
let remaining = entry.ttl.saturating_sub(age);
|
||||
match &worst {
|
||||
None => worst = Some((domain.clone(), *qtype, remaining)),
|
||||
Some((_, _, w)) if remaining < *w => {
|
||||
worst = Some((domain.clone(), *qtype, remaining));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((domain, qtype, _)) = worst {
|
||||
if let Some(type_map) = self.entries.get_mut(&domain) {
|
||||
if type_map.remove(&qtype).is_some() {
|
||||
self.entry_count -= 1;
|
||||
}
|
||||
if type_map.is_empty() {
|
||||
self.entries.remove(&domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CacheInfo {
|
||||
@@ -318,11 +205,20 @@ pub struct CacheInfo {
|
||||
pub ttl_remaining: u32,
|
||||
}
|
||||
|
||||
fn extract_min_ttl(records: &[DnsRecord]) -> Option<u32> {
|
||||
records.iter().map(|r| r.ttl()).min()
|
||||
}
|
||||
|
||||
fn adjust_ttls(records: &mut [DnsRecord], new_ttl: u32) {
|
||||
for record in records.iter_mut() {
|
||||
record.set_ttl(new_ttl);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
#[test]
|
||||
fn heap_bytes_grows_with_entries() {
|
||||
@@ -337,66 +233,4 @@ mod tests {
|
||||
cache.insert("example.com", QueryType::A, &pkt);
|
||||
assert!(cache.heap_bytes() > empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ttl_remaining_returns_values_for_fresh_entry() {
|
||||
let mut cache = DnsCache::new(100, 60, 3600);
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.answers.push(DnsRecord::A {
|
||||
domain: "example.com".into(),
|
||||
addr: "1.2.3.4".parse().unwrap(),
|
||||
ttl: 300,
|
||||
});
|
||||
cache.insert("example.com", QueryType::A, &pkt);
|
||||
let (remaining, total) = cache.ttl_remaining("example.com", QueryType::A).unwrap();
|
||||
assert_eq!(total, 300);
|
||||
assert!(remaining <= 300);
|
||||
assert!(remaining > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ttl_remaining_none_for_missing() {
|
||||
let cache = DnsCache::new(100, 1, 3600);
|
||||
assert!(cache.ttl_remaining("missing.com", QueryType::A).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_warm_true_when_missing() {
|
||||
let cache = DnsCache::new(100, 1, 3600);
|
||||
assert!(cache.needs_warm("missing.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_warm_false_when_fresh() {
|
||||
let mut cache = DnsCache::new(100, 1, 3600);
|
||||
let mut pkt_a = DnsPacket::new();
|
||||
pkt_a.answers.push(DnsRecord::A {
|
||||
domain: "example.com".into(),
|
||||
addr: "1.2.3.4".parse().unwrap(),
|
||||
ttl: 300,
|
||||
});
|
||||
let mut pkt_aaaa = DnsPacket::new();
|
||||
pkt_aaaa.answers.push(DnsRecord::AAAA {
|
||||
domain: "example.com".into(),
|
||||
addr: "::1".parse().unwrap(),
|
||||
ttl: 300,
|
||||
});
|
||||
cache.insert("example.com", QueryType::A, &pkt_a);
|
||||
cache.insert("example.com", QueryType::AAAA, &pkt_aaaa);
|
||||
assert!(!cache.needs_warm("example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_warm_true_when_only_a_cached() {
|
||||
let mut cache = DnsCache::new(100, 1, 3600);
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.answers.push(DnsRecord::A {
|
||||
domain: "example.com".into(),
|
||||
addr: "1.2.3.4".parse().unwrap(),
|
||||
ttl: 300,
|
||||
});
|
||||
cache.insert("example.com", QueryType::A, &pkt);
|
||||
// AAAA missing → needs warm
|
||||
assert!(cache.needs_warm("example.com"));
|
||||
}
|
||||
}
|
||||
|
||||
281
src/config.rs
281
src/config.rs
@@ -33,39 +33,6 @@ pub struct Config {
|
||||
pub dot: DotConfig,
|
||||
#[serde(default)]
|
||||
pub mobile: MobileConfig,
|
||||
#[serde(default)]
|
||||
pub forwarding: Vec<ForwardingRuleConfig>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct ForwardingRuleConfig {
|
||||
#[serde(deserialize_with = "string_or_vec")]
|
||||
pub suffix: Vec<String>,
|
||||
pub upstream: String,
|
||||
}
|
||||
|
||||
impl ForwardingRuleConfig {
|
||||
fn to_runtime_rules(&self) -> Result<Vec<crate::system_dns::ForwardingRule>> {
|
||||
let addr = crate::forward::parse_upstream_addr(&self.upstream, 53)
|
||||
.map_err(|e| format!("forwarding rule for upstream '{}': {}", self.upstream, e))?;
|
||||
Ok(self
|
||||
.suffix
|
||||
.iter()
|
||||
.map(|s| crate::system_dns::ForwardingRule::new(s.clone(), addr))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge_forwarding_rules(
|
||||
config_rules: &[ForwardingRuleConfig],
|
||||
discovered: Vec<crate::system_dns::ForwardingRule>,
|
||||
) -> Result<Vec<crate::system_dns::ForwardingRule>> {
|
||||
let mut merged: Vec<crate::system_dns::ForwardingRule> = Vec::new();
|
||||
for rule in config_rules {
|
||||
merged.extend(rule.to_runtime_rules()?);
|
||||
}
|
||||
merged.extend(discovered);
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -130,16 +97,12 @@ impl UpstreamMode {
|
||||
pub struct UpstreamConfig {
|
||||
#[serde(default)]
|
||||
pub mode: UpstreamMode,
|
||||
#[serde(default, deserialize_with = "string_or_vec")]
|
||||
pub address: Vec<String>,
|
||||
#[serde(default = "default_upstream_addr")]
|
||||
pub address: String,
|
||||
#[serde(default = "default_upstream_port")]
|
||||
pub port: u16,
|
||||
#[serde(default)]
|
||||
pub fallback: Vec<String>,
|
||||
#[serde(default = "default_timeout_ms")]
|
||||
pub timeout_ms: u64,
|
||||
#[serde(default = "default_hedge_ms")]
|
||||
pub hedge_ms: u64,
|
||||
#[serde(default = "default_root_hints")]
|
||||
pub root_hints: Vec<String>,
|
||||
#[serde(default = "default_prime_tlds")]
|
||||
@@ -152,11 +115,9 @@ impl Default for UpstreamConfig {
|
||||
fn default() -> Self {
|
||||
UpstreamConfig {
|
||||
mode: UpstreamMode::default(),
|
||||
address: Vec::new(),
|
||||
address: default_upstream_addr(),
|
||||
port: default_upstream_port(),
|
||||
fallback: Vec::new(),
|
||||
timeout_ms: default_timeout_ms(),
|
||||
hedge_ms: default_hedge_ms(),
|
||||
root_hints: default_root_hints(),
|
||||
prime_tlds: default_prime_tlds(),
|
||||
srtt: default_srtt(),
|
||||
@@ -164,33 +125,6 @@ impl Default for UpstreamConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn string_or_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
struct Visitor;
|
||||
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||
type Value = Vec<String>;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("string or array of strings")
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
|
||||
Ok(vec![v.to_string()])
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(
|
||||
self,
|
||||
mut seq: A,
|
||||
) -> std::result::Result<Self::Value, A::Error> {
|
||||
let mut v = Vec::new();
|
||||
while let Some(s) = seq.next_element::<String>()? {
|
||||
v.push(s);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_any(Visitor)
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -268,15 +202,15 @@ fn default_root_hints() -> Vec<String> {
|
||||
]
|
||||
}
|
||||
|
||||
fn default_upstream_addr() -> String {
|
||||
String::new() // empty = auto-detect from system resolver
|
||||
}
|
||||
fn default_upstream_port() -> u16 {
|
||||
53
|
||||
}
|
||||
fn default_timeout_ms() -> u64 {
|
||||
5000
|
||||
}
|
||||
fn default_hedge_ms() -> u64 {
|
||||
10
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CacheConfig {
|
||||
@@ -286,8 +220,6 @@ pub struct CacheConfig {
|
||||
pub min_ttl: u32,
|
||||
#[serde(default = "default_max_ttl")]
|
||||
pub max_ttl: u32,
|
||||
#[serde(default)]
|
||||
pub warm: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for CacheConfig {
|
||||
@@ -296,13 +228,12 @@ impl Default for CacheConfig {
|
||||
max_entries: default_max_entries(),
|
||||
min_ttl: default_min_ttl(),
|
||||
max_ttl: default_max_ttl(),
|
||||
warm: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_max_entries() -> usize {
|
||||
100_000
|
||||
10000
|
||||
}
|
||||
fn default_min_ttl() -> u32 {
|
||||
60
|
||||
@@ -450,7 +381,7 @@ pub struct DnssecConfig {
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct DotConfig {
|
||||
#[serde(default = "default_dot_enabled")]
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_dot_port")]
|
||||
pub port: u16,
|
||||
@@ -467,7 +398,7 @@ pub struct DotConfig {
|
||||
impl Default for DotConfig {
|
||||
fn default() -> Self {
|
||||
DotConfig {
|
||||
enabled: default_dot_enabled(),
|
||||
enabled: false,
|
||||
port: default_dot_port(),
|
||||
bind_addr: default_dot_bind_addr(),
|
||||
cert_path: None,
|
||||
@@ -476,9 +407,6 @@ impl Default for DotConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_dot_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_dot_port() -> u16 {
|
||||
853
|
||||
}
|
||||
@@ -597,184 +525,6 @@ mod tests {
|
||||
assert!(config.services[0].routes[0].strip);
|
||||
assert!(!config.services[0].routes[1].strip); // default false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_string_parses_to_vec() {
|
||||
let config: Config = toml::from_str("[upstream]\naddress = \"1.2.3.4\"").unwrap();
|
||||
assert_eq!(config.upstream.address, vec!["1.2.3.4"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_array_parses() {
|
||||
let config: Config =
|
||||
toml::from_str("[upstream]\naddress = [\"1.2.3.4\", \"5.6.7.8:5353\"]").unwrap();
|
||||
assert_eq!(config.upstream.address, vec!["1.2.3.4", "5.6.7.8:5353"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_parses() {
|
||||
let config: Config =
|
||||
toml::from_str("[upstream]\nfallback = [\"8.8.8.8\", \"1.1.1.1\"]").unwrap();
|
||||
assert_eq!(config.upstream.fallback, vec!["8.8.8.8", "1.1.1.1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_address_gives_empty_vec() {
|
||||
let config: Config = toml::from_str("").unwrap();
|
||||
assert!(config.upstream.address.is_empty());
|
||||
assert!(config.upstream.fallback.is_empty());
|
||||
}
|
||||
|
||||
// ── issue #82: [[forwarding]] config section ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn forwarding_empty_by_default() {
|
||||
let config: Config = toml::from_str("").unwrap();
|
||||
assert!(config.forwarding.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_parses_single_rule() {
|
||||
let toml = r#"
|
||||
[[forwarding]]
|
||||
suffix = "home.local"
|
||||
upstream = "100.90.1.63:5361"
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.forwarding.len(), 1);
|
||||
assert_eq!(config.forwarding[0].suffix, &["home.local"]);
|
||||
assert_eq!(config.forwarding[0].upstream, "100.90.1.63:5361");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_parses_reverse_dns_zone() {
|
||||
let toml = r#"
|
||||
[[forwarding]]
|
||||
suffix = "168.192.in-addr.arpa"
|
||||
upstream = "100.90.1.63:5361"
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.forwarding.len(), 1);
|
||||
assert_eq!(config.forwarding[0].suffix, &["168.192.in-addr.arpa"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_parses_multiple_rules() {
|
||||
let toml = r#"
|
||||
[[forwarding]]
|
||||
suffix = "168.192.in-addr.arpa"
|
||||
upstream = "100.90.1.63:5361"
|
||||
|
||||
[[forwarding]]
|
||||
suffix = "home.local"
|
||||
upstream = "10.0.0.1"
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.forwarding.len(), 2);
|
||||
assert_eq!(config.forwarding[1].upstream, "10.0.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_parses_suffix_array() {
|
||||
let toml = r#"
|
||||
[[forwarding]]
|
||||
suffix = ["168.192.in-addr.arpa", "onsite"]
|
||||
upstream = "192.168.88.1"
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(config.forwarding.len(), 1);
|
||||
assert_eq!(
|
||||
config.forwarding[0].suffix,
|
||||
&["168.192.in-addr.arpa", "onsite"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_suffix_array_expands_to_multiple_runtime_rules() {
|
||||
let rule = ForwardingRuleConfig {
|
||||
suffix: vec!["168.192.in-addr.arpa".to_string(), "onsite".to_string()],
|
||||
upstream: "192.168.88.1".to_string(),
|
||||
};
|
||||
let runtime = rule.to_runtime_rules().unwrap();
|
||||
assert_eq!(runtime.len(), 2);
|
||||
assert_eq!(runtime[0].suffix, "168.192.in-addr.arpa");
|
||||
assert_eq!(runtime[1].suffix, "onsite");
|
||||
assert_eq!(runtime[0].upstream, runtime[1].upstream);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_upstream_with_explicit_port() {
|
||||
let rule = ForwardingRuleConfig {
|
||||
suffix: vec!["home.local".to_string()],
|
||||
upstream: "100.90.1.63:5361".to_string(),
|
||||
};
|
||||
let runtime = rule.to_runtime_rules().unwrap();
|
||||
assert_eq!(runtime.len(), 1);
|
||||
assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:5361");
|
||||
assert_eq!(runtime[0].suffix, "home.local");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_upstream_defaults_to_port_53() {
|
||||
let rule = ForwardingRuleConfig {
|
||||
suffix: vec!["home.local".to_string()],
|
||||
upstream: "100.90.1.63".to_string(),
|
||||
};
|
||||
let runtime = rule.to_runtime_rules().unwrap();
|
||||
assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:53");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_invalid_upstream_returns_error() {
|
||||
let rule = ForwardingRuleConfig {
|
||||
suffix: vec!["home.local".to_string()],
|
||||
upstream: "not-a-valid-host".to_string(),
|
||||
};
|
||||
assert!(rule.to_runtime_rules().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_config_rules_take_precedence_over_discovered() {
|
||||
let config_rules = vec![ForwardingRuleConfig {
|
||||
suffix: vec!["home.local".to_string()],
|
||||
upstream: "10.0.0.1:53".to_string(),
|
||||
}];
|
||||
let discovered = vec![crate::system_dns::ForwardingRule::new(
|
||||
"home.local".to_string(),
|
||||
"192.168.1.1:53".parse().unwrap(),
|
||||
)];
|
||||
let merged = merge_forwarding_rules(&config_rules, discovered).unwrap();
|
||||
let picked = crate::system_dns::match_forwarding_rule("host.home.local", &merged)
|
||||
.expect("rule should match");
|
||||
assert_eq!(picked.to_string(), "10.0.0.1:53");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_merge_preserves_non_overlapping_discovered() {
|
||||
let config_rules = vec![ForwardingRuleConfig {
|
||||
suffix: vec!["home.local".to_string()],
|
||||
upstream: "10.0.0.1:53".to_string(),
|
||||
}];
|
||||
let discovered = vec![crate::system_dns::ForwardingRule::new(
|
||||
"corp.example".to_string(),
|
||||
"192.168.1.1:53".parse().unwrap(),
|
||||
)];
|
||||
let merged = merge_forwarding_rules(&config_rules, discovered).unwrap();
|
||||
assert_eq!(merged.len(), 2);
|
||||
let picked = crate::system_dns::match_forwarding_rule("host.corp.example", &merged)
|
||||
.expect("discovered rule should still match");
|
||||
assert_eq!(picked.to_string(), "192.168.1.1:53");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forwarding_merge_suffix_array_expands_to_multiple_rules() {
|
||||
let config_rules = vec![ForwardingRuleConfig {
|
||||
suffix: vec!["a.local".to_string(), "b.local".to_string()],
|
||||
upstream: "10.0.0.1:53".to_string(),
|
||||
}];
|
||||
let merged = merge_forwarding_rules(&config_rules, vec![]).unwrap();
|
||||
assert_eq!(merged.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ConfigLoad {
|
||||
@@ -802,13 +552,6 @@ pub fn load_config(path: &str) -> Result<ConfigLoad> {
|
||||
let filename = p.file_name().unwrap_or(p.as_os_str());
|
||||
v.push(crate::config_dir().join(filename));
|
||||
v.push(crate::data_dir().join(filename));
|
||||
// Interactive root and sudo'd users: always consult the XDG path
|
||||
// so `touch ~/.config/numa/numa.toml` works regardless of whether
|
||||
// config_dir() routed to FHS (issue #81).
|
||||
let suggested = crate::suggested_config_path();
|
||||
if !v.contains(&suggested) {
|
||||
v.push(suggested);
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
@@ -829,7 +572,11 @@ pub fn load_config(path: &str) -> Result<ConfigLoad> {
|
||||
}
|
||||
}
|
||||
|
||||
let display_path = crate::suggested_config_path().to_string_lossy().to_string();
|
||||
// Show config_dir candidate as the "expected" path — it's actionable
|
||||
let display_path = candidates
|
||||
.get(1)
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| resolve_path(path));
|
||||
log::info!("config not found, using defaults (create {})", display_path);
|
||||
Ok(ConfigLoad {
|
||||
config: Config::default(),
|
||||
|
||||
154
src/ctx.rs
154
src/ctx.rs
@@ -1,7 +1,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::sync::{Mutex, RwLock};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
@@ -16,7 +16,7 @@ use crate::blocklist::BlocklistStore;
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::cache::{DnsCache, DnssecStatus};
|
||||
use crate::config::{UpstreamMode, ZoneMap};
|
||||
use crate::forward::{forward_query_raw, forward_with_failover_raw, Upstream, UpstreamPool};
|
||||
use crate::forward::{forward_query, Upstream};
|
||||
use crate::header::ResultCode;
|
||||
use crate::health::HealthMeta;
|
||||
use crate::lan::PeerStore;
|
||||
@@ -27,7 +27,7 @@ use crate::question::QueryType;
|
||||
use crate::record::DnsRecord;
|
||||
use crate::service_store::ServiceStore;
|
||||
use crate::srtt::SrttCache;
|
||||
use crate::stats::{QueryPath, ServerStats, Transport};
|
||||
use crate::stats::{QueryPath, ServerStats};
|
||||
use crate::system_dns::ForwardingRule;
|
||||
|
||||
pub struct ServerCtx {
|
||||
@@ -35,8 +35,6 @@ pub struct ServerCtx {
|
||||
pub zone_map: ZoneMap,
|
||||
/// std::sync::RwLock (not tokio) — locks must never be held across .await points.
|
||||
pub cache: RwLock<DnsCache>,
|
||||
/// Domains currently being refreshed in the background (dedup guard).
|
||||
pub refreshing: Mutex<HashSet<(String, QueryType)>>,
|
||||
pub stats: Mutex<ServerStats>,
|
||||
pub overrides: RwLock<OverrideStore>,
|
||||
pub blocklist: RwLock<BlocklistStore>,
|
||||
@@ -44,12 +42,11 @@ pub struct ServerCtx {
|
||||
pub services: Mutex<ServiceStore>,
|
||||
pub lan_peers: Mutex<PeerStore>,
|
||||
pub forwarding_rules: Vec<ForwardingRule>,
|
||||
pub upstream_pool: Mutex<UpstreamPool>,
|
||||
pub upstream: Mutex<Upstream>,
|
||||
pub upstream_auto: bool,
|
||||
pub upstream_port: u16,
|
||||
pub lan_ip: Mutex<std::net::Ipv4Addr>,
|
||||
pub timeout: Duration,
|
||||
pub hedge_delay: Duration,
|
||||
pub proxy_tld: String,
|
||||
pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation
|
||||
pub lan_enabled: bool,
|
||||
@@ -73,8 +70,6 @@ pub struct ServerCtx {
|
||||
/// Used by `/ca.pem`, `/mobileconfig`, and `/ca.mobileconfig`
|
||||
/// handlers to avoid per-request disk I/O on the hot path.
|
||||
pub ca_pem: Option<String>,
|
||||
pub mobile_enabled: bool,
|
||||
pub mobile_port: u16,
|
||||
}
|
||||
|
||||
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,
|
||||
@@ -84,10 +79,8 @@ pub struct ServerCtx {
|
||||
/// (and logging parse errors) before calling this function.
|
||||
pub async fn resolve_query(
|
||||
query: DnsPacket,
|
||||
raw_wire: &[u8],
|
||||
src_addr: SocketAddr,
|
||||
ctx: &Arc<ServerCtx>,
|
||||
transport: Transport,
|
||||
ctx: &ServerCtx,
|
||||
) -> crate::Result<BytePacketBuffer> {
|
||||
let start = Instant::now();
|
||||
|
||||
@@ -115,10 +108,6 @@ pub async fn resolve_query(
|
||||
300,
|
||||
));
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} 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, DnssecStatus::Indeterminate)
|
||||
} else if is_special_use_domain(&qname) {
|
||||
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
|
||||
let resp = special_use_response(&query, &qname, qtype);
|
||||
@@ -167,20 +156,13 @@ pub async fn resolve_query(
|
||||
60,
|
||||
));
|
||||
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
|
||||
} 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, DnssecStatus::Indeterminate)
|
||||
} else {
|
||||
let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype);
|
||||
if let Some((cached, cached_dnssec, freshness)) = cached {
|
||||
if freshness.needs_refresh() {
|
||||
let key = (qname.clone(), qtype);
|
||||
let already = !ctx.refreshing.lock().unwrap().insert(key.clone());
|
||||
if !already {
|
||||
let ctx = Arc::clone(ctx);
|
||||
tokio::spawn(async move {
|
||||
refresh_entry(&ctx, &key.0, key.1).await;
|
||||
ctx.refreshing.lock().unwrap().remove(&key);
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some((cached, cached_dnssec)) = cached {
|
||||
let mut resp = cached;
|
||||
resp.header.id = query.header.id;
|
||||
if cached_dnssec == DnssecStatus::Secure {
|
||||
@@ -193,8 +175,11 @@ pub async fn resolve_query(
|
||||
// Conditional forwarding takes priority over recursive mode
|
||||
// (e.g. Tailscale .ts.net, VPC private zones)
|
||||
let upstream = Upstream::Udp(fwd_addr);
|
||||
match forward_and_cache(raw_wire, &upstream, ctx, &qname, qtype).await {
|
||||
Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate),
|
||||
match forward_query(&query, &upstream, ctx.timeout).await {
|
||||
Ok(resp) => {
|
||||
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
|
||||
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate)
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"{} | {:?} {} | FORWARD ERROR | {}",
|
||||
@@ -233,27 +218,16 @@ pub async fn resolve_query(
|
||||
}
|
||||
(resp, path, DnssecStatus::Indeterminate)
|
||||
} else {
|
||||
let pool = ctx.upstream_pool.lock().unwrap().clone();
|
||||
match forward_with_failover_raw(
|
||||
raw_wire,
|
||||
&pool,
|
||||
&ctx.srtt,
|
||||
ctx.timeout,
|
||||
ctx.hedge_delay,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp_wire) => match cache_and_parse(ctx, &qname, qtype, &resp_wire) {
|
||||
Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate),
|
||||
Err(e) => {
|
||||
error!("{} | {:?} {} | PARSE ERROR | {}", src_addr, qtype, qname, e);
|
||||
(
|
||||
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
|
||||
QueryPath::UpstreamError,
|
||||
DnssecStatus::Indeterminate,
|
||||
)
|
||||
}
|
||||
},
|
||||
let upstream =
|
||||
match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) {
|
||||
Some(addr) => Upstream::Udp(addr),
|
||||
None => ctx.upstream.lock().unwrap().clone(),
|
||||
};
|
||||
match forward_query(&query, &upstream, ctx.timeout).await {
|
||||
Ok(resp) => {
|
||||
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
|
||||
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate)
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"{} | {:?} {} | UPSTREAM ERROR | {}",
|
||||
@@ -355,7 +329,7 @@ pub async fn resolve_query(
|
||||
// Record stats and query log
|
||||
{
|
||||
let mut s = ctx.stats.lock().unwrap();
|
||||
let total = s.record(path, transport);
|
||||
let total = s.record(path);
|
||||
if total.is_multiple_of(1000) {
|
||||
s.log_summary();
|
||||
}
|
||||
@@ -367,7 +341,6 @@ pub async fn resolve_query(
|
||||
domain: qname,
|
||||
query_type: qtype,
|
||||
path,
|
||||
transport,
|
||||
rescode: response.header.rescode,
|
||||
latency_us: elapsed.as_micros() as u64,
|
||||
dnssec,
|
||||
@@ -376,78 +349,11 @@ pub async fn resolve_query(
|
||||
Ok(resp_buffer)
|
||||
}
|
||||
|
||||
fn cache_and_parse(
|
||||
ctx: &ServerCtx,
|
||||
qname: &str,
|
||||
qtype: QueryType,
|
||||
resp_wire: &[u8],
|
||||
) -> crate::Result<DnsPacket> {
|
||||
ctx.cache
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert_wire(qname, qtype, resp_wire, DnssecStatus::Indeterminate);
|
||||
let mut buf = BytePacketBuffer::from_bytes(resp_wire);
|
||||
DnsPacket::from_buffer(&mut buf)
|
||||
}
|
||||
|
||||
/// Re-resolve a single (domain, qtype) and update the cache.
|
||||
/// Used for both stale-entry refresh and proactive cache warming.
|
||||
pub async fn refresh_entry(ctx: &ServerCtx, qname: &str, qtype: QueryType) {
|
||||
let query = DnsPacket::query(0, qname, qtype);
|
||||
if ctx.upstream_mode == UpstreamMode::Recursive {
|
||||
if let Ok(resp) = crate::recursive::resolve_recursive(
|
||||
qname,
|
||||
qtype,
|
||||
&ctx.cache,
|
||||
&query,
|
||||
&ctx.root_hints,
|
||||
&ctx.srtt,
|
||||
)
|
||||
.await
|
||||
{
|
||||
ctx.cache.write().unwrap().insert(qname, qtype, &resp);
|
||||
}
|
||||
} else {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
if query.write(&mut buf).is_ok() {
|
||||
let pool = ctx.upstream_pool.lock().unwrap().clone();
|
||||
if let Ok(wire) = forward_with_failover_raw(
|
||||
buf.filled(),
|
||||
&pool,
|
||||
&ctx.srtt,
|
||||
ctx.timeout,
|
||||
ctx.hedge_delay,
|
||||
)
|
||||
.await
|
||||
{
|
||||
ctx.cache.write().unwrap().insert_wire(
|
||||
qname,
|
||||
qtype,
|
||||
&wire,
|
||||
DnssecStatus::Indeterminate,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn forward_and_cache(
|
||||
wire: &[u8],
|
||||
upstream: &Upstream,
|
||||
ctx: &ServerCtx,
|
||||
qname: &str,
|
||||
qtype: QueryType,
|
||||
) -> crate::Result<DnsPacket> {
|
||||
let resp_wire = forward_query_raw(wire, upstream, ctx.timeout).await?;
|
||||
cache_and_parse(ctx, qname, qtype, &resp_wire)
|
||||
}
|
||||
|
||||
/// Handle a DNS query received over UDP. Thin wrapper around resolve_query.
|
||||
pub async fn handle_query(
|
||||
mut buffer: BytePacketBuffer,
|
||||
raw_len: usize,
|
||||
src_addr: SocketAddr,
|
||||
ctx: &Arc<ServerCtx>,
|
||||
transport: Transport,
|
||||
ctx: &ServerCtx,
|
||||
) -> crate::Result<()> {
|
||||
let query = match DnsPacket::from_buffer(&mut buffer) {
|
||||
Ok(packet) => packet,
|
||||
@@ -456,7 +362,7 @@ pub async fn handle_query(
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
match resolve_query(query, &buffer.buf[..raw_len], src_addr, ctx, transport).await {
|
||||
match resolve_query(query, src_addr, ctx).await {
|
||||
Ok(resp_buffer) => {
|
||||
ctx.socket.send_to(resp_buffer.filled(), src_addr).await?;
|
||||
}
|
||||
|
||||
224
src/doh.rs
224
src/doh.rs
@@ -1,224 +0,0 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use hyper::StatusCode;
|
||||
use log::warn;
|
||||
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::ctx::{resolve_query, ServerCtx};
|
||||
use crate::header::ResultCode;
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::stats::Transport;
|
||||
|
||||
const MAX_DNS_MSG: usize = 4096;
|
||||
const DOH_CONTENT_TYPE: &str = "application/dns-message";
|
||||
|
||||
pub async fn doh_post(State(state): State<super::proxy::DohState>, req: Request) -> Response {
|
||||
let host = super::proxy::extract_host(&req);
|
||||
if !is_doh_host(host.as_deref(), &state.ctx.proxy_tld) {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
let content_type = req
|
||||
.headers()
|
||||
.get(hyper::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if !content_type.starts_with(DOH_CONTENT_TYPE) {
|
||||
return StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response();
|
||||
}
|
||||
|
||||
let body = match axum::body::to_bytes(req.into_body(), MAX_DNS_MSG).await {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
return (StatusCode::PAYLOAD_TOO_LARGE, "body exceeds 4096 bytes").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if body.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, "empty body").into_response();
|
||||
}
|
||||
|
||||
let src = state
|
||||
.remote_addr
|
||||
.unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 0)));
|
||||
|
||||
resolve_doh(&body, src, &state.ctx).await
|
||||
}
|
||||
|
||||
fn is_doh_host(host: Option<&str>, tld: &str) -> bool {
|
||||
let h = match host {
|
||||
Some(h) => h,
|
||||
None => return false,
|
||||
};
|
||||
let base = strip_port(h).unwrap_or(h);
|
||||
is_loopback_host(base) || is_tld_match(base, tld)
|
||||
}
|
||||
|
||||
fn strip_port(h: &str) -> Option<&str> {
|
||||
if h.starts_with('[') {
|
||||
// [::1]:443 → [::1]
|
||||
let (base, port) = h.rsplit_once("]:")?;
|
||||
port.bytes()
|
||||
.all(|b| b.is_ascii_digit())
|
||||
.then(|| &h[..base.len() + 1])
|
||||
} else {
|
||||
let (base, port) = h.rsplit_once(':')?;
|
||||
// Bare IPv6 like "::1" has multiple colons — not a port suffix
|
||||
if base.contains(':') {
|
||||
return None;
|
||||
}
|
||||
port.bytes().all(|b| b.is_ascii_digit()).then_some(base)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_loopback_host(h: &str) -> bool {
|
||||
matches!(h, "127.0.0.1" | "::1" | "[::1]" | "localhost")
|
||||
}
|
||||
|
||||
fn is_tld_match(h: &str, tld: &str) -> bool {
|
||||
h == tld
|
||||
|| (h.len() == 2 * tld.len() + 1
|
||||
&& h.starts_with(tld)
|
||||
&& h.as_bytes().get(tld.len()) == Some(&b'.')
|
||||
&& h.ends_with(tld))
|
||||
}
|
||||
|
||||
async fn resolve_doh(
|
||||
dns_bytes: &[u8],
|
||||
src: SocketAddr,
|
||||
ctx: &std::sync::Arc<ServerCtx>,
|
||||
) -> Response {
|
||||
let mut buffer = BytePacketBuffer::from_bytes(dns_bytes);
|
||||
let query = match DnsPacket::from_buffer(&mut buffer) {
|
||||
Ok(q) => q,
|
||||
Err(e) => {
|
||||
warn!("DoH: parse error from {}: {}", src, e);
|
||||
let query_id = u16::from_be_bytes([
|
||||
dns_bytes.first().copied().unwrap_or(0),
|
||||
dns_bytes.get(1).copied().unwrap_or(0),
|
||||
]);
|
||||
let mut resp = DnsPacket::new();
|
||||
resp.header.id = query_id;
|
||||
resp.header.response = true;
|
||||
resp.header.rescode = ResultCode::FORMERR;
|
||||
return serialize_response(&resp);
|
||||
}
|
||||
};
|
||||
|
||||
let query_id = query.header.id;
|
||||
let query_rd = query.header.recursion_desired;
|
||||
let questions = query.questions.clone();
|
||||
|
||||
match resolve_query(query, dns_bytes, src, ctx, Transport::Doh).await {
|
||||
Ok(resp_buffer) => {
|
||||
let min_ttl = extract_min_ttl(resp_buffer.filled());
|
||||
dns_response(resp_buffer.filled(), min_ttl)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("DoH: resolve error for {}: {}", src, e);
|
||||
let mut resp = DnsPacket::new();
|
||||
resp.header.id = query_id;
|
||||
resp.header.response = true;
|
||||
resp.header.recursion_desired = query_rd;
|
||||
resp.header.recursion_available = true;
|
||||
resp.header.rescode = ResultCode::SERVFAIL;
|
||||
resp.questions = questions;
|
||||
serialize_response(&resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_min_ttl(wire: &[u8]) -> u32 {
|
||||
crate::wire::scan_ttl_offsets(wire)
|
||||
.ok()
|
||||
.and_then(|meta| crate::wire::min_ttl_from_wire(wire, &meta))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn dns_response(wire: &[u8], min_ttl: u32) -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(hyper::header::CONTENT_TYPE, DOH_CONTENT_TYPE),
|
||||
(
|
||||
hyper::header::CACHE_CONTROL,
|
||||
&format!("max-age={}", min_ttl),
|
||||
),
|
||||
],
|
||||
Bytes::copy_from_slice(wire),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn serialize_response(pkt: &DnsPacket) -> Response {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
match pkt.write(&mut buf) {
|
||||
Ok(_) => dns_response(buf.filled(), 0),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::header::ResultCode;
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
#[test]
|
||||
fn is_doh_host_matches_tld() {
|
||||
assert!(is_doh_host(Some("numa"), "numa"));
|
||||
assert!(is_doh_host(Some("numa.numa"), "numa"));
|
||||
assert!(is_doh_host(Some("127.0.0.1"), "numa"));
|
||||
assert!(is_doh_host(Some("127.0.0.1:443"), "numa"));
|
||||
assert!(is_doh_host(Some("::1"), "numa"));
|
||||
assert!(is_doh_host(Some("[::1]"), "numa"));
|
||||
assert!(is_doh_host(Some("[::1]:443"), "numa"));
|
||||
assert!(is_doh_host(Some("localhost"), "numa"));
|
||||
assert!(is_doh_host(Some("localhost:443"), "numa"));
|
||||
assert!(!is_doh_host(Some("foo.numa"), "numa"));
|
||||
assert!(!is_doh_host(None, "numa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_min_ttl_from_response() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.response = true;
|
||||
pkt.answers.push(DnsRecord::A {
|
||||
domain: "example.com".to_string(),
|
||||
addr: std::net::Ipv4Addr::new(1, 2, 3, 4),
|
||||
ttl: 300,
|
||||
});
|
||||
pkt.answers.push(DnsRecord::A {
|
||||
domain: "example.com".to_string(),
|
||||
addr: std::net::Ipv4Addr::new(5, 6, 7, 8),
|
||||
ttl: 60,
|
||||
});
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
pkt.write(&mut buf).unwrap();
|
||||
assert_eq!(extract_min_ttl(buf.filled()), 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_min_ttl_no_answers() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.response = true;
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
pkt.write(&mut buf).unwrap();
|
||||
assert_eq!(extract_min_ttl(buf.filled()), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_formerr_response() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = 0xABCD;
|
||||
pkt.header.response = true;
|
||||
pkt.header.rescode = ResultCode::FORMERR;
|
||||
let resp = serialize_response(&pkt);
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
29
src/dot.rs
29
src/dot.rs
@@ -15,7 +15,6 @@ use crate::config::DotConfig;
|
||||
use crate::ctx::{resolve_query, ServerCtx};
|
||||
use crate::header::ResultCode;
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::stats::Transport;
|
||||
|
||||
const MAX_CONNECTIONS: usize = 512;
|
||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
@@ -154,11 +153,8 @@ async fn accept_loop(listener: TcpListener, acceptor: TlsAcceptor, ctx: Arc<Serv
|
||||
|
||||
/// Handle a single persistent DoT connection (RFC 7858).
|
||||
/// Reads length-prefixed DNS queries until EOF, idle timeout, or error.
|
||||
async fn handle_dot_connection<S>(
|
||||
mut stream: S,
|
||||
remote_addr: SocketAddr,
|
||||
ctx: &std::sync::Arc<ServerCtx>,
|
||||
) where
|
||||
async fn handle_dot_connection<S>(mut stream: S, remote_addr: SocketAddr, ctx: &ServerCtx)
|
||||
where
|
||||
S: AsyncReadExt + AsyncWriteExt + Unpin,
|
||||
{
|
||||
loop {
|
||||
@@ -181,6 +177,8 @@ async fn handle_dot_connection<S>(
|
||||
break;
|
||||
};
|
||||
|
||||
// Parse query up-front so we can echo its question section in SERVFAIL
|
||||
// responses when resolve_query fails.
|
||||
let query = match DnsPacket::from_buffer(&mut buffer) {
|
||||
Ok(q) => q,
|
||||
Err(e) => {
|
||||
@@ -202,15 +200,7 @@ async fn handle_dot_connection<S>(
|
||||
}
|
||||
};
|
||||
|
||||
match resolve_query(
|
||||
query.clone(),
|
||||
&buffer.buf[..msg_len],
|
||||
remote_addr,
|
||||
ctx,
|
||||
Transport::Dot,
|
||||
)
|
||||
.await
|
||||
{
|
||||
match resolve_query(query.clone(), remote_addr, ctx).await {
|
||||
Ok(resp_buffer) => {
|
||||
if write_framed(&mut stream, resp_buffer.filled())
|
||||
.await
|
||||
@@ -365,7 +355,6 @@ mod tests {
|
||||
m
|
||||
},
|
||||
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
|
||||
refreshing: Mutex::new(std::collections::HashSet::new()),
|
||||
stats: Mutex::new(crate::stats::ServerStats::new()),
|
||||
overrides: RwLock::new(crate::override_store::OverrideStore::new()),
|
||||
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
|
||||
@@ -373,15 +362,11 @@ mod tests {
|
||||
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
||||
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
||||
forwarding_rules: Vec::new(),
|
||||
upstream_pool: Mutex::new(crate::forward::UpstreamPool::new(
|
||||
vec![crate::forward::Upstream::Udp(upstream_addr)],
|
||||
vec![],
|
||||
)),
|
||||
upstream: Mutex::new(crate::forward::Upstream::Udp(upstream_addr)),
|
||||
upstream_auto: false,
|
||||
upstream_port: 53,
|
||||
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
|
||||
timeout: Duration::from_millis(200),
|
||||
hedge_delay: Duration::ZERO,
|
||||
proxy_tld: "numa".to_string(),
|
||||
proxy_tld_suffix: ".numa".to_string(),
|
||||
lan_enabled: false,
|
||||
@@ -398,8 +383,6 @@ mod tests {
|
||||
dnssec_strict: false,
|
||||
health_meta: crate::health::HealthMeta::test_fixture(),
|
||||
ca_pem: None,
|
||||
mobile_enabled: false,
|
||||
mobile_port: 8765,
|
||||
});
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
|
||||
489
src/forward.rs
489
src/forward.rs
@@ -1,14 +1,12 @@
|
||||
use std::fmt;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::RwLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::srtt::SrttCache;
|
||||
use crate::Result;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -18,11 +16,6 @@ pub enum Upstream {
|
||||
url: String,
|
||||
client: reqwest::Client,
|
||||
},
|
||||
Dot {
|
||||
addr: SocketAddr,
|
||||
tls_name: Option<String>,
|
||||
connector: tokio_rustls::TlsConnector,
|
||||
},
|
||||
}
|
||||
|
||||
impl PartialEq for Upstream {
|
||||
@@ -30,7 +23,6 @@ impl PartialEq for Upstream {
|
||||
match (self, other) {
|
||||
(Self::Udp(a), Self::Udp(b)) => a == b,
|
||||
(Self::Doh { url: a, .. }, Self::Doh { url: b, .. }) => a == b,
|
||||
(Self::Dot { addr: a, .. }, Self::Dot { addr: b, .. }) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -41,118 +33,6 @@ impl fmt::Display for Upstream {
|
||||
match self {
|
||||
Upstream::Udp(addr) => write!(f, "{}", addr),
|
||||
Upstream::Doh { url, .. } => f.write_str(url),
|
||||
Upstream::Dot { addr, tls_name, .. } => match tls_name {
|
||||
Some(name) => write!(f, "tls://{}#{}", addr, name),
|
||||
None => write!(f, "tls://{}", addr),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_upstream_addr(s: &str, default_port: u16) -> std::result::Result<SocketAddr, String> {
|
||||
// Try full socket addr first: "1.2.3.4:5353" or "[::1]:5353"
|
||||
if let Ok(addr) = s.parse::<SocketAddr>() {
|
||||
return Ok(addr);
|
||||
}
|
||||
// Bare IP: "1.2.3.4" or "::1"
|
||||
if let Ok(ip) = s.parse::<IpAddr>() {
|
||||
return Ok(SocketAddr::new(ip, default_port));
|
||||
}
|
||||
Err(format!("invalid upstream address: {}", s))
|
||||
}
|
||||
|
||||
pub fn parse_upstream(s: &str, default_port: u16) -> Result<Upstream> {
|
||||
if s.starts_with("https://") {
|
||||
let client = reqwest::Client::builder()
|
||||
.use_rustls_tls()
|
||||
.http2_initial_stream_window_size(65_535)
|
||||
.http2_initial_connection_window_size(65_535)
|
||||
.http2_keep_alive_interval(Duration::from_secs(15))
|
||||
.http2_keep_alive_while_idle(true)
|
||||
.http2_keep_alive_timeout(Duration::from_secs(10))
|
||||
.pool_idle_timeout(Duration::from_secs(300))
|
||||
.pool_max_idle_per_host(1)
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
return Ok(Upstream::Doh {
|
||||
url: s.to_string(),
|
||||
client,
|
||||
});
|
||||
}
|
||||
// tls://IP:PORT#hostname or tls://IP#hostname (default port 853)
|
||||
if let Some(rest) = s.strip_prefix("tls://") {
|
||||
let (addr_part, tls_name) = match rest.find('#') {
|
||||
Some(i) => (&rest[..i], Some(rest[i + 1..].to_string())),
|
||||
None => (rest, None),
|
||||
};
|
||||
let addr = parse_upstream_addr(addr_part, 853)?;
|
||||
let connector = build_dot_connector()?;
|
||||
return Ok(Upstream::Dot {
|
||||
addr,
|
||||
tls_name,
|
||||
connector,
|
||||
});
|
||||
}
|
||||
let addr = parse_upstream_addr(s, default_port)?;
|
||||
Ok(Upstream::Udp(addr))
|
||||
}
|
||||
|
||||
fn build_dot_connector() -> Result<tokio_rustls::TlsConnector> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
Ok(tokio_rustls::TlsConnector::from(std::sync::Arc::new(
|
||||
config,
|
||||
)))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UpstreamPool {
|
||||
primary: Vec<Upstream>,
|
||||
fallback: Vec<Upstream>,
|
||||
}
|
||||
|
||||
impl UpstreamPool {
|
||||
pub fn new(primary: Vec<Upstream>, fallback: Vec<Upstream>) -> Self {
|
||||
Self { primary, fallback }
|
||||
}
|
||||
|
||||
pub fn preferred(&self) -> Option<&Upstream> {
|
||||
self.primary.first().or(self.fallback.first())
|
||||
}
|
||||
|
||||
pub fn set_primary(&mut self, primary: Vec<Upstream>) {
|
||||
self.primary = primary;
|
||||
}
|
||||
|
||||
/// Update the primary upstream if `new_addr` (parsed with `port`) differs
|
||||
/// from the current preferred upstream. Returns `true` if the pool changed.
|
||||
pub fn maybe_update_primary(&mut self, new_addr: &str, port: u16) -> bool {
|
||||
let Ok(new_sock) = format!("{}:{}", new_addr, port).parse::<SocketAddr>() else {
|
||||
return false;
|
||||
};
|
||||
let new_upstream = Upstream::Udp(new_sock);
|
||||
if self.preferred() == Some(&new_upstream) {
|
||||
return false;
|
||||
}
|
||||
self.primary = vec![new_upstream];
|
||||
true
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
match self.preferred() {
|
||||
Some(u) => {
|
||||
let total = self.primary.len() + self.fallback.len();
|
||||
if total > 1 {
|
||||
format!("{} (+{} more)", u, total - 1)
|
||||
} else {
|
||||
u.to_string()
|
||||
}
|
||||
}
|
||||
None => "none".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,11 +42,10 @@ pub async fn forward_query(
|
||||
upstream: &Upstream,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<DnsPacket> {
|
||||
let mut send_buffer = BytePacketBuffer::new();
|
||||
query.write(&mut send_buffer)?;
|
||||
let data = forward_query_raw(send_buffer.filled(), upstream, timeout_duration).await?;
|
||||
let mut recv_buffer = BytePacketBuffer::from_bytes(&data);
|
||||
DnsPacket::from_buffer(&mut recv_buffer)
|
||||
match upstream {
|
||||
Upstream::Udp(addr) => forward_udp(query, *addr, timeout_duration).await,
|
||||
Upstream::Doh { url, client } => forward_doh(query, url, client, timeout_duration).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn forward_udp(
|
||||
@@ -174,10 +53,24 @@ pub(crate) async fn forward_udp(
|
||||
upstream: SocketAddr,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<DnsPacket> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
|
||||
let mut send_buffer = BytePacketBuffer::new();
|
||||
query.write(&mut send_buffer)?;
|
||||
let data = forward_udp_raw(send_buffer.filled(), upstream, timeout_duration).await?;
|
||||
let mut recv_buffer = BytePacketBuffer::from_bytes(&data);
|
||||
|
||||
socket.send_to(send_buffer.filled(), upstream).await?;
|
||||
|
||||
let mut recv_buffer = BytePacketBuffer::new();
|
||||
let (size, _) = timeout(timeout_duration, socket.recv_from(&mut recv_buffer.buf)).await??;
|
||||
|
||||
if size == recv_buffer.buf.len() {
|
||||
log::debug!(
|
||||
"upstream response truncated ({} bytes, buffer {})",
|
||||
size,
|
||||
recv_buffer.buf.len()
|
||||
);
|
||||
}
|
||||
|
||||
DnsPacket::from_buffer(&mut recv_buffer)
|
||||
}
|
||||
|
||||
@@ -214,209 +107,22 @@ pub(crate) async fn forward_tcp(
|
||||
DnsPacket::from_buffer(&mut recv_buffer)
|
||||
}
|
||||
|
||||
async fn forward_dot_raw(
|
||||
wire: &[u8],
|
||||
addr: SocketAddr,
|
||||
tls_name: &Option<String>,
|
||||
connector: &tokio_rustls::TlsConnector,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<Vec<u8>> {
|
||||
use rustls::pki_types::ServerName;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
let server_name = match tls_name {
|
||||
Some(name) => ServerName::try_from(name.clone())?,
|
||||
None => ServerName::try_from(addr.ip().to_string())?,
|
||||
};
|
||||
|
||||
let tcp = timeout(timeout_duration, TcpStream::connect(addr)).await??;
|
||||
let mut tls = timeout(timeout_duration, connector.connect(server_name, tcp)).await??;
|
||||
|
||||
let mut outbuf = Vec::with_capacity(2 + wire.len());
|
||||
outbuf.extend_from_slice(&(wire.len() as u16).to_be_bytes());
|
||||
outbuf.extend_from_slice(wire);
|
||||
timeout(timeout_duration, tls.write_all(&outbuf)).await??;
|
||||
|
||||
let mut len_buf = [0u8; 2];
|
||||
timeout(timeout_duration, tls.read_exact(&mut len_buf)).await??;
|
||||
let resp_len = u16::from_be_bytes(len_buf) as usize;
|
||||
|
||||
let mut data = vec![0u8; resp_len];
|
||||
timeout(timeout_duration, tls.read_exact(&mut data)).await??;
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub async fn forward_query_raw(
|
||||
wire: &[u8],
|
||||
upstream: &Upstream,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<Vec<u8>> {
|
||||
match upstream {
|
||||
Upstream::Udp(addr) => forward_udp_raw(wire, *addr, timeout_duration).await,
|
||||
Upstream::Doh { url, client } => forward_doh_raw(wire, url, client, timeout_duration).await,
|
||||
Upstream::Dot {
|
||||
addr,
|
||||
tls_name,
|
||||
connector,
|
||||
} => forward_dot_raw(wire, *addr, tls_name, connector, timeout_duration).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn forward_with_hedging_raw(
|
||||
wire: &[u8],
|
||||
primary: &Upstream,
|
||||
secondary: &Upstream,
|
||||
hedge_delay: Duration,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<Vec<u8>> {
|
||||
use tokio::time::sleep;
|
||||
|
||||
let primary_fut = forward_query_raw(wire, primary, timeout_duration);
|
||||
tokio::pin!(primary_fut);
|
||||
|
||||
let delay = sleep(hedge_delay);
|
||||
tokio::pin!(delay);
|
||||
|
||||
// Phase 1: wait for either primary to return, or the hedge delay.
|
||||
tokio::select! {
|
||||
result = &mut primary_fut => return result,
|
||||
_ = &mut delay => {}
|
||||
}
|
||||
|
||||
// Phase 2: hedge delay expired — fire secondary while still polling primary.
|
||||
let secondary_fut = forward_query_raw(wire, secondary, timeout_duration);
|
||||
tokio::pin!(secondary_fut);
|
||||
|
||||
// First successful response wins. If one errors, wait for the other.
|
||||
let mut primary_err: Option<crate::Error> = None;
|
||||
let mut secondary_err: Option<crate::Error> = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
r = &mut primary_fut, if primary_err.is_none() => {
|
||||
match r {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(e) => {
|
||||
if let Some(se) = secondary_err.take() {
|
||||
return Err(se);
|
||||
}
|
||||
primary_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
r = &mut secondary_fut, if secondary_err.is_none() => {
|
||||
match r {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(e) => {
|
||||
if let Some(pe) = primary_err.take() {
|
||||
return Err(pe);
|
||||
}
|
||||
secondary_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (primary_err, secondary_err) {
|
||||
(Some(pe), Some(_)) => return Err(pe),
|
||||
(pe, se) => {
|
||||
primary_err = pe;
|
||||
secondary_err = se;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn forward_with_failover_raw(
|
||||
wire: &[u8],
|
||||
pool: &UpstreamPool,
|
||||
srtt: &RwLock<SrttCache>,
|
||||
timeout_duration: Duration,
|
||||
hedge_delay: Duration,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut candidates: Vec<(usize, u64)> = pool
|
||||
.primary
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, u)| {
|
||||
let rtt = match u {
|
||||
Upstream::Udp(addr) => srtt.read().unwrap().get(addr.ip()),
|
||||
_ => 0,
|
||||
};
|
||||
(i, rtt)
|
||||
})
|
||||
.collect();
|
||||
candidates.sort_by_key(|&(_, rtt)| rtt);
|
||||
|
||||
let all_upstreams: Vec<&Upstream> = candidates
|
||||
.iter()
|
||||
.map(|&(i, _)| &pool.primary[i])
|
||||
.chain(pool.fallback.iter())
|
||||
.collect();
|
||||
|
||||
let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
|
||||
|
||||
for upstream in &all_upstreams {
|
||||
let start = Instant::now();
|
||||
let result = if !hedge_delay.is_zero() {
|
||||
// Hedge against the same upstream: independent h2 streams (DoH),
|
||||
// independent UDP packets (plain DNS), or independent TLS
|
||||
// connections (DoT). Rescues packet loss, dispatch spikes, and
|
||||
// TLS handshake stalls.
|
||||
forward_with_hedging_raw(wire, upstream, upstream, hedge_delay, timeout_duration).await
|
||||
} else {
|
||||
forward_query_raw(wire, upstream, timeout_duration).await
|
||||
};
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
if let Upstream::Udp(addr) = upstream {
|
||||
let rtt_ms = start.elapsed().as_millis() as u64;
|
||||
srtt.write().unwrap().record_rtt(addr.ip(), rtt_ms, false);
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
Err(e) => {
|
||||
if let Upstream::Udp(addr) = upstream {
|
||||
srtt.write().unwrap().record_failure(addr.ip());
|
||||
}
|
||||
log::debug!("upstream {} failed: {}", upstream, e);
|
||||
last_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_err.unwrap_or_else(|| "no upstream configured".into()))
|
||||
}
|
||||
|
||||
async fn forward_udp_raw(
|
||||
wire: &[u8],
|
||||
upstream: SocketAddr,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<Vec<u8>> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
socket.send_to(wire, upstream).await?;
|
||||
|
||||
let mut recv_buf = vec![0u8; 4096];
|
||||
let (size, _) = timeout(timeout_duration, socket.recv_from(&mut recv_buf)).await??;
|
||||
recv_buf.truncate(size);
|
||||
Ok(recv_buf)
|
||||
}
|
||||
|
||||
async fn forward_doh_raw(
|
||||
wire: &[u8],
|
||||
async fn forward_doh(
|
||||
query: &DnsPacket,
|
||||
url: &str,
|
||||
client: &reqwest::Client,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<Vec<u8>> {
|
||||
) -> Result<DnsPacket> {
|
||||
let mut send_buffer = BytePacketBuffer::new();
|
||||
query.write(&mut send_buffer)?;
|
||||
|
||||
let resp = timeout(
|
||||
timeout_duration,
|
||||
client
|
||||
.post(url)
|
||||
.header("content-type", "application/dns-message")
|
||||
.header("accept", "application/dns-message")
|
||||
.body(wire.to_vec())
|
||||
.body(send_buffer.filled().to_vec())
|
||||
.send(),
|
||||
)
|
||||
.await??
|
||||
@@ -424,25 +130,9 @@ async fn forward_doh_raw(
|
||||
|
||||
let bytes = resp.bytes().await?;
|
||||
log::debug!("DoH response: {} bytes", bytes.len());
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
/// Send a lightweight keepalive query to a DoH upstream to prevent
|
||||
/// the HTTP/2 + TLS connection from going idle and being torn down.
|
||||
pub async fn keepalive_doh(upstream: &Upstream) {
|
||||
if let Upstream::Doh { url, client } = upstream {
|
||||
// Query for . NS — minimal, always succeeds, response is small
|
||||
let wire: &[u8] = &[
|
||||
0x00, 0x00, // ID
|
||||
0x01, 0x00, // flags: RD=1
|
||||
0x00, 0x01, // QDCOUNT=1
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // AN=0, NS=0, AR=0
|
||||
0x00, // root name (.)
|
||||
0x00, 0x02, // type NS
|
||||
0x00, 0x01, // class IN
|
||||
];
|
||||
let _ = forward_doh_raw(wire, url, client, Duration::from_secs(5)).await;
|
||||
}
|
||||
let mut recv_buffer = BytePacketBuffer::from_bytes(&bytes);
|
||||
DnsPacket::from_buffer(&mut recv_buffer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -581,121 +271,4 @@ mod tests {
|
||||
let result = forward_query(&make_query(), &upstream, Duration::from_millis(100)).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_addr_ip_only() {
|
||||
let addr = parse_upstream_addr("1.2.3.4", 53).unwrap();
|
||||
assert_eq!(addr, "1.2.3.4:53".parse::<SocketAddr>().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_addr_ip_port() {
|
||||
let addr = parse_upstream_addr("1.2.3.4:5353", 53).unwrap();
|
||||
assert_eq!(addr, "1.2.3.4:5353".parse::<SocketAddr>().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_addr_ipv6_bracketed() {
|
||||
let addr = parse_upstream_addr("[::1]:5553", 53).unwrap();
|
||||
assert_eq!(addr, "[::1]:5553".parse::<SocketAddr>().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_addr_ipv6_bare() {
|
||||
let addr = parse_upstream_addr("::1", 53).unwrap();
|
||||
assert_eq!(addr, "[::1]:53".parse::<SocketAddr>().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_label_single() {
|
||||
let pool = UpstreamPool::new(vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())], vec![]);
|
||||
assert_eq!(pool.label(), "1.2.3.4:53");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_label_multi() {
|
||||
let pool = UpstreamPool::new(
|
||||
vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())],
|
||||
vec![Upstream::Udp("8.8.8.8:53".parse().unwrap())],
|
||||
);
|
||||
assert_eq!(pool.label(), "1.2.3.4:53 (+1 more)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failover_tries_next_on_failure() {
|
||||
// First upstream is unreachable, second responds
|
||||
let query = make_query();
|
||||
let response_bytes = to_wire(&make_response(&query));
|
||||
|
||||
let app = axum::Router::new().route(
|
||||
"/dns-query",
|
||||
axum::routing::post(move || {
|
||||
let body = response_bytes.clone();
|
||||
async move {
|
||||
(
|
||||
[(axum::http::header::CONTENT_TYPE, "application/dns-message")],
|
||||
body,
|
||||
)
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let good_addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(axum::serve(listener, app).into_future());
|
||||
|
||||
// Unreachable UDP upstream + working DoH upstream
|
||||
let pool = UpstreamPool::new(
|
||||
vec![
|
||||
Upstream::Udp("127.0.0.1:1".parse().unwrap()), // will fail
|
||||
Upstream::Doh {
|
||||
url: format!("http://{}/dns-query", good_addr),
|
||||
client: reqwest::Client::new(),
|
||||
},
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
|
||||
let srtt = RwLock::new(SrttCache::new(true));
|
||||
let wire = to_wire(&query);
|
||||
let resp_wire = forward_with_failover_raw(
|
||||
&wire,
|
||||
&pool,
|
||||
&srtt,
|
||||
Duration::from_millis(500),
|
||||
Duration::ZERO,
|
||||
)
|
||||
.await
|
||||
.expect("should fail over to second upstream");
|
||||
|
||||
let mut buf = BytePacketBuffer::from_bytes(&resp_wire);
|
||||
let result = DnsPacket::from_buffer(&mut buf).unwrap();
|
||||
assert_eq!(result.header.id, 0xABCD);
|
||||
assert_eq!(result.answers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_update_primary_swaps_when_different() {
|
||||
let mut pool = UpstreamPool::new(
|
||||
vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())],
|
||||
vec![Upstream::Udp("8.8.8.8:53".parse().unwrap())],
|
||||
);
|
||||
assert!(pool.maybe_update_primary("5.6.7.8", 53));
|
||||
assert_eq!(pool.preferred().unwrap().to_string(), "5.6.7.8:53");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_update_primary_noop_when_same() {
|
||||
let mut pool =
|
||||
UpstreamPool::new(vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())], vec![]);
|
||||
assert!(!pool.maybe_update_primary("1.2.3.4", 53));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_update_primary_rejects_invalid_addr() {
|
||||
let mut pool =
|
||||
UpstreamPool::new(vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())], vec![]);
|
||||
assert!(!pool.maybe_update_primary("not-an-ip", 53));
|
||||
assert_eq!(pool.preferred().unwrap().to_string(), "1.2.3.4:53");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,15 +73,11 @@ impl HealthMeta {
|
||||
recursive_enabled: bool,
|
||||
mdns_enabled: bool,
|
||||
blocking_enabled: bool,
|
||||
doh_enabled: bool,
|
||||
) -> Self {
|
||||
let ca_path = data_dir.join("ca.pem");
|
||||
let ca_fingerprint_sha256 = compute_ca_fingerprint(&ca_path);
|
||||
|
||||
let mut features = Vec::new();
|
||||
if doh_enabled {
|
||||
features.push("doh".to_string());
|
||||
}
|
||||
if dot_enabled {
|
||||
features.push("dot".to_string());
|
||||
}
|
||||
|
||||
107
src/lib.rs
107
src/lib.rs
@@ -5,7 +5,6 @@ pub mod cache;
|
||||
pub mod config;
|
||||
pub mod ctx;
|
||||
pub mod dnssec;
|
||||
pub mod doh;
|
||||
pub mod dot;
|
||||
pub mod forward;
|
||||
pub mod header;
|
||||
@@ -26,7 +25,6 @@ pub mod srtt;
|
||||
pub mod stats;
|
||||
pub mod system_dns;
|
||||
pub mod tls;
|
||||
pub mod wire;
|
||||
|
||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -45,42 +43,6 @@ pub fn hostname() -> String {
|
||||
.unwrap_or_else(|| "numa".to_string())
|
||||
}
|
||||
|
||||
/// Path to suggest to an interactive user when asking them to create
|
||||
/// `numa.toml`. Prefers `$HOME/.config/numa/numa.toml` when HOME is set
|
||||
/// (actionable without sudo); falls back to `config_dir()` otherwise.
|
||||
///
|
||||
/// Note: `config_dir()` routes interactive root to FHS (`/var/lib/numa`)
|
||||
/// so that runtime state like `services.json` stays continuous with the
|
||||
/// installed daemon. This helper exists specifically to give advisories
|
||||
/// and `load_config` an XDG-aware path for user-authored config, without
|
||||
/// moving runtime state out of FHS — see issue #81.
|
||||
pub(crate) fn suggested_config_path() -> std::path::PathBuf {
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
resolve_suggested_config_path(std::env::var("HOME").ok().as_deref(), config_dir)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
config_dir().join("numa.toml")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn resolve_suggested_config_path<F>(home: Option<&str>, fallback_dir: F) -> std::path::PathBuf
|
||||
where
|
||||
F: FnOnce() -> std::path::PathBuf,
|
||||
{
|
||||
if let Some(home) = home {
|
||||
if !home.is_empty() && home != "/" {
|
||||
return std::path::PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("numa")
|
||||
.join("numa.toml");
|
||||
}
|
||||
}
|
||||
fallback_dir().join("numa.toml")
|
||||
}
|
||||
|
||||
/// Shared config directory for persistent data (services.json, etc).
|
||||
/// Unix users: ~/.config/numa/
|
||||
/// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa
|
||||
@@ -200,73 +162,4 @@ mod tests {
|
||||
fn linux_data_dir_only_fhs_uses_fhs() {
|
||||
assert_eq!(resolve_linux_data_dir(false, true), "/var/lib/numa");
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn fhs() -> std::path::PathBuf {
|
||||
std::path::PathBuf::from("/var/lib/numa")
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn suggested_config_path_prefers_home() {
|
||||
assert_eq!(
|
||||
resolve_suggested_config_path(Some("/home/alice"), fhs),
|
||||
std::path::PathBuf::from("/home/alice/.config/numa/numa.toml"),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn suggested_config_path_prefers_root_home_over_fhs() {
|
||||
// Interactive root: HOME=/root is a real user context, not a daemon signal.
|
||||
// Advisory must point where load_config will actually look — issue #81.
|
||||
assert_eq!(
|
||||
resolve_suggested_config_path(Some("/root"), fhs),
|
||||
std::path::PathBuf::from("/root/.config/numa/numa.toml"),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn suggested_config_path_falls_back_when_home_unset() {
|
||||
assert_eq!(
|
||||
resolve_suggested_config_path(None, fhs),
|
||||
std::path::PathBuf::from("/var/lib/numa/numa.toml"),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn suggested_config_path_falls_back_when_home_is_root() {
|
||||
// systemd services sometimes have HOME=/ — don't treat that as a real home.
|
||||
assert_eq!(
|
||||
resolve_suggested_config_path(Some("/"), fhs),
|
||||
std::path::PathBuf::from("/var/lib/numa/numa.toml"),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn suggested_config_path_falls_back_when_home_is_empty() {
|
||||
assert_eq!(
|
||||
resolve_suggested_config_path(Some(""), fhs),
|
||||
std::path::PathBuf::from("/var/lib/numa/numa.toml"),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn suggested_config_path_skips_fallback_when_home_valid() {
|
||||
// Happy path shouldn't probe the filesystem via config_dir().
|
||||
let called = std::cell::Cell::new(false);
|
||||
let fallback = || {
|
||||
called.set(true);
|
||||
std::path::PathBuf::from("/should/not/be/used")
|
||||
};
|
||||
let _ = resolve_suggested_config_path(Some("/home/alice"), fallback);
|
||||
assert!(
|
||||
!called.get(),
|
||||
"fallback must not be invoked when HOME is valid"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
186
src/main.rs
186
src/main.rs
@@ -11,11 +11,11 @@ use numa::buffer::BytePacketBuffer;
|
||||
use numa::cache::DnsCache;
|
||||
use numa::config::{build_zone_map, load_config, ConfigLoad};
|
||||
use numa::ctx::{handle_query, ServerCtx};
|
||||
use numa::forward::{parse_upstream, Upstream, UpstreamPool};
|
||||
use numa::forward::Upstream;
|
||||
use numa::override_store::OverrideStore;
|
||||
use numa::query_log::QueryLog;
|
||||
use numa::service_store::ServiceStore;
|
||||
use numa::stats::{ServerStats, Transport};
|
||||
use numa::stats::ServerStats;
|
||||
use numa::system_dns::{
|
||||
discover_system_dns, install_service, restart_service, service_status, uninstall_service,
|
||||
};
|
||||
@@ -129,18 +129,18 @@ async fn main() -> numa::Result<()> {
|
||||
|
||||
let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints);
|
||||
|
||||
let recursive_pool = || {
|
||||
let dummy = UpstreamPool::new(vec![Upstream::Udp("0.0.0.0:0".parse().unwrap())], vec![]);
|
||||
(dummy, "recursive (root hints)".to_string())
|
||||
};
|
||||
|
||||
let (resolved_mode, upstream_auto, pool, upstream_label) = match config.upstream.mode {
|
||||
let (resolved_mode, upstream_auto, upstream, upstream_label) = match config.upstream.mode {
|
||||
numa::config::UpstreamMode::Auto => {
|
||||
info!("auto mode: probing recursive resolution...");
|
||||
if numa::recursive::probe_recursive(&root_hints).await {
|
||||
info!("recursive probe succeeded — self-sovereign mode");
|
||||
let (pool, label) = recursive_pool();
|
||||
(numa::config::UpstreamMode::Recursive, false, pool, label)
|
||||
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
|
||||
(
|
||||
numa::config::UpstreamMode::Recursive,
|
||||
false,
|
||||
dummy,
|
||||
"recursive (root hints)".to_string(),
|
||||
)
|
||||
} else {
|
||||
log::warn!("recursive probe failed — falling back to Quad9 DoH");
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -149,45 +149,55 @@ async fn main() -> numa::Result<()> {
|
||||
.unwrap_or_default();
|
||||
let url = DOH_FALLBACK.to_string();
|
||||
let label = url.clone();
|
||||
let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]);
|
||||
(numa::config::UpstreamMode::Forward, false, pool, label)
|
||||
(
|
||||
numa::config::UpstreamMode::Forward,
|
||||
false,
|
||||
Upstream::Doh { url, client },
|
||||
label,
|
||||
)
|
||||
}
|
||||
}
|
||||
numa::config::UpstreamMode::Recursive => {
|
||||
let (pool, label) = recursive_pool();
|
||||
(numa::config::UpstreamMode::Recursive, false, pool, label)
|
||||
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
|
||||
(
|
||||
numa::config::UpstreamMode::Recursive,
|
||||
false,
|
||||
dummy,
|
||||
"recursive (root hints)".to_string(),
|
||||
)
|
||||
}
|
||||
numa::config::UpstreamMode::Forward => {
|
||||
let addrs = if config.upstream.address.is_empty() {
|
||||
let detected = system_dns
|
||||
let upstream_addr = if config.upstream.address.is_empty() {
|
||||
system_dns
|
||||
.default_upstream
|
||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||
.unwrap_or_else(|| {
|
||||
info!("could not detect system DNS, falling back to Quad9 DoH");
|
||||
DOH_FALLBACK.to_string()
|
||||
});
|
||||
vec![detected]
|
||||
})
|
||||
} else {
|
||||
config.upstream.address.clone()
|
||||
};
|
||||
|
||||
let primary: Vec<Upstream> = addrs
|
||||
.iter()
|
||||
.map(|s| parse_upstream(s, config.upstream.port))
|
||||
.collect::<numa::Result<Vec<_>>>()?;
|
||||
let fallback: Vec<Upstream> = config
|
||||
.upstream
|
||||
.fallback
|
||||
.iter()
|
||||
.map(|s| parse_upstream(s, config.upstream.port))
|
||||
.collect::<numa::Result<Vec<_>>>()?;
|
||||
|
||||
let pool = UpstreamPool::new(primary, fallback);
|
||||
let label = pool.label();
|
||||
let upstream: Upstream = if upstream_addr.starts_with("https://") {
|
||||
let client = reqwest::Client::builder()
|
||||
.use_rustls_tls()
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
Upstream::Doh {
|
||||
url: upstream_addr,
|
||||
client,
|
||||
}
|
||||
} else {
|
||||
let addr: SocketAddr =
|
||||
format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
|
||||
Upstream::Udp(addr)
|
||||
};
|
||||
let label = upstream.to_string();
|
||||
(
|
||||
numa::config::UpstreamMode::Forward,
|
||||
config.upstream.address.is_empty(),
|
||||
pool,
|
||||
upstream,
|
||||
label,
|
||||
)
|
||||
}
|
||||
@@ -210,13 +220,7 @@ async fn main() -> numa::Result<()> {
|
||||
}
|
||||
service_store.load_persisted();
|
||||
|
||||
for fwd in &config.forwarding {
|
||||
for suffix in &fwd.suffix {
|
||||
info!("forwarding .{} to {} (config rule)", suffix, fwd.upstream);
|
||||
}
|
||||
}
|
||||
let forwarding_rules =
|
||||
numa::config::merge_forwarding_rules(&config.forwarding, system_dns.forwarding_rules)?;
|
||||
let forwarding_rules = system_dns.forwarding_rules;
|
||||
|
||||
// Resolve data_dir from config, falling back to the platform default.
|
||||
// Used for TLS CA storage below and stored on ServerCtx for runtime use.
|
||||
@@ -249,7 +253,6 @@ async fn main() -> numa::Result<()> {
|
||||
None
|
||||
};
|
||||
|
||||
let doh_enabled = initial_tls.is_some();
|
||||
let health_meta = numa::health::HealthMeta::build(
|
||||
&resolved_data_dir,
|
||||
config.dot.enabled,
|
||||
@@ -259,7 +262,6 @@ async fn main() -> numa::Result<()> {
|
||||
resolved_mode == numa::config::UpstreamMode::Recursive,
|
||||
config.lan.enabled,
|
||||
config.blocking.enabled,
|
||||
doh_enabled,
|
||||
);
|
||||
|
||||
let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok();
|
||||
@@ -285,7 +287,6 @@ async fn main() -> numa::Result<()> {
|
||||
config.cache.min_ttl,
|
||||
config.cache.max_ttl,
|
||||
)),
|
||||
refreshing: Mutex::new(std::collections::HashSet::new()),
|
||||
stats: Mutex::new(ServerStats::new()),
|
||||
overrides: RwLock::new(OverrideStore::new()),
|
||||
blocklist: RwLock::new(blocklist),
|
||||
@@ -293,12 +294,11 @@ async fn main() -> numa::Result<()> {
|
||||
services: Mutex::new(service_store),
|
||||
lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)),
|
||||
forwarding_rules,
|
||||
upstream_pool: Mutex::new(pool),
|
||||
upstream: Mutex::new(upstream),
|
||||
upstream_auto,
|
||||
upstream_port: config.upstream.port,
|
||||
lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)),
|
||||
timeout: Duration::from_millis(config.upstream.timeout_ms),
|
||||
hedge_delay: Duration::from_millis(config.upstream.hedge_ms),
|
||||
proxy_tld_suffix: if config.proxy.tld.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -319,8 +319,6 @@ async fn main() -> numa::Result<()> {
|
||||
dnssec_strict: config.dnssec.strict,
|
||||
health_meta,
|
||||
ca_pem,
|
||||
mobile_enabled: config.mobile.enabled,
|
||||
mobile_port: config.mobile.port,
|
||||
});
|
||||
|
||||
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
||||
@@ -412,9 +410,6 @@ async fn main() -> numa::Result<()> {
|
||||
g,
|
||||
&format!("max {} entries", config.cache.max_entries),
|
||||
);
|
||||
if !config.cache.warm.is_empty() {
|
||||
row("Warm", g, &format!("{} domains", config.cache.warm.len()));
|
||||
}
|
||||
row(
|
||||
"Blocking",
|
||||
g,
|
||||
@@ -441,13 +436,6 @@ async fn main() -> numa::Result<()> {
|
||||
if config.dot.enabled {
|
||||
row("DoT", g, &format!("tls://:{}", config.dot.port));
|
||||
}
|
||||
if doh_enabled {
|
||||
row(
|
||||
"DoH",
|
||||
g,
|
||||
&format!("https://:{}/dns-query", config.proxy.tls_port),
|
||||
);
|
||||
}
|
||||
if config.lan.enabled {
|
||||
row("LAN", g, "mDNS (_numa._tcp.local)");
|
||||
}
|
||||
@@ -504,23 +492,6 @@ async fn main() -> numa::Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn cache warming for user-configured domains
|
||||
if !config.cache.warm.is_empty() {
|
||||
let warm_ctx = Arc::clone(&ctx);
|
||||
let warm_domains = config.cache.warm.clone();
|
||||
tokio::spawn(async move {
|
||||
cache_warm_loop(warm_ctx, warm_domains).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn DoH connection keepalive — prevents idle TLS teardown
|
||||
{
|
||||
let keepalive_ctx = Arc::clone(&ctx);
|
||||
tokio::spawn(async move {
|
||||
doh_keepalive_loop(keepalive_ctx).await;
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn HTTP API server
|
||||
let api_ctx = Arc::clone(&ctx);
|
||||
let api_addr: SocketAddr = format!("{}:{}", config.server.api_bind_addr, api_port).parse()?;
|
||||
@@ -600,7 +571,7 @@ async fn main() -> numa::Result<()> {
|
||||
#[allow(clippy::infinite_loop)]
|
||||
loop {
|
||||
let mut buffer = BytePacketBuffer::new();
|
||||
let (len, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await {
|
||||
let (_, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {
|
||||
// Windows delivers ICMP port-unreachable as ConnectionReset on UDP sockets
|
||||
@@ -608,9 +579,10 @@ async fn main() -> numa::Result<()> {
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let ctx = Arc::clone(&ctx);
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_query(buffer, len, src_addr, &ctx, Transport::Udp).await {
|
||||
if let Err(e) = handle_query(buffer, src_addr, &ctx).await {
|
||||
error!("{} | HANDLER ERROR | {}", src_addr, e);
|
||||
}
|
||||
});
|
||||
@@ -639,17 +611,27 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-detect upstream every 30s or on LAN IP change (auto-detect only)
|
||||
if ctx.upstream_auto && (changed || tick.is_multiple_of(6)) {
|
||||
// Re-detect upstream every 30s or on LAN IP change (UDP only —
|
||||
// DoH upstreams are explicitly configured via URL, not auto-detected)
|
||||
if ctx.upstream_auto
|
||||
&& matches!(*ctx.upstream.lock().unwrap(), Upstream::Udp(_))
|
||||
&& (changed || tick.is_multiple_of(6))
|
||||
{
|
||||
let dns_info = numa::system_dns::discover_system_dns();
|
||||
let new_addr = dns_info
|
||||
.default_upstream
|
||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||
.unwrap_or_else(|| QUAD9_IP.to_string());
|
||||
let mut pool = ctx.upstream_pool.lock().unwrap();
|
||||
if pool.maybe_update_primary(&new_addr, ctx.upstream_port) {
|
||||
info!("upstream changed → {}", pool.label());
|
||||
changed = true;
|
||||
if let Ok(new_sock) =
|
||||
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
|
||||
{
|
||||
let new_upstream = Upstream::Udp(new_sock);
|
||||
let mut upstream = ctx.upstream.lock().unwrap();
|
||||
if *upstream != new_upstream {
|
||||
info!("upstream changed: {} → {}", upstream, new_upstream);
|
||||
*upstream = new_upstream;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,45 +738,3 @@ async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) {
|
||||
downloaded.len()
|
||||
);
|
||||
}
|
||||
|
||||
async fn warm_domain(ctx: &ServerCtx, domain: &str) {
|
||||
for qtype in [
|
||||
numa::question::QueryType::A,
|
||||
numa::question::QueryType::AAAA,
|
||||
] {
|
||||
numa::ctx::refresh_entry(ctx, domain, qtype).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn doh_keepalive_loop(ctx: Arc<ServerCtx>) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(25));
|
||||
interval.tick().await; // skip first immediate tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let pool = ctx.upstream_pool.lock().unwrap().clone();
|
||||
if let Some(upstream) = pool.preferred() {
|
||||
numa::forward::keepalive_doh(upstream).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cache_warm_loop(ctx: Arc<ServerCtx>, domains: Vec<String>) {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
for domain in &domains {
|
||||
warm_domain(&ctx, domain).await;
|
||||
}
|
||||
info!("cache warm: {} domains resolved at startup", domains.len());
|
||||
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
interval.tick().await;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
for domain in &domains {
|
||||
let refresh = ctx.cache.read().unwrap().needs_warm(domain);
|
||||
if refresh {
|
||||
warm_domain(&ctx, domain).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,8 @@ fn build_ca_payload(ca_pem: &str) -> String {
|
||||
}
|
||||
|
||||
/// Render the `com.apple.dnsSettings.managed` payload dict for Full mode.
|
||||
/// Pins the device to Numa as its system resolver over DoT with
|
||||
/// `ServerName = "numa.numa"` (must match the DoT cert SAN).
|
||||
fn build_dns_payload(lan_ip: Ipv4Addr) -> String {
|
||||
format!(
|
||||
r#" <dict>
|
||||
@@ -158,21 +160,8 @@ fn build_dns_payload(lan_ip: Ipv4Addr) -> String {
|
||||
<key>ServerName</key>
|
||||
<string>numa.numa</string>
|
||||
</dict>
|
||||
<key>OnDemandRules</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>Action</key>
|
||||
<string>Connect</string>
|
||||
<key>InterfaceTypeMatch</key>
|
||||
<string>WiFi</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>Action</key>
|
||||
<string>Disconnect</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Routes DNS queries through Numa over DoT when on Wi-Fi</string>
|
||||
<string>Routes all DNS queries through Numa over DNS-over-TLS</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Numa DNS-over-TLS</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
|
||||
36
src/proxy.rs
36
src/proxy.rs
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::{any, post};
|
||||
use axum::routing::any;
|
||||
use axum::Router;
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::StatusCode;
|
||||
@@ -18,14 +18,6 @@ use crate::ctx::ServerCtx;
|
||||
|
||||
type HttpClient = Client<hyper_util::client::legacy::connect::HttpConnector, Body>;
|
||||
|
||||
/// State passed to the DoH handler. Includes the remote address so
|
||||
/// `resolve_query` can log the client IP.
|
||||
#[derive(Clone)]
|
||||
pub struct DohState {
|
||||
pub ctx: Arc<ServerCtx>,
|
||||
pub remote_addr: Option<std::net::SocketAddr>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProxyState {
|
||||
ctx: Arc<ServerCtx>,
|
||||
@@ -82,17 +74,9 @@ pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, bind_addr: Ipv4Addr
|
||||
|
||||
// Hold a separate Arc so we can access tls_config after ctx moves into ProxyState
|
||||
let tls_holder = Arc::clone(&ctx);
|
||||
let proxy_state = ProxyState {
|
||||
ctx: Arc::clone(&ctx),
|
||||
client,
|
||||
};
|
||||
let state = ProxyState { ctx, client };
|
||||
|
||||
// DoH route (RFC 8484) served only on the TLS listener.
|
||||
// DohState.remote_addr is set per-connection below.
|
||||
let doh_state = DohState {
|
||||
ctx,
|
||||
remote_addr: None,
|
||||
};
|
||||
let app = Router::new().fallback(any(proxy_handler)).with_state(state);
|
||||
|
||||
loop {
|
||||
let (tcp_stream, remote_addr) = match listener.accept().await {
|
||||
@@ -107,17 +91,7 @@ pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, bind_addr: Ipv4Addr
|
||||
// unwrap safe: guarded by is_none() check above
|
||||
let acceptor =
|
||||
TlsAcceptor::from(Arc::clone(&*tls_holder.tls_config.as_ref().unwrap().load()));
|
||||
|
||||
let mut conn_doh_state = doh_state.clone();
|
||||
conn_doh_state.remote_addr = Some(remote_addr);
|
||||
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/dns-query",
|
||||
post(crate::doh::doh_post).with_state(conn_doh_state),
|
||||
)
|
||||
.fallback(any(proxy_handler))
|
||||
.with_state(proxy_state.clone());
|
||||
let app = app.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let tls_stream = match acceptor.accept(tcp_stream).await {
|
||||
@@ -258,7 +232,7 @@ pre .str {{ color: #d48a5a }}
|
||||
)
|
||||
}
|
||||
|
||||
pub fn extract_host(req: &Request) -> Option<String> {
|
||||
fn extract_host(req: &Request) -> Option<String> {
|
||||
req.headers()
|
||||
.get(hyper::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::time::SystemTime;
|
||||
use crate::cache::DnssecStatus;
|
||||
use crate::header::ResultCode;
|
||||
use crate::question::QueryType;
|
||||
use crate::stats::{QueryPath, Transport};
|
||||
use crate::stats::QueryPath;
|
||||
|
||||
pub struct QueryLogEntry {
|
||||
pub timestamp: SystemTime,
|
||||
@@ -13,7 +13,6 @@ pub struct QueryLogEntry {
|
||||
pub domain: String,
|
||||
pub query_type: QueryType,
|
||||
pub path: QueryPath,
|
||||
pub transport: Transport,
|
||||
pub rescode: ResultCode,
|
||||
pub latency_us: u64,
|
||||
pub dnssec: DnssecStatus,
|
||||
@@ -108,7 +107,6 @@ mod tests {
|
||||
domain: "example.com".into(),
|
||||
query_type: QueryType::A,
|
||||
path: QueryPath::Forwarded,
|
||||
transport: Transport::Udp,
|
||||
rescode: ResultCode::NOERROR,
|
||||
latency_us: 500,
|
||||
dnssec: DnssecStatus::Indeterminate,
|
||||
|
||||
221
src/recursive.rs
221
src/recursive.rs
@@ -15,8 +15,8 @@ use crate::srtt::SrttCache;
|
||||
|
||||
const MAX_REFERRAL_DEPTH: u8 = 10;
|
||||
const MAX_CNAME_DEPTH: u8 = 8;
|
||||
const NS_QUERY_TIMEOUT: Duration = Duration::from_millis(400);
|
||||
const TCP_TIMEOUT: Duration = Duration::from_millis(400);
|
||||
const NS_QUERY_TIMEOUT: Duration = Duration::from_millis(800);
|
||||
const TCP_TIMEOUT: Duration = Duration::from_millis(1500);
|
||||
const UDP_FAIL_THRESHOLD: u8 = 3;
|
||||
|
||||
static QUERY_ID: AtomicU16 = AtomicU16::new(1);
|
||||
@@ -202,24 +202,23 @@ pub(crate) fn resolve_iterative<'a>(
|
||||
let mut ns_idx = 0;
|
||||
|
||||
for _ in 0..MAX_REFERRAL_DEPTH {
|
||||
if ns_idx >= ns_addrs.len() {
|
||||
return Err("no nameserver available".into());
|
||||
}
|
||||
let ns_addr = match ns_addrs.get(ns_idx) {
|
||||
Some(addr) => *addr,
|
||||
None => return Err("no nameserver available".into()),
|
||||
};
|
||||
|
||||
let (q_name, q_type) = minimize_query(qname, qtype, ¤t_zone);
|
||||
|
||||
debug!(
|
||||
"recursive: querying {} (+ hedge) for {:?} {} (zone: {}, depth {})",
|
||||
ns_addrs[ns_idx], q_type, q_name, current_zone, referral_depth
|
||||
"recursive: querying {} for {:?} {} (zone: {}, depth {})",
|
||||
ns_addr, q_type, q_name, current_zone, referral_depth
|
||||
);
|
||||
|
||||
let response = match send_query_hedged(q_name, q_type, &ns_addrs[ns_idx..], srtt).await
|
||||
{
|
||||
let response = match send_query(q_name, q_type, ns_addr, srtt).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
debug!("recursive: NS query failed: {}", e);
|
||||
let remaining = ns_addrs.len().saturating_sub(ns_idx);
|
||||
ns_idx += remaining.min(2);
|
||||
debug!("recursive: NS {} failed: {}", ns_addr, e);
|
||||
ns_idx += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -229,9 +228,6 @@ pub(crate) fn resolve_iterative<'a>(
|
||||
{
|
||||
if let Some(zone) = referral_zone(&response) {
|
||||
current_zone = zone;
|
||||
let mut cache_w = cache.write().unwrap();
|
||||
cache_ns_delegation(&mut cache_w, ¤t_zone, &response);
|
||||
drop(cache_w);
|
||||
}
|
||||
let mut all_ns = extract_ns_from_records(&response.answers);
|
||||
if all_ns.is_empty() {
|
||||
@@ -300,7 +296,6 @@ pub(crate) fn resolve_iterative<'a>(
|
||||
|
||||
{
|
||||
let mut cache_w = cache.write().unwrap();
|
||||
cache_ns_delegation(&mut cache_w, ¤t_zone, &response);
|
||||
cache_ds_from_authority(&mut cache_w, &response);
|
||||
}
|
||||
let mut new_ns_addrs = resolve_ns_addrs_from_glue(&response, &ns_names, cache);
|
||||
@@ -565,23 +560,6 @@ fn cache_ds_from_authority(cache: &mut DnsCache, response: &DnsPacket) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache NS delegation records from a referral response so that
|
||||
/// `find_closest_ns` can skip re-querying TLD servers on subsequent lookups.
|
||||
fn cache_ns_delegation(cache: &mut DnsCache, zone: &str, response: &DnsPacket) {
|
||||
let ns_records: Vec<_> = response
|
||||
.authorities
|
||||
.iter()
|
||||
.filter(|r| matches!(r, DnsRecord::NS { .. }))
|
||||
.cloned()
|
||||
.collect();
|
||||
if ns_records.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut pkt = make_glue_packet();
|
||||
pkt.answers = ns_records;
|
||||
cache.insert(zone, QueryType::NS, &pkt);
|
||||
}
|
||||
|
||||
fn make_glue_packet() -> DnsPacket {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.response = true;
|
||||
@@ -609,115 +587,6 @@ async fn tcp_with_srtt(
|
||||
}
|
||||
}
|
||||
|
||||
/// Smart NS query: fire to two servers simultaneously when SRTT is unknown
|
||||
/// (cold queries), or to the best server with SRTT-based hedge when known.
|
||||
async fn send_query_hedged(
|
||||
qname: &str,
|
||||
qtype: QueryType,
|
||||
servers: &[SocketAddr],
|
||||
srtt: &RwLock<SrttCache>,
|
||||
) -> crate::Result<DnsPacket> {
|
||||
if servers.is_empty() {
|
||||
return Err("no nameserver available".into());
|
||||
}
|
||||
if servers.len() == 1 {
|
||||
return send_query(qname, qtype, servers[0], srtt).await;
|
||||
}
|
||||
|
||||
let primary = servers[0];
|
||||
let secondary = servers[1];
|
||||
let primary_known = srtt.read().unwrap().is_known(primary.ip());
|
||||
|
||||
if !primary_known {
|
||||
// Cold: fire both simultaneously, first response wins
|
||||
debug!(
|
||||
"recursive: parallel query to {} and {} for {:?} {}",
|
||||
primary, secondary, qtype, qname
|
||||
);
|
||||
let fut_a = send_query(qname, qtype, primary, srtt);
|
||||
let fut_b = send_query(qname, qtype, secondary, srtt);
|
||||
tokio::pin!(fut_a);
|
||||
tokio::pin!(fut_b);
|
||||
|
||||
// First Ok wins. If one errors, wait for the other.
|
||||
let mut a_done = false;
|
||||
let mut b_done = false;
|
||||
let mut a_err: Option<crate::Error> = None;
|
||||
let mut b_err: Option<crate::Error> = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
r = &mut fut_a, if !a_done => {
|
||||
match r {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(e) => { a_done = true; a_err = Some(e); }
|
||||
}
|
||||
}
|
||||
r = &mut fut_b, if !b_done => {
|
||||
match r {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(e) => { b_done = true; b_err = Some(e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
match (a_err.take(), b_err.take()) {
|
||||
(Some(e), Some(_)) => return Err(e),
|
||||
(a, b) => {
|
||||
a_err = a;
|
||||
b_err = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Warm: send to best, hedge after SRTT × 3 if slow
|
||||
let hedge_ms = srtt.read().unwrap().get(primary.ip()) * 3;
|
||||
let hedge_delay = Duration::from_millis(hedge_ms.max(50));
|
||||
|
||||
let fut_a = send_query(qname, qtype, primary, srtt);
|
||||
tokio::pin!(fut_a);
|
||||
let delay = tokio::time::sleep(hedge_delay);
|
||||
tokio::pin!(delay);
|
||||
|
||||
tokio::select! {
|
||||
r = &mut fut_a => return r,
|
||||
_ = &mut delay => {}
|
||||
}
|
||||
|
||||
debug!(
|
||||
"recursive: hedging {} -> {} after {}ms for {:?} {}",
|
||||
primary, secondary, hedge_ms, qtype, qname
|
||||
);
|
||||
let fut_b = send_query(qname, qtype, secondary, srtt);
|
||||
tokio::pin!(fut_b);
|
||||
|
||||
// First Ok wins; if one errors, wait for the other.
|
||||
let mut a_err: Option<crate::Error> = None;
|
||||
let mut b_err: Option<crate::Error> = None;
|
||||
loop {
|
||||
tokio::select! {
|
||||
r = &mut fut_a, if a_err.is_none() => {
|
||||
match r {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(e) => {
|
||||
if b_err.is_some() { return Err(e); }
|
||||
a_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
r = &mut fut_b, if b_err.is_none() => {
|
||||
match r {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(e) => {
|
||||
if let Some(ae) = a_err.take() { return Err(ae); }
|
||||
b_err = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_query(
|
||||
qname: &str,
|
||||
qtype: QueryType,
|
||||
@@ -765,13 +634,9 @@ async fn send_query(
|
||||
"send_query: {} consecutive UDP failures — switching to TCP-first",
|
||||
fails
|
||||
);
|
||||
// Now that UDP is disabled, retry this query via TCP
|
||||
return tcp_with_srtt(&query, server, srtt, start).await;
|
||||
}
|
||||
// UDP works in general (priming succeeded) but this server timed out.
|
||||
// Don't waste another 400ms on TCP — the server is unreachable.
|
||||
srtt.write().unwrap().record_failure(server.ip());
|
||||
Err(e)
|
||||
debug!("send_query: UDP failed for {}: {}, trying TCP", server, e);
|
||||
tcp_with_srtt(&query, server, srtt, start).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -813,10 +678,6 @@ mod tests {
|
||||
use super::*;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
/// Tests that mutate the global UDP_DISABLED / UDP_FAILURES flags must hold
|
||||
/// this lock to avoid racing with each other under `cargo test` parallelism.
|
||||
static UDP_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn extract_ns_from_authority() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
@@ -1055,11 +916,10 @@ mod tests {
|
||||
}
|
||||
|
||||
/// TCP-only server returns authoritative answer directly.
|
||||
/// Verifies: when UDP is disabled, TCP-first resolves.
|
||||
/// Verifies: UDP fails → TCP fallback → resolves.
|
||||
#[tokio::test]
|
||||
async fn tcp_fallback_resolves_when_udp_blocked() {
|
||||
let _guard = UDP_STATE_LOCK.lock().unwrap();
|
||||
UDP_DISABLED.store(true, Ordering::Relaxed);
|
||||
UDP_DISABLED.store(false, Ordering::Relaxed);
|
||||
UDP_FAILURES.store(0, Ordering::Release);
|
||||
|
||||
let server_addr = spawn_tcp_dns_server(|query| {
|
||||
@@ -1090,32 +950,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// TCP round-trip through mock: query → authoritative answer via forward_tcp.
|
||||
/// Uses forward_tcp directly to avoid dependence on the global UDP_DISABLED flag
|
||||
/// which is shared across concurrent tests.
|
||||
/// Full iterative resolution through TCP-only mock: root referral → authoritative answer.
|
||||
/// The mock plays both roles (returns referral for NS queries, answer for A queries).
|
||||
#[tokio::test]
|
||||
async fn tcp_only_iterative_resolution() {
|
||||
UDP_DISABLED.store(true, Ordering::Release); // Skip UDP entirely for speed
|
||||
|
||||
let server_addr = spawn_tcp_dns_server(|query| {
|
||||
let q = match query.questions.first() {
|
||||
Some(q) => q,
|
||||
None => return DnsPacket::response_from(query, ResultCode::SERVFAIL),
|
||||
};
|
||||
|
||||
let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR);
|
||||
resp.header.authoritative_answer = true;
|
||||
resp.answers.push(DnsRecord::A {
|
||||
domain: q.name.clone(),
|
||||
addr: Ipv4Addr::new(10, 0, 0, 42),
|
||||
ttl: 300,
|
||||
});
|
||||
resp
|
||||
if q.qtype == QueryType::NS || q.name == "com" {
|
||||
// Return referral — NS points back to ourselves (same IP, port 53 in glue
|
||||
// won't work, but cache will have our address from root_hints)
|
||||
let mut resp = DnsPacket::new();
|
||||
resp.header.id = query.header.id;
|
||||
resp.header.response = true;
|
||||
resp.header.rescode = ResultCode::NOERROR;
|
||||
resp.questions = query.questions.clone();
|
||||
resp.authorities.push(DnsRecord::NS {
|
||||
domain: "com".into(),
|
||||
host: "ns1.com".into(),
|
||||
ttl: 3600,
|
||||
});
|
||||
resp
|
||||
} else {
|
||||
// Return authoritative answer
|
||||
let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR);
|
||||
resp.header.authoritative_answer = true;
|
||||
resp.answers.push(DnsRecord::A {
|
||||
domain: q.name.clone(),
|
||||
addr: Ipv4Addr::new(10, 0, 0, 42),
|
||||
ttl: 300,
|
||||
});
|
||||
resp
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let query = DnsPacket::query(0x1234, "hello.example.com", QueryType::A);
|
||||
let resp = crate::forward::forward_tcp(&query, server_addr, TCP_TIMEOUT)
|
||||
.await
|
||||
.expect("TCP query should work");
|
||||
let srtt = RwLock::new(SrttCache::new(true));
|
||||
let result = send_query("hello.example.com", QueryType::A, server_addr, &srtt).await;
|
||||
let resp = result.expect("TCP-only send_query should work");
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
match &resp.answers[0] {
|
||||
DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 0, 0, 42)),
|
||||
@@ -1125,8 +1002,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn tcp_fallback_handles_nxdomain() {
|
||||
let _guard = UDP_STATE_LOCK.lock().unwrap();
|
||||
UDP_DISABLED.store(true, Ordering::Relaxed);
|
||||
UDP_DISABLED.store(false, Ordering::Relaxed);
|
||||
UDP_FAILURES.store(0, Ordering::Release);
|
||||
|
||||
let server_addr = spawn_tcp_dns_server(|query| {
|
||||
@@ -1158,7 +1034,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn udp_auto_disable_resets() {
|
||||
let _guard = UDP_STATE_LOCK.lock().unwrap();
|
||||
UDP_DISABLED.store(true, Ordering::Release);
|
||||
UDP_FAILURES.store(5, Ordering::Relaxed);
|
||||
|
||||
|
||||
@@ -45,11 +45,6 @@ impl SrttCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether we have observed RTT data for this IP.
|
||||
pub fn is_known(&self, ip: IpAddr) -> bool {
|
||||
self.entries.contains_key(&ip)
|
||||
}
|
||||
|
||||
/// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL.
|
||||
fn decayed_srtt(entry: &SrttEntry) -> u64 {
|
||||
Self::decay_for_age(entry.srtt_ms, entry.updated_at.elapsed().as_secs())
|
||||
|
||||
43
src/stats.rs
43
src/stats.rs
@@ -97,32 +97,9 @@ pub struct ServerStats {
|
||||
queries_local: u64,
|
||||
queries_overridden: u64,
|
||||
upstream_errors: u64,
|
||||
transport_udp: u64,
|
||||
transport_tcp: u64,
|
||||
transport_dot: u64,
|
||||
transport_doh: u64,
|
||||
started_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Transport {
|
||||
Udp,
|
||||
Tcp,
|
||||
Dot,
|
||||
Doh,
|
||||
}
|
||||
|
||||
impl Transport {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Transport::Udp => "UDP",
|
||||
Transport::Tcp => "TCP",
|
||||
Transport::Dot => "DOT",
|
||||
Transport::Doh => "DOH",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum QueryPath {
|
||||
Local,
|
||||
@@ -190,15 +167,11 @@ impl ServerStats {
|
||||
queries_local: 0,
|
||||
queries_overridden: 0,
|
||||
upstream_errors: 0,
|
||||
transport_udp: 0,
|
||||
transport_tcp: 0,
|
||||
transport_dot: 0,
|
||||
transport_doh: 0,
|
||||
started_at: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record(&mut self, path: QueryPath, transport: Transport) -> u64 {
|
||||
pub fn record(&mut self, path: QueryPath) -> u64 {
|
||||
self.queries_total += 1;
|
||||
match path {
|
||||
QueryPath::Local => self.queries_local += 1,
|
||||
@@ -210,12 +183,6 @@ impl ServerStats {
|
||||
QueryPath::Overridden => self.queries_overridden += 1,
|
||||
QueryPath::UpstreamError => self.upstream_errors += 1,
|
||||
}
|
||||
match transport {
|
||||
Transport::Udp => self.transport_udp += 1,
|
||||
Transport::Tcp => self.transport_tcp += 1,
|
||||
Transport::Dot => self.transport_dot += 1,
|
||||
Transport::Doh => self.transport_doh += 1,
|
||||
}
|
||||
self.queries_total
|
||||
}
|
||||
|
||||
@@ -239,10 +206,6 @@ impl ServerStats {
|
||||
overridden: self.queries_overridden,
|
||||
blocked: self.queries_blocked,
|
||||
errors: self.upstream_errors,
|
||||
transport_udp: self.transport_udp,
|
||||
transport_tcp: self.transport_tcp,
|
||||
transport_dot: self.transport_dot,
|
||||
transport_doh: self.transport_doh,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,8 +242,4 @@ pub struct StatsSnapshot {
|
||||
pub overridden: u64,
|
||||
pub blocked: u64,
|
||||
pub errors: u64,
|
||||
pub transport_udp: u64,
|
||||
pub transport_tcp: u64,
|
||||
pub transport_dot: u64,
|
||||
pub transport_doh: u64,
|
||||
}
|
||||
|
||||
@@ -25,17 +25,6 @@ pub struct ForwardingRule {
|
||||
pub upstream: SocketAddr,
|
||||
}
|
||||
|
||||
impl ForwardingRule {
|
||||
pub fn new(suffix: String, upstream: SocketAddr) -> Self {
|
||||
let dot_suffix = format!(".{}", suffix);
|
||||
Self {
|
||||
suffix,
|
||||
dot_suffix,
|
||||
upstream,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of system DNS discovery — default upstream + conditional forwarding rules.
|
||||
pub struct SystemDnsInfo {
|
||||
pub default_upstream: Option<String>,
|
||||
@@ -102,7 +91,7 @@ pub fn try_port53_advisory(bind_addr: &str, err: &std::io::Error) -> Option<Stri
|
||||
sudo numa install (on Windows, run as Administrator)
|
||||
|
||||
2. Run on a non-privileged port for testing.
|
||||
Create {} with:
|
||||
Create ~/.config/numa/numa.toml with:
|
||||
|
||||
[server]
|
||||
bind_addr = \"127.0.0.1:5354\"
|
||||
@@ -111,8 +100,7 @@ pub fn try_port53_advisory(bind_addr: &str, err: &std::io::Error) -> Option<Stri
|
||||
Then run: numa
|
||||
Test with: dig @127.0.0.1 -p 5354 example.com
|
||||
|
||||
",
|
||||
crate::suggested_config_path().display()
|
||||
"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -232,8 +220,12 @@ fn discover_macos() -> SystemDnsInfo {
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> {
|
||||
let addr = crate::forward::parse_upstream_addr(nameserver, 53).ok()?;
|
||||
Some(ForwardingRule::new(domain.to_string(), addr))
|
||||
let addr: SocketAddr = format!("{}:53", nameserver).parse().ok()?;
|
||||
Some(ForwardingRule {
|
||||
dot_suffix: format!(".{}", domain),
|
||||
suffix: domain.to_string(),
|
||||
upstream: addr,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
88
src/tls.rs
88
src/tls.rs
@@ -66,14 +66,13 @@ pub fn try_data_dir_advisory(err: &crate::Error, data_dir: &Path) -> Option<Stri
|
||||
sudo numa install (on Windows, run as Administrator)
|
||||
|
||||
2. Point data_dir at a path you can write.
|
||||
Create {} with:
|
||||
Create ~/.config/numa/numa.toml with:
|
||||
|
||||
[server]
|
||||
data_dir = \"/path/you/can/write\"
|
||||
|
||||
",
|
||||
data_dir.display(),
|
||||
crate::suggested_config_path().display()
|
||||
data_dir.display()
|
||||
))
|
||||
}
|
||||
|
||||
@@ -186,19 +185,8 @@ fn generate_service_cert(
|
||||
}
|
||||
}
|
||||
|
||||
// Loopback IP SANs so browsers can reach DoH at https://127.0.0.1/dns-query
|
||||
sans.push(SanType::IpAddress(std::net::IpAddr::V4(
|
||||
std::net::Ipv4Addr::LOCALHOST,
|
||||
)));
|
||||
sans.push(SanType::IpAddress(std::net::IpAddr::V6(
|
||||
std::net::Ipv6Addr::LOCALHOST,
|
||||
)));
|
||||
|
||||
for name in ["localhost", tld] {
|
||||
match name.to_string().try_into() {
|
||||
Ok(ia5) => sans.push(SanType::DnsName(ia5)),
|
||||
Err(e) => warn!("invalid SAN {}: {}", name, e),
|
||||
}
|
||||
if sans.is_empty() {
|
||||
return Err("no valid service names for TLS cert".into());
|
||||
}
|
||||
|
||||
params.subject_alt_names = sans;
|
||||
@@ -251,72 +239,4 @@ mod tests {
|
||||
let err: crate::Error = "rcgen failure".into();
|
||||
assert!(try_data_dir_advisory(&err, &PathBuf::from("/x")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_cert_contains_expected_sans() {
|
||||
use x509_parser::prelude::GeneralName;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("numa-test-san-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let (ca_der, issuer) = ensure_ca(&dir).unwrap();
|
||||
|
||||
let names = vec!["grafana".into(), "router".into()];
|
||||
let (chain, _) = generate_service_cert(&ca_der, &issuer, "numa", &names).unwrap();
|
||||
assert_eq!(chain.len(), 2, "chain should be [leaf, CA]");
|
||||
|
||||
let (_, cert) = x509_parser::parse_x509_certificate(chain[0].as_ref()).unwrap();
|
||||
let san = cert
|
||||
.tbs_certificate
|
||||
.subject_alternative_name()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let dns: Vec<&str> = san
|
||||
.value
|
||||
.general_names
|
||||
.iter()
|
||||
.filter_map(|gn| match gn {
|
||||
GeneralName::DNSName(s) => Some(*s),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let ips: Vec<std::net::IpAddr> = san
|
||||
.value
|
||||
.general_names
|
||||
.iter()
|
||||
.filter_map(|gn| match gn {
|
||||
GeneralName::IPAddress(b) => match b.len() {
|
||||
4 => Some(std::net::IpAddr::V4(std::net::Ipv4Addr::new(
|
||||
b[0], b[1], b[2], b[3],
|
||||
))),
|
||||
16 => {
|
||||
let a: [u8; 16] = (*b).try_into().unwrap();
|
||||
Some(std::net::IpAddr::V6(std::net::Ipv6Addr::from(a)))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// DNS SANs
|
||||
assert!(dns.contains(&"*.numa"), "missing wildcard SAN");
|
||||
assert!(dns.contains(&"grafana.numa"), "missing service SAN");
|
||||
assert!(dns.contains(&"router.numa"), "missing service SAN");
|
||||
assert!(dns.contains(&"localhost"), "missing localhost SAN");
|
||||
assert!(dns.contains(&"numa"), "missing bare TLD SAN");
|
||||
|
||||
// IP SANs
|
||||
assert!(
|
||||
ips.contains(&std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
|
||||
"missing 127.0.0.1 SAN"
|
||||
);
|
||||
assert!(
|
||||
ips.contains(&std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)),
|
||||
"missing ::1 SAN"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
1416
src/wire.rs
1416
src/wire.rs
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
import socket, signal
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
|
||||
s.bind(("", 53))
|
||||
signal.pause()
|
||||
@@ -1,164 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# End-to-end validation of the issue #81 fix (config path advisory).
|
||||
#
|
||||
# Builds numa from two source trees — the buggy baseline and the fix
|
||||
# candidate — inside one debian:bookworm container, then runs four
|
||||
# scenarios to prove:
|
||||
#
|
||||
# 1. replication/main — reporter's sequence, bug confirmed
|
||||
# 2. replication/fix — reporter's sequence, bug is gone
|
||||
# 3. existing/main — pre-installed config at FHS data dir still loads
|
||||
# 4. existing/fix — same, unchanged by the fix (no regression)
|
||||
#
|
||||
# Scenarios 3 and 4 guard against the fear that the fix might change
|
||||
# candidate order and break existing daemon installs (including the
|
||||
# macOS Homebrew-prefix layout at /usr/local/var/numa/).
|
||||
#
|
||||
# Usage:
|
||||
# MAIN_SRC=/path/to/main-checkout FIX_SRC=/path/to/fix-worktree \
|
||||
# ./tests/docker/issue-81.sh
|
||||
#
|
||||
# Defaults: MAIN_SRC = $(git rev-parse --show-toplevel), FIX_SRC = same.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MAIN_SRC="${MAIN_SRC:-$(git rev-parse --show-toplevel)}"
|
||||
FIX_SRC="${FIX_SRC:-$MAIN_SRC}"
|
||||
|
||||
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
|
||||
|
||||
echo "── issue #81 validation ──"
|
||||
echo " main: $MAIN_SRC"
|
||||
echo " fix: $FIX_SRC"
|
||||
echo
|
||||
|
||||
docker run --rm \
|
||||
--platform linux/amd64 \
|
||||
-v "$MAIN_SRC:/main:ro" \
|
||||
-v "$FIX_SRC:/fix:ro" \
|
||||
-v "$(dirname "$0")/hold53.py:/tmp/hold53.py:ro" \
|
||||
-v numa-port53-cargo:/root/.cargo \
|
||||
-v numa-port53-target:/work/target \
|
||||
debian:bookworm bash -c '
|
||||
set -euo pipefail
|
||||
|
||||
# Paths and ports used by all scenarios — keep in one place so the
|
||||
# heredocs and the verdict greps cannot drift.
|
||||
XDG_CONFIG="/root/.config/numa/numa.toml"
|
||||
FHS_CONFIG="/var/lib/numa/numa.toml"
|
||||
TEST_PORT="5354"
|
||||
TEST_API_PORT="5380"
|
||||
|
||||
apt-get update -qq && apt-get install -y -qq curl build-essential python3 2>&1 | tail -1
|
||||
if ! command -v cargo &>/dev/null; then
|
||||
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --quiet
|
||||
fi
|
||||
. "$HOME/.cargo/env"
|
||||
|
||||
build_from() {
|
||||
local label="$1"; local src="$2"
|
||||
mkdir -p "/work/$label"
|
||||
tar -C "$src" --exclude=./target --exclude=./.git -cf - . | tar -C "/work/$label" -xf -
|
||||
(cd "/work/$label" && cargo build --release --locked 2>&1 | tail -1)
|
||||
cp "/work/$label/target/release/numa" "/work/numa-$label"
|
||||
}
|
||||
|
||||
build_from main /main
|
||||
build_from fix /fix
|
||||
|
||||
holder=0
|
||||
stop_holder() {
|
||||
if [ "$holder" -ne 0 ]; then
|
||||
kill "$holder" 2>/dev/null || true
|
||||
wait "$holder" 2>/dev/null || true
|
||||
holder=0
|
||||
fi
|
||||
}
|
||||
trap stop_holder EXIT
|
||||
|
||||
start_holder() {
|
||||
python3 /tmp/hold53.py &
|
||||
holder=$!
|
||||
sleep 0.3
|
||||
}
|
||||
|
||||
write_test_config() {
|
||||
local path="$1"
|
||||
mkdir -p "$(dirname "$path")"
|
||||
cat > "$path" <<EOF
|
||||
[server]
|
||||
bind_addr = "127.0.0.1:$TEST_PORT"
|
||||
api_port = $TEST_API_PORT
|
||||
EOF
|
||||
}
|
||||
|
||||
verdict() {
|
||||
local label="$1"; local expected="$2"; local file="$3"
|
||||
# "cannot bind to" is printed by the advisory when numa fails to start.
|
||||
# Its absence is a reliable proxy for "numa bound successfully" because
|
||||
# the banner-only log we capture contains no other failure surface.
|
||||
if grep -q "cannot bind to" "$file"; then
|
||||
echo " [$label] did not bind $TEST_PORT — numa ignored the XDG config"
|
||||
[ "$expected" = "ignored" ] && return 0 || return 1
|
||||
else
|
||||
echo " [$label] bound $TEST_PORT — config loaded"
|
||||
[ "$expected" = "bound" ] && return 0 || return 1
|
||||
fi
|
||||
}
|
||||
|
||||
scenario_replication() {
|
||||
local label="$1"; local bin="/work/numa-$label"; local expected="$2"
|
||||
echo
|
||||
echo "════════ REPLICATION / $label ════════"
|
||||
rm -rf /root/.config/numa /var/lib/numa
|
||||
mkdir -p "$(dirname "$XDG_CONFIG")"
|
||||
|
||||
start_holder
|
||||
set +e
|
||||
timeout 5 "$bin" > /tmp/run1.txt 2>&1
|
||||
set -e
|
||||
echo "── step 1: advisory printed by $label ──"
|
||||
grep -E "Create .* with:" /tmp/run1.txt | sed "s/^/ /" || echo " <no advisory line>"
|
||||
|
||||
write_test_config "$XDG_CONFIG"
|
||||
echo "── step 2: wrote config at $XDG_CONFIG ──"
|
||||
|
||||
set +e
|
||||
timeout 3 "$bin" > /tmp/run2.txt 2>&1
|
||||
set -e
|
||||
stop_holder
|
||||
|
||||
verdict "$label" "$expected" /tmp/run2.txt
|
||||
}
|
||||
|
||||
scenario_existing_install() {
|
||||
local label="$1"; local bin="/work/numa-$label"
|
||||
echo
|
||||
echo "════════ EXISTING INSTALL / $label ════════"
|
||||
rm -rf /root/.config/numa /var/lib/numa
|
||||
write_test_config "$FHS_CONFIG"
|
||||
|
||||
start_holder
|
||||
set +e
|
||||
timeout 3 "$bin" > /tmp/run.txt 2>&1
|
||||
set -e
|
||||
stop_holder
|
||||
|
||||
verdict "$label" "bound" /tmp/run.txt
|
||||
}
|
||||
|
||||
RC=0
|
||||
scenario_replication main ignored || RC=1
|
||||
scenario_replication fix bound || RC=1
|
||||
scenario_existing_install main || RC=1
|
||||
scenario_existing_install fix || RC=1
|
||||
|
||||
echo
|
||||
if [ "$RC" -eq 0 ]; then
|
||||
echo "── all scenarios matched expectations ──"
|
||||
else
|
||||
echo "── FAILURE: one or more scenarios diverged ──"
|
||||
fi
|
||||
exit $RC
|
||||
'
|
||||
@@ -53,17 +53,7 @@ CONF
|
||||
echo "Starting Numa on :$PORT ($SUITE_NAME)..."
|
||||
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
|
||||
NUMA_PID=$!
|
||||
sleep 2
|
||||
|
||||
# Wait for blocklist to load (if blocking is enabled in this suite)
|
||||
if echo "$SUITE_CONFIG" | grep -q 'enabled = true'; then
|
||||
for i in $(seq 1 20); do
|
||||
LOADED=$(curl -sf http://127.0.0.1:$API_PORT/blocking/stats 2>/dev/null \
|
||||
| grep -o '"domains_loaded":[0-9]*' | cut -d: -f2)
|
||||
if [ "${LOADED:-0}" -gt 0 ]; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
sleep 4
|
||||
|
||||
if ! kill -0 "$NUMA_PID" 2>/dev/null; then
|
||||
echo "Failed to start Numa:"
|
||||
@@ -632,54 +622,6 @@ CONF
|
||||
"10.0.0.1" \
|
||||
"$($KDIG +short dot-test.example A 2>/dev/null)"
|
||||
|
||||
echo ""
|
||||
echo "=== DNS-over-HTTPS (RFC 8484) ==="
|
||||
|
||||
DOH_QUERY_FILE=/tmp/numa-doh-query.bin
|
||||
DOH_RESP_FILE=/tmp/numa-doh-resp.bin
|
||||
|
||||
# Build DNS wire-format query for dot-test.example A
|
||||
printf '\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x08dot-test\x07example\x00\x00\x01\x00\x01' > "$DOH_QUERY_FILE"
|
||||
|
||||
# POST valid DoH query
|
||||
DOH_CODE=$(curl -sk -X POST \
|
||||
--resolve "numa.numa:$PROXY_HTTPS_PORT:127.0.0.1" \
|
||||
-H "Content-Type: application/dns-message" \
|
||||
--data-binary @"$DOH_QUERY_FILE" \
|
||||
--cacert "$CA" \
|
||||
-o "$DOH_RESP_FILE" \
|
||||
-w "%{http_code}" \
|
||||
"https://numa.numa:$PROXY_HTTPS_PORT/dns-query")
|
||||
check "DoH POST returns HTTP 200" "200" "$DOH_CODE"
|
||||
|
||||
# Check response contains IP 10.0.0.1 (hex: 0a000001)
|
||||
DOH_HEX=$(xxd -p "$DOH_RESP_FILE" | tr -d '\n')
|
||||
if echo "$DOH_HEX" | grep -q "0a000001"; then
|
||||
check "DoH response resolves dot-test.example → 10.0.0.1" "found" "found"
|
||||
else
|
||||
check "DoH response resolves dot-test.example → 10.0.0.1" "0a000001" "$DOH_HEX"
|
||||
fi
|
||||
|
||||
# Wrong Content-Type → 415
|
||||
DOH_CT_CODE=$(curl -sk -X POST \
|
||||
-H "Host: numa.numa" \
|
||||
-H "Content-Type: text/plain" \
|
||||
--data-binary @"$DOH_QUERY_FILE" \
|
||||
-o /dev/null -w "%{http_code}" \
|
||||
"https://127.0.0.1:$PROXY_HTTPS_PORT/dns-query")
|
||||
check "DoH wrong Content-Type → 415" "415" "$DOH_CT_CODE"
|
||||
|
||||
# Wrong host → 404 (DoH only serves numa.numa)
|
||||
DOH_HOST_CODE=$(curl -sk -X POST \
|
||||
-H "Host: foo.numa" \
|
||||
-H "Content-Type: application/dns-message" \
|
||||
--data-binary @"$DOH_QUERY_FILE" \
|
||||
-o /dev/null -w "%{http_code}" \
|
||||
"https://127.0.0.1:$PROXY_HTTPS_PORT/dns-query")
|
||||
check "DoH wrong host → 404" "404" "$DOH_HOST_CODE"
|
||||
|
||||
rm -f "$DOH_QUERY_FILE" "$DOH_RESP_FILE"
|
||||
|
||||
echo ""
|
||||
echo "=== Proxy TLS works with DoT enabled ==="
|
||||
|
||||
|
||||
Reference in New Issue
Block a user