Compare commits
1 Commits
v0.10.0
...
fix/window
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f80d1ab7f |
90
.github/workflows/release.yml
vendored
90
.github/workflows/release.yml
vendored
@@ -108,3 +108,93 @@ jobs:
|
|||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.zip
|
*.zip
|
||||||
*.sha256
|
*.sha256
|
||||||
|
|
||||||
|
update-homebrew:
|
||||||
|
needs: release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Get version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Download SHA256 files
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Extract checksums
|
||||||
|
id: sha
|
||||||
|
run: |
|
||||||
|
echo "macos_arm=$(awk '{print $1}' numa-macos-aarch64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "macos_x86=$(awk '{print $1}' numa-macos-x86_64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux_arm=$(awk '{print $1}' numa-linux-aarch64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "linux_x86=$(awk '{print $1}' numa-linux-x86_64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Update Homebrew formula
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const version = '${{ steps.version.outputs.version }}';
|
||||||
|
const base = `https://github.com/razvandimescu/numa/releases/download/v${version}`;
|
||||||
|
const formula = `class Numa < Formula
|
||||||
|
desc "Portable DNS resolver with ad blocking, .numa local service proxy, and developer overrides"
|
||||||
|
homepage "https://github.com/razvandimescu/numa"
|
||||||
|
license "MIT"
|
||||||
|
version "${version}"
|
||||||
|
|
||||||
|
on_macos do
|
||||||
|
if Hardware::CPU.arm?
|
||||||
|
url "${base}/numa-macos-aarch64.tar.gz"
|
||||||
|
sha256 "${{ steps.sha.outputs.macos_arm }}"
|
||||||
|
else
|
||||||
|
url "${base}/numa-macos-x86_64.tar.gz"
|
||||||
|
sha256 "${{ steps.sha.outputs.macos_x86 }}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
on_linux do
|
||||||
|
if Hardware::CPU.arm?
|
||||||
|
url "${base}/numa-linux-aarch64.tar.gz"
|
||||||
|
sha256 "${{ steps.sha.outputs.linux_arm }}"
|
||||||
|
else
|
||||||
|
url "${base}/numa-linux-x86_64.tar.gz"
|
||||||
|
sha256 "${{ steps.sha.outputs.linux_x86 }}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def install
|
||||||
|
bin.install "numa"
|
||||||
|
end
|
||||||
|
|
||||||
|
def caveats
|
||||||
|
<<~EOS
|
||||||
|
Numa requires root to bind port 53:
|
||||||
|
sudo numa # start the DNS server
|
||||||
|
sudo numa install # set as system DNS
|
||||||
|
sudo numa service start # run as persistent service
|
||||||
|
|
||||||
|
Dashboard: http://localhost:5380
|
||||||
|
EOS
|
||||||
|
end
|
||||||
|
|
||||||
|
test do
|
||||||
|
assert_match "numa", shell_output("#{bin}/numa --version")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
`.replace(/^ /gm, '');
|
||||||
|
|
||||||
|
const { data: existing } = await github.rest.repos.getContent({
|
||||||
|
owner: 'razvandimescu',
|
||||||
|
repo: 'homebrew-tap',
|
||||||
|
path: 'numa.rb',
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.repos.createOrUpdateFileContents({
|
||||||
|
owner: 'razvandimescu',
|
||||||
|
repo: 'homebrew-tap',
|
||||||
|
path: 'numa.rb',
|
||||||
|
message: `numa ${version}`,
|
||||||
|
content: Buffer.from(formula).toString('base64'),
|
||||||
|
sha: existing.sha,
|
||||||
|
});
|
||||||
|
|||||||
12
Cargo.lock
generated
12
Cargo.lock
generated
@@ -1143,7 +1143,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.10.0"
|
version = "0.9.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1159,7 +1159,6 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pemfile",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"socket2 0.5.10",
|
"socket2 0.5.10",
|
||||||
@@ -1547,15 +1546,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustls-pemfile"
|
|
||||||
version = "2.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
|
||||||
dependencies = [
|
|
||||||
"rustls-pki-types",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.10.0"
|
version = "0.9.1"
|
||||||
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"
|
||||||
@@ -29,7 +29,6 @@ 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"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = { version = "0.5", features = ["html_reports"] }
|
criterion = { version = "0.5", features = ["html_reports"] }
|
||||||
|
|||||||
@@ -13,5 +13,5 @@ RUN cargo build --release
|
|||||||
|
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
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 5380/tcp
|
||||||
ENTRYPOINT ["numa"]
|
ENTRYPOINT ["numa"]
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required.
|
A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required.
|
||||||
|
|
||||||
Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Caching, ad blocking, and local service domains out of the box. Optional recursive resolution from root nameservers with full DNSSEC chain-of-trust validation, plus a DNS-over-TLS listener for encrypted client connections (iOS Private DNS, systemd-resolved, etc.). One ~8MB binary, everything embedded.
|
Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Caching, ad blocking, and local service domains out of the box. Optional recursive resolution from root nameservers with full DNSSEC chain-of-trust validation. One ~8MB binary, everything embedded.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -67,13 +67,6 @@ Three resolution modes:
|
|||||||
|
|
||||||
DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, DS delegation, NSEC/NSEC3 denial proofs. [Read how it works →](https://numa.rs/blog/posts/dnssec-from-scratch.html)
|
DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, DS delegation, NSEC/NSEC3 denial proofs. [Read how it works →](https://numa.rs/blog/posts/dnssec-from-scratch.html)
|
||||||
|
|
||||||
**DNS-over-TLS listener** (RFC 7858) — accept encrypted queries on port 853 from strict clients like iOS Private DNS, systemd-resolved, or stubby. Two modes:
|
|
||||||
|
|
||||||
- **Self-signed** (default) — numa generates a local CA automatically. Works on any network with zero DNS setup, but clients must manually trust the CA (on macOS/Linux add to the system trust store; on iOS install a `.mobileconfig`).
|
|
||||||
- **Bring-your-own cert** — point `[dot] cert_path` / `key_path` at a publicly-trusted cert (e.g., Let's Encrypt via DNS-01 challenge on a domain pointing at your numa instance). Clients connect without any trust-store setup — same UX as AdGuard Home or Cloudflare `1.1.1.1`.
|
|
||||||
|
|
||||||
ALPN `"dot"` is advertised and enforced in both modes; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense.
|
|
||||||
|
|
||||||
## 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:
|
||||||
@@ -103,7 +96,6 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
|
|||||||
| Ad blocking | Yes | Yes | — | 385K+ domains |
|
| Ad blocking | Yes | Yes | — | 385K+ domains |
|
||||||
| Web admin UI | Full | Full | — | Dashboard |
|
| Web admin UI | Full | Full | — | Dashboard |
|
||||||
| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native |
|
| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native |
|
||||||
| Encrypted clients (DoT listener) | Needs stunnel sidecar | Yes | Yes | Native (RFC 7858) |
|
|
||||||
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows |
|
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows |
|
||||||
| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New |
|
| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New |
|
||||||
|
|
||||||
@@ -124,7 +116,6 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
|
|||||||
- [x] `.numa` local domains — auto TLS, path routing, WebSocket proxy
|
- [x] `.numa` local domains — auto TLS, path routing, WebSocket proxy
|
||||||
- [x] LAN service discovery — mDNS, cross-machine DNS + proxy
|
- [x] LAN service discovery — mDNS, cross-machine DNS + proxy
|
||||||
- [x] DNS-over-HTTPS — encrypted upstream
|
- [x] DNS-over-HTTPS — encrypted upstream
|
||||||
- [x] DNS-over-TLS listener — encrypted client connections (RFC 7858, ALPN strict)
|
|
||||||
- [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3
|
- [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3
|
||||||
- [x] SRTT-based nameserver selection
|
- [x] SRTT-based nameserver selection
|
||||||
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT
|
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT
|
||||||
|
|||||||
13
numa.toml
13
numa.toml
@@ -2,11 +2,6 @@
|
|||||||
bind_addr = "0.0.0.0:53"
|
bind_addr = "0.0.0.0:53"
|
||||||
api_port = 5380
|
api_port = 5380
|
||||||
# api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access
|
# api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access
|
||||||
# data_dir = "/usr/local/var/numa" # where numa stores TLS CA and cert material
|
|
||||||
# (default: /usr/local/var/numa on unix,
|
|
||||||
# %PROGRAMDATA%\numa on windows). Override for
|
|
||||||
# containerized deploys or tests that can't
|
|
||||||
# write to the system path.
|
|
||||||
|
|
||||||
# [upstream]
|
# [upstream]
|
||||||
# mode = "forward" # "forward" (default) — relay to upstream
|
# mode = "forward" # "forward" (default) — relay to upstream
|
||||||
@@ -88,14 +83,6 @@ tld = "numa"
|
|||||||
# enabled = false # opt-in: verify chain of trust from root KSK
|
# enabled = false # opt-in: verify chain of trust from root KSK
|
||||||
# strict = false # true = SERVFAIL on bogus signatures
|
# strict = false # true = SERVFAIL on bogus signatures
|
||||||
|
|
||||||
# DNS-over-TLS listener (RFC 7858) — encrypted DNS on port 853
|
|
||||||
# [dot]
|
|
||||||
# enabled = false # opt-in: accept DoT queries
|
|
||||||
# port = 853 # standard DoT port
|
|
||||||
# bind_addr = "0.0.0.0" # IPv4 or IPv6; unspecified binds all interfaces
|
|
||||||
# cert_path = "/etc/numa/dot.crt" # PEM cert; omit to use self-signed (proxy CA if available)
|
|
||||||
# key_path = "/etc/numa/dot.key" # PEM private key; must be set together with cert_path
|
|
||||||
|
|
||||||
# LAN service discovery via mDNS (disabled by default — no network traffic unless enabled)
|
# LAN service discovery via mDNS (disabled by default — no network traffic unless enabled)
|
||||||
# [lan]
|
# [lan]
|
||||||
# enabled = true # discover other Numa instances via mDNS (_numa._tcp.local)
|
# enabled = true # discover other Numa instances via mDNS (_numa._tcp.local)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
use std::net::Ipv6Addr;
|
use std::net::Ipv6Addr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -29,8 +29,6 @@ pub struct Config {
|
|||||||
pub lan: LanConfig,
|
pub lan: LanConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dnssec: DnssecConfig,
|
pub dnssec: DnssecConfig,
|
||||||
#[serde(default)]
|
|
||||||
pub dot: DotConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -41,10 +39,6 @@ pub struct ServerConfig {
|
|||||||
pub api_port: u16,
|
pub api_port: u16,
|
||||||
#[serde(default = "default_api_bind_addr")]
|
#[serde(default = "default_api_bind_addr")]
|
||||||
pub api_bind_addr: String,
|
pub api_bind_addr: String,
|
||||||
/// Where numa writes TLS material (CA, leaf certs, regenerated state).
|
|
||||||
/// Defaults to `crate::data_dir()` (platform-specific system path) if unset.
|
|
||||||
#[serde(default)]
|
|
||||||
pub data_dir: Option<PathBuf>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ServerConfig {
|
impl Default for ServerConfig {
|
||||||
@@ -53,7 +47,6 @@ impl Default for ServerConfig {
|
|||||||
bind_addr: default_bind_addr(),
|
bind_addr: default_bind_addr(),
|
||||||
api_port: default_api_port(),
|
api_port: default_api_port(),
|
||||||
api_bind_addr: default_api_bind_addr(),
|
api_bind_addr: default_api_bind_addr(),
|
||||||
data_dir: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,41 +370,6 @@ pub struct DnssecConfig {
|
|||||||
pub strict: bool,
|
pub strict: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Clone)]
|
|
||||||
pub struct DotConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub enabled: bool,
|
|
||||||
#[serde(default = "default_dot_port")]
|
|
||||||
pub port: u16,
|
|
||||||
#[serde(default = "default_dot_bind_addr")]
|
|
||||||
pub bind_addr: String,
|
|
||||||
/// Path to TLS certificate (PEM). If None, uses self-signed CA.
|
|
||||||
#[serde(default)]
|
|
||||||
pub cert_path: Option<PathBuf>,
|
|
||||||
/// Path to TLS private key (PEM). If None, uses self-signed CA.
|
|
||||||
#[serde(default)]
|
|
||||||
pub key_path: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DotConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
DotConfig {
|
|
||||||
enabled: false,
|
|
||||||
port: default_dot_port(),
|
|
||||||
bind_addr: default_dot_bind_addr(),
|
|
||||||
cert_path: None,
|
|
||||||
key_path: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_dot_port() -> u16 {
|
|
||||||
853
|
|
||||||
}
|
|
||||||
fn default_dot_bind_addr() -> String {
|
|
||||||
"0.0.0.0".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
57
src/ctx.rs
57
src/ctx.rs
@@ -62,21 +62,24 @@ pub struct ServerCtx {
|
|||||||
pub dnssec_strict: bool,
|
pub dnssec_strict: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,
|
pub async fn handle_query(
|
||||||
/// cache, upstream, DNSSEC) and returns the serialized response in a buffer.
|
mut buffer: BytePacketBuffer,
|
||||||
/// Callers use `.filled()` to get the response bytes without heap allocation.
|
|
||||||
/// Callers are responsible for parsing the incoming buffer into a `DnsPacket`
|
|
||||||
/// (and logging parse errors) before calling this function.
|
|
||||||
pub async fn resolve_query(
|
|
||||||
query: DnsPacket,
|
|
||||||
src_addr: SocketAddr,
|
src_addr: SocketAddr,
|
||||||
ctx: &ServerCtx,
|
ctx: &ServerCtx,
|
||||||
) -> crate::Result<BytePacketBuffer> {
|
) -> crate::Result<()> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
|
let query = match DnsPacket::from_buffer(&mut buffer) {
|
||||||
|
Ok(packet) => packet,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("{} | PARSE ERROR | {}", src_addr, e);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let (qname, qtype) = match query.questions.first() {
|
let (qname, qtype) = match query.questions.first() {
|
||||||
Some(q) => (q.name.clone(), q.qtype),
|
Some(q) => (q.name.clone(), q.qtype),
|
||||||
None => return Err("empty question section".into()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream
|
// Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream
|
||||||
@@ -303,17 +306,17 @@ pub async fn resolve_query(
|
|||||||
response.resources.len(),
|
response.resources.len(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Serialize response
|
|
||||||
// TODO: TC bit is UDP-specific; DoT connections could carry up to 65535 bytes.
|
|
||||||
// Once BytePacketBuffer supports larger buffers, skip truncation for TCP/TLS.
|
|
||||||
let mut resp_buffer = BytePacketBuffer::new();
|
let mut resp_buffer = BytePacketBuffer::new();
|
||||||
if response.write(&mut resp_buffer).is_err() {
|
if response.write(&mut resp_buffer).is_err() {
|
||||||
// Response too large — set TC bit and send header + question only
|
// Response too large for UDP — set TC bit and send header + question only
|
||||||
debug!("response too large, setting TC bit for {}", qname);
|
debug!("response too large, setting TC bit for {}", qname);
|
||||||
let mut tc_response = DnsPacket::response_from(&query, response.header.rescode);
|
let mut tc_response = DnsPacket::response_from(&query, response.header.rescode);
|
||||||
tc_response.header.truncated_message = true;
|
tc_response.header.truncated_message = true;
|
||||||
resp_buffer = BytePacketBuffer::new();
|
let mut tc_buffer = BytePacketBuffer::new();
|
||||||
tc_response.write(&mut resp_buffer)?;
|
tc_response.write(&mut tc_buffer)?;
|
||||||
|
ctx.socket.send_to(tc_buffer.filled(), src_addr).await?;
|
||||||
|
} else {
|
||||||
|
ctx.socket.send_to(resp_buffer.filled(), src_addr).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record stats and query log
|
// Record stats and query log
|
||||||
@@ -336,30 +339,6 @@ pub async fn resolve_query(
|
|||||||
dnssec,
|
dnssec,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(resp_buffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a DNS query received over UDP. Thin wrapper around resolve_query.
|
|
||||||
pub async fn handle_query(
|
|
||||||
mut buffer: BytePacketBuffer,
|
|
||||||
src_addr: SocketAddr,
|
|
||||||
ctx: &ServerCtx,
|
|
||||||
) -> crate::Result<()> {
|
|
||||||
let query = match DnsPacket::from_buffer(&mut buffer) {
|
|
||||||
Ok(packet) => packet,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("{} | PARSE ERROR | {}", src_addr, e);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
match resolve_query(query, src_addr, ctx).await {
|
|
||||||
Ok(resp_buffer) => {
|
|
||||||
ctx.socket.send_to(resp_buffer.filled(), src_addr).await?;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("{} | RESOLVE ERROR | {}", src_addr, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
542
src/dot.rs
542
src/dot.rs
@@ -1,542 +0,0 @@
|
|||||||
use std::net::{IpAddr, SocketAddr};
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use log::{debug, error, info, warn};
|
|
||||||
use rustls::ServerConfig;
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
use tokio::sync::Semaphore;
|
|
||||||
use tokio_rustls::TlsAcceptor;
|
|
||||||
|
|
||||||
use crate::buffer::BytePacketBuffer;
|
|
||||||
use crate::config::DotConfig;
|
|
||||||
use crate::ctx::{resolve_query, ServerCtx};
|
|
||||||
use crate::header::ResultCode;
|
|
||||||
use crate::packet::DnsPacket;
|
|
||||||
|
|
||||||
const MAX_CONNECTIONS: usize = 512;
|
|
||||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
|
|
||||||
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
|
|
||||||
const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
|
|
||||||
// Matches BytePacketBuffer::BUF_SIZE — RFC 7858 allows up to 65535 but our
|
|
||||||
// buffer would silently truncate anything larger.
|
|
||||||
const MAX_MSG_LEN: usize = 4096;
|
|
||||||
|
|
||||||
fn dot_alpn() -> Vec<Vec<u8>> {
|
|
||||||
vec![b"dot".to_vec()]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a TLS ServerConfig for DoT from user-provided cert/key PEM files.
|
|
||||||
fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result<Arc<ServerConfig>> {
|
|
||||||
// rustls needs a CryptoProvider installed before ServerConfig::builder().
|
|
||||||
// The proxy's build_tls_config also does this; we repeat it here because
|
|
||||||
// running DoT with user-provided certs while the proxy is disabled would
|
|
||||||
// otherwise panic on first handshake (no default provider).
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
let cert_pem = std::fs::read(cert_path)?;
|
|
||||||
let key_pem = std::fs::read(key_path)?;
|
|
||||||
|
|
||||||
let certs: Vec<_> = rustls_pemfile::certs(&mut &cert_pem[..]).collect::<Result<_, _>>()?;
|
|
||||||
let key = rustls_pemfile::private_key(&mut &key_pem[..])?
|
|
||||||
.ok_or("no private key found in key file")?;
|
|
||||||
|
|
||||||
let mut config = ServerConfig::builder()
|
|
||||||
.with_no_client_auth()
|
|
||||||
.with_single_cert(certs, key)?;
|
|
||||||
config.alpn_protocols = dot_alpn();
|
|
||||||
|
|
||||||
Ok(Arc::new(config))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a self-signed DoT TLS config. Can't reuse `ctx.tls_config` (the
|
|
||||||
/// proxy's shared config) because DoT needs its own ALPN advertisement.
|
|
||||||
///
|
|
||||||
/// Pass `proxy_tld` itself as a service name so the cert gets an explicit
|
|
||||||
/// `{tld}.{tld}` SAN (e.g. "numa.numa") matching the ServerName that
|
|
||||||
/// setup-phone's mobileconfig sends as SNI. The `*.{tld}` wildcard alone
|
|
||||||
/// is rejected by strict TLS clients under single-label TLDs (per the
|
|
||||||
/// note in tls.rs::generate_service_cert).
|
|
||||||
fn self_signed_tls(ctx: &ServerCtx) -> Option<Arc<ServerConfig>> {
|
|
||||||
let service_names = [ctx.proxy_tld.clone()];
|
|
||||||
match crate::tls::build_tls_config(&ctx.proxy_tld, &service_names, dot_alpn(), &ctx.data_dir) {
|
|
||||||
Ok(cfg) => Some(cfg),
|
|
||||||
Err(e) => {
|
|
||||||
warn!(
|
|
||||||
"DoT: failed to generate self-signed TLS: {} — DoT disabled",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start the DNS-over-TLS listener (RFC 7858).
|
|
||||||
pub async fn start_dot(ctx: Arc<ServerCtx>, config: &DotConfig) {
|
|
||||||
let tls_config = match (&config.cert_path, &config.key_path) {
|
|
||||||
(Some(cert), Some(key)) => match load_tls_config(cert, key) {
|
|
||||||
Ok(cfg) => cfg,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("DoT: failed to load TLS cert/key: {} — DoT disabled", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => match self_signed_tls(&ctx) {
|
|
||||||
Some(cfg) => cfg,
|
|
||||||
None => return,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let bind_addr: IpAddr = config
|
|
||||||
.bind_addr
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
|
|
||||||
let addr = SocketAddr::new(bind_addr, config.port);
|
|
||||||
let listener = match TcpListener::bind(addr).await {
|
|
||||||
Ok(l) => l,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("DoT: could not bind {} ({}) — DoT disabled", addr, e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
info!("DoT listening on {}", addr);
|
|
||||||
|
|
||||||
accept_loop(listener, TlsAcceptor::from(tls_config), ctx).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn accept_loop(listener: TcpListener, acceptor: TlsAcceptor, ctx: Arc<ServerCtx>) {
|
|
||||||
let semaphore = Arc::new(Semaphore::new(MAX_CONNECTIONS));
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let (tcp_stream, remote_addr) = match listener.accept().await {
|
|
||||||
Ok(conn) => conn,
|
|
||||||
Err(e) => {
|
|
||||||
error!("DoT: TCP accept error: {}", e);
|
|
||||||
// Back off to avoid tight-looping on persistent failures (e.g. fd exhaustion).
|
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let permit = match semaphore.clone().try_acquire_owned() {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => {
|
|
||||||
debug!("DoT: connection limit reached, rejecting {}", remote_addr);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let acceptor = acceptor.clone();
|
|
||||||
let ctx = Arc::clone(&ctx);
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _permit = permit; // held until task exits
|
|
||||||
|
|
||||||
let tls_stream =
|
|
||||||
match tokio::time::timeout(HANDSHAKE_TIMEOUT, acceptor.accept(tcp_stream)).await {
|
|
||||||
Ok(Ok(s)) => s,
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
debug!("DoT: TLS handshake failed from {}: {}", remote_addr, e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
debug!("DoT: TLS handshake timeout from {}", remote_addr);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handle_dot_connection(tls_stream, remote_addr, &ctx).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a single persistent DoT connection (RFC 7858).
|
|
||||||
/// Reads length-prefixed DNS queries until EOF, idle timeout, or error.
|
|
||||||
async fn handle_dot_connection<S>(mut stream: S, remote_addr: SocketAddr, ctx: &ServerCtx)
|
|
||||||
where
|
|
||||||
S: AsyncReadExt + AsyncWriteExt + Unpin,
|
|
||||||
{
|
|
||||||
loop {
|
|
||||||
// Read 2-byte length prefix (RFC 1035 §4.2.2) with idle timeout
|
|
||||||
let mut len_buf = [0u8; 2];
|
|
||||||
let Ok(Ok(_)) = tokio::time::timeout(IDLE_TIMEOUT, stream.read_exact(&mut len_buf)).await
|
|
||||||
else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
let msg_len = u16::from_be_bytes(len_buf) as usize;
|
|
||||||
if msg_len > MAX_MSG_LEN {
|
|
||||||
debug!("DoT: oversized message {} from {}", msg_len, remote_addr);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut buffer = BytePacketBuffer::new();
|
|
||||||
let Ok(Ok(_)) =
|
|
||||||
tokio::time::timeout(IDLE_TIMEOUT, stream.read_exact(&mut buffer.buf[..msg_len])).await
|
|
||||||
else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse query up-front so we can echo its question section in SERVFAIL
|
|
||||||
// responses when resolve_query fails.
|
|
||||||
let query = match DnsPacket::from_buffer(&mut buffer) {
|
|
||||||
Ok(q) => q,
|
|
||||||
Err(e) => {
|
|
||||||
warn!("{} | PARSE ERROR | {}", remote_addr, e);
|
|
||||||
// BytePacketBuffer is zero-initialized, so buf[0..2] reads as 0x0000
|
|
||||||
// for sub-2-byte messages — harmless FORMERR with id=0.
|
|
||||||
let query_id = u16::from_be_bytes([buffer.buf[0], buffer.buf[1]]);
|
|
||||||
let mut resp = DnsPacket::new();
|
|
||||||
resp.header.id = query_id;
|
|
||||||
resp.header.response = true;
|
|
||||||
resp.header.rescode = ResultCode::FORMERR;
|
|
||||||
if send_response(&mut stream, &resp, remote_addr)
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match resolve_query(query.clone(), remote_addr, ctx).await {
|
|
||||||
Ok(resp_buffer) => {
|
|
||||||
if write_framed(&mut stream, resp_buffer.filled())
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("{} | RESOLVE ERROR | {}", remote_addr, e);
|
|
||||||
// SERVFAIL that echoes the original question section.
|
|
||||||
let resp = DnsPacket::response_from(&query, ResultCode::SERVFAIL);
|
|
||||||
if send_response(&mut stream, &resp, remote_addr)
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize a DNS response and send it framed. Logs serialization failures
|
|
||||||
/// and returns Err so the caller can tear down the connection.
|
|
||||||
async fn send_response<S>(
|
|
||||||
stream: &mut S,
|
|
||||||
resp: &DnsPacket,
|
|
||||||
remote_addr: SocketAddr,
|
|
||||||
) -> std::io::Result<()>
|
|
||||||
where
|
|
||||||
S: AsyncWriteExt + Unpin,
|
|
||||||
{
|
|
||||||
let mut out_buf = BytePacketBuffer::new();
|
|
||||||
if resp.write(&mut out_buf).is_err() {
|
|
||||||
debug!(
|
|
||||||
"DoT: failed to serialize {:?} response for {}",
|
|
||||||
resp.header.rescode, remote_addr
|
|
||||||
);
|
|
||||||
return Err(std::io::Error::other("serialize failed"));
|
|
||||||
}
|
|
||||||
write_framed(stream, out_buf.filled()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write a DNS message with its 2-byte length prefix, coalesced into one syscall.
|
|
||||||
/// Bounded by WRITE_TIMEOUT so a stalled reader can't indefinitely hold a worker.
|
|
||||||
async fn write_framed<S>(stream: &mut S, msg: &[u8]) -> std::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);
|
|
||||||
match tokio::time::timeout(WRITE_TIMEOUT, async {
|
|
||||||
stream.write_all(&out).await?;
|
|
||||||
stream.flush().await
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(_) => Err(std::io::Error::other("write timeout")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{Mutex, RwLock};
|
|
||||||
|
|
||||||
use rcgen::{CertificateParams, DnType, KeyPair};
|
|
||||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName};
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
|
|
||||||
use crate::buffer::BytePacketBuffer;
|
|
||||||
use crate::header::ResultCode;
|
|
||||||
use crate::packet::DnsPacket;
|
|
||||||
use crate::question::QueryType;
|
|
||||||
use crate::record::DnsRecord;
|
|
||||||
|
|
||||||
/// Generate a self-signed DoT server config and return its leaf cert DER
|
|
||||||
/// so callers can build matching client configs with arbitrary ALPN.
|
|
||||||
fn test_tls_configs() -> (Arc<ServerConfig>, CertificateDer<'static>) {
|
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
||||||
|
|
||||||
// Mirror production self_signed_tls SAN shape: *.numa wildcard plus
|
|
||||||
// explicit numa.numa apex (the ServerName setup-phone uses as SNI).
|
|
||||||
let key_pair = KeyPair::generate().unwrap();
|
|
||||||
let mut params = CertificateParams::default();
|
|
||||||
params
|
|
||||||
.distinguished_name
|
|
||||||
.push(DnType::CommonName, "Numa .numa services");
|
|
||||||
params.subject_alt_names = vec![
|
|
||||||
rcgen::SanType::DnsName("*.numa".try_into().unwrap()),
|
|
||||||
rcgen::SanType::DnsName("numa.numa".try_into().unwrap()),
|
|
||||||
];
|
|
||||||
let cert = params.self_signed(&key_pair).unwrap();
|
|
||||||
|
|
||||||
let cert_der = CertificateDer::from(cert.der().to_vec());
|
|
||||||
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der()));
|
|
||||||
|
|
||||||
let mut server_config = ServerConfig::builder()
|
|
||||||
.with_no_client_auth()
|
|
||||||
.with_single_cert(vec![cert_der.clone()], key_der)
|
|
||||||
.unwrap();
|
|
||||||
server_config.alpn_protocols = dot_alpn();
|
|
||||||
|
|
||||||
(Arc::new(server_config), cert_der)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a TLS client config that trusts `cert_der` and advertises the
|
|
||||||
/// given ALPN protocols. Used by tests to vary ALPN per test case.
|
|
||||||
fn dot_client(
|
|
||||||
cert_der: &CertificateDer<'static>,
|
|
||||||
alpn: Vec<Vec<u8>>,
|
|
||||||
) -> Arc<rustls::ClientConfig> {
|
|
||||||
let mut root_store = rustls::RootCertStore::empty();
|
|
||||||
root_store.add(cert_der.clone()).unwrap();
|
|
||||||
let mut config = rustls::ClientConfig::builder()
|
|
||||||
.with_root_certificates(root_store)
|
|
||||||
.with_no_client_auth();
|
|
||||||
config.alpn_protocols = alpn;
|
|
||||||
Arc::new(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spin up a DoT listener with a test TLS config. Returns the bind addr
|
|
||||||
/// and the leaf cert DER so callers can build clients with arbitrary ALPN.
|
|
||||||
/// The upstream is pointed at a bound-but-unresponsive UDP socket we own, so
|
|
||||||
/// any query that escapes to the upstream path times out deterministically
|
|
||||||
/// (SERVFAIL) regardless of what the host has running on port 53.
|
|
||||||
async fn spawn_dot_server() -> (SocketAddr, CertificateDer<'static>) {
|
|
||||||
let (server_tls, cert_der) = test_tls_configs();
|
|
||||||
|
|
||||||
let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
// Bind an unresponsive upstream and leak it so it lives for the test duration.
|
|
||||||
let blackhole = Box::leak(Box::new(std::net::UdpSocket::bind("127.0.0.1:0").unwrap()));
|
|
||||||
let upstream_addr = blackhole.local_addr().unwrap();
|
|
||||||
let ctx = Arc::new(ServerCtx {
|
|
||||||
socket,
|
|
||||||
zone_map: {
|
|
||||||
let mut m = HashMap::new();
|
|
||||||
let mut inner = HashMap::new();
|
|
||||||
inner.insert(
|
|
||||||
QueryType::A,
|
|
||||||
vec![DnsRecord::A {
|
|
||||||
domain: "dot-test.example".to_string(),
|
|
||||||
addr: std::net::Ipv4Addr::new(10, 0, 0, 1),
|
|
||||||
ttl: 300,
|
|
||||||
}],
|
|
||||||
);
|
|
||||||
m.insert("dot-test.example".to_string(), inner);
|
|
||||||
m
|
|
||||||
},
|
|
||||||
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
|
|
||||||
stats: Mutex::new(crate::stats::ServerStats::new()),
|
|
||||||
overrides: RwLock::new(crate::override_store::OverrideStore::new()),
|
|
||||||
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
|
|
||||||
query_log: Mutex::new(crate::query_log::QueryLog::new(100)),
|
|
||||||
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
|
||||||
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
|
||||||
forwarding_rules: Vec::new(),
|
|
||||||
upstream: Mutex::new(crate::forward::Upstream::Udp(upstream_addr)),
|
|
||||||
upstream_auto: false,
|
|
||||||
upstream_port: 53,
|
|
||||||
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
|
|
||||||
timeout: Duration::from_millis(200),
|
|
||||||
proxy_tld: "numa".to_string(),
|
|
||||||
proxy_tld_suffix: ".numa".to_string(),
|
|
||||||
lan_enabled: false,
|
|
||||||
config_path: String::new(),
|
|
||||||
config_found: false,
|
|
||||||
config_dir: std::path::PathBuf::from("/tmp"),
|
|
||||||
data_dir: std::path::PathBuf::from("/tmp"),
|
|
||||||
tls_config: Some(arc_swap::ArcSwap::from(server_tls)),
|
|
||||||
upstream_mode: crate::config::UpstreamMode::Forward,
|
|
||||||
root_hints: Vec::new(),
|
|
||||||
srtt: RwLock::new(crate::srtt::SrttCache::new(true)),
|
|
||||||
inflight: Mutex::new(HashMap::new()),
|
|
||||||
dnssec_enabled: false,
|
|
||||||
dnssec_strict: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
|
|
||||||
let tls_config = Arc::clone(&*ctx.tls_config.as_ref().unwrap().load());
|
|
||||||
let acceptor = TlsAcceptor::from(tls_config);
|
|
||||||
|
|
||||||
tokio::spawn(accept_loop(listener, acceptor, ctx));
|
|
||||||
|
|
||||||
(addr, cert_der)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open a TLS connection to the DoT server and return the stream.
|
|
||||||
/// Uses SNI "numa.numa" to mirror what setup-phone's mobileconfig sends.
|
|
||||||
async fn dot_connect(
|
|
||||||
addr: SocketAddr,
|
|
||||||
client_config: &Arc<rustls::ClientConfig>,
|
|
||||||
) -> tokio_rustls::client::TlsStream<tokio::net::TcpStream> {
|
|
||||||
let connector = tokio_rustls::TlsConnector::from(Arc::clone(client_config));
|
|
||||||
let tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
|
|
||||||
connector
|
|
||||||
.connect(ServerName::try_from("numa.numa").unwrap(), tcp)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a DNS query over a DoT stream and read the response.
|
|
||||||
async fn dot_exchange(
|
|
||||||
stream: &mut tokio_rustls::client::TlsStream<tokio::net::TcpStream>,
|
|
||||||
query: &DnsPacket,
|
|
||||||
) -> DnsPacket {
|
|
||||||
let mut buf = BytePacketBuffer::new();
|
|
||||||
query.write(&mut buf).unwrap();
|
|
||||||
let msg = buf.filled();
|
|
||||||
|
|
||||||
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.unwrap();
|
|
||||||
|
|
||||||
let mut len_buf = [0u8; 2];
|
|
||||||
stream.read_exact(&mut len_buf).await.unwrap();
|
|
||||||
let resp_len = u16::from_be_bytes(len_buf) as usize;
|
|
||||||
|
|
||||||
let mut data = vec![0u8; resp_len];
|
|
||||||
stream.read_exact(&mut data).await.unwrap();
|
|
||||||
|
|
||||||
let mut resp_buf = BytePacketBuffer::from_bytes(&data);
|
|
||||||
DnsPacket::from_buffer(&mut resp_buf).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dot_resolves_local_zone() {
|
|
||||||
let (addr, cert_der) = spawn_dot_server().await;
|
|
||||||
let client_config = dot_client(&cert_der, dot_alpn());
|
|
||||||
let mut stream = dot_connect(addr, &client_config).await;
|
|
||||||
|
|
||||||
let query = DnsPacket::query(0x1234, "dot-test.example", QueryType::A);
|
|
||||||
let resp = dot_exchange(&mut stream, &query).await;
|
|
||||||
|
|
||||||
assert_eq!(resp.header.id, 0x1234);
|
|
||||||
assert!(resp.header.response);
|
|
||||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
|
||||||
assert_eq!(resp.answers.len(), 1);
|
|
||||||
match &resp.answers[0] {
|
|
||||||
DnsRecord::A { domain, addr, ttl } => {
|
|
||||||
assert_eq!(domain, "dot-test.example");
|
|
||||||
assert_eq!(*addr, std::net::Ipv4Addr::new(10, 0, 0, 1));
|
|
||||||
assert_eq!(*ttl, 300);
|
|
||||||
}
|
|
||||||
other => panic!("expected A record, got {:?}", other),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dot_multiple_queries_on_persistent_connection() {
|
|
||||||
let (addr, cert_der) = spawn_dot_server().await;
|
|
||||||
let client_config = dot_client(&cert_der, dot_alpn());
|
|
||||||
let mut stream = dot_connect(addr, &client_config).await;
|
|
||||||
|
|
||||||
for i in 0..3u16 {
|
|
||||||
let query = DnsPacket::query(0xA000 + i, "dot-test.example", QueryType::A);
|
|
||||||
let resp = dot_exchange(&mut stream, &query).await;
|
|
||||||
assert_eq!(resp.header.id, 0xA000 + i);
|
|
||||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
|
||||||
assert_eq!(resp.answers.len(), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dot_nxdomain_for_unknown() {
|
|
||||||
let (addr, cert_der) = spawn_dot_server().await;
|
|
||||||
let client_config = dot_client(&cert_der, dot_alpn());
|
|
||||||
let mut stream = dot_connect(addr, &client_config).await;
|
|
||||||
|
|
||||||
let query = DnsPacket::query(0xBEEF, "nonexistent.test", QueryType::A);
|
|
||||||
let resp = dot_exchange(&mut stream, &query).await;
|
|
||||||
|
|
||||||
assert_eq!(resp.header.id, 0xBEEF);
|
|
||||||
assert!(resp.header.response);
|
|
||||||
// Query goes to the blackhole upstream which never replies → SERVFAIL.
|
|
||||||
// The SERVFAIL response echoes the question section.
|
|
||||||
assert_eq!(resp.header.rescode, ResultCode::SERVFAIL);
|
|
||||||
assert_eq!(resp.questions.len(), 1);
|
|
||||||
assert_eq!(resp.questions[0].name, "nonexistent.test");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dot_negotiates_alpn() {
|
|
||||||
let (addr, cert_der) = spawn_dot_server().await;
|
|
||||||
let client_config = dot_client(&cert_der, dot_alpn());
|
|
||||||
let stream = dot_connect(addr, &client_config).await;
|
|
||||||
let (_io, conn) = stream.get_ref();
|
|
||||||
assert_eq!(conn.alpn_protocol(), Some(&b"dot"[..]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dot_rejects_non_dot_alpn() {
|
|
||||||
// Cross-protocol confusion defense: a client that only offers "h2"
|
|
||||||
// (e.g. an HTTP/2 client mistakenly hitting :853) must not complete
|
|
||||||
// a TLS handshake with the DoT server. Verifies the rustls server
|
|
||||||
// sends `no_application_protocol` rather than silently negotiating.
|
|
||||||
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\""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dot_concurrent_connections() {
|
|
||||||
let (addr, cert_der) = spawn_dot_server().await;
|
|
||||||
let client_config = dot_client(&cert_der, dot_alpn());
|
|
||||||
|
|
||||||
let mut handles = Vec::new();
|
|
||||||
for i in 0..5u16 {
|
|
||||||
let cfg = Arc::clone(&client_config);
|
|
||||||
handles.push(tokio::spawn(async move {
|
|
||||||
let mut stream = dot_connect(addr, &cfg).await;
|
|
||||||
let query = DnsPacket::query(0xC000 + i, "dot-test.example", QueryType::A);
|
|
||||||
let resp = dot_exchange(&mut stream, &query).await;
|
|
||||||
assert_eq!(resp.header.id, 0xC000 + i);
|
|
||||||
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
|
|
||||||
assert_eq!(resp.answers.len(), 1);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
for h in handles {
|
|
||||||
h.await.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ pub mod cache;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod ctx;
|
pub mod ctx;
|
||||||
pub mod dnssec;
|
pub mod dnssec;
|
||||||
pub mod dot;
|
|
||||||
pub mod forward;
|
pub mod forward;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod lan;
|
pub mod lan;
|
||||||
@@ -66,9 +65,7 @@ fn config_dir_unix() -> std::path::PathBuf {
|
|||||||
std::path::PathBuf::from("/usr/local/var/numa")
|
std::path::PathBuf::from("/usr/local/var/numa")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default system-wide data directory for TLS certs. Overridable via
|
/// System-wide data directory for TLS certs.
|
||||||
/// `[server] data_dir = "..."` in numa.toml — this function only provides
|
|
||||||
/// the fallback when the config doesn't set it.
|
|
||||||
/// Unix: /usr/local/var/numa
|
/// Unix: /usr/local/var/numa
|
||||||
/// Windows: %PROGRAMDATA%\numa
|
/// Windows: %PROGRAMDATA%\numa
|
||||||
pub fn data_dir() -> std::path::PathBuf {
|
pub fn data_dir() -> std::path::PathBuf {
|
||||||
|
|||||||
29
src/main.rs
29
src/main.rs
@@ -204,23 +204,10 @@ async fn main() -> numa::Result<()> {
|
|||||||
|
|
||||||
let forwarding_rules = system_dns.forwarding_rules;
|
let forwarding_rules = system_dns.forwarding_rules;
|
||||||
|
|
||||||
// Resolve data_dir from config, falling back to the platform default.
|
|
||||||
// Used for TLS CA storage below and stored on ServerCtx for runtime use.
|
|
||||||
let resolved_data_dir = config
|
|
||||||
.server
|
|
||||||
.data_dir
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(numa::data_dir);
|
|
||||||
|
|
||||||
// Build initial TLS config before ServerCtx (so ArcSwap is ready at construction)
|
// Build initial TLS config before ServerCtx (so ArcSwap is ready at construction)
|
||||||
let initial_tls = if config.proxy.enabled && config.proxy.tls_port > 0 {
|
let initial_tls = if config.proxy.enabled && config.proxy.tls_port > 0 {
|
||||||
let service_names = service_store.names();
|
let service_names = service_store.names();
|
||||||
match numa::tls::build_tls_config(
|
match numa::tls::build_tls_config(&config.proxy.tld, &service_names) {
|
||||||
&config.proxy.tld,
|
|
||||||
&service_names,
|
|
||||||
Vec::new(),
|
|
||||||
&resolved_data_dir,
|
|
||||||
) {
|
|
||||||
Ok(tls_config) => Some(ArcSwap::from(tls_config)),
|
Ok(tls_config) => Some(ArcSwap::from(tls_config)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e);
|
log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e);
|
||||||
@@ -261,7 +248,7 @@ async fn main() -> numa::Result<()> {
|
|||||||
config_path: resolved_config_path,
|
config_path: resolved_config_path,
|
||||||
config_found,
|
config_found,
|
||||||
config_dir: numa::config_dir(),
|
config_dir: numa::config_dir(),
|
||||||
data_dir: resolved_data_dir,
|
data_dir: numa::data_dir(),
|
||||||
tls_config: initial_tls,
|
tls_config: initial_tls,
|
||||||
upstream_mode: resolved_mode,
|
upstream_mode: resolved_mode,
|
||||||
root_hints,
|
root_hints,
|
||||||
@@ -383,9 +370,6 @@ async fn main() -> numa::Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if config.dot.enabled {
|
|
||||||
row("DoT", g, &format!("tls://:{}", config.dot.port));
|
|
||||||
}
|
|
||||||
if config.lan.enabled {
|
if config.lan.enabled {
|
||||||
row("LAN", g, "mDNS (_numa._tcp.local)");
|
row("LAN", g, "mDNS (_numa._tcp.local)");
|
||||||
}
|
}
|
||||||
@@ -493,15 +477,6 @@ async fn main() -> numa::Result<()> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn DNS-over-TLS listener (RFC 7858)
|
|
||||||
if config.dot.enabled {
|
|
||||||
let dot_ctx = Arc::clone(&ctx);
|
|
||||||
let dot_config = config.dot.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
numa::dot::start_dot(dot_ctx, &dot_config).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// UDP DNS listener
|
// UDP DNS listener
|
||||||
#[allow(clippy::infinite_loop)]
|
#[allow(clippy::infinite_loop)]
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -870,25 +870,14 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let handler = handler.clone();
|
let handler = handler.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let timeout = std::time::Duration::from_secs(5);
|
|
||||||
// Read length-prefixed DNS query
|
// Read length-prefixed DNS query
|
||||||
let mut len_buf = [0u8; 2];
|
let mut len_buf = [0u8; 2];
|
||||||
if tokio::time::timeout(timeout, stream.read_exact(&mut len_buf))
|
if stream.read_exact(&mut len_buf).await.is_err() {
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.and_then(|r| r.ok())
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let len = u16::from_be_bytes(len_buf) as usize;
|
let len = u16::from_be_bytes(len_buf) as usize;
|
||||||
let mut data = vec![0u8; len];
|
let mut data = vec![0u8; len];
|
||||||
if tokio::time::timeout(timeout, stream.read_exact(&mut data))
|
if stream.read_exact(&mut data).await.is_err() {
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.and_then(|r| r.ok())
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
src/tls.rs
19
src/tls.rs
@@ -24,7 +24,7 @@ pub fn regenerate_tls(ctx: &ServerCtx) {
|
|||||||
names.extend(ctx.lan_peers.lock().unwrap().names());
|
names.extend(ctx.lan_peers.lock().unwrap().names());
|
||||||
let names: Vec<String> = names.into_iter().collect();
|
let names: Vec<String> = names.into_iter().collect();
|
||||||
|
|
||||||
match build_tls_config(&ctx.proxy_tld, &names, Vec::new(), &ctx.data_dir) {
|
match build_tls_config(&ctx.proxy_tld, &names) {
|
||||||
Ok(new_config) => {
|
Ok(new_config) => {
|
||||||
tls.store(new_config);
|
tls.store(new_config);
|
||||||
info!("TLS cert regenerated for {} services", names.len());
|
info!("TLS cert regenerated for {} services", names.len());
|
||||||
@@ -36,26 +36,17 @@ pub fn regenerate_tls(ctx: &ServerCtx) {
|
|||||||
/// Build a TLS config with a cert covering all provided service names.
|
/// Build a TLS config with a cert covering all provided service names.
|
||||||
/// Wildcards under single-label TLDs (*.numa) are rejected by browsers,
|
/// Wildcards under single-label TLDs (*.numa) are rejected by browsers,
|
||||||
/// so we list each service explicitly as a SAN.
|
/// so we list each service explicitly as a SAN.
|
||||||
/// `alpn` is advertised in the TLS ServerHello — pass empty for the proxy
|
pub fn build_tls_config(tld: &str, service_names: &[String]) -> crate::Result<Arc<ServerConfig>> {
|
||||||
/// (which accepts any ALPN), or `[b"dot"]` for DoT (RFC 7858 §3.2).
|
let dir = crate::data_dir();
|
||||||
/// `data_dir` is where the CA material is stored — taken from
|
let (ca_cert, ca_key) = ensure_ca(&dir)?;
|
||||||
/// `[server] data_dir` in numa.toml (defaults to `crate::data_dir()`).
|
|
||||||
pub fn build_tls_config(
|
|
||||||
tld: &str,
|
|
||||||
service_names: &[String],
|
|
||||||
alpn: Vec<Vec<u8>>,
|
|
||||||
data_dir: &Path,
|
|
||||||
) -> crate::Result<Arc<ServerConfig>> {
|
|
||||||
let (ca_cert, ca_key) = 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_cert, &ca_key, 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();
|
||||||
|
|
||||||
let mut config = ServerConfig::builder()
|
let config = ServerConfig::builder()
|
||||||
.with_no_client_auth()
|
.with_no_client_auth()
|
||||||
.with_single_cert(cert_chain, key)?;
|
.with_single_cert(cert_chain, key)?;
|
||||||
config.alpn_protocols = alpn;
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"TLS configured for {} .{} domains",
|
"TLS configured for {} .{} domains",
|
||||||
|
|||||||
@@ -404,241 +404,6 @@ check "Cache flushed" \
|
|||||||
|
|
||||||
kill "$NUMA_PID" 2>/dev/null || true
|
kill "$NUMA_PID" 2>/dev/null || true
|
||||||
wait "$NUMA_PID" 2>/dev/null || true
|
wait "$NUMA_PID" 2>/dev/null || true
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# ---- Suite 5: DNS-over-TLS (RFC 7858) ----
|
|
||||||
echo ""
|
|
||||||
echo "╔══════════════════════════════════════════╗"
|
|
||||||
echo "║ Suite 5: DNS-over-TLS (RFC 7858) ║"
|
|
||||||
echo "╚══════════════════════════════════════════╝"
|
|
||||||
|
|
||||||
if ! command -v kdig >/dev/null 2>&1; then
|
|
||||||
printf " ${DIM}skipped — install 'knot' for kdig${RESET}\n"
|
|
||||||
elif ! command -v openssl >/dev/null 2>&1; then
|
|
||||||
printf " ${DIM}skipped — openssl not found${RESET}\n"
|
|
||||||
else
|
|
||||||
DOT_PORT=8853
|
|
||||||
DOT_CERT=/tmp/numa-integration-dot.crt
|
|
||||||
DOT_KEY=/tmp/numa-integration-dot.key
|
|
||||||
|
|
||||||
# Generate a test cert mirroring production self_signed_tls SAN shape
|
|
||||||
# (*.numa wildcard + explicit numa.numa apex).
|
|
||||||
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
|
|
||||||
-keyout "$DOT_KEY" -out "$DOT_CERT" \
|
|
||||||
-subj "/CN=Numa .numa services" \
|
|
||||||
-addext "subjectAltName=DNS:*.numa,DNS:numa.numa" \
|
|
||||||
>/dev/null 2>&1
|
|
||||||
|
|
||||||
# Suite 5 uses a local zone so it's upstream-independent — the point is
|
|
||||||
# to exercise the DoT transport layer (handshake, ALPN, framing,
|
|
||||||
# persistent connections), not re-test recursive resolution.
|
|
||||||
cat > "$CONFIG" << CONF
|
|
||||||
[server]
|
|
||||||
bind_addr = "127.0.0.1:$PORT"
|
|
||||||
api_port = $API_PORT
|
|
||||||
|
|
||||||
[upstream]
|
|
||||||
mode = "forward"
|
|
||||||
address = "127.0.0.1"
|
|
||||||
port = 65535
|
|
||||||
|
|
||||||
[cache]
|
|
||||||
max_entries = 10000
|
|
||||||
|
|
||||||
[blocking]
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
[proxy]
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
[dot]
|
|
||||||
enabled = true
|
|
||||||
port = $DOT_PORT
|
|
||||||
bind_addr = "127.0.0.1"
|
|
||||||
cert_path = "$DOT_CERT"
|
|
||||||
key_path = "$DOT_KEY"
|
|
||||||
|
|
||||||
[[zones]]
|
|
||||||
domain = "dot-test.example"
|
|
||||||
record_type = "A"
|
|
||||||
value = "10.0.0.1"
|
|
||||||
ttl = 60
|
|
||||||
CONF
|
|
||||||
|
|
||||||
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
|
|
||||||
NUMA_PID=$!
|
|
||||||
sleep 4
|
|
||||||
|
|
||||||
if ! kill -0 "$NUMA_PID" 2>/dev/null; then
|
|
||||||
FAILED=$((FAILED + 1))
|
|
||||||
printf " ${RED}✗${RESET} DoT startup\n"
|
|
||||||
printf " ${DIM}%s${RESET}\n" "$(tail -5 "$LOG")"
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "=== Listener ==="
|
|
||||||
|
|
||||||
check "DoT bound on 127.0.0.1:$DOT_PORT" \
|
|
||||||
"DoT listening on 127.0.0.1:$DOT_PORT" \
|
|
||||||
"$(grep 'DoT listening' "$LOG")"
|
|
||||||
|
|
||||||
KDIG="kdig @127.0.0.1 -p $DOT_PORT +tls +tls-ca=$DOT_CERT +tls-hostname=numa.numa +time=5 +retry=0"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Queries over DoT ==="
|
|
||||||
|
|
||||||
check "DoT local zone A record" \
|
|
||||||
"10.0.0.1" \
|
|
||||||
"$($KDIG +short dot-test.example A 2>/dev/null)"
|
|
||||||
|
|
||||||
# +keepopen reuses one TLS connection for multiple queries — tests
|
|
||||||
# persistent connection handling. kdig applies options left-to-right,
|
|
||||||
# so +short and +keepopen must come before the query specs.
|
|
||||||
check "DoT persistent connection (3 queries, 1 handshake)" \
|
|
||||||
"10.0.0.1" \
|
|
||||||
"$($KDIG +keepopen +short dot-test.example A dot-test.example A dot-test.example A 2>/dev/null | head -1)"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== ALPN ==="
|
|
||||||
|
|
||||||
# Positive case: client offers "dot", server picks it.
|
|
||||||
ALPN_OK=$(echo "" | openssl s_client -connect "127.0.0.1:$DOT_PORT" \
|
|
||||||
-servername numa.numa -alpn dot -CAfile "$DOT_CERT" 2>&1 </dev/null || true)
|
|
||||||
check "DoT negotiates ALPN \"dot\"" \
|
|
||||||
"ALPN protocol: dot" \
|
|
||||||
"$ALPN_OK"
|
|
||||||
|
|
||||||
# Negative case: client offers only "h2", server must reject the
|
|
||||||
# handshake with no_application_protocol alert (cross-protocol
|
|
||||||
# confusion defense, RFC 7858bis §3.2).
|
|
||||||
if echo "" | openssl s_client -connect "127.0.0.1:$DOT_PORT" \
|
|
||||||
-servername numa.numa -alpn h2 -CAfile "$DOT_CERT" \
|
|
||||||
</dev/null >/dev/null 2>&1; then
|
|
||||||
ALPN_MISMATCH="handshake unexpectedly succeeded"
|
|
||||||
else
|
|
||||||
ALPN_MISMATCH="rejected"
|
|
||||||
fi
|
|
||||||
check "DoT rejects non-dot ALPN" \
|
|
||||||
"rejected" \
|
|
||||||
"$ALPN_MISMATCH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
kill "$NUMA_PID" 2>/dev/null || true
|
|
||||||
wait "$NUMA_PID" 2>/dev/null || true
|
|
||||||
rm -f "$DOT_CERT" "$DOT_KEY"
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
# ---- Suite 6: Proxy + DoT coexistence ----
|
|
||||||
echo ""
|
|
||||||
echo "╔══════════════════════════════════════════╗"
|
|
||||||
echo "║ Suite 6: Proxy + DoT Coexistence ║"
|
|
||||||
echo "╚══════════════════════════════════════════╝"
|
|
||||||
|
|
||||||
if ! command -v kdig >/dev/null 2>&1 || ! command -v openssl >/dev/null 2>&1; then
|
|
||||||
printf " ${DIM}skipped — needs kdig + openssl${RESET}\n"
|
|
||||||
else
|
|
||||||
DOT_PORT=8853
|
|
||||||
PROXY_HTTP_PORT=8080
|
|
||||||
PROXY_HTTPS_PORT=8443
|
|
||||||
NUMA_DATA=/tmp/numa-integration-data
|
|
||||||
|
|
||||||
# Fresh data dir so we generate a fresh CA for this suite. Path is set
|
|
||||||
# via [server] data_dir in the TOML below, not an env var — numa treats
|
|
||||||
# its config file as the single source of truth for all knobs.
|
|
||||||
rm -rf "$NUMA_DATA"
|
|
||||||
mkdir -p "$NUMA_DATA"
|
|
||||||
|
|
||||||
cat > "$CONFIG" << CONF
|
|
||||||
[server]
|
|
||||||
bind_addr = "127.0.0.1:$PORT"
|
|
||||||
api_port = $API_PORT
|
|
||||||
data_dir = "$NUMA_DATA"
|
|
||||||
|
|
||||||
[upstream]
|
|
||||||
mode = "forward"
|
|
||||||
address = "127.0.0.1"
|
|
||||||
port = 65535
|
|
||||||
|
|
||||||
[cache]
|
|
||||||
max_entries = 10000
|
|
||||||
|
|
||||||
[blocking]
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
[proxy]
|
|
||||||
enabled = true
|
|
||||||
port = $PROXY_HTTP_PORT
|
|
||||||
tls_port = $PROXY_HTTPS_PORT
|
|
||||||
tld = "numa"
|
|
||||||
bind_addr = "127.0.0.1"
|
|
||||||
|
|
||||||
[dot]
|
|
||||||
enabled = true
|
|
||||||
port = $DOT_PORT
|
|
||||||
bind_addr = "127.0.0.1"
|
|
||||||
|
|
||||||
[[zones]]
|
|
||||||
domain = "dot-test.example"
|
|
||||||
record_type = "A"
|
|
||||||
value = "10.0.0.1"
|
|
||||||
ttl = 60
|
|
||||||
CONF
|
|
||||||
|
|
||||||
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
|
|
||||||
NUMA_PID=$!
|
|
||||||
sleep 4
|
|
||||||
|
|
||||||
if ! kill -0 "$NUMA_PID" 2>/dev/null; then
|
|
||||||
FAILED=$((FAILED + 1))
|
|
||||||
printf " ${RED}✗${RESET} Startup with proxy + DoT\n"
|
|
||||||
printf " ${DIM}%s${RESET}\n" "$(tail -5 "$LOG")"
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "=== Both listeners ==="
|
|
||||||
|
|
||||||
check "DoT listener bound" \
|
|
||||||
"DoT listening on 127.0.0.1:$DOT_PORT" \
|
|
||||||
"$(grep 'DoT listening' "$LOG")"
|
|
||||||
|
|
||||||
check "HTTPS proxy listener bound" \
|
|
||||||
"HTTPS proxy listening on 127.0.0.1:$PROXY_HTTPS_PORT" \
|
|
||||||
"$(grep 'HTTPS proxy listening' "$LOG")"
|
|
||||||
|
|
||||||
PANIC_COUNT=$(grep -c 'panicked' "$LOG" 2>/dev/null || echo 0)
|
|
||||||
check "No startup panics in log" \
|
|
||||||
"^0$" \
|
|
||||||
"$PANIC_COUNT"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== DoT works with proxy enabled ==="
|
|
||||||
|
|
||||||
# Proxy's build_tls_config runs first and creates the CA in
|
|
||||||
# $NUMA_DATA_DIR. DoT self_signed_tls then loads the same CA and
|
|
||||||
# issues its own leaf cert. One CA trusts both listeners.
|
|
||||||
CA="$NUMA_DATA/ca.pem"
|
|
||||||
KDIG="kdig @127.0.0.1 -p $DOT_PORT +tls +tls-ca=$CA +tls-hostname=numa.numa +time=5 +retry=0"
|
|
||||||
|
|
||||||
check "DoT local zone A (with proxy on)" \
|
|
||||||
"10.0.0.1" \
|
|
||||||
"$($KDIG +short dot-test.example A 2>/dev/null)"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Proxy TLS works with DoT enabled ==="
|
|
||||||
|
|
||||||
# Proxy cert has SAN numa.numa (auto-added "numa" service). A
|
|
||||||
# successful handshake validates that the proxy's separate
|
|
||||||
# ServerConfig wasn't disturbed by DoT's own cert generation.
|
|
||||||
PROXY_TLS=$(echo "" | openssl s_client -connect "127.0.0.1:$PROXY_HTTPS_PORT" \
|
|
||||||
-servername numa.numa -CAfile "$CA" 2>&1 </dev/null || true)
|
|
||||||
check "Proxy HTTPS TLS handshake succeeds" \
|
|
||||||
"Verify return code: 0 (ok)" \
|
|
||||||
"$PROXY_TLS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
kill "$NUMA_PID" 2>/dev/null || true
|
|
||||||
wait "$NUMA_PID" 2>/dev/null || true
|
|
||||||
rm -rf "$NUMA_DATA"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
Reference in New Issue
Block a user