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