Skip to main content

dnslib/vendors/technitium/
service.rs

1//! Technitium implementations of the vendor-neutral DNS service traits.
2
3use serde_json::Value;
4use tracing::instrument;
5
6use crate::control_plane::config::VendorKind;
7use crate::core::dns::capabilities::VendorCapabilities;
8use crate::core::dns::records::RecordData;
9use crate::core::dns::responses::ListRecordsResponse;
10use crate::core::dns::service::{
11    AccessListRead, AccessListWrite, CacheRead, CacheWrite, DnsVendor, ListRecordsOptions,
12    RecordWrite, SettingsRead, StatsRead, ZoneExport, ZoneImport, ZoneRead, ZoneWrite,
13};
14use crate::core::dns::logs::{LogLevel, LogLine, LogsOptions, LogsRead};
15use crate::core::error::{Error, Result};
16use crate::vendors::technitium::client::TechnitiumClient;
17
18impl DnsVendor for TechnitiumClient {
19    fn kind(&self) -> VendorKind {
20        VendorKind::Technitium
21    }
22
23    fn capabilities(&self) -> VendorCapabilities {
24        VendorCapabilities {
25            zones: true,
26            records: true,
27            cache: true,
28            access_lists: true,
29            settings: true,
30            zone_import: true,
31            zone_export: true,
32            logs: true,
33        }
34    }
35}
36
37impl ZoneRead for TechnitiumClient {
38    #[instrument(skip(self), fields(vendor = "technitium", operation = "list_zones"))]
39    async fn list_zones(&self, page: u32, per_page: u32) -> Result<Value> {
40        self.get(
41            "/api/zones/list",
42            &[
43                ("pageNumber", &page.to_string()),
44                ("zonesPerPage", &per_page.to_string()),
45            ],
46        )
47        .await
48    }
49
50    #[instrument(
51        skip(self, options),
52        fields(vendor = "technitium", operation = "list_records")
53    )]
54    async fn list_records(
55        &self,
56        domain: &str,
57        zone: Option<&str>,
58        options: ListRecordsOptions,
59    ) -> Result<ListRecordsResponse> {
60        // When fetching all subdomains we need every record in the zone, so query
61        // the zone apex instead of the specific domain and let the caller filter.
62        let query_domain = if options.all_subdomains {
63            zone.unwrap_or(domain)
64        } else {
65            domain
66        };
67        let mut params = vec![("domain", query_domain)];
68        if let Some(z) = zone {
69            params.push(("zone", z));
70        }
71        if options.all_subdomains {
72            params.push(("listZone", "true"));
73        }
74        let raw = self.get("/api/zones/records/get", &params).await?;
75        ListRecordsResponse::from_value(&raw)
76    }
77}
78
79impl ZoneWrite for TechnitiumClient {
80    #[instrument(skip(self), fields(vendor = "technitium", operation = "create_zone"))]
81    async fn create_zone(&self, zone: &str, zone_type: &str) -> Result<Value> {
82        self.post("/api/zones/create", &[("zone", zone), ("type", zone_type)])
83            .await
84    }
85
86    #[instrument(skip(self), fields(vendor = "technitium", operation = "delete_zone"))]
87    async fn delete_zone(&self, zone: &str) -> Result<Value> {
88        self.post("/api/zones/delete", &[("zone", zone)]).await
89    }
90
91    #[instrument(skip(self), fields(vendor = "technitium", operation = "enable_zone"))]
92    async fn enable_zone(&self, zone: &str) -> Result<Value> {
93        self.post("/api/zones/enable", &[("zone", zone)]).await
94    }
95
96    #[instrument(skip(self), fields(vendor = "technitium", operation = "disable_zone"))]
97    async fn disable_zone(&self, zone: &str) -> Result<Value> {
98        self.post("/api/zones/disable", &[("zone", zone)]).await
99    }
100}
101
102impl RecordWrite for TechnitiumClient {
103    #[instrument(
104        skip(self, record),
105        fields(vendor = "technitium", operation = "add_record")
106    )]
107    async fn add_record(
108        &self,
109        zone: &str,
110        domain: &str,
111        ttl: u32,
112        record: &RecordData,
113    ) -> Result<Value> {
114        let ttl_s = ttl.to_string();
115        let type_params = record.to_api_params();
116
117        let mut form: Vec<(&str, &str)> = vec![("zone", zone), ("domain", domain), ("ttl", &ttl_s)];
118        let type_refs: Vec<(&str, &str)> =
119            type_params.iter().map(|(k, v)| (*k, v.as_str())).collect();
120        form.extend(type_refs);
121
122        self.post("/api/zones/records/add", &form).await
123    }
124
125    #[instrument(
126        skip(self, type_params),
127        fields(vendor = "technitium", operation = "delete_record")
128    )]
129    async fn delete_record(
130        &self,
131        zone: &str,
132        domain: &str,
133        type_params: &[(&str, String)],
134    ) -> Result<Value> {
135        let mut form: Vec<(&str, &str)> = vec![("zone", zone), ("domain", domain)];
136        let type_refs: Vec<(&str, &str)> =
137            type_params.iter().map(|(k, v)| (*k, v.as_str())).collect();
138        form.extend(type_refs);
139        self.post("/api/zones/records/delete", &form).await
140    }
141}
142
143impl CacheRead for TechnitiumClient {
144    #[instrument(skip(self), fields(vendor = "technitium", operation = "list_cache"))]
145    async fn list_cache(&self, domain: &str) -> Result<Value> {
146        self.get("/api/cache/list", &[("domain", domain)]).await
147    }
148}
149
150impl CacheWrite for TechnitiumClient {
151    #[instrument(
152        skip(self),
153        fields(vendor = "technitium", operation = "delete_cache_zone")
154    )]
155    async fn delete_cache_zone(&self, domain: &str) -> Result<Value> {
156        self.post("/api/cache/delete", &[("domain", domain)]).await
157    }
158
159    #[instrument(skip(self), fields(vendor = "technitium", operation = "flush_cache"))]
160    async fn flush_cache(&self) -> Result<Value> {
161        self.get("/api/cache/flush", &[]).await
162    }
163}
164
165impl StatsRead for TechnitiumClient {
166    #[instrument(skip(self), fields(vendor = "technitium", operation = "get_stats"))]
167    async fn get_stats(&self, stats_type: &str) -> Result<Value> {
168        self.get("/api/dashboard/stats/get", &[("type", stats_type)])
169            .await
170    }
171}
172
173impl AccessListRead for TechnitiumClient {
174    #[instrument(skip(self), fields(vendor = "technitium", operation = "list_blocked"))]
175    async fn list_blocked(&self) -> Result<Value> {
176        self.get("/api/blocked/list", &[]).await
177    }
178
179    #[instrument(skip(self), fields(vendor = "technitium", operation = "list_allowed"))]
180    async fn list_allowed(&self) -> Result<Value> {
181        self.get("/api/allowed/list", &[]).await
182    }
183}
184
185impl AccessListWrite for TechnitiumClient {
186    #[instrument(skip(self), fields(vendor = "technitium", operation = "add_blocked"))]
187    async fn add_blocked(&self, domain: &str) -> Result<Value> {
188        self.post("/api/blocked/add", &[("domain", domain)]).await
189    }
190
191    #[instrument(
192        skip(self),
193        fields(vendor = "technitium", operation = "delete_blocked")
194    )]
195    async fn delete_blocked(&self, domain: &str) -> Result<Value> {
196        self.post("/api/blocked/delete", &[("domain", domain)])
197            .await
198    }
199
200    #[instrument(skip(self), fields(vendor = "technitium", operation = "add_allowed"))]
201    async fn add_allowed(&self, domain: &str) -> Result<Value> {
202        self.post("/api/allowed/add", &[("domain", domain)]).await
203    }
204
205    #[instrument(
206        skip(self),
207        fields(vendor = "technitium", operation = "delete_allowed")
208    )]
209    async fn delete_allowed(&self, domain: &str) -> Result<Value> {
210        self.post("/api/allowed/delete", &[("domain", domain)])
211            .await
212    }
213}
214
215impl ZoneImport for TechnitiumClient {
216    #[instrument(
217        skip(self, file_bytes),
218        fields(vendor = "technitium", operation = "import_zone_file")
219    )]
220    async fn import_zone_file(
221        &self,
222        zone: &str,
223        file_name: String,
224        file_bytes: Vec<u8>,
225        overwrite: bool,
226        overwrite_zone: bool,
227        overwrite_soa_serial: bool,
228    ) -> Result<Value> {
229        self.post_file(
230            "/api/zones/import",
231            &[
232                ("zone", zone),
233                ("overwrite", if overwrite { "true" } else { "false" }),
234                (
235                    "overwriteZone",
236                    if overwrite_zone { "true" } else { "false" },
237                ),
238                (
239                    "overwriteSoaSerial",
240                    if overwrite_soa_serial {
241                        "true"
242                    } else {
243                        "false"
244                    },
245                ),
246            ],
247            file_name,
248            file_bytes,
249        )
250        .await
251    }
252}
253
254impl ZoneExport for TechnitiumClient {
255    #[instrument(
256        skip(self),
257        fields(vendor = "technitium", operation = "export_zone_file")
258    )]
259    async fn export_zone_file<'a>(&'a self, zone: &'a str) -> Result<String> {
260        self.get_text("/api/zones/export", &[("zone", zone)]).await
261    }
262}
263
264impl SettingsRead for TechnitiumClient {
265    #[instrument(skip(self), fields(vendor = "technitium", operation = "get_settings"))]
266    async fn get_settings(&self) -> Result<Value> {
267        self.get("/api/settings/get", &[]).await
268    }
269}
270
271impl LogsRead for TechnitiumClient {
272    #[instrument(skip(self, options), fields(vendor = "technitium", operation = "get_logs"))]
273    async fn get_logs(&self, options: LogsOptions) -> Result<Vec<LogLine>> {
274        let lines = options.lines.to_string();
275        let mut params: Vec<(&str, &str)> = vec![("entriesPerPage", &lines)];
276        if let Some(ref s) = options.start { params.push(("start", s)); }
277        if let Some(ref e) = options.end   { params.push(("end",   e)); }
278        let response_type = match options.level {
279            Some(LogLevel::Critical) | Some(LogLevel::Error) => Some("Dropped"),
280            Some(LogLevel::Warning)                          => Some("Blocked"),
281            _                                                => None,
282        };
283        if let Some(rt) = response_type { params.push(("responseType", rt)); }
284        let raw = self.get("/api/log/query", &params).await?;
285        parse_log_lines(&raw)
286    }
287}
288
289fn parse_log_lines(raw: &Value) -> Result<Vec<LogLine>> {
290    let entries = raw["response"]["entries"]
291        .as_array()
292        .ok_or_else(|| Error::parse("log query response missing entries array"))?;
293    let lines = entries.iter().map(|e| {
294        let response_type = e["responseType"].as_str().unwrap_or("");
295        let level = match response_type {
296            "Dropped"                                                    => LogLevel::Error,
297            "Blocked"                                                    => LogLevel::Warning,
298            "Cached" | "Recursive" | "Authoritative" | "LocallyServed"  => LogLevel::Info,
299            _                                                            => LogLevel::Debug,
300        };
301        let name  = e["question"]["name"].as_str().unwrap_or("");
302        let qtype = e["question"]["type"].as_str().unwrap_or("");
303        let title = if name.is_empty() { None } else { Some(format!("{name} ({qtype})")) };
304        let rcode     = e["rCode"].as_str().unwrap_or("");
305        let client_ip = e["clientIpAddress"].as_str().unwrap_or("");
306        let message   = format!("{response_type}: {rcode} from {client_ip}");
307        let timestamp = e["timestamp"].as_str().unwrap_or("").to_string();
308        LogLine { timestamp, level, title, message }
309    }).collect();
310    Ok(lines)
311}