1use crate::llm::actions::{
6 protocol_trait::{ActionResult, Protocol, Server},
7 ActionDefinition, Parameter,
8};
9use crate::protocol::log_template::LogTemplate;
10use crate::protocol::EventType;
11use crate::server::dns::actions::DnsProtocol;
12use crate::state::app_state::AppState;
13use anyhow::Result;
14use serde_json::json;
15use std::sync::LazyLock;
16
17pub static DOH_QUERY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
20 let dns_actions = DnsProtocol::query_actions();
22
23 EventType::new(
24 "doh_query",
25 "Client sent DNS query over HTTPS",
26 json!({"type": "placeholder", "event_id": "doh_query"}),
27 )
28 .with_parameters(vec![
29 Parameter {
30 name: "query_id".to_string(),
31 type_hint: "number".to_string(),
32 description: "DNS query ID from the request packet".to_string(),
33 required: true,
34 },
35 Parameter {
36 name: "domain".to_string(),
37 type_hint: "string".to_string(),
38 description: "Domain name being queried".to_string(),
39 required: true,
40 },
41 Parameter {
42 name: "query_type".to_string(),
43 type_hint: "string".to_string(),
44 description: "DNS query type (A, AAAA, MX, TXT, CNAME, etc.)".to_string(),
45 required: true,
46 },
47 Parameter {
48 name: "peer_addr".to_string(),
49 type_hint: "string".to_string(),
50 description: "Client IP address and port".to_string(),
51 required: true,
52 },
53 Parameter {
54 name: "method".to_string(),
55 type_hint: "string".to_string(),
56 description: "HTTP method used (GET or POST)".to_string(),
57 required: true,
58 },
59 ])
60 .with_log_template(
61 LogTemplate::new()
62 .with_info("DoH {query_type} {domain} via {method}")
63 .with_debug("DoH query from {peer_addr}: {query_type} {domain} via {method}")
64 .with_trace("DoH: {json_pretty(.)}"),
65 )
66 .with_actions(dns_actions)
67});
68
69pub struct DohProtocol {
72 dns_protocol: DnsProtocol,
73}
74
75impl DohProtocol {
76 pub fn new() -> Self {
77 Self {
78 dns_protocol: DnsProtocol::new(),
79 }
80 }
81}
82
83impl Protocol for DohProtocol {
85 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
86 vec![crate::llm::actions::ParameterDefinition {
87 name: "idle_timeout_secs".to_string(),
88 type_hint: "number".to_string(),
89 description: "Seconds an established DoH connection may carry no query before the \
90 server sends GOAWAY and closes it. Default 300, above the 90 seconds \
91 reqwest keeps an idle pooled connection. A query still being \
92 answered never counts as idle."
93 .to_string(),
94 required: false,
95 example: json!(300),
96 default: Some(serde_json::json!(
97 super::IDLE_BETWEEN_QUERIES_TIMEOUT.as_secs()
98 )),
99 }]
100 }
101 fn get_async_actions(&self, state: &AppState) -> Vec<ActionDefinition> {
102 self.dns_protocol.get_async_actions(state)
103 }
104 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
105 DnsProtocol::query_actions()
106 }
107 fn protocol_name(&self) -> &'static str {
108 "DoH"
109 }
110 fn get_event_types(&self) -> Vec<EventType> {
111 vec![DOH_QUERY_EVENT.clone()]
112 }
113 fn stack_name(&self) -> &'static str {
114 "ETH>IP>TCP>TLS>HTTP2>DNS"
115 }
116 fn keywords(&self) -> Vec<&'static str> {
117 vec!["doh", "dns-over-https", "dns over https"]
118 }
119 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
120 use crate::protocol::metadata::{
121 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
122 };
123
124 ProtocolMetadataV2::builder()
125 .state(DevelopmentState::Beta)
126 .privilege_requirement(PrivilegeRequirement::PrivilegedPort(443))
128 .well_known_port(443)
129 .implementation("hickory-proto + hyper + tokio-rustls; DNS actions and action execution are delegated to the DNS protocol")
130 .llm_control("Same as DNS (delegates to DNS protocol)")
131 .e2e_testing(
132 "TWO independent peers, neither #[ignore]d and neither able to skip. \
133 (1) tests/server/doh/real_client_test.rs drives the real kdig binary (Knot DNS \
134 3.6, a C implementation sharing no code with this tree) through BOTH RFC 8484 \
135 encodings it chose for itself -- `+https` POSTing application/dns-message and \
136 `+https-get` sending GET ?dns=<base64url>. kdig is a resolver, not a decoder: \
137 it picks the transaction id, matches the reply against it, confirms the \
138 question section and renders the answer through its own presentation writer. \
139 Two queries for different names with different addresses, so a reply bound to \
140 the wrong question is visible. The test FAILS, naming `brew install knot`, \
141 when kdig is absent. \
142 (2) tests/server/doh/e2e_test.rs: reqwest (hyper + rustls) is an independent \
143 HTTP/2 and TLS implementation and exercises both encodings, the latter sent \
144 with mixed case and a charset parameter. It asserts the 200 status, the \
145 Content-Type, that the transaction id and question are echoed, and the address \
146 by value. \
147 THAT SECOND TEST WAS HALF CIRCULAR AND IS NO LONGER THE WHOLE RATING. reqwest \
148 proves an HTTP server answers, not that the DNS on top is right, and the DNS \
149 message is decoded with hickory-proto -- the codec this server encodes with. \
150 ALPN was also unproven, because that client connects with \
151 http2_prior_knowledge() and skips ALPN entirely; kdig negotiates it, and \
152 changing the advertised protocol from h2 to http/1.1 now fails the test with \
153 kdig's own TLS alert. Verified the same way for the transaction id: answering \
154 with a fixed id instead of the client's fails the test. \
155 UNPROVEN: certificate validation of any kind. The certificate is self-signed, \
156 and kdig is run with a path-only authority so it keeps the opportunistic \
157 profile -- naming an authority makes it verify, and an IP literal there is \
158 rejected as SNI by rustls outright.",
159 )
160 .notes("GET/POST, HTTP/2 only (no HTTP/1.1), self-signed certs, any request path accepted. Advertises ALPN h2 - without it a negotiating client falls back to HTTP/1.1, which this server does not speak")
161 .max_inbound_bytes(crate::server::doh::MAX_DOH_BODY_BYTES as usize)
162 .request_only("DNS over HTTPS only answers queries; a server cannot send a peer anything unprompted")
163 .build()
164 }
165 fn description(&self) -> &'static str {
166 "DNS-over-HTTPS server for secure domain resolution"
167 }
168 fn example_prompt(&self) -> &'static str {
169 "DNS-over-HTTPS server on port 443 resolving all queries to 93.184.216.34"
170 }
171 fn group_name(&self) -> &'static str {
172 "Core"
173 }
174 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
175 use crate::llm::actions::StartupExamples;
176 StartupExamples::new(
177 json!({
179 "type": "open_server",
180 "port": 443,
181 "base_stack": "doh",
182 "instruction": "DNS-over-HTTPS server resolving all A queries for example.com to 93.184.216.34, NXDOMAIN for others"
183 }),
184 json!({
186 "type": "open_server",
187 "port": 443,
188 "base_stack": "doh",
189 "event_handlers": [{
190 "event_pattern": "doh_query",
191 "handler": {
192 "type": "script",
193 "language": "python",
194 "code": "# Echo the client's transaction ID and queried name\nif event.get('domain', '').rstrip('.') == 'example.com':\n respond([{'type': 'send_dns_a_response', 'query_id': event['query_id'], 'domain': event['domain'], 'ip': '93.184.216.34', 'ttl': 300}])\nelse:\n respond([{'type': 'send_dns_nxdomain', 'query_id': event['query_id'], 'domain': event['domain'], 'query_type': event.get('query_type', 'A')}])"
195 }
196 }]
197 }),
198 json!({
204 "type": "open_server",
205 "port": 443,
206 "base_stack": "doh",
207 "event_handlers": [{
208 "event_pattern": "doh_query",
209 "handler": {
210 "type": "static",
211 "actions": [{
212 "type": "ignore_query"
213 }]
214 }
215 }]
216 }),
217 )
218 }
219}
220
221impl Server for DohProtocol {
223 fn spawn(
224 &self,
225 ctx: crate::protocol::SpawnContext,
226 ) -> std::pin::Pin<
227 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
228 > {
229 Box::pin(async move {
230 use crate::server::doh::DohServer;
231 let idle_timeout = ctx
237 .startup_params
238 .as_ref()
239 .map(|p| p.get_optional_u64("idle_timeout_secs"))
240 .transpose()?
241 .flatten()
242 .map(std::time::Duration::from_secs)
243 .unwrap_or(crate::server::doh::IDLE_BETWEEN_QUERIES_TIMEOUT);
244 DohServer::spawn(
245 ctx.legacy_listen_addr(),
246 ctx.llm_client,
247 ctx.state,
248 ctx.server_id,
249 ctx.status_tx,
250 idle_timeout,
251 )
252 .await
253 })
254 }
255 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
256 self.dns_protocol.execute_action(action)
257 }
258}