Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d4e48bbd6 | ||
|
|
724c4a6017 | ||
|
|
2b29a44ee0 | ||
|
|
588e5226fd | ||
|
|
501902d569 | ||
|
|
77d2c8bbcd | ||
|
|
274338e7f9 | ||
|
|
305935ed98 | ||
|
|
bd505813b6 | ||
|
|
115a55b199 | ||
|
|
3665deb56b | ||
|
|
c074d728e9 | ||
|
|
2101dfcf17 | ||
|
|
27dc53aebb | ||
|
|
8085c10687 | ||
|
|
02e1449a45 | ||
|
|
50828c411a | ||
|
|
5184891985 | ||
|
|
6d9ee14ea6 | ||
|
|
3c49b0e65d | ||
|
|
8ef95383a2 | ||
|
|
571ce2f013 | ||
|
|
043a7e1ba5 | ||
|
|
05d5a5145f | ||
|
|
15058aea83 | ||
|
|
628ed00074 | ||
|
|
85cff052a4 | ||
|
|
67b472fea7 | ||
|
|
700cca9cb6 | ||
|
|
f705f8c49f | ||
|
|
17a1a6ddba | ||
|
|
72b540a44a | ||
|
|
c1b651aa63 | ||
|
|
5d9a3a809b | ||
|
|
7efac85836 | ||
|
|
4f46550283 | ||
|
|
05baad0cc0 | ||
|
|
7047767dc2 | ||
|
|
22bebb85a0 | ||
|
|
289f2b973b | ||
|
|
fb4cbe0b2a | ||
|
|
2de1bc2efc | ||
|
|
156b68de87 | ||
|
|
7d6b0ed568 | ||
|
|
7770129589 | ||
|
|
8abcd91f95 | ||
|
|
a96b84fdeb | ||
|
|
23ff3ce455 | ||
|
|
2c20c56421 | ||
|
|
921ed68d54 | ||
|
|
8da03b1b8c |
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
|
||||
run: sudo apt-get install -y pandoc
|
||||
uses: pandoc/actions/setup@v1
|
||||
- name: Generate blog HTML
|
||||
run: make blog
|
||||
- name: Setup Pages
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
||||
CLAUDE.md
|
||||
docs/
|
||||
site/blog/posts/
|
||||
ios/
|
||||
|
||||
462
Cargo.lock
generated
462
Cargo.lock
generated
@@ -82,6 +82,12 @@ 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"
|
||||
@@ -142,6 +148,17 @@ 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"
|
||||
@@ -410,6 +427,21 @@ 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"
|
||||
@@ -493,6 +525,18 @@ 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"
|
||||
@@ -554,6 +598,12 @@ 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"
|
||||
@@ -679,11 +729,24 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"r-efi 5.3.0",
|
||||
"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"
|
||||
@@ -714,12 +777,82 @@ 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"
|
||||
@@ -802,7 +935,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -909,6 +1042,12 @@ 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"
|
||||
@@ -937,7 +1076,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1029,6 +1183,12 @@ 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"
|
||||
@@ -1041,6 +1201,15 @@ 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"
|
||||
@@ -1098,6 +1267,23 @@ 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"
|
||||
@@ -1144,13 +1330,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "numa"
|
||||
version = "0.11.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
"criterion",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"hickory-proto",
|
||||
"hickory-resolver",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
@@ -1170,6 +1358,8 @@ dependencies = [
|
||||
"tokio-rustls",
|
||||
"toml",
|
||||
"tower",
|
||||
"webpki-roots 1.0.6",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1186,6 +1376,10 @@ 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"
|
||||
@@ -1209,6 +1403,29 @@ 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"
|
||||
@@ -1304,6 +1521,16 @@ 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"
|
||||
@@ -1389,6 +1616,12 @@ 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"
|
||||
@@ -1452,6 +1685,15 @@ 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"
|
||||
@@ -1517,9 +1759,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -1617,6 +1865,18 @@ 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"
|
||||
@@ -1779,6 +2039,12 @@ 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"
|
||||
@@ -2037,6 +2303,12 @@ 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"
|
||||
@@ -2067,6 +2339,17 @@ 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"
|
||||
@@ -2101,6 +2384,15 @@ 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"
|
||||
@@ -2156,6 +2448,40 @@ 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"
|
||||
@@ -2176,6 +2502,15 @@ 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"
|
||||
@@ -2185,6 +2520,12 @@ 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"
|
||||
@@ -2222,6 +2563,35 @@ 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"
|
||||
@@ -2389,6 +2759,88 @@ 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.11.0"
|
||||
version = "0.13.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,12 +30,16 @@ tokio-rustls = "0.26"
|
||||
arc-swap = "1"
|
||||
ring = "0.17"
|
||||
rustls-pemfile = "2.2.0"
|
||||
qrcode = { version = "0.14", default-features = false }
|
||||
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||
webpki-roots = "1"
|
||||
|
||||
[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"
|
||||
@@ -48,3 +52,7 @@ harness = false
|
||||
[[bench]]
|
||||
name = "dnssec"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "recursive_compare"
|
||||
harness = false
|
||||
|
||||
27
README.md
27
README.md
@@ -77,6 +77,14 @@ DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification,
|
||||
|
||||
ALPN `"dot"` is advertised and enforced in both modes; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense.
|
||||
|
||||
**Phone setup** — point your iPhone or Android at Numa in one step:
|
||||
|
||||
```bash
|
||||
numa setup-phone
|
||||
```
|
||||
|
||||
Prints a QR code. Scan it, install the profile, toggle certificate trust — your phone's DNS now routes through Numa over TLS. Requires `[mobile] enabled = true` in `numa.toml`.
|
||||
|
||||
## LAN Discovery
|
||||
|
||||
Run Numa on multiple machines. They find each other automatically via mDNS:
|
||||
@@ -105,17 +113,22 @@ 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) | Needs cloudflared | Yes | — | Native |
|
||||
| Encrypted upstream (DoH/DoT) | Needs cloudflared | DoH only | DoT only | DoH + DoT (`tls://`) |
|
||||
| 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
|
||||
|
||||
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/)
|
||||
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/)
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Blog: DNS-over-TLS from Scratch in Rust](https://numa.rs/blog/posts/dot-from-scratch.html)
|
||||
- [Blog: Implementing DNSSEC from Scratch in Rust](https://numa.rs/blog/posts/dnssec-from-scratch.html)
|
||||
- [Blog: I Built a DNS Resolver from Scratch](https://numa.rs/blog/posts/dns-from-scratch.html)
|
||||
- [Configuration reference](numa.toml) — all options documented inline
|
||||
@@ -126,10 +139,16 @@ 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
|
||||
- [x] DNS-over-TLS listener — encrypted client connections (RFC 7858, ALPN strict)
|
||||
- [x] DNS-over-HTTPS — encrypted upstream + server endpoint (RFC 8484)
|
||||
- [x] DNS-over-TLS — encrypted client listener (RFC 7858) + upstream forwarding (`tls://`)
|
||||
- [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
|
||||
|
||||
|
||||
30
benches/numa-bench-recursive.toml
Normal file
30
benches/numa-bench-recursive.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[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
|
||||
31
benches/numa-bench.toml
Normal file
31
benches/numa-bench.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
[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
|
||||
1100
benches/recursive_compare.rs
Normal file
1100
benches/recursive_compare.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -163,12 +163,12 @@ The fix has three parts:
|
||||
|
||||
**TCP fallback.** Every outbound query tries UDP first (800ms timeout). If UDP fails or the response is truncated, retry immediately over TCP. TCP uses a 2-byte length prefix before the DNS message — trivial to implement, and it handles DNSSEC responses that exceed the UDP payload limit.
|
||||
|
||||
**UDP auto-disable.** After 3 consecutive UDP failures, flip a global `AtomicBool` and skip UDP entirely — go TCP-first for all queries. This avoids burning 800ms per hop on a network where UDP will never work. The flag resets when the network changes (detected via LAN IP monitoring).
|
||||
**UDP auto-disable.** After 3 consecutive UDP failures, flip a global `AtomicBool` and skip UDP entirely — go TCP-first for all queries. The flag resets when the network changes (detected via LAN IP monitoring).
|
||||
|
||||
<img src="../hostile-network.svg" alt="Latency profile on a hostile network: queries 1-3 each spend 800ms waiting for a UDP timeout before retrying over TCP, taking 1,100ms total per query. After 3 consecutive failures the UDP auto-disable flag flips, and queries 4+ go TCP-first and complete in 300ms each — 3.7× faster.">
|
||||
|
||||
**Query minimization (RFC 7816).** When querying root servers, send only the TLD — `com` instead of `secret-project.example.com`. Root servers handle trillions of queries and are operated by 12 organizations. Minimization reduces what they learn from yours.
|
||||
|
||||
The result: on a network that blocks UDP:53, Numa detects the block within the first 3 queries, switches to TCP, and resolves normally at 300-500ms per cold query. Cached queries remain 0ms. No manual config change needed — switch networks and it adapts.
|
||||
|
||||
I wouldn't have found this without dogfooding. The code worked perfectly on my home network. It took a real hostile network to expose the assumption that UDP always works.
|
||||
|
||||
## What I learned
|
||||
|
||||
176
blog/dot-from-scratch.md
Normal file
176
blog/dot-from-scratch.md
Normal file
@@ -0,0 +1,176 @@
|
||||
---
|
||||
title: DNS-over-TLS from Scratch in Rust
|
||||
description: Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, and two bugs that only the strict clients caught.
|
||||
date: April 2026
|
||||
---
|
||||
|
||||
The [previous post](/blog/posts/dnssec-from-scratch.html) ended with "DoT — the last encrypted transport we don't support." This post is about building it.
|
||||
|
||||
Numa now runs a DoT listener on port 853. My iPhone uses it as its system resolver, so ad blocking, DNSSEC validation, and recursive resolution follow my phone through the day. No cloud, no account, no companion app — a self-signed cert, a `.mobileconfig` profile, and a QR code in the terminal.
|
||||
|
||||
RFC 7858 is ten pages. The hard parts weren't in the RFC. They were in cross-protocol confusion defenses, a crypto-provider init gotcha that only triggered in one specific config combination, and a certificate SAN bug iOS was happy to accept and `kdig` immediately rejected. This post is about those parts.
|
||||
|
||||
## Why DoT when you already have DoH?
|
||||
|
||||
Numa has shipped DoH since v0.1. Both protocols tunnel DNS over TLS; DoH wraps queries in HTTP/2, DoT is DNS-over-TCP with TLS in front. Same privacy guarantees, different wrapper.
|
||||
|
||||
The answer to "why both" is that **phones ask for DoT by name.** iOS system DNS configures it with two fields (IP + server name) instead of a URL template. Android 9+ "Private DNS" speaks DoT natively. Linux stubs default to DoT. I wanted my phone on Numa without installing anything on the phone itself, and DoT is the protocol iOS and Android already speak for that.
|
||||
|
||||
## The wire format is refreshingly small
|
||||
|
||||
RFC 7858 is one sentence of wire protocol: *DNS-over-TCP (RFC 1035 §4.2.2) with TLS in front, on port 853.* DNS-over-TCP has existed since 1987 — a 2-byte length prefix followed by the DNS message. DoT is that, wrapped in a TLS session. The entire framing code is seven lines:
|
||||
|
||||
```rust
|
||||
async fn write_framed<S>(stream: &mut S, msg: &[u8]) -> io::Result<()>
|
||||
where S: AsyncWriteExt + Unpin {
|
||||
let mut out = Vec::with_capacity(2 + msg.len());
|
||||
out.extend_from_slice(&(msg.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(msg);
|
||||
stream.write_all(&out).await?;
|
||||
stream.flush().await
|
||||
}
|
||||
```
|
||||
|
||||
Reads are symmetric: `read_exact` two bytes, convert to `u16`, `read_exact` that many bytes. No HTTP headers, no chunked encoding, no framing layer.
|
||||
|
||||
## Persistent connections
|
||||
|
||||
A fresh TCP+TLS handshake is at least 3 RTTs — about 300ms on a 100ms connection, 60× the cost of a UDP query. RFC 7858 §3.4 says clients SHOULD reuse the TCP connection for multiple queries, and every real DoT client does: iOS, Android, systemd, stubby. A single connection often carries hundreds of queries.
|
||||
|
||||
<img src="../dot-handshake.svg" alt="Timing diagram comparing a DNS lookup over plain UDP (1 RTT), over DoT on a fresh connection (3 RTTs — TCP handshake, TLS 1.3 handshake, then the query), and over a reused DoT session (1 RTT, same as UDP).">
|
||||
|
||||
The amortization point is the whole game. If you only ever do one query per connection, DoT is roughly 3× slower than UDP and you should not use it. If you reuse the same TLS session for a browsing session's worth of queries, the handshake is paid once and every subsequent query is effectively free.
|
||||
|
||||
The server is a loop that reads a length-prefixed message, resolves it, writes the response framed the same way, waits for the next one. Three timeouts keep it honest:
|
||||
|
||||
- **Handshake timeout (10s)** — a slowloris that opens TCP but never sends a ClientHello can't pin a worker.
|
||||
- **Idle timeout (30s)** — a connected client with nothing to say gets dropped.
|
||||
- **Write timeout (10s)** — a stalled reader can't hold a response buffer indefinitely.
|
||||
|
||||
A semaphore caps concurrent connections at 512 so a burst of handshakes can't exhaust the tokio runtime.
|
||||
|
||||
## ALPN, the cross-protocol defense that matters
|
||||
|
||||
If DoT lives on port 853 and HTTPS on 443, what stops an HTTP/2 client from hitting 853 and getting confused replies? [Cross-protocol attacks](https://alpaca-attack.com/) exist and have had real CVEs. The defense is ALPN: during the TLS handshake the client advertises protocols, the server picks one it supports or fails. A DoT server advertises `"dot"`; a client offering only `"h2"` gets a `no_application_protocol` fatal alert before any frames are exchanged.
|
||||
|
||||
rustls enforces this by default when you set `alpn_protocols`:
|
||||
|
||||
```rust
|
||||
let mut config = ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)?;
|
||||
config.alpn_protocols = vec![b"dot".to_vec()];
|
||||
```
|
||||
|
||||
"The library enforces it by default" has a latent risk: a future rustls upgrade could change the default, and the defense would quietly evaporate. I wrote a test that pins the behavior so any regression in a dependency update fails loudly:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn dot_rejects_non_dot_alpn() {
|
||||
let (addr, cert_der) = spawn_dot_server().await;
|
||||
let client_config = dot_client(&cert_der, vec![b"h2".to_vec()]);
|
||||
let connector = tokio_rustls::TlsConnector::from(client_config);
|
||||
let tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
let result = connector
|
||||
.connect(ServerName::try_from("numa.numa").unwrap(), tcp)
|
||||
.await;
|
||||
assert!(result.is_err(),
|
||||
"DoT server must reject ALPN that doesn't include \"dot\"");
|
||||
}
|
||||
```
|
||||
|
||||
When you're leaning on a library's default for a security-critical invariant, the test is the contract.
|
||||
|
||||
## Two bugs that hid for days
|
||||
|
||||
Both were fixed before v0.10 shipped. Both stayed hidden because my initial tests used *permissive* clients.
|
||||
|
||||
### The rustls crypto provider panic
|
||||
|
||||
rustls 0.23 requires a `CryptoProvider` installed before you can build a `ServerConfig`. Numa's HTTPS proxy calls `install_default` as a side effect when it builds its own config, so DoT "just worked" for users who enabled both — the proxy had already initialized the provider before DoT's first handshake.
|
||||
|
||||
Then I added support for user-provided DoT certificates. Someone running DoT with their own Let's Encrypt cert, with the HTTPS proxy disabled, would hit:
|
||||
|
||||
```
|
||||
thread 'dot' panicked at rustls-0.23.25/src/crypto/mod.rs:185:14:
|
||||
no process-level CryptoProvider available -- call
|
||||
CryptoProvider::install_default() before this point
|
||||
```
|
||||
|
||||
The panic happened on the first client connection, not at startup. While writing the integration suite for "DoT with BYO cert, proxy disabled" — the one combination nobody had ever actually exercised — the first run panicked. Fix is two lines: call `install_default` inside `load_tls_config` so DoT can stand alone. If a side effect initializes something and you have a path that skips that side effect, you have a bug waiting for a specific deployment.
|
||||
|
||||
### The SAN bug iOS was happy to accept
|
||||
|
||||
Numa's self-signed DoT cert is generated on first run from a local CA alongside the data directory. It needs to match whatever `ServerName` the client sends as SNI. For the HTTPS proxy, that's the wildcard domain pattern `*.numa` (matching `frontend.numa`, `api.numa`, etc.). I initially reused the same SAN list for DoT: a wildcard `*.numa` and nothing else.
|
||||
|
||||
On an iPhone this worked perfectly. Full browsing session, persistent connections in the log, ad blocking active. I was about to merge when I ran one last smoke test with `kdig` (GnuTLS-backed, from [Knot DNS](https://www.knot-dns.cz/)):
|
||||
|
||||
```
|
||||
$ kdig @192.168.1.16 -p 853 +tls \
|
||||
+tls-ca=/usr/local/var/numa/ca.pem \
|
||||
+tls-hostname=numa.numa example.com A
|
||||
|
||||
;; TLS, handshake failed (Error in the certificate.)
|
||||
```
|
||||
|
||||
Huh.
|
||||
|
||||
[RFC 6125 §6.4.3](https://datatracker.ietf.org/doc/html/rfc6125#section-6.4.3): a wildcard in a certificate's DNS-ID matches exactly one label. `*.numa` matches `frontend.numa`, but not `numa.numa`, because the wildcard wants at least one label to substitute and strict clients reject wildcards in the leftmost label under single-label TLDs as ambiguous.
|
||||
|
||||
iOS's TLS stack is lenient and accepts it. GnuTLS, NSS (Firefox), and most non-Apple validators don't. The fix is five lines — add an explicit `numa.numa` SAN alongside the wildcard. But the lesson is the one that stuck: I wrote a commit message saying "fix an iOS bug" and had to rewrite it, because iOS was fine. The real bug was that every GnuTLS/NSS-based client on the planet would have rejected the cert, and I only found it by running one more test with a stricter tool.
|
||||
|
||||
> Test with the strict client. The permissive client hides your bugs.
|
||||
|
||||
## Getting your phone onto it
|
||||
|
||||
A DoT server is useless without a way to point a phone at it. iOS won't let you type an IP and a server name into Settings directly — you install a `.mobileconfig` profile that bundles the CA as a trust anchor and the DNS settings in a single payload.
|
||||
|
||||
Numa ships a subcommand that builds one on the fly and serves it over a QR code in the terminal:
|
||||
|
||||
```
|
||||
$ numa setup-phone
|
||||
|
||||
Numa Phone Setup
|
||||
|
||||
Profile URL: http://192.168.1.10: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
|
||||
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.
|
||||
|
||||
## What I learned
|
||||
|
||||
**RFC-level small, API-level hard.** RFC 7858 is ten pages. The framing is trivial. But the subtle stuff — ALPN, timeouts, connection caps, handshake vs idle vs write deadlines, backoff on accept errors — isn't in the RFC. Miss any of it and you leak a DoS vector or a protocol confusion hole.
|
||||
|
||||
**Your test matrix is your security matrix.** Both bugs in this post were hidden by lenient clients. In both cases the strict client — kdig, or a specific config combination — surfaced the bug instantly. Pick test tools for strictness, not convenience. The moment you find yourself thinking "but iOS accepts it," stop and run kdig.
|
||||
|
||||
**Don't initialize global state via side effects.** "Module A installs a global, module B silently depends on it, disabling A breaks B" is a bug pattern that keeps coming back. Fix: have module B initialize its dependency explicitly, even if it means calling an idempotent `install_default` twice. The dependency graph should be local and obvious.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
|
||||
The code is at [github.com/razvandimescu/numa](https://github.com/razvandimescu/numa) — the DoT listener is in [`src/dot.rs`](https://github.com/razvandimescu/numa/blob/main/src/dot.rs) and the phone onboarding flow is in [`src/setup_phone.rs`](https://github.com/razvandimescu/numa/blob/main/src/setup_phone.rs) and [`src/mobileconfig.rs`](https://github.com/razvandimescu/numa/blob/main/src/mobileconfig.rs). MIT license.
|
||||
26
numa.toml
26
numa.toml
@@ -12,11 +12,18 @@ 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 = "https://cloudflare-dns.com/dns-query" # Cloudflare DoH
|
||||
# address = "9.9.9.9" # plain UDP
|
||||
# port = 53 # only for forward mode, plain UDP
|
||||
# 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
|
||||
# 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)
|
||||
@@ -44,6 +51,14 @@ 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
|
||||
@@ -51,9 +66,10 @@ api_port = 5380
|
||||
# allowlist = ["example.com"] # domains to never block
|
||||
|
||||
[cache]
|
||||
max_entries = 10000
|
||||
max_entries = 100000
|
||||
min_ttl = 60
|
||||
max_ttl = 86400
|
||||
# warm = ["google.com", "github.com"] # resolve at startup, refresh before TTL expiry
|
||||
|
||||
[proxy]
|
||||
enabled = true
|
||||
@@ -91,7 +107,7 @@ tld = "numa"
|
||||
|
||||
# DNS-over-TLS listener (RFC 7858) — encrypted DNS on port 853
|
||||
# [dot]
|
||||
# enabled = false # opt-in: accept DoT queries
|
||||
# enabled = true # on by default; set false to disable
|
||||
# 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,18 +7,19 @@
|
||||
# The script:
|
||||
# 1. Opens the dashboard in Chrome --app mode (clean, no address bar)
|
||||
# 2. Generates DNS traffic (forward, cache hit, blocked)
|
||||
# 3. Types "peekm" / "6419" into the Local Services form on camera
|
||||
# 4. 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
|
||||
# 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
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# --------------- Configuration ---------------
|
||||
OUTPUT="${1:-assets/hero-demo.gif}"
|
||||
PORT=5380
|
||||
RECORD_SECONDS=20
|
||||
RECORD_SECONDS=24
|
||||
VIEWPORT_W=1800
|
||||
VIEWPORT_H=1100
|
||||
FPS=12
|
||||
@@ -230,8 +231,16 @@ 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: Add peekm service via UI (3-7s) ---------------
|
||||
log "Scene 2: Adding peekm.numa service..."
|
||||
# --------------- 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..."
|
||||
|
||||
# Services panel is now first — scroll to it
|
||||
run_js "
|
||||
@@ -249,18 +258,18 @@ sleep 0.3
|
||||
run_js "document.querySelector('#serviceForm .btn-add').click();"
|
||||
sleep 2
|
||||
|
||||
# --------------- Scene 3: Open peekm.numa (7-11s) ---------------
|
||||
log "Scene 3: Opening peekm.numa in browser..."
|
||||
# --------------- Scene 4: Open peekm.numa (11-15s) ---------------
|
||||
log "Scene 4: Opening peekm.numa in browser..."
|
||||
open "http://peekm.numa/view/peekm/README.md" 2>/dev/null || true
|
||||
sleep 4
|
||||
|
||||
# --------------- Scene 4: Back to dashboard (11-14s) ---------------
|
||||
log "Scene 4: Back to dashboard — LAN badges + LOCAL queries visible..."
|
||||
# --------------- Scene 5: Back to dashboard (15-18s) ---------------
|
||||
log "Scene 5: Back to dashboard — LAN badges + LOCAL queries visible..."
|
||||
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $CHROME_PID) to true" 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
# --------------- Scene 5: Check Domain blocker (14-17s) ---------------
|
||||
log "Scene 5: Check Domain — blocked tracker..."
|
||||
# --------------- Scene 6: Check Domain blocker (18-21s) ---------------
|
||||
log "Scene 6: Check Domain — blocked tracker..."
|
||||
# Scroll down to blocking panel
|
||||
run_js "
|
||||
var blockPanel = document.getElementById('blockingPanel');
|
||||
@@ -273,8 +282,8 @@ sleep 0.3
|
||||
run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();"
|
||||
sleep 2
|
||||
|
||||
# --------------- Scene 6: Terminal-style dig overlay (17-20s) ---------------
|
||||
log "Scene 6: dig proof overlay..."
|
||||
# --------------- Scene 7: Terminal-style dig overlay (21-24s) ---------------
|
||||
log "Scene 7: 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');
|
||||
|
||||
@@ -74,6 +74,7 @@ body::before {
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
text-transform: none;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.blog-nav .wordmark:hover { color: var(--amber); }
|
||||
|
||||
129
site/blog/dot-handshake.svg
Normal file
129
site/blog/dot-handshake.svg
Normal file
@@ -0,0 +1,129 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 360" font-family="'DM Sans', system-ui, sans-serif" font-size="12">
|
||||
<defs>
|
||||
<marker id="arr-amber" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#c0623a"/>
|
||||
</marker>
|
||||
<marker id="arr-dim" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#a39888"/>
|
||||
</marker>
|
||||
<filter id="shadow" x="-3%" y="-3%" width="106%" height="106%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.06"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="720" height="360" rx="8" fill="#faf7f2"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="360" y="32" text-anchor="middle" font-size="15" font-weight="600" fill="#2c2418" font-family="'Instrument Serif', Georgia, serif" letter-spacing="-0.02em">UDP vs DoT — one lookup, three scenarios</text>
|
||||
<text x="360" y="50" text-anchor="middle" font-size="11" fill="#a39888">Time flows downward. Amber = DNS work. Gray = TCP/TLS handshake overhead.</text>
|
||||
|
||||
<!-- ==================== Column 1: Plain UDP ==================== -->
|
||||
<g transform="translate(20, 0)">
|
||||
<!-- Column header -->
|
||||
<text x="90" y="84" text-anchor="middle" font-size="13" font-weight="600" fill="#2c2418">Plain UDP DNS</text>
|
||||
<text x="90" y="101" text-anchor="middle" font-size="10" fill="#a39888" letter-spacing="0.06em">PORT 53 · CLEARTEXT</text>
|
||||
|
||||
<!-- Lane labels -->
|
||||
<text x="25" y="128" font-size="10" fill="#6b5e4f">client</text>
|
||||
<text x="133" y="128" font-size="10" fill="#6b5e4f">server</text>
|
||||
|
||||
<!-- Lanes -->
|
||||
<line x1="35" y1="138" x2="35" y2="198" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
|
||||
<line x1="145" y1="138" x2="145" y2="198" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
|
||||
|
||||
<!-- query -->
|
||||
<line x1="37" y1="148" x2="143" y2="160" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
|
||||
<text x="90" y="143" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">query</text>
|
||||
|
||||
<!-- response -->
|
||||
<line x1="143" y1="178" x2="37" y2="190" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
|
||||
<text x="90" y="205" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">response</text>
|
||||
|
||||
<!-- Total cost badge -->
|
||||
<rect x="20" y="225" width="140" height="32" rx="4" fill="#faf7f2" stroke="#d4cbba" stroke-width="1" filter="url(#shadow)"/>
|
||||
<text x="90" y="241" text-anchor="middle" font-size="9" fill="#a39888" letter-spacing="0.04em">TOTAL LATENCY</text>
|
||||
<text x="90" y="253" text-anchor="middle" font-size="11" font-weight="600" fill="#c0623a" font-family="'JetBrains Mono', monospace">1 × RTT</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== Column 2: DoT cold ==================== -->
|
||||
<g transform="translate(270, 0)">
|
||||
<!-- Column header -->
|
||||
<text x="90" y="84" text-anchor="middle" font-size="13" font-weight="600" fill="#2c2418">DoT — first query</text>
|
||||
<text x="90" y="101" text-anchor="middle" font-size="10" fill="#a39888" letter-spacing="0.06em">PORT 853 · NEW CONNECTION</text>
|
||||
|
||||
<!-- Lane labels -->
|
||||
<text x="25" y="128" font-size="10" fill="#6b5e4f">client</text>
|
||||
<text x="133" y="128" font-size="10" fill="#6b5e4f">server</text>
|
||||
|
||||
<!-- Lanes -->
|
||||
<line x1="35" y1="138" x2="35" y2="308" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
|
||||
<line x1="145" y1="138" x2="145" y2="308" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
|
||||
|
||||
<!-- === RTT 1: TCP handshake === -->
|
||||
<!-- SYN -->
|
||||
<line x1="37" y1="145" x2="143" y2="153" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
|
||||
<!-- SYN-ACK -->
|
||||
<line x1="143" y1="163" x2="37" y2="171" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
|
||||
<!-- ACK -->
|
||||
<line x1="37" y1="181" x2="143" y2="189" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
|
||||
<!-- Label + RTT marker -->
|
||||
<text x="168" y="170" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">1 rtt</text>
|
||||
<text x="90" y="143" text-anchor="middle" font-size="9" fill="#6b5e4f" font-style="italic">TCP handshake</text>
|
||||
|
||||
<!-- === RTT 2: TLS 1.3 handshake === -->
|
||||
<!-- ClientHello -->
|
||||
<line x1="37" y1="208" x2="143" y2="216" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
|
||||
<!-- ServerHello + Cert + Finished -->
|
||||
<line x1="143" y1="226" x2="37" y2="234" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
|
||||
<!-- Label + RTT marker -->
|
||||
<text x="168" y="222" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">2 rtt</text>
|
||||
<text x="90" y="205" text-anchor="middle" font-size="9" fill="#6b5e4f" font-style="italic">TLS 1.3 handshake</text>
|
||||
|
||||
<!-- === RTT 3: DNS exchange === -->
|
||||
<!-- query (piggybacked on ClientFinished) -->
|
||||
<line x1="37" y1="253" x2="143" y2="261" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
|
||||
<!-- response -->
|
||||
<line x1="143" y1="271" x2="37" y2="279" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
|
||||
<!-- Label + RTT marker -->
|
||||
<text x="168" y="267" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">3 rtt</text>
|
||||
<text x="90" y="250" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">query + response</text>
|
||||
|
||||
<!-- Total cost badge -->
|
||||
<rect x="20" y="295" width="140" height="32" rx="4" fill="#faf7f2" stroke="#d4cbba" stroke-width="1" filter="url(#shadow)"/>
|
||||
<text x="90" y="311" text-anchor="middle" font-size="9" fill="#a39888" letter-spacing="0.04em">TOTAL LATENCY</text>
|
||||
<text x="90" y="323" text-anchor="middle" font-size="11" font-weight="600" fill="#c0623a" font-family="'JetBrains Mono', monospace">3 × RTT</text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== Column 3: DoT reused ==================== -->
|
||||
<g transform="translate(520, 0)">
|
||||
<!-- Column header -->
|
||||
<text x="90" y="84" text-anchor="middle" font-size="13" font-weight="600" fill="#2c2418">DoT — reused session</text>
|
||||
<text x="90" y="101" text-anchor="middle" font-size="10" fill="#a39888" letter-spacing="0.06em">PORT 853 · PERSISTENT TCP/TLS</text>
|
||||
|
||||
<!-- Lane labels -->
|
||||
<text x="25" y="128" font-size="10" fill="#6b5e4f">client</text>
|
||||
<text x="133" y="128" font-size="10" fill="#6b5e4f">server</text>
|
||||
|
||||
<!-- Lanes -->
|
||||
<line x1="35" y1="138" x2="35" y2="198" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
|
||||
<line x1="145" y1="138" x2="145" y2="198" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
|
||||
|
||||
<!-- query -->
|
||||
<line x1="37" y1="148" x2="143" y2="160" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
|
||||
<text x="90" y="143" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">query</text>
|
||||
|
||||
<!-- response -->
|
||||
<line x1="143" y1="178" x2="37" y2="190" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
|
||||
<text x="90" y="205" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">response</text>
|
||||
|
||||
<!-- Total cost badge -->
|
||||
<rect x="20" y="225" width="140" height="32" rx="4" fill="#faf7f2" stroke="#d4cbba" stroke-width="1" filter="url(#shadow)"/>
|
||||
<text x="90" y="241" text-anchor="middle" font-size="9" fill="#a39888" letter-spacing="0.04em">TOTAL LATENCY</text>
|
||||
<text x="90" y="253" text-anchor="middle" font-size="11" font-weight="600" fill="#c0623a" font-family="'JetBrains Mono', monospace">1 × RTT</text>
|
||||
|
||||
<!-- Tiny caption -->
|
||||
<text x="90" y="280" text-anchor="middle" font-size="9" fill="#a39888" font-style="italic">(handshake amortized</text>
|
||||
<text x="90" y="292" text-anchor="middle" font-size="9" fill="#a39888" font-style="italic">across the session)</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
92
site/blog/hostile-network.svg
Normal file
92
site/blog/hostile-network.svg
Normal file
@@ -0,0 +1,92 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 330" font-family="'DM Sans', system-ui, sans-serif" font-size="12">
|
||||
<defs>
|
||||
<filter id="shadow" x="-3%" y="-3%" width="106%" height="106%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.06"/>
|
||||
</filter>
|
||||
<!-- Diagonal hatch for "wasted" UDP timeout regions. Darker warm gray
|
||||
base + slightly darker diagonal stripes at 45°. The stripe pattern
|
||||
is the Gantt convention for "dead/blocked time" — it reads as
|
||||
"this time was thrown away" without needing the legend. -->
|
||||
<pattern id="wasted-hatch" patternUnits="userSpaceOnUse" width="7" height="7" patternTransform="rotate(-45)">
|
||||
<rect width="7" height="7" fill="#8b7f6f"/>
|
||||
<line x1="0" y1="0" x2="0" y2="7" stroke="#3d3427" stroke-width="1.6" opacity="0.38"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="720" height="330" rx="8" fill="#faf7f2"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="360" y="32" text-anchor="middle" font-size="15" font-weight="600" fill="#2c2418" font-family="'Instrument Serif', Georgia, serif" letter-spacing="-0.02em">TCP fallback with UDP auto-disable</text>
|
||||
<text x="360" y="50" text-anchor="middle" font-size="11" fill="#a39888">Latency profile on an ISP that blocks outbound UDP:53</text>
|
||||
|
||||
<!-- Legend -->
|
||||
<g transform="translate(160, 70)">
|
||||
<rect width="14" height="12" rx="2" fill="url(#wasted-hatch)"/>
|
||||
<text x="22" y="10" font-size="11" fill="#6b5e4f">UDP timeout — 800 ms wasted</text>
|
||||
<rect x="220" width="14" height="12" rx="2" fill="#c0623a"/>
|
||||
<text x="242" y="10" font-size="11" fill="#6b5e4f">TCP — successful exchange</text>
|
||||
</g>
|
||||
|
||||
<!-- Time axis -->
|
||||
<!-- bar area: x=90 to x=570 (480px), representing 0-1200ms, scale 0.4 px/ms -->
|
||||
<line x1="90" y1="108" x2="570" y2="108" stroke="#d4cbba" stroke-width="1"/>
|
||||
<!-- tick marks -->
|
||||
<line x1="90" y1="106" x2="90" y2="112" stroke="#a39888" stroke-width="1"/>
|
||||
<line x1="210" y1="106" x2="210" y2="112" stroke="#a39888" stroke-width="1"/>
|
||||
<line x1="330" y1="106" x2="330" y2="112" stroke="#a39888" stroke-width="1"/>
|
||||
<line x1="410" y1="106" x2="410" y2="112" stroke="#a39888" stroke-width="1"/>
|
||||
<line x1="530" y1="106" x2="530" y2="112" stroke="#a39888" stroke-width="1"/>
|
||||
<!-- tick labels -->
|
||||
<text x="90" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">0</text>
|
||||
<text x="210" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">300</text>
|
||||
<text x="330" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">600</text>
|
||||
<text x="410" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">800</text>
|
||||
<text x="530" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">1100 ms</text>
|
||||
|
||||
<!-- ============ Phase 1: UDP-first (wasted 800ms per query) ============ -->
|
||||
|
||||
<!-- Query 1 -->
|
||||
<text x="82" y="135" text-anchor="end" font-size="11" fill="#6b5e4f">query 1</text>
|
||||
<rect x="90" y="125" width="320" height="16" rx="2" fill="url(#wasted-hatch)"/>
|
||||
<rect x="410" y="125" width="120" height="16" rx="2" fill="#c0623a"/>
|
||||
<text x="540" y="137" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">1,100 ms</text>
|
||||
|
||||
<!-- Query 2 -->
|
||||
<text x="82" y="159" text-anchor="end" font-size="11" fill="#6b5e4f">query 2</text>
|
||||
<rect x="90" y="149" width="320" height="16" rx="2" fill="url(#wasted-hatch)"/>
|
||||
<rect x="410" y="149" width="120" height="16" rx="2" fill="#c0623a"/>
|
||||
<text x="540" y="161" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">1,100 ms</text>
|
||||
|
||||
<!-- Query 3 -->
|
||||
<text x="82" y="183" text-anchor="end" font-size="11" fill="#6b5e4f">query 3</text>
|
||||
<rect x="90" y="173" width="320" height="16" rx="2" fill="url(#wasted-hatch)"/>
|
||||
<rect x="410" y="173" width="120" height="16" rx="2" fill="#c0623a"/>
|
||||
<text x="540" y="185" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">1,100 ms</text>
|
||||
|
||||
<!-- State-change divider -->
|
||||
<line x1="90" y1="206" x2="570" y2="206" stroke="#6b7c4e" stroke-width="1" stroke-dasharray="4 3"/>
|
||||
<rect x="200" y="198" width="260" height="18" rx="9" fill="#faf7f2" stroke="#6b7c4e" stroke-width="1" filter="url(#shadow)"/>
|
||||
<text x="330" y="210" text-anchor="middle" font-size="10" fill="#566540" font-weight="500">3 consecutive failures → UDP auto-disabled</text>
|
||||
|
||||
<!-- ============ Phase 2: TCP-first (UDP skipped) ============ -->
|
||||
|
||||
<!-- Query 4 -->
|
||||
<text x="82" y="235" text-anchor="end" font-size="11" fill="#6b5e4f">query 4</text>
|
||||
<rect x="90" y="225" width="120" height="16" rx="2" fill="#c0623a"/>
|
||||
<text x="220" y="237" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">300 ms</text>
|
||||
|
||||
<!-- Query 5 -->
|
||||
<text x="82" y="259" text-anchor="end" font-size="11" fill="#6b5e4f">query 5</text>
|
||||
<rect x="90" y="249" width="120" height="16" rx="2" fill="#c0623a"/>
|
||||
<text x="220" y="261" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">300 ms</text>
|
||||
|
||||
<!-- Speedup callout -->
|
||||
<g transform="translate(300, 246)">
|
||||
<line x1="0" y1="-10" x2="0" y2="22" stroke="#6b7c4e" stroke-width="1" stroke-dasharray="2 2"/>
|
||||
<text x="10" y="6" font-size="10" fill="#566540" font-style="italic">3.7× faster — no more UDP wait</text>
|
||||
</g>
|
||||
|
||||
<!-- Footer caption -->
|
||||
<text x="360" y="298" text-anchor="middle" font-size="10" fill="#a39888" font-style="italic">The flag resets on network change (LAN IP delta). Switch back to a clean network and UDP is tried again.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
@@ -67,6 +67,7 @@ body::before {
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
text-transform: none;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.blog-nav .wordmark:hover { color: var(--amber); }
|
||||
@@ -167,6 +168,13 @@ body::before {
|
||||
<main class="blog-index">
|
||||
<h1>Blog</h1>
|
||||
<ul class="post-list">
|
||||
<li>
|
||||
<a href="/blog/posts/dot-from-scratch.html">
|
||||
<div class="post-title">DNS-over-TLS from Scratch in Rust</div>
|
||||
<div class="post-desc">Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, iPhone dogfooding, and two bugs that only the strict clients caught.</div>
|
||||
<div class="post-date">April 2026</div>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/blog/posts/dnssec-from-scratch.html">
|
||||
<div class="post-title">Implementing DNSSEC from Scratch in Rust</div>
|
||||
|
||||
BIN
site/blog/phone-setup-dashboard.png
Normal file
BIN
site/blog/phone-setup-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 310 KiB |
@@ -223,6 +223,10 @@ 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;
|
||||
@@ -288,6 +292,10 @@ 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 */
|
||||
@@ -554,6 +562,20 @@ 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">
|
||||
@@ -608,6 +630,16 @@ 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 -->
|
||||
@@ -629,6 +661,14 @@ 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>
|
||||
@@ -640,6 +680,7 @@ body {
|
||||
<th>Type</th>
|
||||
<th>Domain</th>
|
||||
<th>Path</th>
|
||||
<th>Transport</th>
|
||||
<th>Result</th>
|
||||
<th>Latency</th>
|
||||
</tr>
|
||||
@@ -788,6 +829,34 @@ 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+$/, '');
|
||||
@@ -865,6 +934,27 @@ 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' },
|
||||
@@ -876,20 +966,23 @@ const PATH_DEFS = [
|
||||
];
|
||||
|
||||
function renderPaths(queries) {
|
||||
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('');
|
||||
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)';
|
||||
}
|
||||
|
||||
function renderQueryLog(entries) {
|
||||
@@ -900,6 +993,7 @@ 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) {
|
||||
@@ -908,6 +1002,9 @@ 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 =
|
||||
@@ -925,6 +1022,7 @@ 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>`;
|
||||
@@ -1058,6 +1156,14 @@ 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;
|
||||
@@ -1091,11 +1197,13 @@ 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';
|
||||
document.getElementById('qps').textContent = `~${qps}/s`;
|
||||
const encTag = q.total > 0 ? ` · ${encPct}% enc` : '';
|
||||
document.getElementById('qps').textContent = `~${qps}/s${encTag}`;
|
||||
}
|
||||
prevTotal = q.total;
|
||||
prevTime = now;
|
||||
@@ -1107,6 +1215,7 @@ async function refresh() {
|
||||
|
||||
// Panels
|
||||
renderPaths(q);
|
||||
renderTransport(stats.transport);
|
||||
renderQueryLog(logs);
|
||||
renderOverrides(overrides);
|
||||
renderCache(cache);
|
||||
|
||||
@@ -188,11 +188,50 @@ p.lead {
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
TOP NAV
|
||||
=========================== */
|
||||
.site-nav {
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.site-nav a:hover { color: var(--amber); }
|
||||
|
||||
.site-nav .wordmark {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
text-transform: none;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.site-nav .wordmark:hover { color: var(--amber); }
|
||||
|
||||
.site-nav .sep {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
HERO
|
||||
=========================== */
|
||||
.hero {
|
||||
min-height: 100vh;
|
||||
min-height: calc(100vh - 5rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
@@ -1158,6 +1197,9 @@ footer .closing {
|
||||
@media (max-width: 600px) {
|
||||
section { padding: 4rem 0; }
|
||||
.container { padding: 0 1.25rem; }
|
||||
.site-nav { padding: 1rem 1.25rem; gap: 1rem; }
|
||||
.site-nav .wordmark { font-size: 1.2rem; }
|
||||
.hero { min-height: calc(100vh - 4rem); }
|
||||
.network-grid { grid-template-columns: 1fr; }
|
||||
.pipeline { flex-direction: column; align-items: stretch; gap: 0; }
|
||||
.pipeline-arrow { transform: rotate(90deg); padding: 0.15rem 0; align-self: center; }
|
||||
@@ -1171,6 +1213,14 @@ footer .closing {
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="site-nav">
|
||||
<a href="/" class="wordmark">Numa</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="/blog/">Blog</a>
|
||||
<span class="sep">/</span>
|
||||
<a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener">GitHub</a>
|
||||
</nav>
|
||||
|
||||
<!-- ==================== HERO ==================== -->
|
||||
<section class="hero">
|
||||
<div class="roman-bricks" aria-hidden="true"></div>
|
||||
@@ -1243,6 +1293,8 @@ footer .closing {
|
||||
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
||||
<li>Recursive resolution — opt-in, resolve from root nameservers, no upstream needed</li>
|
||||
<li>DNSSEC validation — chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li>
|
||||
<li>DNS-over-TLS listener — encrypted DNS for phones and strict clients (RFC 7858 with ALPN defense)</li>
|
||||
<li>Hostile-network resilience — TCP fallback with UDP auto-disable when ISPs block port 53</li>
|
||||
<li>TTL-aware caching (sub-ms lookups)</li>
|
||||
<li>Single binary, portable — macOS, Linux, and Windows</li>
|
||||
</ul>
|
||||
@@ -1261,7 +1313,7 @@ footer .closing {
|
||||
</ul>
|
||||
</div>
|
||||
<div class="layer-card reveal reveal-delay-3">
|
||||
<div class="layer-badge">Coming Next</div>
|
||||
<div class="layer-badge">The Vision</div>
|
||||
<h3>Self-Sovereign DNS</h3>
|
||||
<ul>
|
||||
<li>pkarr integration — DNS via Mainline DHT, no registrar needed</li>
|
||||
@@ -1342,6 +1394,14 @@ footer .closing {
|
||||
<td class="cross">No</td>
|
||||
<td class="check">Root hints + full DNSSEC</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>DNSSEC validation</td>
|
||||
<td class="muted">Passthrough</td>
|
||||
<td class="muted">Cloud only</td>
|
||||
<td class="muted">Cloud only</td>
|
||||
<td class="muted">Passthrough</td>
|
||||
<td class="check">Full chain-of-trust</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Ad & tracker blocking</td>
|
||||
<td class="check">Yes</td>
|
||||
@@ -1398,6 +1458,14 @@ footer .closing {
|
||||
<td class="cross">No</td>
|
||||
<td class="check">Built in (HTTP/2 + rustls)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>DNS-over-TLS listener</td>
|
||||
<td class="cross">No</td>
|
||||
<td class="muted">Cloud only</td>
|
||||
<td class="muted">Cloud only</td>
|
||||
<td class="check">Yes (cert required)</td>
|
||||
<td class="check">Self-signed or BYO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Conditional forwarding</td>
|
||||
<td class="cross">No</td>
|
||||
@@ -1567,11 +1635,14 @@ footer .closing {
|
||||
<dt>Resolution Modes</dt>
|
||||
<dd>Recursive (iterative from root hints, CNAME chasing, glue extraction) or Forward (DoH / plain UDP)</dd>
|
||||
|
||||
<dt>Listeners</dt>
|
||||
<dd>UDP:53 + TCP:53 (plain DNS), DoT:853 (RFC 7858 + ALPN), HTTP proxy :80 / HTTPS proxy :443, dashboard :5380</dd>
|
||||
|
||||
<dt>DNSSEC</dt>
|
||||
<dd>Chain-of-trust via ring — RSA/SHA-256, ECDSA P-256, Ed25519. NSEC/NSEC3 denial proofs. EDNS0 DO bit, 1232-byte payload (DNS Flag Day 2020).</dd>
|
||||
|
||||
<dt>Dependencies</dt>
|
||||
<dd>19 runtime crates — tokio, axum, hyper, ring (DNSSEC), reqwest (DoH), rcgen + rustls (TLS), socket2 (multicast), serde, and more</dd>
|
||||
<dd>A focused set — tokio, axum, hyper, ring (DNSSEC), reqwest (DoH), rcgen + rustls + tokio-rustls (TLS/DoT), socket2 (multicast), serde. No transitive DNS library.</dd>
|
||||
|
||||
<dt>Packet Format</dt>
|
||||
<dd>RFC 1035 compliant. EDNS0 OPT pseudo-record. Parses A, AAAA, NS, CNAME, MX, SOA, SRV, HTTPS, DNSKEY, DS, RRSIG, NSEC, NSEC3.</dd>
|
||||
@@ -1586,7 +1657,7 @@ footer .closing {
|
||||
<span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-fsSL</span> https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh <span class="flag">|</span> <span class="cmd">sh</span>
|
||||
|
||||
<span class="comment"># Run</span>
|
||||
<span class="prompt">$</span> <span class="cmd">sudo numa</span> <span class="comment"># bind to :53, :80, :5380</span>
|
||||
<span class="prompt">$</span> <span class="cmd">sudo numa</span> <span class="comment"># bind :53, :80, :443, :853, :5380</span>
|
||||
<span class="prompt">$</span> <span class="cmd">dig</span> <span class="flag">@127.0.0.1</span> google.com <span class="comment"># test resolution</span>
|
||||
<span class="prompt">$</span> <span class="cmd">open</span> http://localhost:5380 <span class="comment"># dashboard</span>
|
||||
<span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-X POST</span> localhost:5380/services \
|
||||
@@ -1639,16 +1710,28 @@ footer .closing {
|
||||
<span class="phase">Phase 7</span>
|
||||
<span class="phase-desc">DNSSEC validation — chain-of-trust, NSEC/NSEC3 denial proofs, RSA + ECDSA + Ed25519</span>
|
||||
</div>
|
||||
<div class="roadmap-item phase-teal">
|
||||
<div class="roadmap-item done">
|
||||
<span class="phase">Phase 8</span>
|
||||
<span class="phase-desc">Hostile-network resilience — TCP fallback with UDP auto-disable when ISPs block :53, RFC 7816 query minimization</span>
|
||||
</div>
|
||||
<div class="roadmap-item done">
|
||||
<span class="phase">Phase 9</span>
|
||||
<span class="phase-desc">Windows support — cross-platform install/uninstall, <code>netsh</code> DNS config, service integration</span>
|
||||
</div>
|
||||
<div class="roadmap-item done">
|
||||
<span class="phase">Phase 10</span>
|
||||
<span class="phase-desc">DNS-over-TLS listener (RFC 7858) — ALPN enforcement, persistent connections, self-signed or BYO cert</span>
|
||||
</div>
|
||||
<div class="roadmap-item phase-teal">
|
||||
<span class="phase">Phase 11</span>
|
||||
<span class="phase-desc">pkarr integration — self-sovereign DNS via Mainline DHT, no registrar needed</span>
|
||||
</div>
|
||||
<div class="roadmap-item phase-teal">
|
||||
<span class="phase">Phase 9</span>
|
||||
<span class="phase">Phase 12</span>
|
||||
<span class="phase-desc">Global .numa names — self-publish, DHT-backed, first-come-first-served</span>
|
||||
</div>
|
||||
<div class="roadmap-item phase-teal">
|
||||
<span class="phase">Phase 10</span>
|
||||
<span class="phase">Phase 13</span>
|
||||
<span class="phase-desc">.onion bridge — human-readable Tor naming via Ed25519 same-key binding</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
73
src/api.rs
73
src/api.rs
@@ -57,6 +57,7 @@ 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",
|
||||
@@ -151,6 +152,7 @@ struct QueryLogResponse {
|
||||
domain: String,
|
||||
query_type: String,
|
||||
path: String,
|
||||
transport: String,
|
||||
rescode: String,
|
||||
latency_ms: f64,
|
||||
dnssec: String,
|
||||
@@ -166,13 +168,29 @@ 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,
|
||||
@@ -403,9 +421,12 @@ async fn diagnose(
|
||||
}
|
||||
|
||||
// Check upstream (async, no locks held)
|
||||
let upstream = ctx.upstream.lock().unwrap().clone();
|
||||
let (upstream_matched, upstream_detail) =
|
||||
forward_query_for_diagnose(&domain_lower, &upstream, ctx.timeout).await;
|
||||
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())
|
||||
};
|
||||
steps.push(DiagnoseStep {
|
||||
source: "upstream".to_string(),
|
||||
matched: upstream_matched,
|
||||
@@ -472,6 +493,7 @@ 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(),
|
||||
@@ -512,7 +534,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.lock().unwrap().to_string()
|
||||
ctx.upstream_pool.lock().unwrap().label()
|
||||
};
|
||||
|
||||
Json(StatsResponse {
|
||||
@@ -534,6 +556,12 @@ 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,
|
||||
@@ -551,6 +579,10 @@ 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,
|
||||
@@ -931,6 +963,28 @@ 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 {
|
||||
(
|
||||
[
|
||||
@@ -975,6 +1029,7 @@ 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()),
|
||||
@@ -982,13 +1037,17 @@ mod tests {
|
||||
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
||||
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
||||
forwarding_rules: Vec::new(),
|
||||
upstream: Mutex::new(crate::forward::Upstream::Udp(
|
||||
"127.0.0.1:53".parse().unwrap(),
|
||||
upstream_pool: Mutex::new(crate::forward::UpstreamPool::new(
|
||||
vec![crate::forward::Upstream::Udp(
|
||||
"127.0.0.1:53".parse().unwrap(),
|
||||
)],
|
||||
vec![],
|
||||
)),
|
||||
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,
|
||||
@@ -1005,6 +1064,8 @@ mod tests {
|
||||
dnssec_strict: false,
|
||||
health_meta: crate::health::HealthMeta::test_fixture(),
|
||||
ca_pem: None,
|
||||
mobile_enabled: false,
|
||||
mobile_port: 8765,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
173
src/blocklist.rs
173
src/blocklist.rs
@@ -81,66 +81,70 @@ impl BlocklistStore {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(until) = self.paused_until {
|
||||
if Instant::now() < until {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if self.allowlist.contains(domain) {
|
||||
let domain = Self::normalize(domain);
|
||||
if Self::find_in_set(&domain, &self.allowlist).is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.domains.contains(domain) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Walk up: ads.tracker.example.com → tracker.example.com → example.com
|
||||
let mut d = domain;
|
||||
while let Some(dot) = d.find('.') {
|
||||
d = &d[dot + 1..];
|
||||
if self.allowlist.contains(d) {
|
||||
return false;
|
||||
}
|
||||
if self.domains.contains(d) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
Self::find_in_set(&domain, &self.domains).is_some()
|
||||
}
|
||||
|
||||
/// Check if a domain is blocked and return the reason.
|
||||
pub fn check(&self, domain: &str) -> BlockCheckResult {
|
||||
let domain = domain.to_lowercase();
|
||||
|
||||
if !self.enabled {
|
||||
return BlockCheckResult::disabled();
|
||||
}
|
||||
|
||||
if self.allowlist.contains(&domain) {
|
||||
return BlockCheckResult::allowed(&domain, "exact match in allowlist");
|
||||
if let Some(until) = self.paused_until {
|
||||
if Instant::now() < until {
|
||||
return BlockCheckResult::disabled();
|
||||
}
|
||||
}
|
||||
|
||||
if self.domains.contains(&domain) {
|
||||
return BlockCheckResult::blocked(&domain, "exact match in blocklist");
|
||||
let domain = Self::normalize(domain);
|
||||
|
||||
if let Some(matched) = Self::find_in_set(&domain, &self.allowlist) {
|
||||
let reason = if matched == domain {
|
||||
"exact match in allowlist"
|
||||
} else {
|
||||
"parent domain in allowlist"
|
||||
};
|
||||
return BlockCheckResult::allowed(matched, reason);
|
||||
}
|
||||
|
||||
let mut d = domain.as_str();
|
||||
while let Some(dot) = d.find('.') {
|
||||
d = &d[dot + 1..];
|
||||
if self.allowlist.contains(d) {
|
||||
return BlockCheckResult::allowed(d, "parent domain in allowlist");
|
||||
}
|
||||
if self.domains.contains(d) {
|
||||
return BlockCheckResult::blocked(d, "parent domain in blocklist");
|
||||
}
|
||||
if let Some(matched) = Self::find_in_set(&domain, &self.domains) {
|
||||
let reason = if matched == domain {
|
||||
"exact match in blocklist"
|
||||
} else {
|
||||
"parent domain in blocklist"
|
||||
};
|
||||
return BlockCheckResult::blocked(matched, reason);
|
||||
}
|
||||
|
||||
BlockCheckResult::not_blocked()
|
||||
}
|
||||
|
||||
fn normalize(domain: &str) -> String {
|
||||
domain.to_lowercase().trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
fn find_in_set<'a>(domain: &'a str, set: &HashSet<String>) -> Option<&'a str> {
|
||||
if set.contains(domain) {
|
||||
return Some(domain);
|
||||
}
|
||||
let mut d = domain;
|
||||
while let Some(dot) = d.find('.') {
|
||||
d = &d[dot + 1..];
|
||||
if set.contains(d) {
|
||||
return Some(d);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Atomically swap in a new domain set. Build the set outside the lock,
|
||||
/// then call this to swap — keeps lock hold time sub-microsecond.
|
||||
pub fn swap_domains(&mut self, domains: HashSet<String>, sources: Vec<String>) {
|
||||
@@ -172,11 +176,11 @@ impl BlocklistStore {
|
||||
}
|
||||
|
||||
pub fn add_to_allowlist(&mut self, domain: &str) {
|
||||
self.allowlist.insert(domain.to_lowercase());
|
||||
self.allowlist.insert(Self::normalize(domain));
|
||||
}
|
||||
|
||||
pub fn remove_from_allowlist(&mut self, domain: &str) -> bool {
|
||||
self.allowlist.remove(&domain.to_lowercase())
|
||||
self.allowlist.remove(&Self::normalize(domain))
|
||||
}
|
||||
|
||||
pub fn allowlist(&self) -> Vec<String> {
|
||||
@@ -247,6 +251,97 @@ pub fn parse_blocklist(text: &str) -> HashSet<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn store_with(domains: &[&str], allowlist: &[&str]) -> BlocklistStore {
|
||||
let mut store = BlocklistStore::new();
|
||||
store.swap_domains(domains.iter().map(|s| s.to_string()).collect(), vec![]);
|
||||
for d in allowlist {
|
||||
store.add_to_allowlist(d);
|
||||
}
|
||||
store
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_block() {
|
||||
let store = store_with(&["ads.example.com"], &[]);
|
||||
assert!(store.is_blocked("ads.example.com"));
|
||||
assert!(!store.is_blocked("example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_block_covers_subdomain() {
|
||||
let store = store_with(&["tracker.com"], &[]);
|
||||
assert!(store.is_blocked("tracker.com"));
|
||||
assert!(store.is_blocked("www.tracker.com"));
|
||||
assert!(store.is_blocked("deep.sub.tracker.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_allowlist_unblocks() {
|
||||
let store = store_with(&["ads.example.com"], &["ads.example.com"]);
|
||||
assert!(!store.is_blocked("ads.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_allowlist_unblocks_subdomain() {
|
||||
let store = store_with(&["example.com", "www.example.com"], &["example.com"]);
|
||||
assert!(!store.is_blocked("example.com"));
|
||||
assert!(!store.is_blocked("www.example.com"));
|
||||
assert!(!store.is_blocked("sub.deep.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_does_not_unblock_sibling() {
|
||||
let store = store_with(
|
||||
&["www.example.com", "ads.example.com"],
|
||||
&["www.example.com"],
|
||||
);
|
||||
assert!(!store.is_blocked("www.example.com"));
|
||||
assert!(store.is_blocked("ads.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_reports_parent_allowlist() {
|
||||
let store = store_with(
|
||||
&["goatcounter.com", "www.goatcounter.com"],
|
||||
&["goatcounter.com"],
|
||||
);
|
||||
let result = store.check("www.goatcounter.com");
|
||||
assert!(!result.blocked);
|
||||
assert_eq!(result.matched_rule.as_deref(), Some("goatcounter.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_never_blocks() {
|
||||
let mut store = store_with(&["ads.example.com"], &[]);
|
||||
store.set_enabled(false);
|
||||
assert!(!store.is_blocked("ads.example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_dot_normalized() {
|
||||
let store = store_with(&["ads.example.com"], &["safe.example.com"]);
|
||||
assert!(store.is_blocked("ads.example.com."));
|
||||
assert!(!store.is_blocked("safe.example.com."));
|
||||
let result = store.check("ads.example.com.");
|
||||
assert!(result.blocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive() {
|
||||
let store = store_with(&["ads.example.com"], &["safe.example.com"]);
|
||||
assert!(store.is_blocked("ADS.Example.COM"));
|
||||
assert!(!store.is_blocked("Safe.Example.COM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_in_neither_list() {
|
||||
let store = store_with(&["ads.example.com"], &[]);
|
||||
let result = store.check("clean.example.org");
|
||||
assert!(!result.blocked);
|
||||
assert_eq!(result.reason, "not in blocklist");
|
||||
assert!(result.matched_rule.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heap_bytes_grows_with_domains() {
|
||||
let mut store = BlocklistStore::new();
|
||||
|
||||
248
src/cache.rs
248
src/cache.rs
@@ -1,9 +1,26 @@
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::question::QueryType;
|
||||
use crate::record::DnsRecord;
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum DnssecStatus {
|
||||
@@ -26,14 +43,16 @@ impl DnssecStatus {
|
||||
}
|
||||
|
||||
struct CacheEntry {
|
||||
packet: DnsPacket,
|
||||
wire: Vec<u8>,
|
||||
meta: WireMeta,
|
||||
inserted_at: Instant,
|
||||
ttl: Duration,
|
||||
dnssec_status: DnssecStatus,
|
||||
}
|
||||
|
||||
/// DNS cache using a two-level map (domain -> query_type -> entry) so that
|
||||
/// lookups can borrow `&str` instead of allocating a `String` key.
|
||||
const STALE_WINDOW: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// DNS cache with serve-stale (RFC 8767). Stores raw wire bytes.
|
||||
pub struct DnsCache {
|
||||
entries: HashMap<String, HashMap<QueryType, CacheEntry>>,
|
||||
entry_count: usize,
|
||||
@@ -53,54 +72,60 @@ 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(
|
||||
/// 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(
|
||||
&self,
|
||||
domain: &str,
|
||||
qtype: QueryType,
|
||||
) -> Option<(DnsPacket, DnssecStatus)> {
|
||||
new_id: u16,
|
||||
) -> Option<(Vec<u8>, DnssecStatus, Freshness)> {
|
||||
let type_map = self.entries.get(domain)?;
|
||||
let entry = type_map.get(&qtype)?;
|
||||
|
||||
let elapsed = entry.inserted_at.elapsed();
|
||||
if elapsed >= entry.ttl {
|
||||
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 {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let remaining_secs = (entry.ttl - elapsed).as_secs() as u32;
|
||||
let remaining = remaining_secs.max(1);
|
||||
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 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))
|
||||
Some((wire, entry.dnssec_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(
|
||||
pub fn insert_wire(
|
||||
&mut self,
|
||||
domain: &str,
|
||||
qtype: QueryType,
|
||||
packet: &DnsPacket,
|
||||
wire: &[u8],
|
||||
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 {
|
||||
return;
|
||||
self.evict_stalest();
|
||||
}
|
||||
}
|
||||
|
||||
let min_ttl = extract_min_ttl(&packet.answers)
|
||||
let min_ttl = crate::wire::min_ttl_from_wire(wire, &meta)
|
||||
.unwrap_or(self.min_ttl)
|
||||
.clamp(self.min_ttl, self.max_ttl);
|
||||
|
||||
@@ -117,7 +142,8 @@ impl DnsCache {
|
||||
type_map.insert(
|
||||
qtype,
|
||||
CacheEntry {
|
||||
packet: packet.clone(),
|
||||
wire: wire.to_vec(),
|
||||
meta,
|
||||
inserted_at: Instant::now(),
|
||||
ttl: Duration::from_secs(min_ttl as u64),
|
||||
dnssec_status,
|
||||
@@ -125,6 +151,64 @@ 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
|
||||
}
|
||||
@@ -156,7 +240,8 @@ impl DnsCache {
|
||||
+ 1;
|
||||
total += type_map.capacity() * inner_slot;
|
||||
for entry in type_map.values() {
|
||||
total += entry.packet.heap_bytes();
|
||||
total += entry.wire.capacity()
|
||||
+ entry.meta.ttl_offsets.capacity() * std::mem::size_of::<usize>();
|
||||
}
|
||||
}
|
||||
total
|
||||
@@ -197,6 +282,34 @@ 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 {
|
||||
@@ -205,20 +318,11 @@ 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() {
|
||||
@@ -233,4 +337,66 @@ 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,6 +33,39 @@ 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)]
|
||||
@@ -97,12 +130,16 @@ impl UpstreamMode {
|
||||
pub struct UpstreamConfig {
|
||||
#[serde(default)]
|
||||
pub mode: UpstreamMode,
|
||||
#[serde(default = "default_upstream_addr")]
|
||||
pub address: String,
|
||||
#[serde(default, deserialize_with = "string_or_vec")]
|
||||
pub address: Vec<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")]
|
||||
@@ -115,9 +152,11 @@ impl Default for UpstreamConfig {
|
||||
fn default() -> Self {
|
||||
UpstreamConfig {
|
||||
mode: UpstreamMode::default(),
|
||||
address: default_upstream_addr(),
|
||||
address: Vec::new(),
|
||||
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(),
|
||||
@@ -125,6 +164,33 @@ 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
|
||||
}
|
||||
@@ -202,15 +268,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 {
|
||||
@@ -220,6 +286,8 @@ 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 {
|
||||
@@ -228,12 +296,13 @@ 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 {
|
||||
10000
|
||||
100_000
|
||||
}
|
||||
fn default_min_ttl() -> u32 {
|
||||
60
|
||||
@@ -381,7 +450,7 @@ pub struct DnssecConfig {
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct DotConfig {
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_dot_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_dot_port")]
|
||||
pub port: u16,
|
||||
@@ -398,7 +467,7 @@ pub struct DotConfig {
|
||||
impl Default for DotConfig {
|
||||
fn default() -> Self {
|
||||
DotConfig {
|
||||
enabled: false,
|
||||
enabled: default_dot_enabled(),
|
||||
port: default_dot_port(),
|
||||
bind_addr: default_dot_bind_addr(),
|
||||
cert_path: None,
|
||||
@@ -407,6 +476,9 @@ impl Default for DotConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_dot_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_dot_port() -> u16 {
|
||||
853
|
||||
}
|
||||
@@ -525,6 +597,184 @@ 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 {
|
||||
@@ -552,6 +802,13 @@ 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
|
||||
};
|
||||
@@ -572,11 +829,7 @@ pub fn load_config(path: &str) -> Result<ConfigLoad> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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));
|
||||
let display_path = crate::suggested_config_path().to_string_lossy().to_string();
|
||||
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;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, RwLock};
|
||||
use std::sync::{Arc, 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, Upstream};
|
||||
use crate::forward::{forward_query_raw, forward_with_failover_raw, Upstream, UpstreamPool};
|
||||
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};
|
||||
use crate::stats::{QueryPath, ServerStats, Transport};
|
||||
use crate::system_dns::ForwardingRule;
|
||||
|
||||
pub struct ServerCtx {
|
||||
@@ -35,6 +35,8 @@ 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>,
|
||||
@@ -42,11 +44,12 @@ pub struct ServerCtx {
|
||||
pub services: Mutex<ServiceStore>,
|
||||
pub lan_peers: Mutex<PeerStore>,
|
||||
pub forwarding_rules: Vec<ForwardingRule>,
|
||||
pub upstream: Mutex<Upstream>,
|
||||
pub upstream_pool: Mutex<UpstreamPool>,
|
||||
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,
|
||||
@@ -70,6 +73,8 @@ 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,
|
||||
@@ -79,8 +84,10 @@ 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: &ServerCtx,
|
||||
ctx: &Arc<ServerCtx>,
|
||||
transport: Transport,
|
||||
) -> crate::Result<BytePacketBuffer> {
|
||||
let start = Instant::now();
|
||||
|
||||
@@ -108,6 +115,10 @@ 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);
|
||||
@@ -156,13 +167,20 @@ 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)) = cached {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut resp = cached;
|
||||
resp.header.id = query.header.id;
|
||||
if cached_dnssec == DnssecStatus::Secure {
|
||||
@@ -175,11 +193,8 @@ 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_query(&query, &upstream, ctx.timeout).await {
|
||||
Ok(resp) => {
|
||||
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
|
||||
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate)
|
||||
}
|
||||
match forward_and_cache(raw_wire, &upstream, ctx, &qname, qtype).await {
|
||||
Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"{} | {:?} {} | FORWARD ERROR | {}",
|
||||
@@ -218,16 +233,27 @@ pub async fn resolve_query(
|
||||
}
|
||||
(resp, path, DnssecStatus::Indeterminate)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
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,
|
||||
)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!(
|
||||
"{} | {:?} {} | UPSTREAM ERROR | {}",
|
||||
@@ -329,7 +355,7 @@ pub async fn resolve_query(
|
||||
// Record stats and query log
|
||||
{
|
||||
let mut s = ctx.stats.lock().unwrap();
|
||||
let total = s.record(path);
|
||||
let total = s.record(path, transport);
|
||||
if total.is_multiple_of(1000) {
|
||||
s.log_summary();
|
||||
}
|
||||
@@ -341,6 +367,7 @@ pub async fn resolve_query(
|
||||
domain: qname,
|
||||
query_type: qtype,
|
||||
path,
|
||||
transport,
|
||||
rescode: response.header.rescode,
|
||||
latency_us: elapsed.as_micros() as u64,
|
||||
dnssec,
|
||||
@@ -349,11 +376,78 @@ pub async fn resolve_query(
|
||||
Ok(resp_buffer)
|
||||
}
|
||||
|
||||
/// Handle a DNS query received over UDP. Thin wrapper around resolve_query.
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn handle_query(
|
||||
mut buffer: BytePacketBuffer,
|
||||
raw_len: usize,
|
||||
src_addr: SocketAddr,
|
||||
ctx: &ServerCtx,
|
||||
ctx: &Arc<ServerCtx>,
|
||||
transport: Transport,
|
||||
) -> crate::Result<()> {
|
||||
let query = match DnsPacket::from_buffer(&mut buffer) {
|
||||
Ok(packet) => packet,
|
||||
@@ -362,7 +456,7 @@ pub async fn handle_query(
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
match resolve_query(query, src_addr, ctx).await {
|
||||
match resolve_query(query, &buffer.buf[..raw_len], src_addr, ctx, transport).await {
|
||||
Ok(resp_buffer) => {
|
||||
ctx.socket.send_to(resp_buffer.filled(), src_addr).await?;
|
||||
}
|
||||
|
||||
224
src/doh.rs
Normal file
224
src/doh.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
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,6 +15,7 @@ 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);
|
||||
@@ -153,8 +154,11 @@ 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: &ServerCtx)
|
||||
where
|
||||
async fn handle_dot_connection<S>(
|
||||
mut stream: S,
|
||||
remote_addr: SocketAddr,
|
||||
ctx: &std::sync::Arc<ServerCtx>,
|
||||
) where
|
||||
S: AsyncReadExt + AsyncWriteExt + Unpin,
|
||||
{
|
||||
loop {
|
||||
@@ -177,8 +181,6 @@ where
|
||||
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) => {
|
||||
@@ -200,7 +202,15 @@ where
|
||||
}
|
||||
};
|
||||
|
||||
match resolve_query(query.clone(), remote_addr, ctx).await {
|
||||
match resolve_query(
|
||||
query.clone(),
|
||||
&buffer.buf[..msg_len],
|
||||
remote_addr,
|
||||
ctx,
|
||||
Transport::Dot,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp_buffer) => {
|
||||
if write_framed(&mut stream, resp_buffer.filled())
|
||||
.await
|
||||
@@ -355,6 +365,7 @@ 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()),
|
||||
@@ -362,11 +373,15 @@ mod tests {
|
||||
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
||||
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
||||
forwarding_rules: Vec::new(),
|
||||
upstream: Mutex::new(crate::forward::Upstream::Udp(upstream_addr)),
|
||||
upstream_pool: Mutex::new(crate::forward::UpstreamPool::new(
|
||||
vec![crate::forward::Upstream::Udp(upstream_addr)],
|
||||
vec![],
|
||||
)),
|
||||
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,
|
||||
@@ -383,6 +398,8 @@ 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,12 +1,14 @@
|
||||
use std::fmt;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::RwLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
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)]
|
||||
@@ -16,6 +18,11 @@ pub enum Upstream {
|
||||
url: String,
|
||||
client: reqwest::Client,
|
||||
},
|
||||
Dot {
|
||||
addr: SocketAddr,
|
||||
tls_name: Option<String>,
|
||||
connector: tokio_rustls::TlsConnector,
|
||||
},
|
||||
}
|
||||
|
||||
impl PartialEq for Upstream {
|
||||
@@ -23,6 +30,7 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -33,6 +41,118 @@ 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,10 +162,11 @@ pub async fn forward_query(
|
||||
upstream: &Upstream,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<DnsPacket> {
|
||||
match upstream {
|
||||
Upstream::Udp(addr) => forward_udp(query, *addr, timeout_duration).await,
|
||||
Upstream::Doh { url, client } => forward_doh(query, url, client, timeout_duration).await,
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
pub(crate) async fn forward_udp(
|
||||
@@ -53,24 +174,10 @@ 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)?;
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
let data = forward_udp_raw(send_buffer.filled(), upstream, timeout_duration).await?;
|
||||
let mut recv_buffer = BytePacketBuffer::from_bytes(&data);
|
||||
DnsPacket::from_buffer(&mut recv_buffer)
|
||||
}
|
||||
|
||||
@@ -107,22 +214,209 @@ pub(crate) async fn forward_tcp(
|
||||
DnsPacket::from_buffer(&mut recv_buffer)
|
||||
}
|
||||
|
||||
async fn forward_doh(
|
||||
query: &DnsPacket,
|
||||
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],
|
||||
url: &str,
|
||||
client: &reqwest::Client,
|
||||
timeout_duration: Duration,
|
||||
) -> Result<DnsPacket> {
|
||||
let mut send_buffer = BytePacketBuffer::new();
|
||||
query.write(&mut send_buffer)?;
|
||||
|
||||
) -> Result<Vec<u8>> {
|
||||
let resp = timeout(
|
||||
timeout_duration,
|
||||
client
|
||||
.post(url)
|
||||
.header("content-type", "application/dns-message")
|
||||
.header("accept", "application/dns-message")
|
||||
.body(send_buffer.filled().to_vec())
|
||||
.body(wire.to_vec())
|
||||
.send(),
|
||||
)
|
||||
.await??
|
||||
@@ -130,9 +424,25 @@ async fn forward_doh(
|
||||
|
||||
let bytes = resp.bytes().await?;
|
||||
log::debug!("DoH response: {} bytes", bytes.len());
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
let mut recv_buffer = BytePacketBuffer::from_bytes(&bytes);
|
||||
DnsPacket::from_buffer(&mut recv_buffer)
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -271,4 +581,121 @@ 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,11 +73,15 @@ 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,6 +5,7 @@ pub mod cache;
|
||||
pub mod config;
|
||||
pub mod ctx;
|
||||
pub mod dnssec;
|
||||
pub mod doh;
|
||||
pub mod dot;
|
||||
pub mod forward;
|
||||
pub mod header;
|
||||
@@ -25,6 +26,7 @@ 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>;
|
||||
@@ -43,6 +45,42 @@ 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
|
||||
@@ -162,4 +200,73 @@ 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::Upstream;
|
||||
use numa::forward::{parse_upstream, Upstream, UpstreamPool};
|
||||
use numa::override_store::OverrideStore;
|
||||
use numa::query_log::QueryLog;
|
||||
use numa::service_store::ServiceStore;
|
||||
use numa::stats::ServerStats;
|
||||
use numa::stats::{ServerStats, Transport};
|
||||
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 (resolved_mode, upstream_auto, upstream, upstream_label) = match config.upstream.mode {
|
||||
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 {
|
||||
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 dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
|
||||
(
|
||||
numa::config::UpstreamMode::Recursive,
|
||||
false,
|
||||
dummy,
|
||||
"recursive (root hints)".to_string(),
|
||||
)
|
||||
let (pool, label) = recursive_pool();
|
||||
(numa::config::UpstreamMode::Recursive, false, pool, label)
|
||||
} else {
|
||||
log::warn!("recursive probe failed — falling back to Quad9 DoH");
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -149,55 +149,45 @@ async fn main() -> numa::Result<()> {
|
||||
.unwrap_or_default();
|
||||
let url = DOH_FALLBACK.to_string();
|
||||
let label = url.clone();
|
||||
(
|
||||
numa::config::UpstreamMode::Forward,
|
||||
false,
|
||||
Upstream::Doh { url, client },
|
||||
label,
|
||||
)
|
||||
let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]);
|
||||
(numa::config::UpstreamMode::Forward, false, pool, label)
|
||||
}
|
||||
}
|
||||
numa::config::UpstreamMode::Recursive => {
|
||||
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
|
||||
(
|
||||
numa::config::UpstreamMode::Recursive,
|
||||
false,
|
||||
dummy,
|
||||
"recursive (root hints)".to_string(),
|
||||
)
|
||||
let (pool, label) = recursive_pool();
|
||||
(numa::config::UpstreamMode::Recursive, false, pool, label)
|
||||
}
|
||||
numa::config::UpstreamMode::Forward => {
|
||||
let upstream_addr = if config.upstream.address.is_empty() {
|
||||
system_dns
|
||||
let addrs = if config.upstream.address.is_empty() {
|
||||
let detected = 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 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();
|
||||
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();
|
||||
(
|
||||
numa::config::UpstreamMode::Forward,
|
||||
config.upstream.address.is_empty(),
|
||||
upstream,
|
||||
pool,
|
||||
label,
|
||||
)
|
||||
}
|
||||
@@ -220,7 +210,13 @@ async fn main() -> numa::Result<()> {
|
||||
}
|
||||
service_store.load_persisted();
|
||||
|
||||
let forwarding_rules = system_dns.forwarding_rules;
|
||||
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)?;
|
||||
|
||||
// Resolve data_dir from config, falling back to the platform default.
|
||||
// Used for TLS CA storage below and stored on ServerCtx for runtime use.
|
||||
@@ -253,6 +249,7 @@ 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,
|
||||
@@ -262,6 +259,7 @@ 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();
|
||||
@@ -287,6 +285,7 @@ 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),
|
||||
@@ -294,11 +293,12 @@ 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: Mutex::new(upstream),
|
||||
upstream_pool: Mutex::new(pool),
|
||||
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,6 +319,8 @@ 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();
|
||||
@@ -410,6 +412,9 @@ 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,
|
||||
@@ -436,6 +441,13 @@ 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)");
|
||||
}
|
||||
@@ -492,6 +504,23 @@ 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()?;
|
||||
@@ -571,7 +600,7 @@ async fn main() -> numa::Result<()> {
|
||||
#[allow(clippy::infinite_loop)]
|
||||
loop {
|
||||
let mut buffer = BytePacketBuffer::new();
|
||||
let (_, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await {
|
||||
let (len, 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
|
||||
@@ -579,10 +608,9 @@ 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, src_addr, &ctx).await {
|
||||
if let Err(e) = handle_query(buffer, len, src_addr, &ctx, Transport::Udp).await {
|
||||
error!("{} | HANDLER ERROR | {}", src_addr, e);
|
||||
}
|
||||
});
|
||||
@@ -611,27 +639,17 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
{
|
||||
// Re-detect upstream every 30s or on LAN IP change (auto-detect only)
|
||||
if ctx.upstream_auto && (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());
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,3 +756,45 @@ 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,8 +144,6 @@ 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>
|
||||
@@ -160,8 +158,21 @@ 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 all DNS queries through Numa over DNS-over-TLS</string>
|
||||
<string>Routes DNS queries through Numa over DoT when on Wi-Fi</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;
|
||||
use axum::routing::{any, post};
|
||||
use axum::Router;
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::StatusCode;
|
||||
@@ -18,6 +18,14 @@ 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>,
|
||||
@@ -74,9 +82,17 @@ 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 state = ProxyState { ctx, client };
|
||||
let proxy_state = ProxyState {
|
||||
ctx: Arc::clone(&ctx),
|
||||
client,
|
||||
};
|
||||
|
||||
let app = Router::new().fallback(any(proxy_handler)).with_state(state);
|
||||
// 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,
|
||||
};
|
||||
|
||||
loop {
|
||||
let (tcp_stream, remote_addr) = match listener.accept().await {
|
||||
@@ -91,7 +107,17 @@ 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 app = app.clone();
|
||||
|
||||
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());
|
||||
|
||||
tokio::spawn(async move {
|
||||
let tls_stream = match acceptor.accept(tcp_stream).await {
|
||||
@@ -232,7 +258,7 @@ pre .str {{ color: #d48a5a }}
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_host(req: &Request) -> Option<String> {
|
||||
pub 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;
|
||||
use crate::stats::{QueryPath, Transport};
|
||||
|
||||
pub struct QueryLogEntry {
|
||||
pub timestamp: SystemTime,
|
||||
@@ -13,6 +13,7 @@ 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,
|
||||
@@ -107,6 +108,7 @@ 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(800);
|
||||
const TCP_TIMEOUT: Duration = Duration::from_millis(1500);
|
||||
const NS_QUERY_TIMEOUT: Duration = Duration::from_millis(400);
|
||||
const TCP_TIMEOUT: Duration = Duration::from_millis(400);
|
||||
const UDP_FAIL_THRESHOLD: u8 = 3;
|
||||
|
||||
static QUERY_ID: AtomicU16 = AtomicU16::new(1);
|
||||
@@ -202,23 +202,24 @@ pub(crate) fn resolve_iterative<'a>(
|
||||
let mut ns_idx = 0;
|
||||
|
||||
for _ in 0..MAX_REFERRAL_DEPTH {
|
||||
let ns_addr = match ns_addrs.get(ns_idx) {
|
||||
Some(addr) => *addr,
|
||||
None => return Err("no nameserver available".into()),
|
||||
};
|
||||
if ns_idx >= ns_addrs.len() {
|
||||
return Err("no nameserver available".into());
|
||||
}
|
||||
|
||||
let (q_name, q_type) = minimize_query(qname, qtype, ¤t_zone);
|
||||
|
||||
debug!(
|
||||
"recursive: querying {} for {:?} {} (zone: {}, depth {})",
|
||||
ns_addr, q_type, q_name, current_zone, referral_depth
|
||||
"recursive: querying {} (+ hedge) for {:?} {} (zone: {}, depth {})",
|
||||
ns_addrs[ns_idx], q_type, q_name, current_zone, referral_depth
|
||||
);
|
||||
|
||||
let response = match send_query(q_name, q_type, ns_addr, srtt).await {
|
||||
let response = match send_query_hedged(q_name, q_type, &ns_addrs[ns_idx..], srtt).await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
debug!("recursive: NS {} failed: {}", ns_addr, e);
|
||||
ns_idx += 1;
|
||||
debug!("recursive: NS query failed: {}", e);
|
||||
let remaining = ns_addrs.len().saturating_sub(ns_idx);
|
||||
ns_idx += remaining.min(2);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -228,6 +229,9 @@ 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() {
|
||||
@@ -296,6 +300,7 @@ 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);
|
||||
@@ -560,6 +565,23 @@ 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;
|
||||
@@ -587,6 +609,115 @@ 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,
|
||||
@@ -634,9 +765,13 @@ 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;
|
||||
}
|
||||
debug!("send_query: UDP failed for {}: {}, trying TCP", server, e);
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -678,6 +813,10 @@ 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();
|
||||
@@ -916,10 +1055,11 @@ mod tests {
|
||||
}
|
||||
|
||||
/// TCP-only server returns authoritative answer directly.
|
||||
/// Verifies: UDP fails → TCP fallback → resolves.
|
||||
/// Verifies: when UDP is disabled, TCP-first resolves.
|
||||
#[tokio::test]
|
||||
async fn tcp_fallback_resolves_when_udp_blocked() {
|
||||
UDP_DISABLED.store(false, Ordering::Relaxed);
|
||||
let _guard = UDP_STATE_LOCK.lock().unwrap();
|
||||
UDP_DISABLED.store(true, Ordering::Relaxed);
|
||||
UDP_FAILURES.store(0, Ordering::Release);
|
||||
|
||||
let server_addr = spawn_tcp_dns_server(|query| {
|
||||
@@ -950,49 +1090,32 @@ mod 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).
|
||||
/// 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.
|
||||
#[tokio::test]
|
||||
async fn tcp_only_iterative_resolution() {
|
||||
UDP_DISABLED.store(true, Ordering::Release); // Skip UDP entirely for speed
|
||||
|
||||
let server_addr = spawn_tcp_dns_server(|query| {
|
||||
let q = match query.questions.first() {
|
||||
Some(q) => q,
|
||||
None => return DnsPacket::response_from(query, ResultCode::SERVFAIL),
|
||||
};
|
||||
|
||||
if q.qtype == QueryType::NS || q.name == "com" {
|
||||
// Return referral — NS points back to ourselves (same IP, port 53 in glue
|
||||
// won't work, but cache will have our address from root_hints)
|
||||
let mut resp = DnsPacket::new();
|
||||
resp.header.id = query.header.id;
|
||||
resp.header.response = true;
|
||||
resp.header.rescode = ResultCode::NOERROR;
|
||||
resp.questions = query.questions.clone();
|
||||
resp.authorities.push(DnsRecord::NS {
|
||||
domain: "com".into(),
|
||||
host: "ns1.com".into(),
|
||||
ttl: 3600,
|
||||
});
|
||||
resp
|
||||
} else {
|
||||
// Return authoritative answer
|
||||
let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR);
|
||||
resp.header.authoritative_answer = true;
|
||||
resp.answers.push(DnsRecord::A {
|
||||
domain: q.name.clone(),
|
||||
addr: Ipv4Addr::new(10, 0, 0, 42),
|
||||
ttl: 300,
|
||||
});
|
||||
resp
|
||||
}
|
||||
let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR);
|
||||
resp.header.authoritative_answer = true;
|
||||
resp.answers.push(DnsRecord::A {
|
||||
domain: q.name.clone(),
|
||||
addr: Ipv4Addr::new(10, 0, 0, 42),
|
||||
ttl: 300,
|
||||
});
|
||||
resp
|
||||
})
|
||||
.await;
|
||||
|
||||
let srtt = RwLock::new(SrttCache::new(true));
|
||||
let result = send_query("hello.example.com", QueryType::A, server_addr, &srtt).await;
|
||||
let resp = result.expect("TCP-only send_query should work");
|
||||
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");
|
||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
||||
match &resp.answers[0] {
|
||||
DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 0, 0, 42)),
|
||||
@@ -1002,7 +1125,8 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn tcp_fallback_handles_nxdomain() {
|
||||
UDP_DISABLED.store(false, Ordering::Relaxed);
|
||||
let _guard = UDP_STATE_LOCK.lock().unwrap();
|
||||
UDP_DISABLED.store(true, Ordering::Relaxed);
|
||||
UDP_FAILURES.store(0, Ordering::Release);
|
||||
|
||||
let server_addr = spawn_tcp_dns_server(|query| {
|
||||
@@ -1034,6 +1158,7 @@ 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,6 +45,11 @@ 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,9 +97,32 @@ 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,
|
||||
@@ -167,11 +190,15 @@ 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) -> u64 {
|
||||
pub fn record(&mut self, path: QueryPath, transport: Transport) -> u64 {
|
||||
self.queries_total += 1;
|
||||
match path {
|
||||
QueryPath::Local => self.queries_local += 1,
|
||||
@@ -183,6 +210,12 @@ 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
|
||||
}
|
||||
|
||||
@@ -206,6 +239,10 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,4 +279,8 @@ 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,6 +25,17 @@ 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>,
|
||||
@@ -91,7 +102,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 ~/.config/numa/numa.toml with:
|
||||
Create {} with:
|
||||
|
||||
[server]
|
||||
bind_addr = \"127.0.0.1:5354\"
|
||||
@@ -100,7 +111,8 @@ 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()
|
||||
))
|
||||
}
|
||||
|
||||
@@ -220,12 +232,8 @@ fn discover_macos() -> SystemDnsInfo {
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> {
|
||||
let addr: SocketAddr = format!("{}:53", nameserver).parse().ok()?;
|
||||
Some(ForwardingRule {
|
||||
dot_suffix: format!(".{}", domain),
|
||||
suffix: domain.to_string(),
|
||||
upstream: addr,
|
||||
})
|
||||
let addr = crate::forward::parse_upstream_addr(nameserver, 53).ok()?;
|
||||
Some(ForwardingRule::new(domain.to_string(), addr))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
88
src/tls.rs
88
src/tls.rs
@@ -66,13 +66,14 @@ 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 ~/.config/numa/numa.toml with:
|
||||
Create {} with:
|
||||
|
||||
[server]
|
||||
data_dir = \"/path/you/can/write\"
|
||||
|
||||
",
|
||||
data_dir.display()
|
||||
data_dir.display(),
|
||||
crate::suggested_config_path().display()
|
||||
))
|
||||
}
|
||||
|
||||
@@ -185,8 +186,19 @@ fn generate_service_cert(
|
||||
}
|
||||
}
|
||||
|
||||
if sans.is_empty() {
|
||||
return Err("no valid service names for TLS cert".into());
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
|
||||
params.subject_alt_names = sans;
|
||||
@@ -239,4 +251,72 @@ 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
Normal file
1416
src/wire.rs
Normal file
File diff suppressed because it is too large
Load Diff
5
tests/docker/hold53.py
Normal file
5
tests/docker/hold53.py
Normal file
@@ -0,0 +1,5 @@
|
||||
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()
|
||||
164
tests/docker/issue-81.sh
Executable file
164
tests/docker/issue-81.sh
Executable file
@@ -0,0 +1,164 @@
|
||||
#!/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,7 +53,17 @@ CONF
|
||||
echo "Starting Numa on :$PORT ($SUITE_NAME)..."
|
||||
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
|
||||
NUMA_PID=$!
|
||||
sleep 4
|
||||
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
|
||||
|
||||
if ! kill -0 "$NUMA_PID" 2>/dev/null; then
|
||||
echo "Failed to start Numa:"
|
||||
@@ -622,6 +632,54 @@ 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