pub mod actions;
use crate::llm::action_helper::call_llm;
use crate::llm::ollama_client::OllamaClient;
use crate::logging::emit::Log;
use crate::protocol::Event;
use crate::server::connection::ConnectionId;
use crate::server::DohProtocol;
use crate::state::app_state::AppState;
use crate::state::ServerId;
use actions::DOH_QUERY_EVENT;
use anyhow::{Context, Result};
use hickory_proto::op::Message as DnsMessage;
use http_body_util::{BodyExt, Full};
use hyper::body::Bytes;
use hyper::server::conn::http2;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use serde_json::json;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio::time::{timeout, Duration};
use tokio_rustls::TlsAcceptor;
use tracing::{debug, error};
const TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
pub const IDLE_BETWEEN_QUERIES_TIMEOUT: Duration = Duration::from_secs(300);
const GOAWAY_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
pub const MAX_DOH_BODY_BYTES: u64 = 65_535;
const ACCEPT_ERROR_BACKOFF: Duration = Duration::from_millis(100);
const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
const CONNECTION_CAP_REFUSAL: &[u8] = b"";
pub struct DohServer;
impl DohServer {
pub async fn spawn(
bind_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
server_id: ServerId,
status_tx: mpsc::UnboundedSender<String>,
idle_timeout: Duration,
) -> Result<SocketAddr> {
let tls_config =
crate::server::tls_cert_manager::generate_default_tls_config_with_alpn(&["h2"])
.context("Failed to generate TLS configuration")?;
Log::new(Some(&status_tx)).info(format!("Starting DoH server on {}", bind_addr));
let listener = TcpListener::bind(bind_addr)
.await
.context("Failed to bind DoH TCP listener")?;
let local_addr = listener
.local_addr()
.context("Failed to get DoH listener local address")?;
Log::new(Some(&status_tx)).info(format!("DoH server listening on {}", local_addr));
let task_registrar = app_state.clone();
let handle = tokio::spawn(async move {
if let Err(e) = Self::run(
listener,
tls_config,
llm_client,
app_state,
server_id,
status_tx,
idle_timeout,
)
.await
{
error!("DoH server error: {}", e);
}
});
task_registrar.register_server_task(server_id, handle).await;
Ok(local_addr)
}
async fn run(
listener: TcpListener,
tls_config: Arc<rustls::ServerConfig>,
llm_client: OllamaClient,
app_state: Arc<AppState>,
server_id: ServerId,
status_tx: mpsc::UnboundedSender<String>,
idle_timeout: Duration,
) -> Result<()> {
let acceptor = TlsAcceptor::from(tls_config);
let local_addr = listener
.local_addr()
.context("Failed to get DoH listener local address")?;
let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
loop {
match crate::server::accept_bounded::accept_bounded(
&listener,
&limiter,
CONNECTION_CAP_REFUSAL,
"DOH",
Some(&status_tx),
)
.await
{
Ok((stream, peer_addr, permit)) => {
Log::new(Some(&status_tx))
.debug(format!("DoH TCP connection from {}", peer_addr));
let connection_id = ConnectionId::new(app_state.get_next_unified_id().await);
{
use crate::state::server::{
ConnectionState as ServerConnectionState, ConnectionStatus,
ProtocolConnectionInfo,
};
let now = crate::utils::clock::Instant::now();
app_state
.add_connection_to_server(
server_id,
ServerConnectionState {
id: connection_id,
remote_addr: peer_addr,
local_addr,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::empty(),
},
)
.await;
}
let _ = status_tx.send("__UPDATE_UI__".to_string());
let acceptor = acceptor.clone();
let llm_client = llm_client.clone();
let conn_state = app_state.clone();
let app_state = app_state.clone();
let status_tx = status_tx.clone();
let handle = tokio::spawn(async move {
let _permit = permit;
if let Err(e) = Self::handle_connection(
stream,
peer_addr,
connection_id,
acceptor,
llm_client,
app_state,
server_id,
status_tx,
idle_timeout,
)
.await
{
error!("DoH connection error from {}: {:#}", peer_addr, e);
}
});
conn_state.register_server_task(server_id, handle).await;
}
Err(e) => {
Log::new(Some(&status_tx))
.warn(format!("Failed to accept DoH TCP connection: {}", e));
tokio::time::sleep(ACCEPT_ERROR_BACKOFF).await;
}
}
}
}
#[allow(clippy::too_many_arguments)]
async fn handle_connection(
stream: tokio::net::TcpStream,
peer_addr: SocketAddr,
connection_id: ConnectionId,
acceptor: TlsAcceptor,
llm_client: OllamaClient,
app_state: Arc<AppState>,
server_id: ServerId,
status_tx: mpsc::UnboundedSender<String>,
idle_timeout: Duration,
) -> Result<()> {
let closer = app_state.clone();
let close_tx = status_tx.clone();
let outcome = Self::serve_connection(
stream,
peer_addr,
connection_id,
acceptor,
llm_client,
app_state,
server_id,
status_tx,
idle_timeout,
)
.await;
closer
.close_connection_on_server(server_id, connection_id)
.await;
let _ = close_tx.send("__UPDATE_UI__".to_string());
outcome
}
#[allow(clippy::too_many_arguments)]
async fn serve_connection(
stream: tokio::net::TcpStream,
peer_addr: SocketAddr,
connection_id: ConnectionId,
acceptor: TlsAcceptor,
llm_client: OllamaClient,
app_state: Arc<AppState>,
server_id: ServerId,
status_tx: mpsc::UnboundedSender<String>,
idle_timeout: Duration,
) -> Result<()> {
let tls_stream = timeout(TLS_HANDSHAKE_TIMEOUT, acceptor.accept(stream))
.await
.map_err(|_| {
anyhow::anyhow!(
"TLS handshake with {peer_addr} did not complete within {:?}",
TLS_HANDSHAKE_TIMEOUT
)
})?
.context("TLS handshake failed")?;
Log::new(Some(&status_tx)).debug(format!("DoH TLS handshake complete with {}", peer_addr));
let io = TokioIo::new(tls_stream);
let activity = Arc::new(crate::server::accept_bounded::ConnectionActivity::new());
let activity_for_service = Arc::clone(&activity);
let idle_log_tx = status_tx.clone();
let service = service_fn(move |req: Request<hyper::body::Incoming>| {
let llm_client = llm_client.clone();
let app_state = app_state.clone();
let status_tx = status_tx.clone();
let busy = activity_for_service.busy();
async move {
let _busy = busy;
Self::handle_request(
req,
peer_addr,
connection_id,
server_id,
llm_client,
app_state,
status_tx,
)
.await
}
});
let conn =
http2::Builder::new(hyper_util::rt::TokioExecutor::new()).serve_connection(io, service);
tokio::pin!(conn);
let result = tokio::select! {
result = conn.as_mut() => result,
_ = crate::server::accept_bounded::watch_idle(Arc::clone(&activity), idle_timeout) => {
Log::new(Some(&idle_log_tx)).debug(format!(
"DoH connection {} from {} carried no query for {}s; sending GOAWAY",
connection_id,
peer_addr,
idle_timeout.as_secs()
));
conn.as_mut().graceful_shutdown();
timeout(GOAWAY_DRAIN_TIMEOUT, conn.as_mut())
.await
.unwrap_or(Ok(()))
}
};
if let Err(e) = result {
debug!("DoH HTTP/2 connection error: {}", e);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_request(
req: Request<hyper::body::Incoming>,
peer_addr: SocketAddr,
connection_id: ConnectionId,
server_id: ServerId,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
) -> Result<Response<Full<Bytes>>, hyper::Error> {
let method = req.method().clone();
let uri = req.uri().clone();
Log::new(Some(&status_tx)).debug(format!("DoH request: {} {}", method, uri));
let dns_bytes = match method {
Method::GET => {
let query = uri.query().unwrap_or("");
let mut dns_param = None;
for param in query.split('&') {
if let Some(value) = param.strip_prefix("dns=") {
dns_param = Some(value);
break;
}
}
match dns_param {
Some(encoded) => {
match base64_url_decode(encoded) {
Ok(bytes) => bytes,
Err(e) => {
Log::new(Some(&status_tx))
.warn(format!("Invalid base64url DNS query: {}", e));
return Ok(error_response(
StatusCode::BAD_REQUEST,
"Invalid DNS query encoding",
));
}
}
}
None => {
Log::new(Some(&status_tx))
.warn("Missing dns= parameter in DoH GET request");
return Ok(error_response(
StatusCode::BAD_REQUEST,
"Missing dns parameter",
));
}
}
}
Method::POST => {
if let Some(content_type) = req.headers().get("content-type") {
if !is_dns_message_content_type(content_type) {
Log::new(Some(&status_tx))
.warn(format!("Invalid DoH Content-Type: {:?}", content_type));
return Ok(error_response(
StatusCode::BAD_REQUEST,
"Invalid Content-Type",
));
}
} else {
Log::new(Some(&status_tx)).warn("Missing Content-Type in DoH POST");
return Ok(error_response(
StatusCode::BAD_REQUEST,
"Missing Content-Type",
));
}
use http_body_util::Limited;
let limited = Limited::new(req.into_body(), MAX_DOH_BODY_BYTES as usize);
match limited.collect().await {
Ok(collected) => collected.to_bytes().to_vec(),
Err(e) => {
Log::new(Some(&status_tx)).warn(format!(
"DoH POST body from {} exceeded {} bytes or could not be read: {}",
peer_addr, MAX_DOH_BODY_BYTES, e
));
return Ok(error_response(
StatusCode::PAYLOAD_TOO_LARGE,
"DNS message too large",
));
}
}
}
_ => {
Log::new(Some(&status_tx)).warn(format!("Unsupported DoH method: {}", method));
return Ok(error_response(
StatusCode::METHOD_NOT_ALLOWED,
"Only GET and POST are supported",
));
}
};
let log = Log::new(Some(&status_tx));
log.debug(format!("DoH received {} bytes", dns_bytes.len()));
log.trace(format!("DoH DNS query hex: {}", hex::encode(&dns_bytes)));
app_state
.update_connection_stats(
server_id,
connection_id,
Some(dns_bytes.len() as u64),
None,
Some(1),
None,
)
.await;
let dns_message = match DnsMessage::from_vec(&dns_bytes) {
Ok(msg) => msg,
Err(e) => {
Log::new(Some(&status_tx)).warn(format!("Failed to parse DoH DNS message: {}", e));
return Ok(error_response(
StatusCode::BAD_REQUEST,
"Invalid DNS message",
));
}
};
let queries = dns_message.queries();
if queries.is_empty() {
Log::new(Some(&status_tx)).warn("DoH DNS message has no queries");
return Ok(error_response(StatusCode::BAD_REQUEST, "No DNS queries"));
}
let query = &queries[0];
let domain = query.name().to_utf8();
let query_type = query.query_type().to_string();
let query_id = dns_message.id();
Log::new(Some(&status_tx)).info(format!(
"DoH query: {} {} (ID: {})",
domain, query_type, query_id
));
let event = Event::new(
&DOH_QUERY_EVENT,
json!({
"query_id": query_id,
"domain": domain,
"query_type": query_type,
"peer_addr": peer_addr.to_string(),
"method": method.to_string(),
}),
);
let protocol = Arc::new(DohProtocol::new());
Log::new(Some(&status_tx)).debug(format!("DoH calling LLM for query from {}", peer_addr));
let execution_result = match call_llm(
&llm_client,
&app_state,
server_id,
Some(connection_id),
&event,
protocol.as_ref(),
)
.await
{
Ok(result) => result,
Err(e) => {
let decision = if crate::llm::is_overload_error(&e) {
"fail_closed_llm_overload"
} else {
"fail_closed_llm_error"
};
Log::new(Some(&status_tx)).warn(format!(
"DoH answering SERVFAIL to {} decision={}: {}",
peer_addr, decision, e
));
return Ok(
match crate::server::dns::actions::build_servfail(&dns_message) {
Ok(packet) => dns_message_response(packet),
Err(build_err) => {
Log::new(Some(&status_tx)).error(format!(
"DoH failed to build SERVFAIL for {}: {}",
peer_addr, build_err
));
let failure = crate::utils::WireFailure::classify(&e);
match failure {
crate::utils::WireFailure::Overloaded => retry_later_response(),
crate::utils::WireFailure::Unavailable => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
failure.prefixed_text(),
),
}
}
},
);
}
};
for message in &execution_result.messages {
Log::new(Some(&status_tx)).info(format!("{}", message));
}
Log::new(Some(&status_tx)).debug(format!(
"DoH got {} protocol results",
execution_result.protocol_results.len()
));
for protocol_result in &execution_result.protocol_results {
use crate::llm::actions::protocol_trait::ActionResult;
match protocol_result {
ActionResult::Output(bytes) => {
let log = Log::new(Some(&status_tx));
log.debug(format!("DoH sending {} bytes", bytes.len()));
log.trace(format!("DoH response hex: {}", hex::encode(bytes)));
app_state
.update_connection_stats(
server_id,
connection_id,
None,
Some(bytes.len() as u64),
None,
Some(1),
)
.await;
return Ok(dns_message_response(bytes.clone()));
}
ActionResult::Custom { data, .. } => {
if let Some(output_data) = data.get("output_data").and_then(|v| v.as_str()) {
if let Ok(response_bytes) = hex::decode(output_data) {
let log = Log::new(Some(&status_tx));
log.debug(format!("DoH sending {} bytes", response_bytes.len()));
log.trace(format!("DoH response hex: {}", output_data));
app_state
.update_connection_stats(
server_id,
connection_id,
None,
Some(response_bytes.len() as u64),
None,
Some(1),
)
.await;
return Ok(dns_message_response(response_bytes));
}
}
}
ActionResult::NoAction => {
Log::new(Some(&status_tx)).debug("DoH query ignored by LLM");
return Ok(error_response(StatusCode::NOT_FOUND, "Query ignored"));
}
_ => {}
}
}
Ok(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"No response generated",
))
}
}
fn base64_url_decode(encoded: &str) -> Result<Vec<u8>> {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
URL_SAFE_NO_PAD
.decode(encoded)
.context("Failed to decode base64url")
}
fn dns_message_response(body: Vec<u8>) -> Response<Full<Bytes>> {
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/dns-message")
.body(Full::new(Bytes::from(body)))
.expect("constant status and headers always build a valid response")
}
fn is_dns_message_content_type(value: &hyper::header::HeaderValue) -> bool {
let Ok(text) = value.to_str() else {
return false;
};
let media_type = text.split(';').next().unwrap_or("").trim();
media_type.eq_ignore_ascii_case("application/dns-message")
}
fn retry_later_response() -> Response<Full<Bytes>> {
Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header("Content-Type", "text/plain")
.header("Retry-After", "1")
.body(Full::new(Bytes::from(
crate::utils::WireFailure::Overloaded.prefixed_text(),
)))
.expect("constant status and headers always build a valid response")
}
fn error_response(status: StatusCode, message: &str) -> Response<Full<Bytes>> {
Response::builder()
.status(status)
.header("Content-Type", "text/plain")
.body(Full::new(Bytes::from(message.to_string())))
.expect("constant status and headers always build a valid response")
}