Skip to main content

dnslib/mcp/tools/
settings.rs

1use rmcp::{ErrorData as McpError, model::*};
2
3use crate::{
4    control_plane::policy::Policy, core::dns::service::DnsService, core::dns::settings,
5    mcp::helpers::run_json,
6};
7
8pub async fn handle_get_settings<C: DnsService + Send + Sync>(
9    client: &C,
10    policy: &Policy,
11    show_secrets: bool,
12) -> Result<CallToolResult, McpError> {
13    Ok(
14        run_json("dns_get_settings", policy.check_read(), async move {
15            if show_secrets {
16                settings::get_settings_unredacted(client).await
17            } else {
18                settings::get_settings(client).await
19            }
20        })
21        .await,
22    )
23}
24
25#[cfg(test)]
26mod tests {
27    use serde_json::{Value, json};
28
29    use super::*;
30    use crate::{
31        control_plane::{
32            config::VendorKind,
33            policy::{Policy, PolicyRule},
34        },
35        core::{
36            dns::{
37                capabilities::VendorCapabilities,
38                logs::{LogLine, LogsOptions, LogsRead},
39                records::RecordData,
40                responses::ListRecordsResponse,
41                service::{
42                    AccessListRead, AccessListWrite, CacheRead, CacheWrite, DnsVendor,
43                    ListRecordsOptions, RecordWrite, SettingsRead, StatsRead, ZoneExport,
44                    ZoneImport, ZoneRead, ZoneWrite,
45                },
46            },
47            error::Result,
48            redaction::REDACTED_MARKER,
49        },
50    };
51
52    /// Minimal test double for `handle_get_settings`.
53    ///
54    /// `FakeDnsService` exists only to satisfy the handler's `DnsService`
55    /// bound in settings-handler tests. The tests should exercise only
56    /// `SettingsRead::get_settings`, which returns a clone of the stored
57    /// settings payload. All other DNS trait methods are intentionally stubbed
58    /// with `unreachable!()` so an accidental call outside the settings path
59    /// fails immediately.
60    struct FakeDnsService {
61        settings: Value,
62    }
63
64    impl DnsVendor for FakeDnsService {
65        fn kind(&self) -> VendorKind {
66            VendorKind::Technitium
67        }
68
69        fn capabilities(&self) -> VendorCapabilities {
70            VendorCapabilities {
71                settings: true,
72                ..VendorCapabilities::default()
73            }
74        }
75    }
76
77    impl ZoneRead for FakeDnsService {
78        async fn list_zones(&self, _page: u32, _per_page: u32) -> Result<Value> {
79            unreachable!("not used by settings handler")
80        }
81
82        async fn list_records(
83            &self,
84            _domain: &str,
85            _zone: Option<&str>,
86            _options: ListRecordsOptions,
87        ) -> Result<ListRecordsResponse> {
88            unreachable!("not used by settings handler")
89        }
90    }
91
92    impl ZoneWrite for FakeDnsService {
93        async fn create_zone(&self, _zone: &str, _zone_type: &str) -> Result<Value> {
94            unreachable!("not used by settings handler")
95        }
96
97        async fn delete_zone(&self, _zone: &str) -> Result<Value> {
98            unreachable!("not used by settings handler")
99        }
100
101        async fn enable_zone(&self, _zone: &str) -> Result<Value> {
102            unreachable!("not used by settings handler")
103        }
104
105        async fn disable_zone(&self, _zone: &str) -> Result<Value> {
106            unreachable!("not used by settings handler")
107        }
108    }
109
110    impl RecordWrite for FakeDnsService {
111        async fn add_record(
112            &self,
113            _zone: &str,
114            _domain: &str,
115            _ttl: u32,
116            _record: &RecordData,
117        ) -> Result<Value> {
118            unreachable!("not used by settings handler")
119        }
120
121        async fn delete_record(
122            &self,
123            _zone: &str,
124            _domain: &str,
125            _type_params: &[(&str, String)],
126        ) -> Result<Value> {
127            unreachable!("not used by settings handler")
128        }
129    }
130
131    impl CacheRead for FakeDnsService {
132        async fn list_cache(&self, _domain: &str) -> Result<Value> {
133            unreachable!("not used by settings handler")
134        }
135    }
136
137    impl CacheWrite for FakeDnsService {
138        async fn delete_cache_zone(&self, _domain: &str) -> Result<Value> {
139            unreachable!("not used by settings handler")
140        }
141
142        async fn flush_cache(&self) -> Result<Value> {
143            unreachable!("not used by settings handler")
144        }
145    }
146
147    impl AccessListRead for FakeDnsService {
148        async fn list_blocked(&self) -> Result<Value> {
149            unreachable!("not used by settings handler")
150        }
151
152        async fn list_allowed(&self) -> Result<Value> {
153            unreachable!("not used by settings handler")
154        }
155    }
156
157    impl AccessListWrite for FakeDnsService {
158        async fn add_blocked(&self, _domain: &str) -> Result<Value> {
159            unreachable!("not used by settings handler")
160        }
161
162        async fn delete_blocked(&self, _domain: &str) -> Result<Value> {
163            unreachable!("not used by settings handler")
164        }
165
166        async fn add_allowed(&self, _domain: &str) -> Result<Value> {
167            unreachable!("not used by settings handler")
168        }
169
170        async fn delete_allowed(&self, _domain: &str) -> Result<Value> {
171            unreachable!("not used by settings handler")
172        }
173    }
174
175    impl StatsRead for FakeDnsService {
176        async fn get_stats(&self, _stats_type: &str) -> Result<Value> {
177            unreachable!("not used by settings handler")
178        }
179    }
180
181    impl ZoneImport for FakeDnsService {
182        async fn import_zone_file(
183            &self,
184            _zone: &str,
185            _file_name: String,
186            _file_bytes: Vec<u8>,
187            _overwrite: bool,
188            _overwrite_zone: bool,
189            _overwrite_soa_serial: bool,
190        ) -> Result<Value> {
191            unreachable!("not used by settings handler")
192        }
193    }
194
195    impl ZoneExport for FakeDnsService {
196        async fn export_zone_file(&self, _zone: &str) -> Result<String> {
197            unreachable!("not used by settings handler")
198        }
199    }
200
201    impl SettingsRead for FakeDnsService {
202        async fn get_settings(&self) -> Result<Value> {
203            Ok(self.settings.clone())
204        }
205    }
206
207    impl LogsRead for FakeDnsService {
208        async fn get_logs(&self, _options: LogsOptions) -> Result<Vec<LogLine>> {
209            unreachable!("not used by settings handler")
210        }
211    }
212
213    #[tokio::test]
214    async fn handle_get_settings_returns_redacted_json() {
215        let client = FakeDnsService {
216            settings: json!({
217                "version": "13.4.1",
218                "tsigKeys": [{ "sharedSecret": "actual-secret" }]
219            }),
220        };
221        let policy = Policy::new([PolicyRule::Read], None);
222
223        let result = handle_get_settings(&client, &policy, false).await.unwrap();
224        let text = result.content[0]
225            .as_text()
226            .expect("settings result should be text JSON");
227        let value: Value = serde_json::from_str(&text.text).unwrap();
228
229        assert_eq!(value["version"], "13.4.1");
230        assert_eq!(value["tsigKeys"][0]["sharedSecret"], REDACTED_MARKER);
231    }
232
233    #[tokio::test]
234    async fn handle_get_settings_can_return_unredacted_json() {
235        let client = FakeDnsService {
236            settings: json!({
237                "version": "13.4.1",
238                "tsigKeys": [{ "sharedSecret": "actual-secret" }]
239            }),
240        };
241        let policy = Policy::new([PolicyRule::Read], None);
242
243        let result = handle_get_settings(&client, &policy, true).await.unwrap();
244        let text = result.content[0]
245            .as_text()
246            .expect("settings result should be text JSON");
247        let value: Value = serde_json::from_str(&text.text).unwrap();
248
249        assert_eq!(value["version"], "13.4.1");
250        assert_eq!(value["tsigKeys"][0]["sharedSecret"], "actual-secret");
251    }
252
253    #[tokio::test]
254    async fn handle_get_settings_denies_without_read_policy() {
255        let client = FakeDnsService {
256            settings: json!({
257                "version": "13.4.1",
258                "tsigKeys": [{ "sharedSecret": "actual-secret" }]
259            }),
260        };
261        let policy = Policy::new([PolicyRule::Write], None);
262
263        let result = handle_get_settings(&client, &policy, false).await.unwrap();
264        let text = result.content[0]
265            .as_text()
266            .expect("policy denial should be returned as text JSON");
267
268        assert_eq!(result.is_error, Some(true));
269        assert!(!text.text.contains("actual-secret"));
270        assert!(!text.text.contains("tsigKeys"));
271        assert!(text.text.contains("does not permit read operations"));
272    }
273}