feat(odoh): ship ODoH client + self-hosted relay (RFC 9230) #121
@@ -971,9 +971,11 @@ function renderBarChart(containerId, defs, data, total) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function encryptionPct(transport) {
|
function encryptionPct(data, encryptedKeys, allKeys) {
|
||||||
const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1;
|
const total = allKeys.reduce((s, k) => s + (data[k] || 0), 0);
|
||||||
return (((transport.dot + transport.doh) / total) * 100).toFixed(0);
|
if (total === 0) return 0;
|
||||||
|
const encrypted = encryptedKeys.reduce((s, k) => s + (data[k] || 0), 0);
|
||||||
|
return Math.round((encrypted / total) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PATH_DEFS = [
|
const PATH_DEFS = [
|
||||||
@@ -1001,7 +1003,7 @@ const TRANSPORT_DEFS = [
|
|||||||
function renderTransport(transport) {
|
function renderTransport(transport) {
|
||||||
const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1;
|
const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1;
|
||||||
renderBarChart('transportBars', TRANSPORT_DEFS, transport, total);
|
renderBarChart('transportBars', TRANSPORT_DEFS, transport, total);
|
||||||
const encPct = encryptionPct(transport);
|
const encPct = encryptionPct(transport, ['dot', 'doh'], ['udp', 'tcp', 'dot', 'doh']);
|
||||||
const el = document.getElementById('transportEncrypted');
|
const el = document.getElementById('transportEncrypted');
|
||||||
el.textContent = `${encPct}% encrypted inbound`;
|
el.textContent = `${encPct}% encrypted inbound`;
|
||||||
el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)';
|
el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)';
|
||||||
@@ -1017,8 +1019,7 @@ const UPSTREAM_WIRE_DEFS = [
|
|||||||
function renderUpstreamWire(ut) {
|
function renderUpstreamWire(ut) {
|
||||||
const total = (ut.udp + ut.doh + ut.dot + ut.odoh) || 0;
|
const total = (ut.udp + ut.doh + ut.dot + ut.odoh) || 0;
|
||||||
renderBarChart('upstreamWireBars', UPSTREAM_WIRE_DEFS, ut, total || 1);
|
renderBarChart('upstreamWireBars', UPSTREAM_WIRE_DEFS, ut, total || 1);
|
||||||
const encrypted = ut.doh + ut.dot + ut.odoh;
|
const encPct = encryptionPct(ut, ['doh', 'dot', 'odoh'], ['udp', 'doh', 'dot', 'odoh']);
|
||||||
const encPct = total > 0 ? Math.round((encrypted / total) * 100) : 0;
|
|
||||||
const el = document.getElementById('upstreamWireEncrypted');
|
const el = document.getElementById('upstreamWireEncrypted');
|
||||||
el.textContent = total > 0 ? `${encPct}% encrypted outbound` : '';
|
el.textContent = total > 0 ? `${encPct}% encrypted outbound` : '';
|
||||||
el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)';
|
el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)';
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ use tokio::time::timeout;
|
|||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
/// MIME type used for both directions of the ODoH exchange (RFC 9230 §4).
|
/// MIME type used for both directions of the ODoH exchange (RFC 9230 §4).
|
||||||
const ODOH_CONTENT_TYPE: &str = "application/oblivious-dns-message";
|
pub(crate) const ODOH_CONTENT_TYPE: &str = "application/oblivious-dns-message";
|
||||||
|
|
||||||
/// Cap on the response body we read into memory when the relay returns
|
/// Cap on the response body we read into memory when the relay returns
|
||||||
/// non-success. Protects against a hostile relay streaming a huge body on
|
/// non-success. Protects against a hostile relay streaming a huge body on
|
||||||
|
|||||||
39
src/relay.rs
39
src/relay.rs
@@ -20,10 +20,9 @@ use serde::Deserialize;
|
|||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use crate::forward::build_https_client_with_pool;
|
use crate::forward::build_https_client_with_pool;
|
||||||
|
use crate::odoh::ODOH_CONTENT_TYPE;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
const ODOH_CONTENT_TYPE: &str = "application/oblivious-dns-message";
|
|
||||||
|
|
||||||
/// Cap on the opaque body we accept from a client. ODoH envelopes are
|
/// Cap on the opaque body we accept from a client. ODoH envelopes are
|
||||||
/// ~100–300 bytes in practice; anything larger is malformed or hostile.
|
/// ~100–300 bytes in practice; anything larger is malformed or hostile.
|
||||||
const MAX_BODY_BYTES: usize = 4 * 1024;
|
const MAX_BODY_BYTES: usize = 4 * 1024;
|
||||||
@@ -55,23 +54,30 @@ struct RelayState {
|
|||||||
rejected_bad_request: AtomicU64,
|
rejected_bad_request: AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(addr: SocketAddr) -> Result<()> {
|
impl RelayState {
|
||||||
let state = Arc::new(RelayState {
|
fn new() -> Arc<Self> {
|
||||||
|
Arc::new(RelayState {
|
||||||
client: build_https_client_with_pool(RELAY_POOL_PER_HOST),
|
client: build_https_client_with_pool(RELAY_POOL_PER_HOST),
|
||||||
total_requests: AtomicU64::new(0),
|
total_requests: AtomicU64::new(0),
|
||||||
forwarded_ok: AtomicU64::new(0),
|
forwarded_ok: AtomicU64::new(0),
|
||||||
forwarded_err: AtomicU64::new(0),
|
forwarded_err: AtomicU64::new(0),
|
||||||
rejected_bad_request: AtomicU64::new(0),
|
rejected_bad_request: AtomicU64::new(0),
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let app = Router::new()
|
/// `DefaultBodyLimit` overrides axum's 2 MiB default so hostile clients
|
||||||
|
/// can't force the relay to buffer multi-MB bodies before our own cap.
|
||||||
|
fn build_app(state: Arc<RelayState>) -> Router {
|
||||||
|
Router::new()
|
||||||
.route("/relay", post(handle_relay))
|
.route("/relay", post(handle_relay))
|
||||||
// Overrides axum's default (2 MiB) so hostile clients can't force
|
|
||||||
// the relay to buffer multi-MB bodies before our own cap check.
|
|
||||||
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
|
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
|
||||||
.route("/health", get(handle_health))
|
.route("/health", get(handle_health))
|
||||||
.with_state(state);
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(addr: SocketAddr) -> Result<()> {
|
||||||
|
let app = build_app(RelayState::new());
|
||||||
let listener = TcpListener::bind(addr).await?;
|
let listener = TcpListener::bind(addr).await?;
|
||||||
info!("ODoH relay listening on {}", addr);
|
info!("ODoH relay listening on {}", addr);
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
@@ -199,19 +205,8 @@ mod tests {
|
|||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
|
|
||||||
let state = Arc::new(RelayState {
|
let state = RelayState::new();
|
||||||
client: build_https_client_with_pool(RELAY_POOL_PER_HOST),
|
let app = build_app(state.clone());
|
||||||
total_requests: AtomicU64::new(0),
|
|
||||||
forwarded_ok: AtomicU64::new(0),
|
|
||||||
forwarded_err: AtomicU64::new(0),
|
|
||||||
rejected_bad_request: AtomicU64::new(0),
|
|
||||||
});
|
|
||||||
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/relay", post(handle_relay))
|
|
||||||
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
|
|
||||||
.route("/health", get(handle_health))
|
|
||||||
.with_state(state.clone());
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = axum::serve(listener, app).await;
|
let _ = axum::serve(listener, app).await;
|
||||||
|
|||||||
Reference in New Issue
Block a user