Skip to main content

netget/server/doh/
actions.rs

1//! DNS-over-HTTPS protocol actions implementation
2//!
3//! Reuses DNS actions since DoH is just DNS delivered over HTTPS.
4
5use 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
17/// Event type constant for DoH queries
18/// Reuses DNS action definitions since DoH delegates to DnsProtocol
19pub static DOH_QUERY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
20    // The DNS actions that answer a query (not the zone-transfer ones: no transfer event here)
21    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
69/// DoH protocol action handler
70/// Delegates to DNS protocol for action execution since DoH is DNS over HTTPS
71pub 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
83// Implement Protocol trait (common functionality)
84impl 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            // DoH is normally served on TCP/443, which is a privileged port.
127            .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            // LLM-driven example
178            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            // Script-based example
185            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            // Static handler example: static actions are fixed JSON with no
199            // access to the event, so they cannot echo the client's random
200            // transaction ID or the queried name. Dropping queries is the only
201            // thing static mode does correctly for DNS-over-HTTPS; use script
202            // mode for deterministic answers.
203            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
221// Implement Server trait (server-specific functionality)
222impl 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            // DohServer::spawn binds the listener before returning, so bind
232            // failures surface here and the address it returns is the real
233            // bound address (resolving port 0 to the OS-assigned port).
234            // The idle bound is the operator's to tune; the default is argued beside
235            // IDLE_BETWEEN_QUERIES_TIMEOUT in mod.rs.
236            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}