Compare commits
27 Commits
v0.10.2
...
fix/allowl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3138990a8 | ||
|
|
e5c6caba1f | ||
|
|
ec44829c30 | ||
|
|
c452f99a45 | ||
|
|
d66a88f467 | ||
|
|
8da03b1b8c | ||
|
|
652fca5b80 | ||
|
|
de15b32325 | ||
|
|
6f961c5ec2 | ||
|
|
20bf14e91c | ||
|
|
e860731c01 | ||
|
|
f556b60ce4 | ||
|
|
422726f1c8 | ||
|
|
dd021d8642 | ||
|
|
f20c72a829 | ||
|
|
44cd17cf84 | ||
|
|
fb0a21e5e6 | ||
|
|
66b937f710 | ||
|
|
524aed7fa1 | ||
|
|
11e3fdeae6 | ||
|
|
636c45b3d7 | ||
|
|
f602687d93 | ||
|
|
b8b0fda1e0 | ||
|
|
9a3fae9a0c | ||
|
|
a31ac36957 | ||
|
|
9001b14fed | ||
|
|
63ac69a222 |
34
.github/dependabot.yml
vendored
Normal file
34
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "cargo"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore(deps)"
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
patterns: ["*"]
|
||||||
|
update-types: ["minor", "patch"]
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore(deps)"
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
patterns: ["*"]
|
||||||
|
update-types: ["minor", "patch"]
|
||||||
|
|
||||||
|
- package-ecosystem: "docker"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
commit-message:
|
||||||
|
prefix: "chore(deps)"
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
patterns: ["*"]
|
||||||
|
update-types: ["minor", "patch"]
|
||||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
components: rustfmt, clippy
|
components: rustfmt, clippy
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
check-macos:
|
check-macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
- name: clippy
|
- name: clippy
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
check-windows:
|
check-windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
- name: build
|
- name: build
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
- name: test
|
- name: test
|
||||||
run: cargo test
|
run: cargo test
|
||||||
- name: Upload binary
|
- name: Upload binary
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: numa-windows-x86_64
|
name: numa-windows-x86_64
|
||||||
path: target/debug/numa.exe
|
path: target/debug/numa.exe
|
||||||
|
|||||||
19
.github/workflows/homebrew-bump.yml
vendored
19
.github/workflows/homebrew-bump.yml
vendored
@@ -1,8 +1,12 @@
|
|||||||
name: Bump Homebrew Tap
|
name: Bump Homebrew Tap
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
workflow_call:
|
||||||
types: [published]
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version to bump (e.g. 0.10.0 or v0.10.0)'
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
@@ -16,17 +20,14 @@ jobs:
|
|||||||
bump:
|
bump:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Determine version
|
- name: Determine version
|
||||||
id: ver
|
id: ver
|
||||||
|
env:
|
||||||
|
INPUT_VERSION: ${{ inputs.version }}
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ github.event_name }}" = "release" ]; then
|
V="${INPUT_VERSION#v}"
|
||||||
V="${{ github.event.release.tag_name }}"
|
|
||||||
else
|
|
||||||
V="${{ github.event.inputs.version }}"
|
|
||||||
fi
|
|
||||||
V="${V#v}"
|
|
||||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Fetch sha256 checksums from release assets
|
- name: Fetch sha256 checksums from release assets
|
||||||
|
|||||||
23
.github/workflows/release.yml
vendored
23
.github/workflows/release.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
|||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
(Get-FileHash "${{ matrix.name }}.zip" -Algorithm SHA256).Hash.ToLower() + " ${{ matrix.name }}.zip" | Out-File "${{ matrix.name }}.zip.sha256" -Encoding ascii
|
(Get-FileHash "${{ matrix.name }}.zip" -Algorithm SHA256).Hash.ToLower() + " ${{ matrix.name }}.zip" | Out-File "${{ matrix.name }}.zip.sha256" -Encoding ascii
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.name }}
|
name: ${{ matrix.name }}
|
||||||
path: |
|
path: |
|
||||||
@@ -82,7 +82,7 @@ jobs:
|
|||||||
publish:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -96,23 +96,22 @@ jobs:
|
|||||||
needs: [build, publish]
|
needs: [build, publish]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v8
|
||||||
with:
|
with:
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
# Use a PAT (not the default GITHUB_TOKEN) so the resulting
|
|
||||||
# `release: published` event propagates to downstream workflows
|
|
||||||
# like homebrew-bump.yml. Events triggered by GITHUB_TOKEN are
|
|
||||||
# deliberately not propagated by GitHub Actions to prevent
|
|
||||||
# infinite loops; PAT-authored events are the documented escape
|
|
||||||
# hatch. Reusing HOMEBREW_TAP_GITHUB_TOKEN (already a PAT used
|
|
||||||
# by homebrew-bump.yml itself) keeps the secret surface flat.
|
|
||||||
token: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: |
|
files: |
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.zip
|
*.zip
|
||||||
*.sha256
|
*.sha256
|
||||||
|
|
||||||
|
bump-homebrew:
|
||||||
|
needs: release
|
||||||
|
uses: ./.github/workflows/homebrew-bump.yml
|
||||||
|
with:
|
||||||
|
version: ${{ github.ref_name }}
|
||||||
|
secrets: inherit
|
||||||
|
|||||||
8
.github/workflows/static.yml
vendored
8
.github/workflows/static.yml
vendored
@@ -30,18 +30,18 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
- name: Install pandoc
|
- name: Install pandoc
|
||||||
run: sudo apt-get install -y pandoc
|
run: sudo apt-get install -y pandoc
|
||||||
- name: Generate blog HTML
|
- name: Generate blog HTML
|
||||||
run: make blog
|
run: make blog
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@v5
|
uses: actions/configure-pages@v6
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v4
|
||||||
with:
|
with:
|
||||||
# Upload entire repository
|
# Upload entire repository
|
||||||
path: './site'
|
path: './site'
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
id: deployment
|
id: deployment
|
||||||
uses: actions/deploy-pages@v4
|
uses: actions/deploy-pages@v5
|
||||||
|
|||||||
246
Cargo.lock
generated
246
Cargo.lock
generated
@@ -17,6 +17,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloca"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anes"
|
name = "anes"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -84,9 +93,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asn1-rs"
|
name = "asn1-rs"
|
||||||
version = "0.6.2"
|
version = "0.7.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
|
checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"asn1-rs-derive",
|
"asn1-rs-derive",
|
||||||
"asn1-rs-impl",
|
"asn1-rs-impl",
|
||||||
@@ -94,15 +103,15 @@ dependencies = [
|
|||||||
"nom",
|
"nom",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rusticata-macros",
|
"rusticata-macros",
|
||||||
"thiserror 1.0.69",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asn1-rs-derive"
|
name = "asn1-rs-derive"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
|
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -368,25 +377,24 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "criterion"
|
name = "criterion"
|
||||||
version = "0.5.1"
|
version = "0.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"alloca",
|
||||||
"anes",
|
"anes",
|
||||||
"cast",
|
"cast",
|
||||||
"ciborium",
|
"ciborium",
|
||||||
"clap",
|
"clap",
|
||||||
"criterion-plot",
|
"criterion-plot",
|
||||||
"is-terminal",
|
|
||||||
"itertools",
|
"itertools",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"once_cell",
|
|
||||||
"oorandom",
|
"oorandom",
|
||||||
|
"page_size",
|
||||||
"plotters",
|
"plotters",
|
||||||
"rayon",
|
"rayon",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tinytemplate",
|
"tinytemplate",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -394,9 +402,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "criterion-plot"
|
name = "criterion-plot"
|
||||||
version = "0.5.0"
|
version = "0.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cast",
|
"cast",
|
||||||
"itertools",
|
"itertools",
|
||||||
@@ -441,9 +449,9 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der-parser"
|
name = "der-parser"
|
||||||
version = "9.0.0"
|
version = "10.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
|
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"asn1-rs",
|
"asn1-rs",
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
@@ -514,6 +522,16 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -702,12 +720,6 @@ version = "0.16.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hermit-abi"
|
|
||||||
version = "0.5.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -810,7 +822,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2 0.6.3",
|
"socket2",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -944,17 +956,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "is-terminal"
|
|
||||||
version = "0.4.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
|
||||||
dependencies = [
|
|
||||||
"hermit-abi",
|
|
||||||
"libc",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -963,9 +964,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.10.5"
|
version = "0.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"either",
|
"either",
|
||||||
]
|
]
|
||||||
@@ -1143,7 +1144,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.10.2"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1155,6 +1156,7 @@ dependencies = [
|
|||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"log",
|
"log",
|
||||||
|
"qrcode",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"ring",
|
"ring",
|
||||||
@@ -1162,7 +1164,7 @@ dependencies = [
|
|||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"socket2 0.5.10",
|
"socket2",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
@@ -1172,9 +1174,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oid-registry"
|
name = "oid-registry"
|
||||||
version = "0.7.1"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
|
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"asn1-rs",
|
"asn1-rs",
|
||||||
]
|
]
|
||||||
@@ -1197,6 +1199,16 @@ version = "11.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "page_size"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem"
|
name = "pem"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
@@ -1301,6 +1313,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -1314,8 +1332,8 @@ dependencies = [
|
|||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2 0.6.3",
|
"socket2",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -1336,7 +1354,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"slab",
|
"slab",
|
||||||
"thiserror 2.0.18",
|
"thiserror",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -1351,7 +1369,7 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2 0.6.3",
|
"socket2",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
@@ -1422,9 +1440,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rcgen"
|
name = "rcgen"
|
||||||
version = "0.13.2"
|
version = "0.14.7"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pem",
|
"pem",
|
||||||
"ring",
|
"ring",
|
||||||
@@ -1655,11 +1673,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "0.6.9"
|
version = "1.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1680,6 +1698,16 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -1698,16 +1726,6 @@ version = "1.15.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "socket2"
|
|
||||||
version = "0.5.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"windows-sys 0.52.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.3"
|
version = "0.6.3"
|
||||||
@@ -1761,33 +1779,13 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror"
|
|
||||||
version = "1.0.69"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
|
||||||
dependencies = [
|
|
||||||
"thiserror-impl 1.0.69",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.18",
|
"thiserror-impl",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "thiserror-impl"
|
|
||||||
version = "1.0.69"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1877,7 +1875,8 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2 0.6.3",
|
"signal-hook-registry",
|
||||||
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -1918,44 +1917,42 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.8.23"
|
version = "1.1.2+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"serde_spanned",
|
|
||||||
"toml_datetime",
|
|
||||||
"toml_edit",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml_datetime"
|
|
||||||
version = "0.6.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml_edit"
|
|
||||||
version = "0.22.27"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde",
|
"serde_core",
|
||||||
"serde_spanned",
|
"serde_spanned",
|
||||||
"toml_datetime",
|
"toml_datetime",
|
||||||
"toml_write",
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_write"
|
name = "toml_datetime"
|
||||||
version = "0.1.2"
|
version = "1.1.1+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_parser"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||||
|
dependencies = [
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.1+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
@@ -2188,6 +2185,22 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -2197,6 +2210,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2361,12 +2380,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.7.15"
|
version = "1.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
@@ -2382,9 +2398,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x509-parser"
|
name = "x509-parser"
|
||||||
version = "0.16.0"
|
version = "0.18.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
|
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"asn1-rs",
|
"asn1-rs",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -2394,7 +2410,7 @@ dependencies = [
|
|||||||
"oid-registry",
|
"oid-registry",
|
||||||
"ring",
|
"ring",
|
||||||
"rusticata-macros",
|
"rusticata-macros",
|
||||||
"thiserror 1.0.69",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
13
Cargo.toml
13
Cargo.toml
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.10.2"
|
version = "0.11.0"
|
||||||
authors = ["razvandimescu <razvan@dimescu.com>"]
|
authors = ["razvandimescu <razvan@dimescu.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
||||||
@@ -10,11 +10,11 @@ keywords = ["dns", "dns-server", "ad-blocking", "reverse-proxy", "developer-tool
|
|||||||
categories = ["network-programming", "development-tools"]
|
categories = ["network-programming", "development-tools"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "signal"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
toml = "0.8"
|
toml = "1.1"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
reqwest = { version = "0.12", features = ["rustls-tls", "gzip", "http2"], default-features = false }
|
reqwest = { version = "0.12", features = ["rustls-tls", "gzip", "http2"], default-features = false }
|
||||||
@@ -22,17 +22,18 @@ hyper = { version = "1", features = ["client", "http1", "server"] }
|
|||||||
hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] }
|
hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] }
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
socket2 = { version = "0.5", features = ["all"] }
|
socket2 = { version = "0.6", features = ["all"] }
|
||||||
rcgen = { version = "0.13", features = ["pem", "x509-parser"] }
|
rcgen = { version = "0.14", features = ["pem", "x509-parser"] }
|
||||||
time = "0.3"
|
time = "0.3"
|
||||||
rustls = "0.23"
|
rustls = "0.23"
|
||||||
tokio-rustls = "0.26"
|
tokio-rustls = "0.26"
|
||||||
arc-swap = "1"
|
arc-swap = "1"
|
||||||
ring = "0.17"
|
ring = "0.17"
|
||||||
rustls-pemfile = "2.2.0"
|
rustls-pemfile = "2.2.0"
|
||||||
|
qrcode = { version = "0.14", default-features = false }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = { version = "0.5", features = ["html_reports"] }
|
criterion = { version = "0.8", features = ["html_reports"] }
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
http = "1"
|
http = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM rust:1.88-alpine AS builder
|
FROM rust:1.94-alpine AS builder
|
||||||
RUN apk add --no-cache musl-dev cmake make perl
|
RUN apk add --no-cache musl-dev cmake make perl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
@@ -11,7 +11,7 @@ COPY numa.toml com.numa.dns.plist numa.service ./
|
|||||||
RUN touch src/main.rs src/lib.rs
|
RUN touch src/main.rs src/lib.rs
|
||||||
RUN cargo build --release
|
RUN cargo build --release
|
||||||
|
|
||||||
FROM alpine:3.20
|
FROM alpine:3.23
|
||||||
COPY --from=builder /app/target/release/numa /usr/local/bin/numa
|
COPY --from=builder /app/target/release/numa /usr/local/bin/numa
|
||||||
EXPOSE 53/udp 80/tcp 443/tcp 853/tcp 5380/tcp
|
EXPOSE 53/udp 80/tcp 443/tcp 853/tcp 5380/tcp
|
||||||
ENTRYPOINT ["numa"]
|
ENTRYPOINT ["numa"]
|
||||||
|
|||||||
10
README.md
10
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.
|
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
|
## LAN Discovery
|
||||||
|
|
||||||
Run Numa on multiple machines. They find each other automatically via mDNS:
|
Run Numa on multiple machines. They find each other automatically via mDNS:
|
||||||
@@ -116,6 +124,7 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
|
|||||||
|
|
||||||
## Learn More
|
## 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: 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)
|
- [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
|
- [Configuration reference](numa.toml) — all options documented inline
|
||||||
@@ -130,6 +139,7 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
|
|||||||
- [x] DNS-over-TLS listener — encrypted client connections (RFC 7858, ALPN strict)
|
- [x] DNS-over-TLS listener — encrypted client connections (RFC 7858, ALPN strict)
|
||||||
- [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3
|
- [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3
|
||||||
- [x] SRTT-based nameserver selection
|
- [x] SRTT-based nameserver selection
|
||||||
|
- [x] Mobile onboarding — `setup-phone` QR flow, mobile API, mobileconfig profiles
|
||||||
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT
|
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT
|
||||||
- [ ] Global `.numa` names — DHT-backed, no registrar
|
- [ ] Global `.numa` names — DHT-backed, no registrar
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
**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.
|
**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.
|
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
|
## What I learned
|
||||||
|
|||||||
167
blog/dot-from-scratch.md
Normal file
167
blog/dot-from-scratch.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
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.16:8765/mobileconfig
|
||||||
|
|
||||||
|
█▀▀▀▀▀▀▀█▀▀██ ██ ▀█▀▀▀▀▀▀▀█
|
||||||
|
█ █▀▀▀█ █▀▄▀▀▀▀▄▄█ █▀▀▀█ █
|
||||||
|
...
|
||||||
|
|
||||||
|
On your iPhone:
|
||||||
|
1. Open Camera, point at the QR code, tap the yellow banner
|
||||||
|
2. Allow the download when Safari asks
|
||||||
|
3. Settings → "Profile Downloaded" → Install
|
||||||
|
4. Settings → General → About → Certificate Trust Settings
|
||||||
|
Toggle ON "Numa Local CA" — required for DoT to work
|
||||||
|
```
|
||||||
|
|
||||||
|
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** — Numa already has a DoH client; the other half unlocks Firefox's built-in DoH setting pointing at Numa.
|
||||||
|
- **DoQ server (RFC 9250)** — DNS over QUIC. Android 14+ supports it natively.
|
||||||
|
- **DDR (RFC 9462)** — auto-discovery via `_dns.resolver.arpa IN SVCB`, so phones pick up a moved Numa instance without the installed profile going stale.
|
||||||
|
|
||||||
|
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.
|
||||||
19
numa.toml
19
numa.toml
@@ -102,3 +102,22 @@ tld = "numa"
|
|||||||
# enabled = true # discover other Numa instances via mDNS (_numa._tcp.local)
|
# enabled = true # discover other Numa instances via mDNS (_numa._tcp.local)
|
||||||
# broadcast_interval_secs = 30
|
# broadcast_interval_secs = 30
|
||||||
# peer_timeout_secs = 90
|
# peer_timeout_secs = 90
|
||||||
|
|
||||||
|
# Mobile API — persistent HTTP listener serving read-only routes
|
||||||
|
# (/health, /ca.pem, /mobileconfig, /ca.mobileconfig) on a LAN-reachable
|
||||||
|
# port. Consumed by the iOS/Android companion apps for discovery and
|
||||||
|
# profile fetching, and by `numa setup-phone` for QR-based onboarding.
|
||||||
|
#
|
||||||
|
# Opt-in because the listener binds to the LAN by default. None of the
|
||||||
|
# exposed routes are cryptographically sensitive (no private keys, no
|
||||||
|
# state mutations, all idempotent GETs), but enabling it does add a new
|
||||||
|
# listener to any device on the LAN that scans port 8765.
|
||||||
|
#
|
||||||
|
# Safe for home LANs. Think twice before enabling on untrusted LANs
|
||||||
|
# (office Wi-Fi, coffee shops, etc.) — an attacker on the same network
|
||||||
|
# could run a competing Numa instance that shadows yours via mDNS and
|
||||||
|
# trick companion apps into installing their profile instead of yours.
|
||||||
|
[mobile]
|
||||||
|
enabled = true # opt-in to the mobile API listener
|
||||||
|
# port = 8765 # default; matches Discovery.swift defaultAPIPort
|
||||||
|
# bind_addr = "0.0.0.0" # default; set to "127.0.0.1" for localhost-only
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ cargo update --workspace
|
|||||||
git add Cargo.toml Cargo.lock
|
git add Cargo.toml Cargo.lock
|
||||||
git commit -m "chore: bump version to $VERSION"
|
git commit -m "chore: bump version to $VERSION"
|
||||||
git tag "$TAG"
|
git tag "$TAG"
|
||||||
git push origin main --tags
|
git push origin main "$TAG"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Released $TAG — GitHub Actions will build, publish to crates.io, and create the release."
|
echo "Released $TAG — GitHub Actions will build, publish to crates.io, and create the release."
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ body::before {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
text-transform: none;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
.blog-nav .wordmark:hover { color: var(--amber); }
|
.blog-nav .wordmark:hover { color: var(--amber); }
|
||||||
@@ -297,5 +298,7 @@ $body$
|
|||||||
<a href="/blog/">Blog</a>
|
<a href="/blog/">Blog</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script data-goatcounter="https://razvandimescu.goatcounter.com/count"
|
||||||
|
async src="//gc.zgo.at/count.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
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;
|
font-weight: 400;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
text-transform: none;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
.blog-nav .wordmark:hover { color: var(--amber); }
|
.blog-nav .wordmark:hover { color: var(--amber); }
|
||||||
@@ -167,6 +168,13 @@ body::before {
|
|||||||
<main class="blog-index">
|
<main class="blog-index">
|
||||||
<h1>Blog</h1>
|
<h1>Blog</h1>
|
||||||
<ul class="post-list">
|
<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>
|
<li>
|
||||||
<a href="/blog/posts/dnssec-from-scratch.html">
|
<a href="/blog/posts/dnssec-from-scratch.html">
|
||||||
<div class="post-title">Implementing DNSSEC from Scratch in Rust</div>
|
<div class="post-title">Implementing DNSSEC from Scratch in Rust</div>
|
||||||
@@ -189,5 +197,7 @@ body::before {
|
|||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script data-goatcounter="https://razvandimescu.goatcounter.com/count"
|
||||||
|
async src="//gc.zgo.at/count.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ body {
|
|||||||
.path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); }
|
.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.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.COALESCED { background: rgba(138, 104, 158, 0.12); color: var(--violet-dim); }
|
||||||
|
.src-tag { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.02em; }
|
||||||
|
|
||||||
/* Sidebar panels */
|
/* Sidebar panels */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -787,6 +788,13 @@ function formatTime(epoch) {
|
|||||||
return d.toLocaleTimeString([], { hour12: false });
|
return d.toLocaleTimeString([], { hour12: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortSrc(addr) {
|
||||||
|
if (!addr) return '';
|
||||||
|
const ip = addr.replace(/:\d+$/, '');
|
||||||
|
if (ip === '127.0.0.1' || ip === '::1') return 'localhost';
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
function formatRemaining(secs) {
|
function formatRemaining(secs) {
|
||||||
if (secs == null) return 'permanent';
|
if (secs == null) return 'permanent';
|
||||||
if (secs < 60) return `${secs}s left`;
|
if (secs < 60) return `${secs}s left`;
|
||||||
@@ -912,8 +920,8 @@ function applyLogFilter() {
|
|||||||
? ` <button class="btn-delete" onclick="allowDomain('${e.domain}')" title="Allow this domain" style="color:var(--emerald);font-size:0.65rem;">allow</button>`
|
? ` <button class="btn-delete" onclick="allowDomain('${e.domain}')" title="Allow this domain" style="color:var(--emerald);font-size:0.65rem;">allow</button>`
|
||||||
: '';
|
: '';
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr title="Source: ${e.src || 'unknown'}">
|
||||||
<td>${formatTime(e.timestamp_epoch)}</td>
|
<td>${formatTime(e.timestamp_epoch)}<br><span class="src-tag">${shortSrc(e.src)}</span></td>
|
||||||
<td>${e.query_type}</td>
|
<td>${e.query_type}</td>
|
||||||
<td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</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.path}">${e.path}</span></td>
|
||||||
|
|||||||
@@ -188,11 +188,50 @@ p.lead {
|
|||||||
line-height: 1.8;
|
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
|
||||||
=========================== */
|
=========================== */
|
||||||
.hero {
|
.hero {
|
||||||
min-height: 100vh;
|
min-height: calc(100vh - 5rem);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1158,6 +1197,9 @@ footer .closing {
|
|||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
section { padding: 4rem 0; }
|
section { padding: 4rem 0; }
|
||||||
.container { padding: 0 1.25rem; }
|
.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; }
|
.network-grid { grid-template-columns: 1fr; }
|
||||||
.pipeline { flex-direction: column; align-items: stretch; gap: 0; }
|
.pipeline { flex-direction: column; align-items: stretch; gap: 0; }
|
||||||
.pipeline-arrow { transform: rotate(90deg); padding: 0.15rem 0; align-self: center; }
|
.pipeline-arrow { transform: rotate(90deg); padding: 0.15rem 0; align-self: center; }
|
||||||
@@ -1171,6 +1213,14 @@ footer .closing {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<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 ==================== -->
|
<!-- ==================== HERO ==================== -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="roman-bricks" aria-hidden="true"></div>
|
<div class="roman-bricks" aria-hidden="true"></div>
|
||||||
@@ -1243,6 +1293,8 @@ footer .closing {
|
|||||||
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
||||||
<li>Recursive resolution — opt-in, resolve from root nameservers, no upstream needed</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>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>TTL-aware caching (sub-ms lookups)</li>
|
||||||
<li>Single binary, portable — macOS, Linux, and Windows</li>
|
<li>Single binary, portable — macOS, Linux, and Windows</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1261,7 +1313,7 @@ footer .closing {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="layer-card reveal reveal-delay-3">
|
<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>
|
<h3>Self-Sovereign DNS</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>pkarr integration — DNS via Mainline DHT, no registrar needed</li>
|
<li>pkarr integration — DNS via Mainline DHT, no registrar needed</li>
|
||||||
@@ -1342,6 +1394,14 @@ footer .closing {
|
|||||||
<td class="cross">No</td>
|
<td class="cross">No</td>
|
||||||
<td class="check">Root hints + full DNSSEC</td>
|
<td class="check">Root hints + full DNSSEC</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td>Ad & tracker blocking</td>
|
<td>Ad & tracker blocking</td>
|
||||||
<td class="check">Yes</td>
|
<td class="check">Yes</td>
|
||||||
@@ -1398,6 +1458,14 @@ footer .closing {
|
|||||||
<td class="cross">No</td>
|
<td class="cross">No</td>
|
||||||
<td class="check">Built in (HTTP/2 + rustls)</td>
|
<td class="check">Built in (HTTP/2 + rustls)</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td>Conditional forwarding</td>
|
<td>Conditional forwarding</td>
|
||||||
<td class="cross">No</td>
|
<td class="cross">No</td>
|
||||||
@@ -1567,11 +1635,14 @@ footer .closing {
|
|||||||
<dt>Resolution Modes</dt>
|
<dt>Resolution Modes</dt>
|
||||||
<dd>Recursive (iterative from root hints, CNAME chasing, glue extraction) or Forward (DoH / plain UDP)</dd>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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>
|
<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="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="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">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">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 \
|
<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">Phase 7</span>
|
||||||
<span class="phase-desc">DNSSEC validation — chain-of-trust, NSEC/NSEC3 denial proofs, RSA + ECDSA + Ed25519</span>
|
<span class="phase-desc">DNSSEC validation — chain-of-trust, NSEC/NSEC3 denial proofs, RSA + ECDSA + Ed25519</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item phase-teal">
|
<div class="roadmap-item done">
|
||||||
<span class="phase">Phase 8</span>
|
<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>
|
<span class="phase-desc">pkarr integration — self-sovereign DNS via Mainline DHT, no registrar needed</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item phase-teal">
|
<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>
|
<span class="phase-desc">Global .numa names — self-publish, DHT-backed, first-come-first-served</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="roadmap-item phase-teal">
|
<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>
|
<span class="phase-desc">.onion bridge — human-readable Tor naming via Ed25519 same-key binding</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1686,5 +1769,7 @@ const observer = new IntersectionObserver((entries) => {
|
|||||||
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
|
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script data-goatcounter="https://razvandimescu.goatcounter.com/count"
|
||||||
|
async src="//gc.zgo.at/count.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
27
src/api.rs
27
src/api.rs
@@ -592,8 +592,19 @@ async fn flush_cache_domain(
|
|||||||
StatusCode::NO_CONTENT
|
StatusCode::NO_CONTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn health() -> Json<serde_json::Value> {
|
/// Enriched `/health` handler shared between the main API and the mobile API.
|
||||||
Json(serde_json::json!({ "status": "ok" }))
|
///
|
||||||
|
/// Returns the cached `HealthMeta` assembled with live fields (LAN IP,
|
||||||
|
/// uptime). Backward compatible with the previous minimal response in
|
||||||
|
/// that `status` is still the first field and `"ok"` is still the value.
|
||||||
|
/// The iOS companion app's `HealthInfo` Swift struct decodes the full
|
||||||
|
/// response; any HTTP client asserting only on `"status"` keeps working.
|
||||||
|
pub async fn health(State(ctx): State<Arc<ServerCtx>>) -> Json<crate::health::HealthResponse> {
|
||||||
|
let lan_ip = Some(*ctx.lan_ip.lock().unwrap());
|
||||||
|
Json(crate::health::HealthResponse::build(
|
||||||
|
&ctx.health_meta,
|
||||||
|
lan_ip,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Blocking handlers ---
|
// --- Blocking handlers ---
|
||||||
@@ -905,12 +916,8 @@ async fn remove_route(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
|
pub async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
|
||||||
let ca_path = ctx.data_dir.join(crate::tls::CA_FILE_NAME);
|
let pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?;
|
||||||
let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path))
|
|
||||||
.await
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
|
||||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
|
||||||
Ok((
|
Ok((
|
||||||
[
|
[
|
||||||
(header::CONTENT_TYPE, "application/x-pem-file"),
|
(header::CONTENT_TYPE, "application/x-pem-file"),
|
||||||
@@ -920,7 +927,7 @@ async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse
|
|||||||
),
|
),
|
||||||
(header::CACHE_CONTROL, "public, max-age=86400"),
|
(header::CACHE_CONTROL, "public, max-age=86400"),
|
||||||
],
|
],
|
||||||
bytes,
|
pem.to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,6 +1003,8 @@ mod tests {
|
|||||||
inflight: Mutex::new(std::collections::HashMap::new()),
|
inflight: Mutex::new(std::collections::HashMap::new()),
|
||||||
dnssec_enabled: false,
|
dnssec_enabled: false,
|
||||||
dnssec_strict: false,
|
dnssec_strict: false,
|
||||||
|
health_meta: crate::health::HealthMeta::test_fixture(),
|
||||||
|
ca_pem: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
173
src/blocklist.rs
173
src/blocklist.rs
@@ -81,66 +81,70 @@ impl BlocklistStore {
|
|||||||
if !self.enabled {
|
if !self.enabled {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(until) = self.paused_until {
|
if let Some(until) = self.paused_until {
|
||||||
if Instant::now() < until {
|
if Instant::now() < until {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let domain = Self::normalize(domain);
|
||||||
if self.allowlist.contains(domain) {
|
if Self::find_in_set(&domain, &self.allowlist).is_some() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
Self::find_in_set(&domain, &self.domains).is_some()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a domain is blocked and return the reason.
|
|
||||||
pub fn check(&self, domain: &str) -> BlockCheckResult {
|
pub fn check(&self, domain: &str) -> BlockCheckResult {
|
||||||
let domain = domain.to_lowercase();
|
|
||||||
|
|
||||||
if !self.enabled {
|
if !self.enabled {
|
||||||
return BlockCheckResult::disabled();
|
return BlockCheckResult::disabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.allowlist.contains(&domain) {
|
if let Some(until) = self.paused_until {
|
||||||
return BlockCheckResult::allowed(&domain, "exact match in allowlist");
|
if Instant::now() < until {
|
||||||
|
return BlockCheckResult::disabled();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.domains.contains(&domain) {
|
let domain = Self::normalize(domain);
|
||||||
return BlockCheckResult::blocked(&domain, "exact match in blocklist");
|
|
||||||
|
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();
|
if let Some(matched) = Self::find_in_set(&domain, &self.domains) {
|
||||||
while let Some(dot) = d.find('.') {
|
let reason = if matched == domain {
|
||||||
d = &d[dot + 1..];
|
"exact match in blocklist"
|
||||||
if self.allowlist.contains(d) {
|
} else {
|
||||||
return BlockCheckResult::allowed(d, "parent domain in allowlist");
|
"parent domain in blocklist"
|
||||||
}
|
};
|
||||||
if self.domains.contains(d) {
|
return BlockCheckResult::blocked(matched, reason);
|
||||||
return BlockCheckResult::blocked(d, "parent domain in blocklist");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BlockCheckResult::not_blocked()
|
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,
|
/// Atomically swap in a new domain set. Build the set outside the lock,
|
||||||
/// then call this to swap — keeps lock hold time sub-microsecond.
|
/// then call this to swap — keeps lock hold time sub-microsecond.
|
||||||
pub fn swap_domains(&mut self, domains: HashSet<String>, sources: Vec<String>) {
|
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) {
|
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 {
|
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> {
|
pub fn allowlist(&self) -> Vec<String> {
|
||||||
@@ -247,6 +251,97 @@ pub fn parse_blocklist(text: &str) -> HashSet<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn heap_bytes_grows_with_domains() {
|
fn heap_bytes_grows_with_domains() {
|
||||||
let mut store = BlocklistStore::new();
|
let mut store = BlocklistStore::new();
|
||||||
|
|||||||
239
src/buffer.rs
239
src/buffer.rs
@@ -84,6 +84,11 @@ impl BytePacketBuffer {
|
|||||||
|
|
||||||
/// Read a qname, handling label compression (pointer jumps).
|
/// Read a qname, handling label compression (pointer jumps).
|
||||||
/// Converts wire format like [3]www[6]google[3]com[0] into "www.google.com".
|
/// Converts wire format like [3]www[6]google[3]com[0] into "www.google.com".
|
||||||
|
///
|
||||||
|
/// Label bytes are escaped per RFC 1035 §5.1:
|
||||||
|
/// - literal `.` within a label → `\.`
|
||||||
|
/// - literal `\` → `\\`
|
||||||
|
/// - bytes outside `0x21..=0x7E` (excluding `.` and `\`) → `\DDD` (3-digit decimal)
|
||||||
pub fn read_qname(&mut self, outstr: &mut String) -> Result<()> {
|
pub fn read_qname(&mut self, outstr: &mut String) -> Result<()> {
|
||||||
let mut pos = self.pos();
|
let mut pos = self.pos();
|
||||||
let mut jumped = false;
|
let mut jumped = false;
|
||||||
@@ -121,7 +126,18 @@ impl BytePacketBuffer {
|
|||||||
|
|
||||||
let str_buffer = self.get_range(pos, len as usize)?;
|
let str_buffer = self.get_range(pos, len as usize)?;
|
||||||
for &b in str_buffer {
|
for &b in str_buffer {
|
||||||
outstr.push(b.to_ascii_lowercase() as char);
|
let c = b.to_ascii_lowercase();
|
||||||
|
match c {
|
||||||
|
b'.' => outstr.push_str("\\."),
|
||||||
|
b'\\' => outstr.push_str("\\\\"),
|
||||||
|
0x21..=0x7E => outstr.push(c as char),
|
||||||
|
_ => {
|
||||||
|
outstr.push('\\');
|
||||||
|
outstr.push((b'0' + c / 100) as char);
|
||||||
|
outstr.push((b'0' + (c / 10) % 10) as char);
|
||||||
|
outstr.push((b'0' + c % 10) as char);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
delim = ".";
|
delim = ".";
|
||||||
@@ -163,24 +179,68 @@ impl BytePacketBuffer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write a qname in wire format, parsing RFC 1035 §5.1 text escapes.
|
||||||
|
/// See `read_qname` for the escape grammar.
|
||||||
pub fn write_qname(&mut self, qname: &str) -> Result<()> {
|
pub fn write_qname(&mut self, qname: &str) -> Result<()> {
|
||||||
if qname.is_empty() || qname == "." {
|
if qname.is_empty() || qname == "." {
|
||||||
self.write_u8(0)?;
|
self.write_u8(0)?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
for label in qname.split('.') {
|
let bytes = qname.as_bytes();
|
||||||
let len = label.len();
|
let mut i = 0;
|
||||||
if len == 0 {
|
while i < bytes.len() {
|
||||||
continue; // skip empty labels from trailing dot
|
let len_pos = self.pos;
|
||||||
}
|
self.write_u8(0)?; // placeholder length byte, backpatched below
|
||||||
if len > 0x3f {
|
let body_start = self.pos;
|
||||||
return Err("Single label exceeds 63 characters of length".into());
|
|
||||||
|
while i < bytes.len() && bytes[i] != b'.' {
|
||||||
|
let b = bytes[i];
|
||||||
|
if b == b'\\' {
|
||||||
|
i += 1;
|
||||||
|
let c1 = *bytes.get(i).ok_or("trailing backslash in qname")?;
|
||||||
|
if c1.is_ascii_digit() {
|
||||||
|
let c2 = *bytes
|
||||||
|
.get(i + 1)
|
||||||
|
.ok_or("invalid \\DDD escape: expected 3 digits")?;
|
||||||
|
let c3 = *bytes
|
||||||
|
.get(i + 2)
|
||||||
|
.ok_or("invalid \\DDD escape: expected 3 digits")?;
|
||||||
|
if !c2.is_ascii_digit() || !c3.is_ascii_digit() {
|
||||||
|
return Err("invalid \\DDD escape: expected 3 digits".into());
|
||||||
|
}
|
||||||
|
let val =
|
||||||
|
(c1 - b'0') as u16 * 100 + (c2 - b'0') as u16 * 10 + (c3 - b'0') as u16;
|
||||||
|
if val > 255 {
|
||||||
|
return Err(format!("\\DDD escape out of range: {}", val).into());
|
||||||
|
}
|
||||||
|
self.write_u8(val as u8)?;
|
||||||
|
i += 3;
|
||||||
|
} else {
|
||||||
|
// \. \\ and any other \X → literal next byte
|
||||||
|
self.write_u8(c1)?;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.write_u8(b)?;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.pos - body_start > 0x3f {
|
||||||
|
return Err("Single label exceeds 63 characters of length".into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.write_u8(len as u8)?;
|
let label_len = self.pos - body_start;
|
||||||
for b in label.as_bytes() {
|
if label_len == 0 && i < bytes.len() {
|
||||||
self.write_u8(*b)?;
|
// Empty label from leading/consecutive dots — roll back the placeholder.
|
||||||
|
self.pos = len_pos;
|
||||||
|
} else {
|
||||||
|
self.set(len_pos, label_len as u8)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < bytes.len() && bytes[i] == b'.' {
|
||||||
|
i += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,3 +272,160 @@ impl BytePacketBuffer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn roundtrip(wire: &[u8]) -> String {
|
||||||
|
let mut buf = BytePacketBuffer::from_bytes(wire);
|
||||||
|
let mut out = String::new();
|
||||||
|
buf.read_qname(&mut out).unwrap();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_then_read(text: &str) -> String {
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
buf.write_qname(text).unwrap();
|
||||||
|
let wire_end = buf.pos();
|
||||||
|
buf.seek(0).unwrap();
|
||||||
|
let mut out = String::new();
|
||||||
|
buf.read_qname(&mut out).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
buf.pos(),
|
||||||
|
wire_end,
|
||||||
|
"reader should consume exactly what writer wrote"
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_plain_domain() {
|
||||||
|
// [3]www[6]google[3]com[0]
|
||||||
|
let wire = b"\x03www\x06google\x03com\x00";
|
||||||
|
assert_eq!(roundtrip(wire), "www.google.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_label_with_literal_dot_is_escaped() {
|
||||||
|
// fanf2's example: [8]exa.mple[3]com[0] — two labels, first contains 0x2E
|
||||||
|
let wire = b"\x08exa.mple\x03com\x00";
|
||||||
|
assert_eq!(roundtrip(wire), "exa\\.mple.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_label_with_backslash_is_escaped() {
|
||||||
|
// [4]a\bc[3]com[0]
|
||||||
|
let wire = b"\x04a\\bc\x03com\x00";
|
||||||
|
assert_eq!(roundtrip(wire), "a\\\\bc.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_label_with_nonprintable_byte_uses_decimal_escape() {
|
||||||
|
// [4]\x00foo[3]com[0] — null byte at label start
|
||||||
|
let wire = b"\x04\x00foo\x03com\x00";
|
||||||
|
assert_eq!(roundtrip(wire), "\\000foo.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_label_with_space_uses_decimal_escape() {
|
||||||
|
// Space (0x20) is outside 0x21..=0x7E, so it must be decimal-escaped.
|
||||||
|
let wire = b"\x05a b c\x00";
|
||||||
|
assert_eq!(roundtrip(wire), "a\\032b\\032c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_plain_domain() {
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
buf.write_qname("www.google.com").unwrap();
|
||||||
|
assert_eq!(&buf.buf[..buf.pos], b"\x03www\x06google\x03com\x00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_escaped_dot_does_not_split_label() {
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
buf.write_qname("exa\\.mple.com").unwrap();
|
||||||
|
assert_eq!(&buf.buf[..buf.pos], b"\x08exa.mple\x03com\x00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_escaped_backslash() {
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
buf.write_qname("a\\\\bc.com").unwrap();
|
||||||
|
assert_eq!(&buf.buf[..buf.pos], b"\x04a\\bc\x03com\x00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_decimal_escape_yields_raw_byte() {
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
buf.write_qname("\\000foo.com").unwrap();
|
||||||
|
assert_eq!(&buf.buf[..buf.pos], b"\x04\x00foo\x03com\x00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_skips_empty_labels() {
|
||||||
|
// Leading dot — first (empty) label is rolled back.
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
buf.write_qname(".foo.com").unwrap();
|
||||||
|
assert_eq!(&buf.buf[..buf.pos], b"\x03foo\x03com\x00");
|
||||||
|
|
||||||
|
// Consecutive dots — middle empty label is rolled back.
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
buf.write_qname("foo..com").unwrap();
|
||||||
|
assert_eq!(&buf.buf[..buf.pos], b"\x03foo\x03com\x00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_rejects_out_of_range_decimal_escape() {
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
assert!(buf.write_qname("\\999foo.com").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_rejects_trailing_backslash() {
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
assert!(buf.write_qname("foo\\").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_rejects_short_decimal_escape() {
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
assert!(buf.write_qname("\\1").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_rejects_label_over_63_bytes() {
|
||||||
|
// 64 bytes exceeds the wire-format label cap.
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
assert!(buf.write_qname(&"a".repeat(64)).is_err());
|
||||||
|
|
||||||
|
// 63 bytes is the maximum permitted label length.
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
assert!(buf.write_qname(&"a".repeat(63)).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_preserves_dot_in_label() {
|
||||||
|
assert_eq!(write_then_read("exa\\.mple.com"), "exa\\.mple.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_preserves_backslash_in_label() {
|
||||||
|
assert_eq!(write_then_read("a\\\\b.com"), "a\\\\b.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_preserves_nonprintable_byte() {
|
||||||
|
assert_eq!(write_then_read("\\000foo.com"), "\\000foo.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_name_empty_and_dot_both_produce_single_zero() {
|
||||||
|
let mut a = BytePacketBuffer::new();
|
||||||
|
a.write_qname("").unwrap();
|
||||||
|
let mut b = BytePacketBuffer::new();
|
||||||
|
b.write_qname(".").unwrap();
|
||||||
|
assert_eq!(&a.buf[..a.pos], b"\x00");
|
||||||
|
assert_eq!(&b.buf[..b.pos], b"\x00");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ pub struct Config {
|
|||||||
pub dnssec: DnssecConfig,
|
pub dnssec: DnssecConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dot: DotConfig,
|
pub dot: DotConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mobile: MobileConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -412,6 +414,53 @@ fn default_dot_bind_addr() -> String {
|
|||||||
"0.0.0.0".to_string()
|
"0.0.0.0".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configuration for the mobile API — a persistent HTTP listener that
|
||||||
|
/// serves a read-only subset of routes (`/health`, `/ca.pem`,
|
||||||
|
/// `/mobileconfig`, `/ca.mobileconfig`) on a LAN-reachable port, for
|
||||||
|
/// consumption by the iOS/Android companion apps.
|
||||||
|
///
|
||||||
|
/// Unlike the main API (port 5380, localhost-only by default, supports
|
||||||
|
/// state-mutating routes), the mobile API is safe to expose on the LAN
|
||||||
|
/// because every route is idempotent and read-only.
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
pub struct MobileConfig {
|
||||||
|
/// If true, spawn the mobile API listener at startup. **Default false.**
|
||||||
|
/// Opt-in because the listener binds to the LAN by default and exposes
|
||||||
|
/// a few read-only endpoints to any device on the same network (`/health`,
|
||||||
|
/// `/ca.pem`, `/mobileconfig`, `/ca.mobileconfig`). None of those are
|
||||||
|
/// cryptographically sensitive (the CA private key is never served),
|
||||||
|
/// but users should enable this explicitly rather than have a new
|
||||||
|
/// LAN-reachable port appear after an upgrade.
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Port for the mobile API. Default 8765.
|
||||||
|
#[serde(default = "default_mobile_port")]
|
||||||
|
pub port: u16,
|
||||||
|
/// Bind address for the mobile API. Default "0.0.0.0" (all interfaces)
|
||||||
|
/// so phones on the LAN can reach it. Set to "127.0.0.1" to restrict
|
||||||
|
/// to localhost — useful if you're running behind another front-end.
|
||||||
|
#[serde(default = "default_mobile_bind_addr")]
|
||||||
|
pub bind_addr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MobileConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
MobileConfig {
|
||||||
|
enabled: false,
|
||||||
|
port: default_mobile_port(),
|
||||||
|
bind_addr: default_mobile_bind_addr(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_mobile_port() -> u16 {
|
||||||
|
8765
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_mobile_bind_addr() -> String {
|
||||||
|
"0.0.0.0".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
10
src/ctx.rs
10
src/ctx.rs
@@ -18,6 +18,7 @@ use crate::cache::{DnsCache, DnssecStatus};
|
|||||||
use crate::config::{UpstreamMode, ZoneMap};
|
use crate::config::{UpstreamMode, ZoneMap};
|
||||||
use crate::forward::{forward_query, Upstream};
|
use crate::forward::{forward_query, Upstream};
|
||||||
use crate::header::ResultCode;
|
use crate::header::ResultCode;
|
||||||
|
use crate::health::HealthMeta;
|
||||||
use crate::lan::PeerStore;
|
use crate::lan::PeerStore;
|
||||||
use crate::override_store::OverrideStore;
|
use crate::override_store::OverrideStore;
|
||||||
use crate::packet::DnsPacket;
|
use crate::packet::DnsPacket;
|
||||||
@@ -60,6 +61,15 @@ pub struct ServerCtx {
|
|||||||
pub inflight: Mutex<InflightMap>,
|
pub inflight: Mutex<InflightMap>,
|
||||||
pub dnssec_enabled: bool,
|
pub dnssec_enabled: bool,
|
||||||
pub dnssec_strict: bool,
|
pub dnssec_strict: bool,
|
||||||
|
/// Cached health metadata (version, hostname, DoT config, CA
|
||||||
|
/// fingerprint, features). Shared between the main and mobile
|
||||||
|
/// API `/health` handlers. Built once at startup in `main.rs`.
|
||||||
|
pub health_meta: HealthMeta,
|
||||||
|
/// CA certificate in PEM form, cached at startup. `None` if no
|
||||||
|
/// TLS-using feature is enabled and the CA hasn't been generated.
|
||||||
|
/// 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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,
|
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use log::{debug, trace};
|
|||||||
use ring::digest;
|
use ring::digest;
|
||||||
use ring::signature;
|
use ring::signature;
|
||||||
|
|
||||||
|
use crate::buffer::BytePacketBuffer;
|
||||||
use crate::cache::{DnsCache, DnssecStatus};
|
use crate::cache::{DnsCache, DnssecStatus};
|
||||||
use crate::packet::DnsPacket;
|
use crate::packet::DnsPacket;
|
||||||
use crate::question::QueryType;
|
use crate::question::QueryType;
|
||||||
@@ -720,22 +721,29 @@ pub fn verify_ds(ds: &DnsRecord, dnskey: &DnsRecord, owner: &str) -> bool {
|
|||||||
|
|
||||||
// -- Canonical wire format --
|
// -- Canonical wire format --
|
||||||
|
|
||||||
|
/// Encode a DNS name in canonical wire form per RFC 4034 §6.2:
|
||||||
|
/// uncompressed, with ASCII letters lowercased.
|
||||||
|
///
|
||||||
|
/// Lowercasing happens *after* escape resolution because `\065` yields
|
||||||
|
/// `'A'`, which canonical form must convert to `'a'`.
|
||||||
pub fn name_to_wire(name: &str) -> Vec<u8> {
|
pub fn name_to_wire(name: &str) -> Vec<u8> {
|
||||||
let mut wire = Vec::with_capacity(name.len() + 2);
|
let mut buf = BytePacketBuffer::new();
|
||||||
if name == "." || name.is_empty() {
|
buf.write_qname(name)
|
||||||
wire.push(0);
|
.expect("name_to_wire: input must parse as a valid DNS name");
|
||||||
return wire;
|
let mut wire = buf.filled().to_vec();
|
||||||
}
|
|
||||||
for label in name.split('.') {
|
let mut i = 0;
|
||||||
if label.is_empty() {
|
while i < wire.len() {
|
||||||
continue;
|
let label_len = wire[i] as usize;
|
||||||
}
|
if label_len == 0 {
|
||||||
wire.push(label.len() as u8);
|
break;
|
||||||
for &b in label.as_bytes() {
|
|
||||||
wire.push(b.to_ascii_lowercase());
|
|
||||||
}
|
}
|
||||||
|
i += 1;
|
||||||
|
let end = i + label_len;
|
||||||
|
wire[i..end].make_ascii_lowercase();
|
||||||
|
i = end;
|
||||||
}
|
}
|
||||||
wire.push(0);
|
|
||||||
wire
|
wire
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1475,6 +1483,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn name_to_wire_escaped_dot_in_label_is_not_a_separator() {
|
||||||
|
// `exa\.mple.com` is two labels: `exa.mple` (8 bytes including the 0x2E) and `com`.
|
||||||
|
let wire = name_to_wire("exa\\.mple.com");
|
||||||
|
assert_eq!(
|
||||||
|
wire,
|
||||||
|
vec![8, b'e', b'x', b'a', b'.', b'm', b'p', b'l', b'e', 3, b'c', b'o', b'm', 0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn name_to_wire_decimal_escape_is_lowercased() {
|
||||||
|
// \065 = 'A', must become 'a' in canonical form.
|
||||||
|
let wire = name_to_wire("\\065bc.com");
|
||||||
|
assert_eq!(wire, vec![3, b'a', b'b', b'c', 3, b'c', b'o', b'm', 0]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parent_zone_cases() {
|
fn parent_zone_cases() {
|
||||||
assert_eq!(parent_zone("example.com"), "com");
|
assert_eq!(parent_zone("example.com"), "com");
|
||||||
|
|||||||
@@ -381,6 +381,8 @@ mod tests {
|
|||||||
inflight: Mutex::new(HashMap::new()),
|
inflight: Mutex::new(HashMap::new()),
|
||||||
dnssec_enabled: false,
|
dnssec_enabled: false,
|
||||||
dnssec_strict: false,
|
dnssec_strict: false,
|
||||||
|
health_meta: crate::health::HealthMeta::test_fixture(),
|
||||||
|
ca_pem: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
|||||||
254
src/health.rs
Normal file
254
src/health.rs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
//! Health metadata and `/health` response shape, shared between the main
|
||||||
|
//! HTTP API and the mobile API.
|
||||||
|
//!
|
||||||
|
//! The static fields (version, hostname, DoT config, CA fingerprint,
|
||||||
|
//! feature list) are computed once at startup and stored in [`HealthMeta`]
|
||||||
|
//! on `ServerCtx`. Per-request fields (uptime, LAN IP) are computed live.
|
||||||
|
//! Both handlers call [`HealthResponse::build`] to assemble the JSON
|
||||||
|
//! response from `HealthMeta` + live inputs.
|
||||||
|
//!
|
||||||
|
//! JSON schema is documented in `docs/implementation/ios-companion-app.md`
|
||||||
|
//! §4.2. The iOS companion app's `HealthInfo` struct is the canonical
|
||||||
|
//! consumer; any change to this response must keep that struct decoding
|
||||||
|
//! cleanly (all consumed fields are optional on the Swift side, but
|
||||||
|
//! `lan_ip` is load-bearing for the pipeline).
|
||||||
|
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use ring::digest::{digest, SHA256};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Immutable health metadata cached on `ServerCtx`. Built once at startup
|
||||||
|
/// from config + file-system state (CA cert).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HealthMeta {
|
||||||
|
pub version: &'static str,
|
||||||
|
pub hostname: String,
|
||||||
|
pub sni: String,
|
||||||
|
pub dot_enabled: bool,
|
||||||
|
pub dot_port: u16,
|
||||||
|
pub api_port: u16,
|
||||||
|
pub ca_fingerprint_sha256: Option<String>,
|
||||||
|
pub features: Vec<String>,
|
||||||
|
pub started_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HealthMeta {
|
||||||
|
/// Minimal `HealthMeta` for unit tests that construct a `ServerCtx`
|
||||||
|
/// without needing the real startup flow (CA file reads, hostname
|
||||||
|
/// detection, etc.). Deterministic values so test JSON assertions
|
||||||
|
/// stay stable.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn test_fixture() -> Self {
|
||||||
|
HealthMeta {
|
||||||
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
|
hostname: "test-host".to_string(),
|
||||||
|
sni: "numa.numa".to_string(),
|
||||||
|
dot_enabled: false,
|
||||||
|
dot_port: 853,
|
||||||
|
api_port: 8765,
|
||||||
|
ca_fingerprint_sha256: None,
|
||||||
|
features: vec![],
|
||||||
|
started_at: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a new HealthMeta from config + startup-time environment.
|
||||||
|
/// Call once at server boot; the returned value is cheap to clone
|
||||||
|
/// (small number of short strings) and lives on `ServerCtx`.
|
||||||
|
///
|
||||||
|
/// The argument count is deliberate — each flag corresponds to a
|
||||||
|
/// specific config value and is clearly named at the call site.
|
||||||
|
/// Collapsing into a struct hides nothing meaningful for a one-call
|
||||||
|
/// initializer.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn build(
|
||||||
|
data_dir: &Path,
|
||||||
|
dot_enabled: bool,
|
||||||
|
dot_port: u16,
|
||||||
|
api_port: u16,
|
||||||
|
dnssec_enabled: bool,
|
||||||
|
recursive_enabled: bool,
|
||||||
|
mdns_enabled: bool,
|
||||||
|
blocking_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 dot_enabled {
|
||||||
|
features.push("dot".to_string());
|
||||||
|
}
|
||||||
|
if recursive_enabled {
|
||||||
|
features.push("recursive".to_string());
|
||||||
|
}
|
||||||
|
if blocking_enabled {
|
||||||
|
features.push("blocking".to_string());
|
||||||
|
}
|
||||||
|
if mdns_enabled {
|
||||||
|
features.push("mdns".to_string());
|
||||||
|
}
|
||||||
|
if dnssec_enabled {
|
||||||
|
features.push("dnssec".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
HealthMeta {
|
||||||
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
|
hostname: crate::hostname(),
|
||||||
|
sni: "numa.numa".to_string(),
|
||||||
|
dot_enabled,
|
||||||
|
dot_port,
|
||||||
|
api_port,
|
||||||
|
ca_fingerprint_sha256,
|
||||||
|
features,
|
||||||
|
started_at: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON response shape returned by `GET /health` on both main and mobile APIs.
|
||||||
|
///
|
||||||
|
/// Fields are organized to match the iOS companion app's
|
||||||
|
/// `HealthInfo` Swift struct — see `ios-companion-app.md` §4.2.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct HealthResponse {
|
||||||
|
pub status: &'static str,
|
||||||
|
pub version: &'static str,
|
||||||
|
pub uptime_secs: u64,
|
||||||
|
pub hostname: String,
|
||||||
|
pub lan_ip: Option<String>,
|
||||||
|
pub sni: String,
|
||||||
|
pub dot: DotBlock,
|
||||||
|
pub api: ApiBlock,
|
||||||
|
pub ca: CaBlock,
|
||||||
|
pub features: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DotBlock {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ApiBlock {
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CaBlock {
|
||||||
|
pub present: bool,
|
||||||
|
pub fingerprint_sha256: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HealthResponse {
|
||||||
|
/// Assemble a fresh `HealthResponse` from the cached metadata and
|
||||||
|
/// the current LAN IP (which may change across network transitions).
|
||||||
|
/// Pass `None` for `lan_ip` if detection fails — the response still
|
||||||
|
/// returns 200 OK, just without the LAN address.
|
||||||
|
pub fn build(meta: &HealthMeta, lan_ip: Option<Ipv4Addr>) -> Self {
|
||||||
|
HealthResponse {
|
||||||
|
status: "ok",
|
||||||
|
version: meta.version,
|
||||||
|
uptime_secs: meta.started_at.elapsed().as_secs(),
|
||||||
|
hostname: meta.hostname.clone(),
|
||||||
|
lan_ip: lan_ip.map(|ip| ip.to_string()),
|
||||||
|
sni: meta.sni.clone(),
|
||||||
|
dot: DotBlock {
|
||||||
|
enabled: meta.dot_enabled,
|
||||||
|
port: if meta.dot_enabled {
|
||||||
|
Some(meta.dot_port)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
},
|
||||||
|
api: ApiBlock {
|
||||||
|
port: meta.api_port,
|
||||||
|
},
|
||||||
|
ca: CaBlock {
|
||||||
|
present: meta.ca_fingerprint_sha256.is_some(),
|
||||||
|
fingerprint_sha256: meta.ca_fingerprint_sha256.clone(),
|
||||||
|
},
|
||||||
|
features: meta.features.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the CA cert at `ca_path` and return its SHA-256 fingerprint as a
|
||||||
|
/// lowercase hex string, or None if the file doesn't exist or can't be read.
|
||||||
|
///
|
||||||
|
/// Hashes the raw PEM bytes for simplicity. A more canonical SPKI-based
|
||||||
|
/// fingerprint would require parsing the PEM → DER → extracting
|
||||||
|
/// SubjectPublicKeyInfo, which adds complexity without meaningful benefit
|
||||||
|
/// for our use case (the iOS app uses the fingerprint only for display
|
||||||
|
/// and to detect rotation).
|
||||||
|
fn compute_ca_fingerprint(ca_path: &Path) -> Option<String> {
|
||||||
|
let pem = std::fs::read(ca_path).ok()?;
|
||||||
|
let hash = digest(&SHA256, &pem);
|
||||||
|
let hex: String = hash.as_ref().iter().map(|b| format!("{:02x}", b)).collect();
|
||||||
|
Some(hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn health_response_contains_required_fields() {
|
||||||
|
let meta = HealthMeta {
|
||||||
|
version: "0.10.0",
|
||||||
|
hostname: "test-host".to_string(),
|
||||||
|
sni: "numa.numa".to_string(),
|
||||||
|
dot_enabled: true,
|
||||||
|
dot_port: 853,
|
||||||
|
api_port: 8765,
|
||||||
|
ca_fingerprint_sha256: Some("abcd1234".to_string()),
|
||||||
|
features: vec!["dot".to_string(), "dnssec".to_string()],
|
||||||
|
started_at: Instant::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = HealthResponse::build(&meta, Some(Ipv4Addr::new(192, 168, 1, 50)));
|
||||||
|
let json = serde_json::to_string(&response).unwrap();
|
||||||
|
|
||||||
|
assert!(json.contains("\"status\":\"ok\""));
|
||||||
|
assert!(json.contains("\"version\":\"0.10.0\""));
|
||||||
|
assert!(json.contains("\"hostname\":\"test-host\""));
|
||||||
|
assert!(json.contains("\"lan_ip\":\"192.168.1.50\""));
|
||||||
|
assert!(json.contains("\"sni\":\"numa.numa\""));
|
||||||
|
assert!(json.contains("\"port\":853"));
|
||||||
|
assert!(json.contains("\"port\":8765"));
|
||||||
|
assert!(json.contains("\"fingerprint_sha256\":\"abcd1234\""));
|
||||||
|
assert!(json.contains("\"features\":[\"dot\",\"dnssec\"]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn health_response_omits_dot_port_when_disabled() {
|
||||||
|
let meta = HealthMeta {
|
||||||
|
version: "0.10.0",
|
||||||
|
hostname: "t".to_string(),
|
||||||
|
sni: "numa.numa".to_string(),
|
||||||
|
dot_enabled: false,
|
||||||
|
dot_port: 853,
|
||||||
|
api_port: 8765,
|
||||||
|
ca_fingerprint_sha256: None,
|
||||||
|
features: vec![],
|
||||||
|
started_at: Instant::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = HealthResponse::build(&meta, None);
|
||||||
|
let json = serde_json::to_string(&response).unwrap();
|
||||||
|
|
||||||
|
assert!(json.contains("\"enabled\":false"));
|
||||||
|
assert!(json.contains("\"dot\":{\"enabled\":false,\"port\":null}"));
|
||||||
|
assert!(json.contains("\"present\":false"));
|
||||||
|
assert!(json.contains("\"lan_ip\":null"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ca_fingerprint_returns_none_for_missing_file() {
|
||||||
|
let fp = compute_ca_fingerprint(Path::new("/nonexistent/ca.pem"));
|
||||||
|
assert!(fp.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/lan.rs
84
src/lan.rs
@@ -9,6 +9,7 @@ use crate::buffer::BytePacketBuffer;
|
|||||||
use crate::config::LanConfig;
|
use crate::config::LanConfig;
|
||||||
use crate::ctx::ServerCtx;
|
use crate::ctx::ServerCtx;
|
||||||
use crate::header::DnsHeader;
|
use crate::header::DnsHeader;
|
||||||
|
use crate::health::HealthMeta;
|
||||||
use crate::question::{DnsQuestion, QueryType};
|
use crate::question::{DnsQuestion, QueryType};
|
||||||
|
|
||||||
// --- Constants ---
|
// --- Constants ---
|
||||||
@@ -18,6 +19,18 @@ const MDNS_PORT: u16 = 5353;
|
|||||||
const SERVICE_TYPE: &str = "_numa._tcp.local";
|
const SERVICE_TYPE: &str = "_numa._tcp.local";
|
||||||
const MDNS_TTL: u32 = 120;
|
const MDNS_TTL: u32 = 120;
|
||||||
|
|
||||||
|
// TXT record key prefixes (including the trailing `=`). Shared between
|
||||||
|
// the sender (`build_announcement`) and the receiver (`parse_mdns_response`)
|
||||||
|
// to prevent drift — both sides match on the same literal, not on two
|
||||||
|
// independent string constants that could diverge.
|
||||||
|
const TXT_SERVICES: &str = "services=";
|
||||||
|
const TXT_ID: &str = "id=";
|
||||||
|
const TXT_VERSION: &str = "version=";
|
||||||
|
const TXT_API_PORT: &str = "api_port=";
|
||||||
|
const TXT_PROTO: &str = "proto=";
|
||||||
|
const TXT_DOT_PORT: &str = "dot_port=";
|
||||||
|
const TXT_CA_FP: &str = "ca_fp=";
|
||||||
|
|
||||||
// --- Peer Store ---
|
// --- Peer Store ---
|
||||||
|
|
||||||
pub struct PeerStore {
|
pub struct PeerStore {
|
||||||
@@ -97,14 +110,16 @@ pub fn detect_lan_ip() -> Option<Ipv4Addr> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Short hostname for mDNS instance names (`<short>._numa._tcp.local`).
|
||||||
|
/// Truncates at the first `.` so `macbook-pro.local` becomes `macbook-pro`.
|
||||||
|
/// Uses the shared `crate::hostname()` helper as the source.
|
||||||
fn get_hostname() -> String {
|
fn get_hostname() -> String {
|
||||||
std::process::Command::new("hostname")
|
crate::hostname()
|
||||||
.output()
|
.split('.')
|
||||||
.ok()
|
.next()
|
||||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|h| h.trim().split('.').next().unwrap_or("numa").to_string())
|
.unwrap_or("numa")
|
||||||
.filter(|h| !h.is_empty())
|
.to_string()
|
||||||
.unwrap_or_else(|| "numa".to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a per-process instance ID for self-filtering on multi-instance hosts
|
/// Generate a per-process instance ID for self-filtering on multi-instance hosts
|
||||||
@@ -168,13 +183,22 @@ pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
|
|||||||
.map(|e| (e.name.clone(), e.target_port))
|
.map(|e| (e.name.clone(), e.target_port))
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
if services.is_empty() {
|
// Note: we always announce ourselves, even when the
|
||||||
continue;
|
// services list is empty. The announcement still carries
|
||||||
}
|
// the mobile API port + version + CA fingerprint in TXT,
|
||||||
|
// which is what the iOS companion app browses for via
|
||||||
|
// NWBrowser on `_numa._tcp.local`. Other Numa peers
|
||||||
|
// receive these empty-services announcements too and
|
||||||
|
// correctly ignore them in parse_mdns_response (the
|
||||||
|
// receiver only processes when services is non-empty).
|
||||||
let current_ip = *sender_ctx.lan_ip.lock().unwrap();
|
let current_ip = *sender_ctx.lan_ip.lock().unwrap();
|
||||||
if let Ok(pkt) =
|
if let Ok(pkt) = build_announcement(
|
||||||
build_announcement(&sender_hostname, current_ip, &services, &sender_instance_id)
|
&sender_hostname,
|
||||||
{
|
current_ip,
|
||||||
|
&services,
|
||||||
|
&sender_instance_id,
|
||||||
|
&sender_ctx.health_meta,
|
||||||
|
) {
|
||||||
let _ = sender_socket.send_to(pkt.filled(), dest).await;
|
let _ = sender_socket.send_to(pkt.filled(), dest).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,6 +264,7 @@ fn build_announcement(
|
|||||||
ip: Ipv4Addr,
|
ip: Ipv4Addr,
|
||||||
services: &[(String, u16)],
|
services: &[(String, u16)],
|
||||||
inst_id: &str,
|
inst_id: &str,
|
||||||
|
meta: &HealthMeta,
|
||||||
) -> crate::Result<BytePacketBuffer> {
|
) -> crate::Result<BytePacketBuffer> {
|
||||||
let mut buf = BytePacketBuffer::new();
|
let mut buf = BytePacketBuffer::new();
|
||||||
let instance_name = format!("{}._numa._tcp.local", hostname);
|
let instance_name = format!("{}._numa._tcp.local", hostname);
|
||||||
@@ -260,7 +285,11 @@ fn build_announcement(
|
|||||||
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
|
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
|
||||||
|
|
||||||
// SRV: <instance>._numa._tcp.local → <hostname>.local
|
// SRV: <instance>._numa._tcp.local → <hostname>.local
|
||||||
// Port in SRV is informational; actual service ports are in TXT
|
// Port = mobile API port, which is what the iOS companion app resolves
|
||||||
|
// the SRV record for. Legacy Numa peers don't read the SRV port (see
|
||||||
|
// parse_mdns_response — it only uses TXT services= for peer discovery),
|
||||||
|
// so changing the SRV port from "first service's port" to the mobile
|
||||||
|
// API port is backwards compatible.
|
||||||
write_record_header(
|
write_record_header(
|
||||||
&mut buf,
|
&mut buf,
|
||||||
&instance_name,
|
&instance_name,
|
||||||
@@ -273,11 +302,13 @@ fn build_announcement(
|
|||||||
let rdata_start = buf.pos();
|
let rdata_start = buf.pos();
|
||||||
buf.write_u16(0)?; // priority
|
buf.write_u16(0)?; // priority
|
||||||
buf.write_u16(0)?; // weight
|
buf.write_u16(0)?; // weight
|
||||||
buf.write_u16(services.first().map(|(_, p)| *p).unwrap_or(0))?; // first service port for SRV display
|
buf.write_u16(meta.api_port)?; // mobile API port, for iOS companion app
|
||||||
buf.write_qname(&host_local)?;
|
buf.write_qname(&host_local)?;
|
||||||
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
|
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
|
||||||
|
|
||||||
// TXT: services + instance ID for self-filtering
|
// TXT: legacy peer-discovery entries (services, id) + enriched entries
|
||||||
|
// for the iOS companion app (version, api_port, proto, dot_port, ca_fp).
|
||||||
|
// All in one TXT RRset per mDNS convention.
|
||||||
write_record_header(
|
write_record_header(
|
||||||
&mut buf,
|
&mut buf,
|
||||||
&instance_name,
|
&instance_name,
|
||||||
@@ -293,8 +324,21 @@ fn build_announcement(
|
|||||||
.map(|(name, port)| format!("{}:{}", name, port))
|
.map(|(name, port)| format!("{}:{}", name, port))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
write_txt_string(&mut buf, &format!("services={}", svc_str))?;
|
// Legacy peer-discovery entries (consumed by parse_mdns_response)
|
||||||
write_txt_string(&mut buf, &format!("id={}", inst_id))?;
|
write_txt_string(&mut buf, &format!("{}{}", TXT_SERVICES, svc_str))?;
|
||||||
|
write_txt_string(&mut buf, &format!("{}{}", TXT_ID, inst_id))?;
|
||||||
|
// Enriched entries (consumed by the iOS/Android companion apps)
|
||||||
|
write_txt_string(&mut buf, &format!("{}{}", TXT_VERSION, meta.version))?;
|
||||||
|
write_txt_string(&mut buf, &format!("{}{}", TXT_API_PORT, meta.api_port))?;
|
||||||
|
if meta.dot_enabled {
|
||||||
|
write_txt_string(&mut buf, &format!("{}dot", TXT_PROTO))?;
|
||||||
|
write_txt_string(&mut buf, &format!("{}{}", TXT_DOT_PORT, meta.dot_port))?;
|
||||||
|
} else {
|
||||||
|
write_txt_string(&mut buf, &format!("{}plain", TXT_PROTO))?;
|
||||||
|
}
|
||||||
|
if let Some(fp) = &meta.ca_fingerprint_sha256 {
|
||||||
|
write_txt_string(&mut buf, &format!("{}{}", TXT_CA_FP, fp))?;
|
||||||
|
}
|
||||||
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
|
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
|
||||||
|
|
||||||
// A: <hostname>.local → IP
|
// A: <hostname>.local → IP
|
||||||
@@ -408,7 +452,7 @@ fn parse_mdns_response(data: &[u8]) -> Option<MdnsAnnouncement> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if let Ok(txt) = std::str::from_utf8(&data[pos..pos + txt_len]) {
|
if let Ok(txt) = std::str::from_utf8(&data[pos..pos + txt_len]) {
|
||||||
if let Some(val) = txt.strip_prefix("services=") {
|
if let Some(val) = txt.strip_prefix(TXT_SERVICES) {
|
||||||
let svcs: Vec<(String, u16)> = val
|
let svcs: Vec<(String, u16)> = val
|
||||||
.split(',')
|
.split(',')
|
||||||
.filter_map(|s| {
|
.filter_map(|s| {
|
||||||
@@ -421,7 +465,7 @@ fn parse_mdns_response(data: &[u8]) -> Option<MdnsAnnouncement> {
|
|||||||
if !svcs.is_empty() {
|
if !svcs.is_empty() {
|
||||||
txt_services = Some(svcs);
|
txt_services = Some(svcs);
|
||||||
}
|
}
|
||||||
} else if let Some(id) = txt.strip_prefix("id=") {
|
} else if let Some(id) = txt.strip_prefix(TXT_ID) {
|
||||||
peer_instance_id = Some(id.to_string());
|
peer_instance_id = Some(id.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/lib.rs
18
src/lib.rs
@@ -8,7 +8,10 @@ pub mod dnssec;
|
|||||||
pub mod dot;
|
pub mod dot;
|
||||||
pub mod forward;
|
pub mod forward;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
|
pub mod health;
|
||||||
pub mod lan;
|
pub mod lan;
|
||||||
|
pub mod mobile_api;
|
||||||
|
pub mod mobileconfig;
|
||||||
pub mod override_store;
|
pub mod override_store;
|
||||||
pub mod packet;
|
pub mod packet;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
@@ -17,6 +20,7 @@ pub mod question;
|
|||||||
pub mod record;
|
pub mod record;
|
||||||
pub mod recursive;
|
pub mod recursive;
|
||||||
pub mod service_store;
|
pub mod service_store;
|
||||||
|
pub mod setup_phone;
|
||||||
pub mod srtt;
|
pub mod srtt;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub mod system_dns;
|
pub mod system_dns;
|
||||||
@@ -25,6 +29,20 @@ pub mod tls;
|
|||||||
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
pub type Error = Box<dyn std::error::Error + Send + Sync>;
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// Detect the machine hostname via the `hostname` command. Returns the
|
||||||
|
/// full hostname (e.g., `macbook-pro.local`), or `"numa"` if the command
|
||||||
|
/// fails. Call sites that need the short form (e.g., mDNS instance
|
||||||
|
/// names) should truncate at the first `.`.
|
||||||
|
pub fn hostname() -> String {
|
||||||
|
std::process::Command::new("hostname")
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|h| h.trim().to_string())
|
||||||
|
.filter(|h| !h.is_empty())
|
||||||
|
.unwrap_or_else(|| "numa".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared config directory for persistent data (services.json, etc).
|
/// Shared config directory for persistent data (services.json, etc).
|
||||||
/// Unix users: ~/.config/numa/
|
/// Unix users: ~/.config/numa/
|
||||||
/// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa
|
/// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa
|
||||||
|
|||||||
50
src/main.rs
50
src/main.rs
@@ -54,6 +54,9 @@ async fn main() -> numa::Result<()> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
"setup-phone" => {
|
||||||
|
return numa::setup_phone::run().await.map_err(|e| e.into());
|
||||||
|
}
|
||||||
"lan" => {
|
"lan" => {
|
||||||
let sub = std::env::args().nth(2).unwrap_or_default();
|
let sub = std::env::args().nth(2).unwrap_or_default();
|
||||||
let config_path = std::env::args()
|
let config_path = std::env::args()
|
||||||
@@ -85,12 +88,27 @@ async fn main() -> numa::Result<()> {
|
|||||||
eprintln!(" service status Check if the service is running");
|
eprintln!(" service status Check if the service is running");
|
||||||
eprintln!(" lan on Enable LAN service discovery (mDNS)");
|
eprintln!(" lan on Enable LAN service discovery (mDNS)");
|
||||||
eprintln!(" lan off Disable LAN service discovery");
|
eprintln!(" lan off Disable LAN service discovery");
|
||||||
|
eprintln!(" setup-phone Generate a QR code to install Numa DoT on a phone");
|
||||||
eprintln!(" help Show this help");
|
eprintln!(" help Show this help");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Config path defaults to numa.toml");
|
eprintln!("Config path defaults to numa.toml");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {
|
||||||
|
if !arg1.is_empty()
|
||||||
|
&& arg1 != "run"
|
||||||
|
&& !arg1.contains('/')
|
||||||
|
&& !arg1.contains('\\')
|
||||||
|
&& !arg1.ends_with(".toml")
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"\x1b[1;38;2;192;98;58mNuma\x1b[0m — unknown command: \x1b[1m{}\x1b[0m\n",
|
||||||
|
arg1
|
||||||
|
);
|
||||||
|
eprintln!("Run \x1b[1mnuma help\x1b[0m for a list of commands.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let config_path = if arg1.is_empty() || arg1 == "run" {
|
let config_path = if arg1.is_empty() || arg1 == "run" {
|
||||||
@@ -235,6 +253,19 @@ async fn main() -> numa::Result<()> {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let health_meta = numa::health::HealthMeta::build(
|
||||||
|
&resolved_data_dir,
|
||||||
|
config.dot.enabled,
|
||||||
|
config.dot.port,
|
||||||
|
config.mobile.port,
|
||||||
|
config.dnssec.enabled,
|
||||||
|
resolved_mode == numa::config::UpstreamMode::Recursive,
|
||||||
|
config.lan.enabled,
|
||||||
|
config.blocking.enabled,
|
||||||
|
);
|
||||||
|
|
||||||
|
let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok();
|
||||||
|
|
||||||
let socket = match UdpSocket::bind(&config.server.bind_addr).await {
|
let socket = match UdpSocket::bind(&config.server.bind_addr).await {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -286,6 +317,8 @@ async fn main() -> numa::Result<()> {
|
|||||||
inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
|
inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
dnssec_enabled: config.dnssec.enabled,
|
dnssec_enabled: config.dnssec.enabled,
|
||||||
dnssec_strict: config.dnssec.strict,
|
dnssec_strict: config.dnssec.strict,
|
||||||
|
health_meta,
|
||||||
|
ca_pem,
|
||||||
});
|
});
|
||||||
|
|
||||||
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
||||||
@@ -469,6 +502,21 @@ async fn main() -> numa::Result<()> {
|
|||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Spawn Mobile API listener (read-only subset for iOS/Android companion
|
||||||
|
// apps, LAN-bound by default so phones can reach it). Only idempotent
|
||||||
|
// GETs; no state-mutating routes are exposed here regardless of
|
||||||
|
// the main API's bind address.
|
||||||
|
if config.mobile.enabled {
|
||||||
|
let mobile_ctx = Arc::clone(&ctx);
|
||||||
|
let mobile_bind = config.mobile.bind_addr.clone();
|
||||||
|
let mobile_port = config.mobile.port;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = numa::mobile_api::start(mobile_ctx, mobile_bind, mobile_port).await {
|
||||||
|
log::warn!("Mobile API listener failed: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let proxy_bind: std::net::Ipv4Addr = config
|
let proxy_bind: std::net::Ipv4Addr = config
|
||||||
.proxy
|
.proxy
|
||||||
.bind_addr
|
.bind_addr
|
||||||
|
|||||||
107
src/mobile_api.rs
Normal file
107
src/mobile_api.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
//! Mobile API — persistent HTTP listener for iOS/Android companion apps.
|
||||||
|
//!
|
||||||
|
//! Read-only subset of Numa's HTTP surface served on a separate port
|
||||||
|
//! (default 8765) bound to the LAN. Unlike the main API on port 5380
|
||||||
|
//! (which defaults to `127.0.0.1` and serves mutating routes like
|
||||||
|
//! `DELETE /services/{name}` or `PUT /blocking/toggle`), this listener
|
||||||
|
//! is safe to expose on the LAN because every route is idempotent and
|
||||||
|
//! read-only.
|
||||||
|
//!
|
||||||
|
//! Routes (all GET):
|
||||||
|
//!
|
||||||
|
//! - `/health` — enriched status + metadata, shares the handler with the
|
||||||
|
//! main API via `crate::api::health`
|
||||||
|
//! - `/ca.pem` — Numa local CA in PEM form, shares the handler with the
|
||||||
|
//! main API via `crate::api::serve_ca`
|
||||||
|
//! - `/mobileconfig` — combined CA + DNS settings profile (Full mode)
|
||||||
|
//! - `/ca.mobileconfig` — CA-only trust profile (no DNS override)
|
||||||
|
//!
|
||||||
|
//! The mobile API does NOT include the mutating routes (overrides, cache
|
||||||
|
//! flush, blocking toggle, service CRUD, etc.). Even if a user sets
|
||||||
|
//! `api_bind_addr` to `0.0.0.0` for the main API, those routes stay on
|
||||||
|
//! port 5380; the mobile API on port 8765 never serves them. This is the
|
||||||
|
//! primary security boundary: anything exposed to the LAN is read-only.
|
||||||
|
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::{header, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use log::info;
|
||||||
|
|
||||||
|
use crate::ctx::ServerCtx;
|
||||||
|
use crate::mobileconfig::{build_mobileconfig, ProfileMode};
|
||||||
|
|
||||||
|
/// Content-Disposition for the full CA + DNS profile download.
|
||||||
|
const FULL_PROFILE_DISPOSITION: &str = "attachment; filename=\"numa.mobileconfig\"";
|
||||||
|
|
||||||
|
/// Content-Disposition for the CA-only profile download.
|
||||||
|
const CA_ONLY_PROFILE_DISPOSITION: &str = "attachment; filename=\"numa-ca.mobileconfig\"";
|
||||||
|
|
||||||
|
/// Build the axum router for the mobile API.
|
||||||
|
///
|
||||||
|
/// Shares handler functions with the main API where possible (`health`,
|
||||||
|
/// `serve_ca`) so the response shapes are identical across both ports.
|
||||||
|
pub fn router(ctx: Arc<ServerCtx>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/health", get(crate::api::health))
|
||||||
|
.route("/ca.pem", get(crate::api::serve_ca))
|
||||||
|
.route("/mobileconfig", get(serve_full_mobileconfig))
|
||||||
|
.route("/ca.mobileconfig", get(serve_ca_only_mobileconfig))
|
||||||
|
.with_state(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the mobile API listener on `bind_addr:port`. Runs until the
|
||||||
|
/// caller cancels the spawned task. Logs the URL on successful bind.
|
||||||
|
pub async fn start(ctx: Arc<ServerCtx>, bind_addr: String, port: u16) -> crate::Result<()> {
|
||||||
|
let addr: std::net::SocketAddr = format!("{}:{}", bind_addr, port).parse()?;
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
|
||||||
|
info!("Mobile API listening on http://{}", addr);
|
||||||
|
|
||||||
|
let app = router(ctx);
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve the full mobileconfig profile (CA + DNS settings), with the
|
||||||
|
/// DNS payload pointing at the current LAN IP. Each request reads the
|
||||||
|
/// fresh LAN IP from `ctx.lan_ip` so the profile always reflects the
|
||||||
|
/// laptop's current network state.
|
||||||
|
async fn serve_full_mobileconfig(
|
||||||
|
State(ctx): State<Arc<ServerCtx>>,
|
||||||
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
|
let ca_pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
let lan_ip: Ipv4Addr = *ctx.lan_ip.lock().unwrap();
|
||||||
|
let profile = build_mobileconfig(ProfileMode::Full { lan_ip }, ca_pem);
|
||||||
|
Ok(profile_response(profile, FULL_PROFILE_DISPOSITION))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve the CA-only mobileconfig profile. Trusts the Numa local CA but
|
||||||
|
/// does NOT change the device's DNS settings. Used by the iOS companion
|
||||||
|
/// app's DoT mode, where the app configures DNS via `NEDNSSettingsManager`
|
||||||
|
/// and only needs the system trust store to accept Numa's self-signed cert.
|
||||||
|
async fn serve_ca_only_mobileconfig(
|
||||||
|
State(ctx): State<Arc<ServerCtx>>,
|
||||||
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
|
let ca_pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
let profile = build_mobileconfig(ProfileMode::CaOnly, ca_pem);
|
||||||
|
Ok(profile_response(profile, CA_ONLY_PROFILE_DISPOSITION))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared response constructor for both mobileconfig variants.
|
||||||
|
/// Identical headers; only the Content-Disposition filename differs.
|
||||||
|
fn profile_response(profile: String, disposition: &'static str) -> impl IntoResponse {
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/x-apple-aspen-config"),
|
||||||
|
(header::CONTENT_DISPOSITION, disposition),
|
||||||
|
(header::CACHE_CONTROL, "no-store"),
|
||||||
|
],
|
||||||
|
profile,
|
||||||
|
)
|
||||||
|
}
|
||||||
294
src/mobileconfig.rs
Normal file
294
src/mobileconfig.rs
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
//! Apple `.mobileconfig` profile generator.
|
||||||
|
//!
|
||||||
|
//! Builds iOS Configuration Profiles that Numa serves to phones for one-tap
|
||||||
|
//! CA trust and DNS-over-TLS setup. The plist structure is hand-rendered
|
||||||
|
//! via `format!` — no plist crate dependency, deterministic output, small
|
||||||
|
//! binary footprint.
|
||||||
|
//!
|
||||||
|
//! Two modes:
|
||||||
|
//!
|
||||||
|
//! - [`ProfileMode::Full`]: CA trust payload + DNS settings payload pointing
|
||||||
|
//! at a specific LAN IP over DoT. This is what `numa setup-phone` has
|
||||||
|
//! always produced — the user scans a QR, installs this profile, and the
|
||||||
|
//! phone is configured for DoT through Numa in a single step (after the
|
||||||
|
//! iOS Certificate Trust Settings toggle, which is a separate system
|
||||||
|
//! gate we can't bypass).
|
||||||
|
//!
|
||||||
|
//! - [`ProfileMode::CaOnly`]: CA trust payload only, no DNS settings. Used
|
||||||
|
//! by the future iOS companion app flow where `NEDNSSettingsManager`
|
||||||
|
//! configures DNS programmatically and we only need the system trust
|
||||||
|
//! store to accept Numa's DoT cert. Installing this profile does NOT
|
||||||
|
//! change the user's DNS at all.
|
||||||
|
//!
|
||||||
|
//! Payload identifiers and UUIDs are fixed (not randomized) so iOS replaces
|
||||||
|
//! the existing profile on re-install rather than accumulating duplicates.
|
||||||
|
//! The `Full` and `CaOnly` profiles have distinct top-level UUIDs so they
|
||||||
|
//! can coexist as separate installed profiles, but they share the same CA
|
||||||
|
//! payload UUID since the CA itself is the same trust anchor in both.
|
||||||
|
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
|
/// Top-level UUID and PayloadIdentifier for the full profile (CA + DNS).
|
||||||
|
/// Changing this breaks in-place replacement on existing iOS installs.
|
||||||
|
const FULL_PROFILE_UUID: &str = "F1E2D3C4-B5A6-7890-1234-567890ABCDEF";
|
||||||
|
const FULL_PROFILE_ID: &str = "com.numa.dns.profile";
|
||||||
|
|
||||||
|
/// Top-level UUID and PayloadIdentifier for the CA-only profile.
|
||||||
|
/// Distinct from `FULL_PROFILE_UUID` so a user can install one, the other,
|
||||||
|
/// or both without the latest install silently replacing a different mode.
|
||||||
|
const CA_ONLY_PROFILE_UUID: &str = "F2E3D4C5-B6A7-8901-2345-67890ABCDEF0";
|
||||||
|
const CA_ONLY_PROFILE_ID: &str = "com.numa.dns.ca.profile";
|
||||||
|
|
||||||
|
/// CA trust payload UUID. Same in both modes — iOS will see "the same CA
|
||||||
|
/// trust anchor" regardless of which wrapping profile contains it.
|
||||||
|
const CA_PAYLOAD_UUID: &str = "B2C3D4E5-F6A7-8901-BCDE-F12345678901";
|
||||||
|
const CA_PAYLOAD_ID: &str = "com.numa.dns.ca";
|
||||||
|
|
||||||
|
/// DNS settings payload UUID (Full mode only).
|
||||||
|
const DNS_PAYLOAD_UUID: &str = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890";
|
||||||
|
const DNS_PAYLOAD_ID: &str = "com.numa.dns.dot";
|
||||||
|
|
||||||
|
/// Profile mode determines which payloads are included in the generated
|
||||||
|
/// `.mobileconfig`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ProfileMode {
|
||||||
|
/// Full profile: CA trust anchor + managed DNS settings payload
|
||||||
|
/// pointing at the given LAN IP over DoT. This is what the classic
|
||||||
|
/// `numa setup-phone` QR flow serves.
|
||||||
|
Full { lan_ip: Ipv4Addr },
|
||||||
|
|
||||||
|
/// CA-only profile: just the trust anchor, no DNS settings. For use
|
||||||
|
/// with the iOS companion app which manages DNS programmatically via
|
||||||
|
/// `NEDNSSettingsManager` and only needs the system trust store to
|
||||||
|
/// accept Numa's self-signed DoT cert.
|
||||||
|
CaOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a full `.mobileconfig` profile as an XML plist string.
|
||||||
|
pub fn build_mobileconfig(mode: ProfileMode, ca_pem: &str) -> String {
|
||||||
|
let ca_payload = build_ca_payload(ca_pem);
|
||||||
|
|
||||||
|
match mode {
|
||||||
|
ProfileMode::Full { lan_ip } => {
|
||||||
|
let dns_payload = build_dns_payload(lan_ip);
|
||||||
|
let payloads = format!("{}\n{}", ca_payload, dns_payload);
|
||||||
|
let description = format!(
|
||||||
|
"Trusts the Numa local CA and routes DNS queries to Numa over DoT on your local network ({lan_ip})"
|
||||||
|
);
|
||||||
|
wrap_plist(
|
||||||
|
&payloads,
|
||||||
|
FULL_PROFILE_UUID,
|
||||||
|
FULL_PROFILE_ID,
|
||||||
|
&description,
|
||||||
|
"Numa DNS",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ProfileMode::CaOnly => wrap_plist(
|
||||||
|
&ca_payload,
|
||||||
|
CA_ONLY_PROFILE_UUID,
|
||||||
|
CA_ONLY_PROFILE_ID,
|
||||||
|
"Trusts the Numa local Certificate Authority. Does not change your DNS settings.",
|
||||||
|
"Numa CA",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip the PEM header/footer and newlines from a CA cert, leaving raw
|
||||||
|
/// base64 for embedding in a plist `<data>` block.
|
||||||
|
fn pem_to_base64(pem: &str) -> String {
|
||||||
|
pem.lines()
|
||||||
|
.filter(|line| !line.starts_with("-----"))
|
||||||
|
.collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap the base64 CA cert at 52 chars per line for plist readability
|
||||||
|
/// (matches Apple convention in hand-written profiles).
|
||||||
|
fn chunk_base64(base64: &str) -> String {
|
||||||
|
base64
|
||||||
|
.chars()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.chunks(52)
|
||||||
|
.map(|chunk| format!("\t\t\t{}", chunk.iter().collect::<String>()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the `com.apple.security.root` payload dict containing the CA cert.
|
||||||
|
fn build_ca_payload(ca_pem: &str) -> String {
|
||||||
|
let ca_wrapped = chunk_base64(&pem_to_base64(ca_pem));
|
||||||
|
format!(
|
||||||
|
r#" <dict>
|
||||||
|
<key>PayloadCertificateFileName</key>
|
||||||
|
<string>numa-ca.pem</string>
|
||||||
|
<key>PayloadContent</key>
|
||||||
|
<data>
|
||||||
|
{ca}
|
||||||
|
</data>
|
||||||
|
<key>PayloadDescription</key>
|
||||||
|
<string>Numa local Certificate Authority — required for DoT trust</string>
|
||||||
|
<key>PayloadDisplayName</key>
|
||||||
|
<string>Numa Local CA</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>{ca_id}</string>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>com.apple.security.root</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>{ca_uuid}</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>"#,
|
||||||
|
ca = ca_wrapped,
|
||||||
|
ca_id = CA_PAYLOAD_ID,
|
||||||
|
ca_uuid = CA_PAYLOAD_UUID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>
|
||||||
|
<key>DNSSettings</key>
|
||||||
|
<dict>
|
||||||
|
<key>DNSProtocol</key>
|
||||||
|
<string>TLS</string>
|
||||||
|
<key>ServerAddresses</key>
|
||||||
|
<array>
|
||||||
|
<string>{ip}</string>
|
||||||
|
</array>
|
||||||
|
<key>ServerName</key>
|
||||||
|
<string>numa.numa</string>
|
||||||
|
</dict>
|
||||||
|
<key>PayloadDescription</key>
|
||||||
|
<string>Routes all DNS queries through Numa over DNS-over-TLS</string>
|
||||||
|
<key>PayloadDisplayName</key>
|
||||||
|
<string>Numa DNS-over-TLS</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>{dns_id}</string>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>com.apple.dnsSettings.managed</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>{dns_uuid}</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>"#,
|
||||||
|
ip = lan_ip,
|
||||||
|
dns_id = DNS_PAYLOAD_ID,
|
||||||
|
dns_uuid = DNS_PAYLOAD_UUID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap one or more payload dicts in the top-level plist structure
|
||||||
|
/// with Configuration type, PayloadContent array, and profile metadata.
|
||||||
|
fn wrap_plist(
|
||||||
|
payloads: &str,
|
||||||
|
top_uuid: &str,
|
||||||
|
top_id: &str,
|
||||||
|
description: &str,
|
||||||
|
display_name: &str,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PayloadContent</key>
|
||||||
|
<array>
|
||||||
|
{payloads}
|
||||||
|
</array>
|
||||||
|
<key>PayloadDescription</key>
|
||||||
|
<string>{description}</string>
|
||||||
|
<key>PayloadDisplayName</key>
|
||||||
|
<string>{display_name}</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>{top_id}</string>
|
||||||
|
<key>PayloadRemovalDisallowed</key>
|
||||||
|
<false/>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>Configuration</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>{top_uuid}</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
"#,
|
||||||
|
payloads = payloads,
|
||||||
|
description = description,
|
||||||
|
display_name = display_name,
|
||||||
|
top_id = top_id,
|
||||||
|
top_uuid = top_uuid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SAMPLE_PEM: &str =
|
||||||
|
"-----BEGIN CERTIFICATE-----\nMIIBkDCCATagAwIBAgIUTEST\n-----END CERTIFICATE-----\n";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pem_to_base64_strips_headers() {
|
||||||
|
let pem = "-----BEGIN CERTIFICATE-----\nABCDEF\nGHIJKL\n-----END CERTIFICATE-----\n";
|
||||||
|
assert_eq!(pem_to_base64(pem), "ABCDEFGHIJKL");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_profile_contains_ip_and_ca() {
|
||||||
|
let config = build_mobileconfig(
|
||||||
|
ProfileMode::Full {
|
||||||
|
lan_ip: Ipv4Addr::new(192, 168, 1, 100),
|
||||||
|
},
|
||||||
|
SAMPLE_PEM,
|
||||||
|
);
|
||||||
|
assert!(config.contains("192.168.1.100"));
|
||||||
|
assert!(config.contains("MIIBkDCCATagAwIBAgIUTEST"));
|
||||||
|
assert!(config.contains("com.apple.security.root"));
|
||||||
|
assert!(config.contains("com.apple.dnsSettings.managed"));
|
||||||
|
assert!(config.contains("DNSProtocol"));
|
||||||
|
assert!(config.contains(FULL_PROFILE_UUID));
|
||||||
|
assert!(config.contains(FULL_PROFILE_ID));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ca_only_profile_contains_ca_but_not_dns() {
|
||||||
|
let config = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM);
|
||||||
|
assert!(config.contains("MIIBkDCCATagAwIBAgIUTEST"));
|
||||||
|
assert!(config.contains("com.apple.security.root"));
|
||||||
|
assert!(!config.contains("com.apple.dnsSettings.managed"));
|
||||||
|
assert!(!config.contains("DNSProtocol"));
|
||||||
|
assert!(!config.contains("ServerAddresses"));
|
||||||
|
assert!(config.contains(CA_ONLY_PROFILE_UUID));
|
||||||
|
assert!(config.contains(CA_ONLY_PROFILE_ID));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_and_ca_only_have_distinct_top_uuids() {
|
||||||
|
let full = build_mobileconfig(
|
||||||
|
ProfileMode::Full {
|
||||||
|
lan_ip: Ipv4Addr::new(10, 0, 0, 1),
|
||||||
|
},
|
||||||
|
SAMPLE_PEM,
|
||||||
|
);
|
||||||
|
let ca_only = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM);
|
||||||
|
assert!(full.contains(FULL_PROFILE_UUID));
|
||||||
|
assert!(!full.contains(CA_ONLY_PROFILE_UUID));
|
||||||
|
assert!(ca_only.contains(CA_ONLY_PROFILE_UUID));
|
||||||
|
assert!(!ca_only.contains(FULL_PROFILE_UUID));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_modes_share_ca_payload_uuid() {
|
||||||
|
let full = build_mobileconfig(
|
||||||
|
ProfileMode::Full {
|
||||||
|
lan_ip: Ipv4Addr::new(10, 0, 0, 1),
|
||||||
|
},
|
||||||
|
SAMPLE_PEM,
|
||||||
|
);
|
||||||
|
let ca_only = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM);
|
||||||
|
assert!(full.contains(CA_PAYLOAD_UUID));
|
||||||
|
assert!(ca_only.contains(CA_PAYLOAD_UUID));
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/setup_phone.rs
Normal file
126
src/setup_phone.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
//! `numa setup-phone` CLI — thin QR wrapper over the persistent mobile API.
|
||||||
|
//!
|
||||||
|
//! Before the mobile API existed, this command spawned its own one-shot
|
||||||
|
//! HTTP server on port 8765 to serve a freshly-generated mobileconfig
|
||||||
|
//! for a single download. That role now belongs to
|
||||||
|
//! [`crate::mobile_api`], which runs persistently alongside the main
|
||||||
|
//! API and serves `/mobileconfig` at the same port whenever Numa is
|
||||||
|
//! running.
|
||||||
|
//!
|
||||||
|
//! This command is now a thin terminal-side wrapper:
|
||||||
|
//!
|
||||||
|
//! 1. Detect the current LAN IP
|
||||||
|
//! 2. Render a terminal QR code pointing at
|
||||||
|
//! `http://<lan_ip>:8765/mobileconfig`
|
||||||
|
//! 3. Print install instructions and exit
|
||||||
|
//!
|
||||||
|
//! The user scans the QR, iOS fetches the profile from the mobile API
|
||||||
|
//! (which is always up as long as `numa` is running), installs, and the
|
||||||
|
//! user walks through Settings → Certificate Trust Settings to enable
|
||||||
|
//! trust.
|
||||||
|
//!
|
||||||
|
//! Numa must be running for the profile download to succeed; if the
|
||||||
|
//! mobile API is not listening on port 8765, the download will fail
|
||||||
|
//! and the user will see Safari's "Cannot Connect to Server" error.
|
||||||
|
//! The CLI prints a reminder about this at the bottom of the output.
|
||||||
|
|
||||||
|
use qrcode::render::unicode;
|
||||||
|
use qrcode::QrCode;
|
||||||
|
|
||||||
|
/// Default port where the persistent mobile API serves `/mobileconfig`.
|
||||||
|
/// Matches `MobileConfig::default().port` in `config.rs`. If the user
|
||||||
|
/// has overridden `[mobile] port = N` in `numa.toml`, they'll need to
|
||||||
|
/// adjust the URL manually — this CLI uses the default without parsing
|
||||||
|
/// `numa.toml`.
|
||||||
|
const SETUP_PORT: u16 = 8765;
|
||||||
|
|
||||||
|
fn render_qr(url: &str) -> Result<String, String> {
|
||||||
|
let code = QrCode::new(url).map_err(|e| format!("failed to encode QR: {}", e))?;
|
||||||
|
Ok(code
|
||||||
|
.render::<unicode::Dense1x2>()
|
||||||
|
.dark_color(unicode::Dense1x2::Light)
|
||||||
|
.light_color(unicode::Dense1x2::Dark)
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the `numa setup-phone` flow.
|
||||||
|
pub async fn run() -> Result<(), String> {
|
||||||
|
let lan_ip = crate::lan::detect_lan_ip()
|
||||||
|
.ok_or("could not detect LAN IP — are you connected to a network?")?;
|
||||||
|
|
||||||
|
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], SETUP_PORT));
|
||||||
|
let api_reachable = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(500),
|
||||||
|
tokio::net::TcpStream::connect(addr),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|r| r.is_ok())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !api_reachable {
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
" \x1b[1;38;2;192;98;58mNuma\x1b[0m — mobile API is not reachable on port {}.",
|
||||||
|
SETUP_PORT
|
||||||
|
);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" The phone won't be able to download the profile until the mobile");
|
||||||
|
eprintln!(" API is running. Add this to your numa.toml and restart Numa:");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" [mobile]");
|
||||||
|
eprintln!(" enabled = true");
|
||||||
|
eprintln!();
|
||||||
|
return Err("mobile API not running".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("http://{}:{}/mobileconfig", lan_ip, SETUP_PORT);
|
||||||
|
let qr = render_qr(&url)?;
|
||||||
|
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" \x1b[1;38;2;192;98;58mNuma Phone Setup\x1b[0m");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" Profile URL: \x1b[36m{}\x1b[0m", url);
|
||||||
|
eprintln!();
|
||||||
|
for line in qr.lines() {
|
||||||
|
eprintln!(" {}", line);
|
||||||
|
}
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(" \x1b[1mOn your iPhone:\x1b[0m");
|
||||||
|
eprintln!(" 1. Open Camera, point at the QR code, tap the yellow banner");
|
||||||
|
eprintln!(" 2. Allow the download when Safari asks");
|
||||||
|
eprintln!(" 3. Open Settings — tap \"Profile Downloaded\" near the top");
|
||||||
|
eprintln!(" (or: Settings → General → VPN & Device Management → Numa DNS)");
|
||||||
|
eprintln!(" 4. Tap Install (top right), enter passcode, Install again");
|
||||||
|
eprintln!(" 5. \x1b[1mSettings → General → About → Certificate Trust Settings\x1b[0m");
|
||||||
|
eprintln!(" Toggle ON \"Numa Local CA\" — required for DoT to work");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
" \x1b[33mNote:\x1b[0m profile uses your laptop's current IP ({}). If your",
|
||||||
|
lan_ip
|
||||||
|
);
|
||||||
|
eprintln!(" laptop changes networks, re-scan this QR — iOS will replace the");
|
||||||
|
eprintln!(" existing profile automatically (fixed UUID).");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!(
|
||||||
|
" \x1b[90mThe profile is served by Numa's persistent mobile API on port {}.\x1b[0m",
|
||||||
|
SETUP_PORT
|
||||||
|
);
|
||||||
|
eprintln!(" \x1b[90mMake sure `numa` is running before scanning. If it's not,\x1b[0m");
|
||||||
|
eprintln!(" \x1b[90mstart it with `sudo numa install` or run it interactively.\x1b[0m");
|
||||||
|
eprintln!();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_qr_produces_unicode() {
|
||||||
|
let qr = render_qr("http://192.168.1.9:8765/mobileconfig").unwrap();
|
||||||
|
assert!(!qr.is_empty());
|
||||||
|
// Dense1x2 uses these block characters
|
||||||
|
assert!(qr.chars().any(|c| matches!(c, '█' | '▀' | '▄' | ' ')));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,17 @@ use std::net::SocketAddr;
|
|||||||
|
|
||||||
use log::info;
|
use log::info;
|
||||||
|
|
||||||
|
fn print_recursive_hint() {
|
||||||
|
let is_recursive = crate::config::load_config("numa.toml")
|
||||||
|
.map(|c| c.config.upstream.mode == crate::config::UpstreamMode::Recursive)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !is_recursive {
|
||||||
|
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
||||||
|
eprintln!(" [upstream]");
|
||||||
|
eprintln!(" mode = \"recursive\"\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_loopback_or_stub(addr: &str) -> bool {
|
fn is_loopback_or_stub(addr: &str) -> bool {
|
||||||
matches!(addr, "127.0.0.1" | "127.0.0.53" | "0.0.0.0" | "::1" | "")
|
matches!(addr, "127.0.0.1" | "127.0.0.53" | "0.0.0.0" | "::1" | "")
|
||||||
}
|
}
|
||||||
@@ -688,9 +699,7 @@ fn install_windows() -> Result<(), String> {
|
|||||||
} else {
|
} else {
|
||||||
eprintln!(" Numa will start automatically on next boot.\n");
|
eprintln!(" Numa will start automatically on next boot.\n");
|
||||||
}
|
}
|
||||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
print_recursive_hint();
|
||||||
eprintln!(" [upstream]");
|
|
||||||
eprintln!(" mode = \"recursive\"\n");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1181,9 +1190,7 @@ fn install_service_macos() -> Result<(), String> {
|
|||||||
eprintln!(" Numa will auto-start on boot and restart if killed.");
|
eprintln!(" Numa will auto-start on boot and restart if killed.");
|
||||||
eprintln!(" Logs: /usr/local/var/log/numa.log");
|
eprintln!(" Logs: /usr/local/var/log/numa.log");
|
||||||
eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n");
|
eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n");
|
||||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
print_recursive_hint();
|
||||||
eprintln!(" [upstream]");
|
|
||||||
eprintln!(" mode = \"recursive\"\n");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1388,9 +1395,7 @@ fn install_service_linux() -> Result<(), String> {
|
|||||||
eprintln!(" Numa will auto-start on boot and restart if killed.");
|
eprintln!(" Numa will auto-start on boot and restart if killed.");
|
||||||
eprintln!(" Logs: journalctl -u numa -f");
|
eprintln!(" Logs: journalctl -u numa -f");
|
||||||
eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n");
|
eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n");
|
||||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
print_recursive_hint();
|
||||||
eprintln!(" [upstream]");
|
|
||||||
eprintln!(" mode = \"recursive\"\n");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
34
src/tls.rs
34
src/tls.rs
@@ -5,7 +5,9 @@ use std::sync::Arc;
|
|||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
|
|
||||||
use crate::ctx::ServerCtx;
|
use crate::ctx::ServerCtx;
|
||||||
use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType};
|
use rcgen::{
|
||||||
|
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
|
||||||
|
};
|
||||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||||
use rustls::ServerConfig;
|
use rustls::ServerConfig;
|
||||||
use time::{Duration, OffsetDateTime};
|
use time::{Duration, OffsetDateTime};
|
||||||
@@ -87,8 +89,8 @@ pub fn build_tls_config(
|
|||||||
alpn: Vec<Vec<u8>>,
|
alpn: Vec<Vec<u8>>,
|
||||||
data_dir: &Path,
|
data_dir: &Path,
|
||||||
) -> crate::Result<Arc<ServerConfig>> {
|
) -> crate::Result<Arc<ServerConfig>> {
|
||||||
let (ca_cert, ca_key) = ensure_ca(data_dir)?;
|
let (ca_der, issuer) = ensure_ca(data_dir)?;
|
||||||
let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?;
|
let (cert_chain, key) = generate_service_cert(&ca_der, &issuer, tld, service_names)?;
|
||||||
|
|
||||||
// Ensure a crypto provider is installed (rustls needs one)
|
// Ensure a crypto provider is installed (rustls needs one)
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
@@ -106,7 +108,7 @@ pub fn build_tls_config(
|
|||||||
Ok(Arc::new(config))
|
Ok(Arc::new(config))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
|
fn ensure_ca(dir: &Path) -> crate::Result<(CertificateDer<'static>, Issuer<'static, KeyPair>)> {
|
||||||
let ca_key_path = dir.join("ca.key");
|
let ca_key_path = dir.join("ca.key");
|
||||||
let ca_cert_path = dir.join(CA_FILE_NAME);
|
let ca_cert_path = dir.join(CA_FILE_NAME);
|
||||||
|
|
||||||
@@ -114,10 +116,12 @@ fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
|
|||||||
let key_pem = std::fs::read_to_string(&ca_key_path)?;
|
let key_pem = std::fs::read_to_string(&ca_key_path)?;
|
||||||
let cert_pem = std::fs::read_to_string(&ca_cert_path)?;
|
let cert_pem = std::fs::read_to_string(&ca_cert_path)?;
|
||||||
let key_pair = KeyPair::from_pem(&key_pem)?;
|
let key_pair = KeyPair::from_pem(&key_pem)?;
|
||||||
let params = CertificateParams::from_ca_cert_pem(&cert_pem)?;
|
let ca_der = rustls_pemfile::certs(&mut cert_pem.as_bytes())
|
||||||
let cert = params.self_signed(&key_pair)?;
|
.next()
|
||||||
|
.ok_or("empty CA PEM file")??;
|
||||||
|
let issuer = Issuer::from_ca_cert_der(&ca_der, key_pair)?;
|
||||||
info!("loaded CA from {:?}", ca_cert_path);
|
info!("loaded CA from {:?}", ca_cert_path);
|
||||||
return Ok((cert, key_pair));
|
return Ok((ca_der, issuer));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new CA
|
// Generate new CA
|
||||||
@@ -145,14 +149,16 @@ fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
info!("generated CA at {:?}", ca_cert_path);
|
info!("generated CA at {:?}", ca_cert_path);
|
||||||
Ok((cert, key_pair))
|
let ca_der = cert.der().clone();
|
||||||
|
let issuer = Issuer::new(params, key_pair);
|
||||||
|
Ok((ca_der, issuer))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a cert with explicit SANs for each service name.
|
/// Generate a cert with explicit SANs for each service name.
|
||||||
/// Always regenerated at startup (~5ms) — no disk caching needed.
|
/// Always regenerated at startup (~5ms) — no disk caching needed.
|
||||||
fn generate_service_cert(
|
fn generate_service_cert(
|
||||||
ca_cert: &rcgen::Certificate,
|
ca_der: &CertificateDer<'static>,
|
||||||
ca_key: &KeyPair,
|
issuer: &Issuer<'_, KeyPair>,
|
||||||
tld: &str,
|
tld: &str,
|
||||||
service_names: &[String],
|
service_names: &[String],
|
||||||
) -> crate::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
|
) -> crate::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
|
||||||
@@ -187,7 +193,7 @@ fn generate_service_cert(
|
|||||||
params.not_before = OffsetDateTime::now_utc();
|
params.not_before = OffsetDateTime::now_utc();
|
||||||
params.not_after = OffsetDateTime::now_utc() + Duration::days(CERT_VALIDITY_DAYS);
|
params.not_after = OffsetDateTime::now_utc() + Duration::days(CERT_VALIDITY_DAYS);
|
||||||
|
|
||||||
let cert = params.signed_by(&key_pair, ca_cert, ca_key)?;
|
let cert = params.signed_by(&key_pair, issuer)?;
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"generated TLS cert for: {}",
|
"generated TLS cert for: {}",
|
||||||
@@ -198,11 +204,11 @@ fn generate_service_cert(
|
|||||||
.join(", ")
|
.join(", ")
|
||||||
);
|
);
|
||||||
|
|
||||||
let cert_der = CertificateDer::from(cert.der().to_vec());
|
let cert_der = cert.der().clone();
|
||||||
let ca_der = CertificateDer::from(ca_cert.der().to_vec());
|
let ca_cert_der = ca_der.clone();
|
||||||
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der()));
|
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der()));
|
||||||
|
|
||||||
Ok((vec![cert_der, ca_der], key_der))
|
Ok((vec![cert_der, ca_cert_der], key_der))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
Reference in New Issue
Block a user