feat(odoh): ship ODoH client + self-hosted relay (RFC 9230) #121
@@ -971,9 +971,11 @@ function renderBarChart(containerId, defs, data, total) {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function encryptionPct(transport) {
|
||||
const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1;
|
||||
return (((transport.dot + transport.doh) / total) * 100).toFixed(0);
|
||||
function encryptionPct(data, encryptedKeys, allKeys) {
|
||||
const total = allKeys.reduce((s, k) => s + (data[k] || 0), 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 = [
|
||||
@@ -1001,7 +1003,7 @@ const TRANSPORT_DEFS = [
|
||||
function renderTransport(transport) {
|
||||
const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1;
|
||||
renderBarChart('transportBars', TRANSPORT_DEFS, transport, total);
|
||||
const encPct = encryptionPct(transport);
|
||||
const encPct = encryptionPct(transport, ['dot', 'doh'], ['udp', 'tcp', 'dot', 'doh']);
|
||||
const el = document.getElementById('transportEncrypted');
|
||||
el.textContent = `${encPct}% encrypted inbound`;
|
||||
el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)';
|
||||
@@ -1017,8 +1019,7 @@ const UPSTREAM_WIRE_DEFS = [
|
||||
function renderUpstreamWire(ut) {
|
||||
const total = (ut.udp + ut.doh + ut.dot + ut.odoh) || 0;
|
||||
renderBarChart('upstreamWireBars', UPSTREAM_WIRE_DEFS, ut, total || 1);
|
||||
const encrypted = ut.doh + ut.dot + ut.odoh;
|
||||
const encPct = total > 0 ? Math.round((encrypted / total) * 100) : 0;
|
||||
const encPct = encryptionPct(ut, ['doh', 'dot', 'odoh'], ['udp', 'doh', 'dot', 'odoh']);
|
||||
const el = document.getElementById('upstreamWireEncrypted');
|
||||
el.textContent = total > 0 ? `${encPct}% encrypted outbound` : '';
|
||||
el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)';
|
||||
|
||||
@@ -25,7 +25,7 @@ use tokio::time::timeout;
|
||||
use crate::Result;
|
||||
|
||||
/// 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
|
||||
/// non-success. Protects against a hostile relay streaming a huge body on
|
||||
|
||||
49
src/relay.rs
49
src/relay.rs
@@ -20,10 +20,9 @@ use serde::Deserialize;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::forward::build_https_client_with_pool;
|
||||
use crate::odoh::ODOH_CONTENT_TYPE;
|
||||
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
|
||||
/// ~100–300 bytes in practice; anything larger is malformed or hostile.
|
||||
const MAX_BODY_BYTES: usize = 4 * 1024;
|
||||
@@ -55,23 +54,30 @@ struct RelayState {
|
||||
rejected_bad_request: AtomicU64,
|
||||
}
|
||||
|
||||
pub async fn run(addr: SocketAddr) -> Result<()> {
|
||||
let state = Arc::new(RelayState {
|
||||
client: build_https_client_with_pool(RELAY_POOL_PER_HOST),
|
||||
total_requests: AtomicU64::new(0),
|
||||
forwarded_ok: AtomicU64::new(0),
|
||||
forwarded_err: AtomicU64::new(0),
|
||||
rejected_bad_request: AtomicU64::new(0),
|
||||
});
|
||||
impl RelayState {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(RelayState {
|
||||
client: build_https_client_with_pool(RELAY_POOL_PER_HOST),
|
||||
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()
|
||||
/// `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))
|
||||
// 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))
|
||||
.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?;
|
||||
info!("ODoH relay listening on {}", addr);
|
||||
axum::serve(listener, app).await?;
|
||||
@@ -199,19 +205,8 @@ mod tests {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
let state = Arc::new(RelayState {
|
||||
client: build_https_client_with_pool(RELAY_POOL_PER_HOST),
|
||||
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());
|
||||
let state = RelayState::new();
|
||||
let app = build_app(state.clone());
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, app).await;
|
||||
|
||||
Reference in New Issue
Block a user