Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60600b045f | ||
|
|
3e6bf3feb0 | ||
|
|
8bed7c4649 | ||
|
|
5b1642c6dc | ||
|
|
01fda7891e | ||
|
|
5e84adbd94 | ||
|
|
15978a7859 | ||
|
|
193b38b85f | ||
|
|
4c685d1602 | ||
|
|
cd6e686a1a |
18
Cargo.lock
generated
18
Cargo.lock
generated
@@ -1547,7 +1547,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.14.0"
|
version = "0.14.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -1562,6 +1562,7 @@ dependencies = [
|
|||||||
"hyper-util",
|
"hyper-util",
|
||||||
"log",
|
"log",
|
||||||
"odoh-rs",
|
"odoh-rs",
|
||||||
|
"psl",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
@@ -1802,6 +1803,21 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psl"
|
||||||
|
version = "2.1.203"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76c0777260d32b76a8c3c197646707085d37e79d63b5872a29192c8d4f60f50b"
|
||||||
|
dependencies = [
|
||||||
|
"psl-types",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psl-types"
|
||||||
|
version = "2.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode"
|
name = "qrcode"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.14.0"
|
version = "0.14.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"
|
||||||
@@ -30,6 +30,7 @@ tokio-rustls = "0.26"
|
|||||||
arc-swap = "1"
|
arc-swap = "1"
|
||||||
ring = "0.17"
|
ring = "0.17"
|
||||||
odoh-rs = "1"
|
odoh-rs = "1"
|
||||||
|
psl = "2"
|
||||||
# rand_core 0.9 matches the version odoh-rs (via hpke 0.13) depends on, so we
|
# rand_core 0.9 matches the version odoh-rs (via hpke 0.13) depends on, so we
|
||||||
# share one RngCore trait and OsRng impl across the dep tree.
|
# share one RngCore trait and OsRng impl across the dep tree.
|
||||||
rand_core = { version = "0.9", features = ["os_rng"] }
|
rand_core = { version = "0.9", features = ["os_rng"] }
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
**DNS you own. Everywhere you go.** — [numa.rs](https://numa.rs)
|
**DNS you own. Everywhere you go.** — [numa.rs](https://numa.rs)
|
||||||
|
|
||||||
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`), override any hostname with auto-revert, and seal every outbound query with **ODoH (RFC 9230)** so no single party sees both who you are and what you asked — 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. 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.). Run `numa relay` and the same binary becomes a public ODoH endpoint too — the curated DNSCrypt list currently has one surviving relay, so every Numa deploy materially expands the ecosystem. One ~8MB binary, everything embedded.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -1244,7 +1244,7 @@ async function refresh() {
|
|||||||
|
|
||||||
// QPS calculation
|
// QPS calculation
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const encPct = encryptionPct(stats.transport);
|
const encPct = encryptionPct(stats.transport, ['dot', 'doh'], ['udp', 'tcp', 'dot', 'doh']);
|
||||||
if (prevTotal !== null && prevTime !== null) {
|
if (prevTotal !== null && prevTime !== null) {
|
||||||
const dt = (now - prevTime) / 1000;
|
const dt = (now - prevTime) / 1000;
|
||||||
const dq = q.total - prevTotal;
|
const dq = q.total - prevTotal;
|
||||||
@@ -1273,6 +1273,7 @@ async function refresh() {
|
|||||||
renderMemory(stats.memory, stats);
|
renderMemory(stats.memory, stats);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('[numa dashboard] render failed:', err);
|
||||||
document.getElementById('statusDot').className = 'status-dot error';
|
document.getElementById('statusDot').className = 'status-dot error';
|
||||||
document.getElementById('statusText').textContent = 'disconnected';
|
document.getElementById('statusText').textContent = 'disconnected';
|
||||||
}
|
}
|
||||||
|
|||||||
138
src/blocklist.rs
138
src/blocklist.rs
@@ -1,5 +1,5 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::time::Instant;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
|
|
||||||
@@ -355,27 +355,139 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RETRY_DELAYS_SECS: &[u64] = &[2, 10, 30];
|
||||||
|
|
||||||
pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> {
|
pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
.gzip(true)
|
.gzip(true)
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let mut results = Vec::new();
|
let fetches = lists.iter().map(|url| {
|
||||||
|
let client = &client;
|
||||||
for url in lists {
|
async move {
|
||||||
match client.get(url).send().await {
|
let text = fetch_with_retry(client, url).await?;
|
||||||
Ok(resp) => match resp.text().await {
|
|
||||||
Ok(text) => {
|
|
||||||
info!("downloaded blocklist: {} ({} bytes)", url, text.len());
|
info!("downloaded blocklist: {} ({} bytes)", url, text.len());
|
||||||
results.push((url.clone(), text));
|
Some((url.clone(), text))
|
||||||
}
|
}
|
||||||
Err(e) => warn!("failed to read blocklist body {}: {}", url, e),
|
});
|
||||||
},
|
futures::future::join_all(fetches)
|
||||||
Err(e) => warn!("failed to download blocklist {}: {}", url, e),
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_with_retry(client: &reqwest::Client, url: &str) -> Option<String> {
|
||||||
|
fetch_with_retry_delays(client, url, RETRY_DELAYS_SECS).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_with_retry_delays(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
url: &str,
|
||||||
|
delays: &[u64],
|
||||||
|
) -> Option<String> {
|
||||||
|
let total = delays.len() + 1;
|
||||||
|
for attempt in 1..=total {
|
||||||
|
match fetch_once(client, url).await {
|
||||||
|
Ok(text) => return Some(text),
|
||||||
|
Err(msg) if attempt < total => {
|
||||||
|
let delay = delays[attempt - 1];
|
||||||
|
warn!(
|
||||||
|
"blocklist {} attempt {}/{} failed: {} — retrying in {}s",
|
||||||
|
url, attempt, total, msg, delay
|
||||||
|
);
|
||||||
|
tokio::time::sleep(Duration::from_secs(delay)).await;
|
||||||
}
|
}
|
||||||
|
Err(msg) => {
|
||||||
|
warn!(
|
||||||
|
"blocklist {} attempt {}/{} failed: {} — giving up",
|
||||||
|
url, attempt, total, msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_once(client: &reqwest::Client, url: &str) -> Result<String, String> {
|
||||||
|
let resp = client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format_error_chain(&e))?;
|
||||||
|
resp.text().await.map_err(|e| format_error_chain(&e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_error_chain(e: &(dyn std::error::Error + 'static)) -> String {
|
||||||
|
let mut parts = vec![e.to_string()];
|
||||||
|
let mut src = e.source();
|
||||||
|
while let Some(s) = src {
|
||||||
|
parts.push(s.to_string());
|
||||||
|
src = s.source();
|
||||||
|
}
|
||||||
|
parts.join(": ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod retry_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
async fn flaky_http_server(drop_first_n: usize, body: &'static str) -> SocketAddr {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
for _ in 0..drop_first_n {
|
||||||
|
if let Ok((sock, _)) = listener.accept().await {
|
||||||
|
drop(sock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
let Ok((mut sock, _)) = listener.accept().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = [0u8; 2048];
|
||||||
|
let _ = sock.read(&mut buf).await;
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n{}",
|
||||||
|
body.len(),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
let _ = sock.write_all(response.as_bytes()).await;
|
||||||
|
let _ = sock.shutdown().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addr
|
||||||
}
|
}
|
||||||
|
|
||||||
results
|
fn zero_delays() -> Vec<u64> {
|
||||||
|
vec![0; RETRY_DELAYS_SECS.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn retry_succeeds_on_final_attempt() {
|
||||||
|
let body = "ads.example.com\ntracker.example.net\n";
|
||||||
|
let delays = zero_delays();
|
||||||
|
let addr = flaky_http_server(delays.len(), body).await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = format!("http://{addr}/");
|
||||||
|
let result = fetch_with_retry_delays(&client, &url, &delays).await;
|
||||||
|
assert_eq!(result.as_deref(), Some(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn retry_gives_up_when_all_attempts_fail() {
|
||||||
|
let delays = zero_delays();
|
||||||
|
let addr = flaky_http_server(delays.len() + 2, "unreachable").await;
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = format!("http://{addr}/");
|
||||||
|
let result = fetch_with_retry_delays(&client, &url, &delays).await;
|
||||||
|
assert_eq!(result, None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,25 +263,29 @@ impl UpstreamConfig {
|
|||||||
if relay_url.scheme() != "https" || target_url.scheme() != "https" {
|
if relay_url.scheme() != "https" || target_url.scheme() != "https" {
|
||||||
return Err("upstream.relay and upstream.target must both use https://".into());
|
return Err("upstream.relay and upstream.target must both use https://".into());
|
||||||
}
|
}
|
||||||
if relay_url.host_str().is_none() || target_url.host_str().is_none() {
|
|
||||||
return Err("upstream.relay and upstream.target must include a host".into());
|
|
||||||
}
|
|
||||||
if relay_url.host_str() == target_url.host_str() {
|
|
||||||
return Err(format!(
|
|
||||||
"upstream.relay and upstream.target resolve to the same host ({}); the privacy property requires distinct operators",
|
|
||||||
relay_url.host_str().unwrap_or("?")
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let relay_host = relay_url
|
let relay_host = relay_url
|
||||||
.host_str()
|
.host_str()
|
||||||
.ok_or("upstream.relay has no host")?
|
.ok_or("upstream.relay must include a host")?
|
||||||
.to_string();
|
.to_string();
|
||||||
let target_host = target_url
|
let target_host = target_url
|
||||||
.host_str()
|
.host_str()
|
||||||
.ok_or("upstream.target has no host")?
|
.ok_or("upstream.target must include a host")?
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
|
if relay_host == target_host {
|
||||||
|
return Err(format!(
|
||||||
|
"upstream.relay and upstream.target resolve to the same host ({}); the privacy property requires distinct operators",
|
||||||
|
relay_host
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
if let Some(shared) = shared_registrable_domain(&relay_host, &target_host) {
|
||||||
|
return Err(format!(
|
||||||
|
"upstream.relay ({}) and upstream.target ({}) share the registrable domain ({}); the privacy property requires distinct operators",
|
||||||
|
relay_host, target_host, shared
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
let target_path = if target_url.path().is_empty() {
|
let target_path = if target_url.path().is_empty() {
|
||||||
"/".to_string()
|
"/".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -303,6 +307,20 @@ impl UpstreamConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the registrable domain (eTLD+1) shared by both hosts, if any.
|
||||||
|
/// Fails open on hosts the PSL can't parse (IP literals, bare TLDs).
|
||||||
|
fn shared_registrable_domain(relay_host: &str, target_host: &str) -> Option<String> {
|
||||||
|
let relay = psl::domain(relay_host.as_bytes())?;
|
||||||
|
let target = psl::domain(target_host.as_bytes())?;
|
||||||
|
if relay.as_bytes() == target.as_bytes() {
|
||||||
|
std::str::from_utf8(relay.as_bytes())
|
||||||
|
.ok()
|
||||||
|
.map(str::to_owned)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn string_or_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
|
fn string_or_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
|
||||||
where
|
where
|
||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
@@ -830,6 +848,59 @@ target = "https://odoh.example.com/dns-query"
|
|||||||
assert!(err.contains("same host"), "got: {err}");
|
assert!(err.contains("same host"), "got: {err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn odoh_rejects_shared_registrable_domain() {
|
||||||
|
let toml = r#"
|
||||||
|
[upstream]
|
||||||
|
mode = "odoh"
|
||||||
|
relay = "https://r.cloudflare.com/relay"
|
||||||
|
target = "https://odoh.cloudflare.com/dns-query"
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
let err = config.upstream.odoh_upstream().unwrap_err().to_string();
|
||||||
|
assert!(err.contains("registrable domain"), "got: {err}");
|
||||||
|
assert!(err.contains("cloudflare.com"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn odoh_rejects_shared_registrable_under_multi_label_suffix() {
|
||||||
|
let toml = r#"
|
||||||
|
[upstream]
|
||||||
|
mode = "odoh"
|
||||||
|
relay = "https://a.foo.co.uk/relay"
|
||||||
|
target = "https://b.foo.co.uk/dns-query"
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
let err = config.upstream.odoh_upstream().unwrap_err().to_string();
|
||||||
|
assert!(err.contains("foo.co.uk"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn odoh_accepts_distinct_registrable_under_multi_label_suffix() {
|
||||||
|
let toml = r#"
|
||||||
|
[upstream]
|
||||||
|
mode = "odoh"
|
||||||
|
relay = "https://relay.foo.co.uk/relay"
|
||||||
|
target = "https://target.bar.co.uk/dns-query"
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert!(config.upstream.odoh_upstream().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn odoh_accepts_distinct_private_psl_suffix_subdomains() {
|
||||||
|
// *.github.io is a public suffix, so foo.github.io and bar.github.io
|
||||||
|
// are independent registrable domains — accept.
|
||||||
|
let toml = r#"
|
||||||
|
[upstream]
|
||||||
|
mode = "odoh"
|
||||||
|
relay = "https://foo.github.io/relay"
|
||||||
|
target = "https://bar.github.io/dns-query"
|
||||||
|
"#;
|
||||||
|
let config: Config = toml::from_str(toml).unwrap();
|
||||||
|
assert!(config.upstream.odoh_upstream().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn odoh_rejects_non_https() {
|
fn odoh_rejects_non_https() {
|
||||||
let toml = r#"
|
let toml = r#"
|
||||||
|
|||||||
Reference in New Issue
Block a user