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::logs::{LogLevel, LogLine, LogsOptions, LogsRead};
9use crate::core::dns::records::RecordData;
10use crate::core::dns::responses::ListRecordsResponse;
11use crate::core::dns::service::{
12    AccessListRead, AccessListWrite, CacheRead, CacheWrite, DnsVendor, ListRecordsOptions,
13    RecordWrite, SettingsRead, StatsRead, ZoneExport, ZoneImport, ZoneRead, ZoneWrite,
14};
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(
273        skip(self, options),
274        fields(vendor = "technitium", operation = "get_logs")
275    )]
276    async fn get_logs(&self, options: LogsOptions) -> Result<Vec<LogLine>> {
277        let raw = self.get("/api/logs/list", &[]).await?;
278        let file_name = latest_log_file_name(&raw)?;
279        let text = self
280            .get_text(
281                "/api/logs/download",
282                &[("fileName", file_name.as_str()), ("limit", "1")],
283            )
284            .await?;
285        Ok(parse_log_file(&text, &options))
286    }
287}
288
289fn latest_log_file_name(raw: &Value) -> Result<String> {
290    let entries = raw["response"]["logFiles"]
291        .as_array()
292        .ok_or_else(|| Error::parse("logs list response missing logFiles array"))?;
293    entries
294        .iter()
295        .filter_map(|entry| entry["fileName"].as_str())
296        .max()
297        .map(ToOwned::to_owned)
298        .ok_or_else(|| Error::parse("logs list response did not include any fileName values"))
299}
300
301fn parse_log_file(text: &str, options: &LogsOptions) -> Vec<LogLine> {
302    let mut lines: Vec<LogLine> = text
303        .lines()
304        .filter_map(parse_log_file_line)
305        .filter(|line| {
306            options
307                .level
308                .map(|level| line.level >= level)
309                .unwrap_or(true)
310        })
311        .filter(|line| {
312            options
313                .start
314                .as_deref()
315                .map(|start| line.timestamp.as_str() >= start)
316                .unwrap_or(true)
317        })
318        .filter(|line| {
319            options
320                .end
321                .as_deref()
322                .map(|end| line.timestamp.as_str() <= end)
323                .unwrap_or(true)
324        })
325        .collect();
326
327    if let Some(requested) = options.lines {
328        let requested = requested as usize;
329        if requested == 0 {
330            lines.clear();
331        } else if lines.len() > requested {
332            lines = lines.split_off(lines.len() - requested);
333        }
334    }
335    lines
336}
337
338fn parse_log_file_line(line: &str) -> Option<LogLine> {
339    let rest = line.strip_prefix('[')?;
340    let (timestamp, rest) = rest.split_once(']')?;
341    let message = rest.trim().to_string();
342    Some(LogLine {
343        timestamp: timestamp.trim().to_string(),
344        level: classify_log_level(&message),
345        title: log_title(&message),
346        message,
347    })
348}
349
350fn classify_log_level(message: &str) -> LogLevel {
351    let lower = message.to_ascii_lowercase();
352    if lower.contains("critical") || lower.contains("fatal") {
353        LogLevel::Critical
354    } else if lower.contains("error")
355        || lower.contains("failed")
356        || lower.contains("refused")
357        || lower.contains("exception")
358    {
359        LogLevel::Error
360    } else if lower.contains("warn") || lower.contains("not allowed") {
361        LogLevel::Warning
362    } else {
363        LogLevel::Info
364    }
365}
366
367fn log_title(message: &str) -> Option<String> {
368    let lower = message.to_ascii_lowercase();
369    let title = if lower.contains("zone transfer") {
370        "zone transfer"
371    } else if lower.contains("notify") {
372        "notify"
373    } else if lower.contains("configuration") {
374        "configuration"
375    } else if lower.contains("new record") {
376        "record"
377    } else {
378        return None;
379    };
380    Some(title.to_string())
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use serde_json::json;
387
388    #[test]
389    fn latest_log_file_name_picks_latest_file() {
390        let raw = json!({
391            "response": {
392                "logFiles": [
393                    {"fileName": "2026-05-28", "size": "1 KB"},
394                    {"fileName": "2026-05-29", "size": "2 KB"}
395                ]
396            },
397            "status": "ok"
398        });
399
400        assert_eq!(latest_log_file_name(&raw).unwrap(), "2026-05-29");
401    }
402
403    #[test]
404    fn parse_log_file_extracts_and_filters_recent_lines() {
405        let text = "\
406[2026-05-29 05:36:25 Local] [10.2.65.122:0] [admin] New record was added to Primary zone 'hankin.io' successfully
407[2026-05-29 05:36:30 Local] DNS Server failed to notify name server '10.5.161.84' (RCODE=Refused) for zone: hankin.io
408[2026-05-29 05:36:31 Local] Saved zone file for domain: hankin.io
409";
410
411        let lines = parse_log_file(
412            text,
413            &LogsOptions {
414                lines: Some(1),
415                start: None,
416                end: None,
417                level: Some(LogLevel::Error),
418            },
419        );
420
421        assert_eq!(lines.len(), 1);
422        assert_eq!(lines[0].level, LogLevel::Error);
423        assert_eq!(lines[0].title.as_deref(), Some("notify"));
424        assert!(lines[0].message.contains("RCODE=Refused"));
425    }
426
427    #[test]
428    fn parse_log_file_line_ignores_unstructured_lines() {
429        assert!(parse_log_file_line("not a technitium log line").is_none());
430    }
431}