Skip to main content

netget/server/dot/
actions.rs

1//! DNS-over-TLS protocol actions implementation
2//!
3//! Reuses DNS actions since DoT is just DNS delivered over TLS.
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 DoT queries
18/// Reuses DNS action definitions since DoT delegates to DnsProtocol
19pub static DOT_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        "dot_query",
25        "Client sent DNS query over TLS",
26        json!({"type": "placeholder", "event_id": "dot_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    ])
54    .with_log_template(
55        LogTemplate::new()
56            .with_info("DoT {query_type} {domain}")
57            .with_debug("DoT query from {peer_addr}: {query_type} {domain}")
58            .with_trace("DoT: {json_pretty(.)}"),
59    )
60    .with_actions(dns_actions)
61});
62
63/// DoT protocol action handler
64/// Delegates to DNS protocol for action execution since DoT is DNS over TLS
65pub struct DotProtocol {
66    dns_protocol: DnsProtocol,
67}
68
69impl DotProtocol {
70    pub fn new() -> Self {
71        Self {
72            dns_protocol: DnsProtocol::new(),
73        }
74    }
75}
76
77// Implement Protocol trait (common functionality)
78impl Protocol for DotProtocol {
79    fn get_async_actions(&self, state: &AppState) -> Vec<ActionDefinition> {
80        self.dns_protocol.get_async_actions(state)
81    }
82    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
83        DnsProtocol::query_actions()
84    }
85    fn protocol_name(&self) -> &'static str {
86        "DoT"
87    }
88    fn get_event_types(&self) -> Vec<EventType> {
89        vec![DOT_QUERY_EVENT.clone()]
90    }
91    fn stack_name(&self) -> &'static str {
92        "ETH>IP>TCP>TLS>DNS"
93    }
94    fn keywords(&self) -> Vec<&'static str> {
95        vec!["dot", "dns-over-tls", "dns over tls"]
96    }
97    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
98        use crate::protocol::metadata::{
99            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
100        };
101
102        ProtocolMetadataV2::builder()
103            .state(DevelopmentState::Beta)
104            // RFC 7858 assigns DoT to TCP/853, which is a privileged port.
105            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(853))
106            .well_known_port(853)
107            .implementation("hickory-proto + tokio-rustls; DNS actions and action execution are delegated to the DNS protocol")
108            .llm_control("Same as DNS (delegates to DNS protocol)")
109            .e2e_testing(
110                "TWO independent peers, neither #[ignore]d and neither able to skip. \
111                 (1) tests/server/dot/real_client_test.rs drives the real kdig binary (Knot DNS \
112                 3.6, a C implementation sharing no code with this tree) through `kdig +tls`: it \
113                 matches the reply's transaction id against the one it chose, confirms the \
114                 question section, decodes the RFC 7858 length prefix and renders the answer \
115                 through its own presentation writer. Two queries for different names with \
116                 different addresses, so a reply bound to the wrong question is visible. The \
117                 test FAILS, naming `brew install knot`, when kdig is absent. \
118                 (2) tests/server/dot/e2e_test.rs: rustls completes a real handshake and the \
119                 test asserts the id and question are echoed and that each domain gets its own \
120                 address. \
121                 UNTIL SEPTEMBER 2026 ONLY (2) EXISTED, and its DNS half was circular: the \
122                 message is hand-assembled over hickory-proto, the codec this server encodes \
123                 with, so it proved our encoder agrees with our decoder. This field named kdig \
124                 as the thing that would close it; kdig is now installed and does. Verified by \
125                 answering with a fixed transaction id instead of the client's, at which point \
126                 kdig waits out its timeout and the test fails. \
127                 UNPROVEN: certificate validation of any kind (the cert is self-signed and plain \
128                 `+tls` does not authenticate it), client auth, and record types beyond A.",
129            )
130            .notes("Self-signed certs only, no client auth, TLS overhead")
131            .build()
132    }
133    fn description(&self) -> &'static str {
134        "DNS-over-TLS server for secure domain resolution"
135    }
136    fn example_prompt(&self) -> &'static str {
137        "DNS-over-TLS server on port 853 resolving all queries to 93.184.216.34"
138    }
139    fn group_name(&self) -> &'static str {
140        "Core"
141    }
142    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
143        use crate::llm::actions::StartupExamples;
144        StartupExamples::new(
145            // LLM-driven example
146            json!({
147                "type": "open_server",
148                "port": 853,
149                "base_stack": "dot",
150                "instruction": "DNS-over-TLS server resolving all A queries for example.com to 93.184.216.34, NXDOMAIN for others"
151            }),
152            // Script-based example
153            json!({
154                "type": "open_server",
155                "port": 853,
156                "base_stack": "dot",
157                "event_handlers": [{
158                    "event_pattern": "dot_query",
159                    "handler": {
160                        "type": "script",
161                        "language": "python",
162                        "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')}])"
163                    }
164                }]
165            }),
166            // Static handler example: static actions are fixed JSON with no
167            // access to the event, so they cannot echo the client's random
168            // transaction ID or the queried name. Dropping queries is the only
169            // thing static mode does correctly for DNS-over-TLS; use script
170            // mode for deterministic answers.
171            json!({
172                "type": "open_server",
173                "port": 853,
174                "base_stack": "dot",
175                "event_handlers": [{
176                    "event_pattern": "dot_query",
177                    "handler": {
178                        "type": "static",
179                        "actions": [{
180                            "type": "ignore_query"
181                        }]
182                    }
183                }]
184            }),
185        )
186    }
187}
188
189// Implement Server trait (server-specific functionality)
190impl Server for DotProtocol {
191    fn spawn(
192        &self,
193        ctx: crate::protocol::SpawnContext,
194    ) -> std::pin::Pin<
195        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
196    > {
197        Box::pin(async move {
198            use crate::server::dot::DotServer;
199            // DotServer::spawn binds the listener before returning, so bind
200            // failures surface here and the address it returns is the real
201            // bound address (resolving port 0 to the OS-assigned port).
202            DotServer::spawn(
203                ctx.legacy_listen_addr(),
204                ctx.llm_client,
205                ctx.state,
206                ctx.server_id,
207                ctx.status_tx,
208            )
209            .await
210        })
211    }
212    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
213        self.dns_protocol.execute_action(action)
214    }
215}